diff --git a/src/lib/analytics-wrapper.ts b/src/lib/analytics-wrapper.ts index a06349a..3873f64 100644 --- a/src/lib/analytics-wrapper.ts +++ b/src/lib/analytics-wrapper.ts @@ -12,7 +12,8 @@ export type RouteHandlerWithAnalytics = ( ) => Promise | Response; /** - * Creates analytics wrapper with injected cache + * Creates analytics wrapper with injected cache. + * Pre-computes static values at registration time to minimize per-request work. */ export function createAnalyticsWrapper(cache: SlackCache) { return function withAnalytics( @@ -20,28 +21,54 @@ export function createAnalyticsWrapper(cache: SlackCache) { _method: string, handler: RouteHandlerWithAnalytics, ) { - // Pre-compute whether this path is dynamic to avoid new URL() on every request const isDynamic = path.includes(":"); + const skipAnalytics = path === "/health"; - return async (request: Request): Promise => { - if (request.method === "OPTIONS") { - return corsPreflightResponse(); - } - - const startTime = performance.now(); - - const recordAnalytics: AnalyticsRecorder = (statusCode: number) => { - if (path === "/health") { - return; - } + // For static paths, pre-bind everything so the recorder is near-zero cost + if (!skipAnalytics && !isDynamic) { + return async (request: Request): Promise => { + if (request.method === "OPTIONS") return corsPreflightResponse(); + const startTime = performance.now(); const userAgent = request.headers.get("user-agent") || ""; const referer = request.headers.get("referer") || undefined; - const analyticsPath = isDynamic ? new URL(request.url).pathname : path; + const recordAnalytics: AnalyticsRecorder = (statusCode) => { + cache.recordRequest( + path, + statusCode, + userAgent, + performance.now() - startTime, + referer, + ); + }; + + const response = await handler(request, recordAnalytics); + return addCorsHeaders(response); + }; + } + + // Skip analytics entirely for health checks + if (skipAnalytics) { + return async (request: Request): Promise => { + if (request.method === "OPTIONS") return corsPreflightResponse(); + const noop: AnalyticsRecorder = () => {}; + const response = await handler(request, noop); + return addCorsHeaders(response); + }; + } + + // Dynamic paths: need URL parsing + return async (request: Request): Promise => { + if (request.method === "OPTIONS") return corsPreflightResponse(); + + const startTime = performance.now(); + const userAgent = request.headers.get("user-agent") || ""; + const referer = request.headers.get("referer") || undefined; + const recordAnalytics: AnalyticsRecorder = (statusCode) => { cache.recordRequest( - analyticsPath, + new URL(request.url).pathname, statusCode, userAgent, performance.now() - startTime, diff --git a/src/lib/cors.ts b/src/lib/cors.ts index 6bc2749..91a3cec 100644 --- a/src/lib/cors.ts +++ b/src/lib/cors.ts @@ -10,16 +10,31 @@ export const CORS_HEADERS: Record = { "Access-Control-Max-Age": "86400", }; +// Pre-built entries array to avoid Object.entries() allocation per request +const CORS_ENTRIES = Object.entries(CORS_HEADERS); + +/** + * Adds CORS headers to a response. + * Tries in-place mutation first; falls back to new Response if headers are immutable. + */ export function addCorsHeaders(response: Response): Response { - const headers = new Headers(response.headers); - for (const [key, value] of Object.entries(CORS_HEADERS)) { - headers.set(key, value); + try { + for (let i = 0; i < CORS_ENTRIES.length; i++) { + response.headers.set(CORS_ENTRIES[i][0], CORS_ENTRIES[i][1]); + } + return response; + } catch { + // Headers are immutable (e.g. Response.json()), create new response + const headers = new Headers(response.headers); + for (let i = 0; i < CORS_ENTRIES.length; i++) { + headers.set(CORS_ENTRIES[i][0], CORS_ENTRIES[i][1]); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); } - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); } export function corsPreflightResponse(): Response {