/// /** * Middleware Execution * * True middleware that wraps the entire RSC handler. * - `await next()` returns actual Response * - Can modify response headers * - Can catch errors from RSC rendering * - Forgiving API: if middleware doesn't return, original response is used */ import { contextGet, contextSet } from "../context-var.js"; import { safeDecodeURIComponent } from "./url-params.js"; import { fireAndForgetWaitUntil } from "../types/request-scope.js"; import type { CollectedMiddleware, MiddlewareCollectableEntry, MiddlewareContext, MiddlewareEntry, MiddlewareFn, ResponseHolder, } from "./middleware-types.js"; import { _getRequestContext } from "../server/request-context.js"; import { isAutoGeneratedRouteName } from "../route-name.js"; import { appendMetric, createMetricsStore } from "./metrics.js"; import { stripInternalParams } from "./handler-context.js"; import { isWebSocketUpgradeResponse } from "../response-utils.js"; // Re-export types and cookie utilities for backward compatibility export type { CookieOptions, CollectedMiddleware, MiddlewareCollectableEntry, MiddlewareContext, MiddlewareEntry, MiddlewareFn, ResponseHolder, } from "./middleware-types.js"; export { parseCookies, serializeCookie } from "./middleware-cookies.js"; const MIDDLEWARE_METRIC_DEPTH = 1; /** Ignore post-next() durations below this threshold (measurement noise). */ const POST_METRIC_MIN_DURATION_MS = 0.01; function getMiddlewareMetricBase( entry: MiddlewareEntry, ordinal: number, ): string { const handlerName = entry.handler.name?.trim(); const scope = entry.pattern ?? "*"; if (handlerName) { return `${handlerName}@${scope}`; } return `${scope}#${ordinal + 1}`; } function getMiddlewareMetricLabel( entry: MiddlewareEntry, ordinal: number, ): string { return `middleware:${getMiddlewareMetricBase(entry, ordinal)}`; } /** * Parse a route pattern into regex and param names * Supports: *, /path, /path/*, /path/:param, /path/:param/* */ export function parsePattern(pattern: string): { regex: RegExp; paramNames: string[]; } { if (pattern === "*") { return { regex: /^.*$/, paramNames: [] }; } const paramNames: string[] = []; let regexStr = "^"; const parts = pattern.split("/").filter(Boolean); for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (part === "*") { // Wildcard - match rest of path regexStr += "(?:/.*)?"; } else if (part.startsWith(":")) { // Param const paramName = part.slice(1); paramNames.push(paramName); regexStr += "/([^/]+)"; } else { // Literal regexStr += "/" + escapeRegex(part); } } // If pattern doesn't end with *, match exact or with trailing segments if (!pattern.endsWith("*")) { regexStr += "/?$"; } else { regexStr += "$"; } return { regex: new RegExp(regexStr), paramNames }; } /** * Escape special regex characters */ function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** * Extract params from a pathname using a pattern's regex and param names. * * Values are URL-decoded so apps see the raw string (e.g. "ivo@example.com") * instead of the percent-encoded form ("ivo%40example.com"). This matches the * contract assumed by ctx.reverse (which re-encodes) and aligns with * Express/React Router/Fastify/Koa. */ export function extractParams( pathname: string, regex: RegExp, paramNames: string[], ): Record { const match = pathname.match(regex); if (!match) return {}; const params: Record = {}; for (let i = 0; i < paramNames.length; i++) { params[paramNames[i]] = safeDecodeURIComponent(match[i + 1] || ""); } return params; } /** * Create middleware context * * Note: The implementation uses runtime values while the interface provides * compile-time type safety. The env/get/set types are resolved at call sites * via conditional types based on TEnv from createRouter(). */ export function createMiddlewareContext( request: Request, env: TEnv, params: Record, variables: Record, responseHolder: ResponseHolder, reverse?: ( name: string, params?: Record, search?: Record, ) => string, ): MiddlewareContext { const url = stripInternalParams(new URL(request.url)); // Track the initial response to detect pre/post-next() phase. // Before next(): responseHolder.response === initialResponse (the stub). // After next(): responseHolder.response is the real downstream response. const initialResponse = responseHolder.response; const isPreNext = () => responseHolder.response === initialResponse; // Delegation strategy for RequestContext (reqCtx): // - res getter: before next() returns shared reqCtx stub; after next() returns // the real downstream response. // - header(): before next() delegates to reqCtx; after next() writes to the // real downstream response. // Cookie operations are handled by the standalone cookies() function which // delegates to the shared RequestContext internally. // The runtime implementation - types are enforced at call sites via MiddlewareContext // Internal helper: resolve the current response (stub before next(), real after). // Not exposed on the public MiddlewareContext type — use ctx.headers instead. const getResponse = (): Response => { if (isPreNext()) { const reqCtx = _getRequestContext(); if (reqCtx) return reqCtx.res; } if (!responseHolder.response) { throw new Error( "Response is not available - responseHolder was not initialized", ); } return responseHolder.response; }; // Capture reqCtx once: the request-scoped platform fields // (originalUrl, executionContext, waitUntil) are immutable per request, // so snapshotting beats re-reading ALS on every access. The lazy getters // below (routeName, theme, setTheme) stay lazy because those can change // during `await next()`. const reqCtx = _getRequestContext(); return { request, url, originalUrl: reqCtx?.originalUrl ?? new URL(request.url), pathname: url.pathname, searchParams: url.searchParams, env: env as MiddlewareContext["env"], params, executionContext: reqCtx?.executionContext, waitUntil: reqCtx ? reqCtx.waitUntil.bind(reqCtx) : fireAndForgetWaitUntil, // Getter: re-derives from request context on each access so that global // middleware sees the matched route name after await next(). get routeName(): MiddlewareContext["routeName"] { const reqCtx = _getRequestContext(); const raw = reqCtx?._routeName; return ( raw && !isAutoGeneratedRouteName(raw) ? raw : undefined ) as MiddlewareContext["routeName"]; }, get headers(): Headers { return getResponse().headers; }, get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as MiddlewareContext["get"], set: ((keyOrVar: any, value: unknown, options?: any) => { contextSet(variables, keyOrVar, value, options); }) as MiddlewareContext["set"], header(name: string, value: string): void { // Before next(): delegate to shared RequestContext stub if (isPreNext()) { const reqCtx = _getRequestContext(); if (reqCtx) { reqCtx.header(name, value); return; } } // After next() or standalone: write to current response if (!responseHolder.response) { throw new Error( "ctx.header() is not available - responseHolder was not initialized", ); } responseHolder.response.headers.set(name, value); }, get theme(): MiddlewareContext["theme"] { return _getRequestContext()?.theme; }, get setTheme(): MiddlewareContext["setTheme"] { return _getRequestContext()?.setTheme; }, setLocationState(entries) { const reqCtx = _getRequestContext(); if (!reqCtx) { throw new Error( "setLocationState() is not available outside a request context", ); } reqCtx.setLocationState(entries); }, reverse: reverse ?? ((name: string) => { throw new Error( `ctx.reverse() is not available - route map was not provided to middleware context`, ); }), debugPerformance(): void { const reqCtx = _getRequestContext(); if (reqCtx) { reqCtx._debugPerformance = true; reqCtx._metricsStore ??= createMetricsStore(true); } }, }; } /** * Match middleware entries against a pathname * Returns entries that match, with extracted params */ export function matchMiddleware( pathname: string, entries: MiddlewareEntry[], ): Array<{ entry: MiddlewareEntry; params: Record }> { const matches: Array<{ entry: MiddlewareEntry; params: Record; }> = []; for (const entry of entries) { // No pattern = matches all (global middleware without pattern) if (!entry.regex) { matches.push({ entry, params: {} }); continue; } // Check if pathname matches if (entry.regex.test(pathname)) { const params = extractParams(pathname, entry.regex, entry.paramNames); matches.push({ entry, params }); } } return matches; } // Set-Cookie is appended; for other headers stubOverridesNonCookie=true // overwrites (chain ran to completion), false fills only missing slots (an // explicit short-circuit Response's own headers win). function mergeStubHeaders( target: Headers, stub: Headers, stubOverridesNonCookie: boolean, ): void { stub.forEach((value, name) => { if (name.toLowerCase() === "set-cookie") { target.append(name, value); } else if (stubOverridesNonCookie || !target.has(name)) { target.set(name, value); } }); } // Set-Cookie is deduped so a nested inner executeMiddleware that already merged // the same reqCtx cookies does not duplicate them; other headers fill if missing. function mergeReqCtxStub( target: Headers, reqCtx: ReturnType, ): void { if (!reqCtx) return; const stubCookies = reqCtx.res.headers.getSetCookie(); if (stubCookies.length > 0) { const existing = new Set(target.getSetCookie()); for (const cookie of stubCookies) { if (!existing.has(cookie)) { target.append("set-cookie", cookie); } } } reqCtx.res.headers.forEach((value, name) => { if (name !== "set-cookie" && !target.has(name)) { target.set(name, value); } }); } /** * Execute middleware chain * * Features: * - `await next()` returns actual Response * - `ctx.headers` available before and after `await next()` * - `ctx.header()` shorthand for setting a single header * - Forgiving: if middleware doesn't return, uses the downstream response * - Short-circuit: return Response to stop chain * - Error catching: try/catch around `next()` works */ export async function executeMiddleware( middlewares: Array<{ entry: MiddlewareEntry; params: Record; }>, request: Request, env: TEnv, variables: Record, finalHandler: () => Promise, reverse?: ( name: string, params?: Record, search?: Record, ) => string, ): Promise { let index = 0; // Create a stub response that's available immediately // This allows middleware to set headers/cookies before calling next() const stubResponse = new Response(null, { status: 200 }); const responseHolder: ResponseHolder = { response: stubResponse }; const next = async (): Promise => { if (index >= middlewares.length) { // End of chain - call actual RSC handler const response = await finalHandler(); const mergedHeaders = new Headers(response.headers); mergeStubHeaders(mergedHeaders, stubResponse.headers, true); mergeReqCtxStub(mergedHeaders, _getRequestContext()); if (isWebSocketUpgradeResponse(response)) { responseHolder.response = response; return response; } // Clone response with merged headers (mutable for post-next() modifications) responseHolder.response = new Response(response.body, { status: response.status, statusText: response.statusText, headers: mergedHeaders, }); return responseHolder.response; } const middlewareOrdinal = index; const { entry, params } = middlewares[index++]; const ctx = createMiddlewareContext( request, env, params, variables, responseHolder, reverse, ); const metricStart = performance.now(); const metricLabel = getMiddlewareMetricLabel(entry, middlewareOrdinal); let middlewareFinished = false; const finishMiddleware = () => { if (!middlewareFinished) { middlewareFinished = true; appendMetric( _getRequestContext()?._metricsStore, `${metricLabel}:pre`, metricStart, performance.now() - metricStart, MIDDLEWARE_METRIC_DEPTH, ); } }; // Track if next() was called and capture its Promise. // Guard against double-calling: a second call would re-enter the // downstream chain and overwrite responseHolder.response. let nextPromise: Promise | null = null; let nextResolvedAt: number | undefined; const wrappedNext = (): Promise => { if (nextPromise) { throw new Error( `[@rangojs/router] Middleware called next() more than once.`, ); } finishMiddleware(); const downstream = next(); nextPromise = downstream.then( (res) => { nextResolvedAt = performance.now(); return res; }, (err) => { nextResolvedAt = performance.now(); throw err; }, ); return nextPromise; }; let result: Response | void; try { result = await entry.handler(ctx, wrappedNext); } catch (error) { // Thrown Response is short-circuit control flow, not an error. // Fall through to the `if (result instanceof Response)` branch below // so stub headers and request-context cookies merge as they do for // an explicit `return new Response(...)`. Real errors propagate. if (error instanceof Response) { result = error; } else { finishMiddleware(); throw error; } } finishMiddleware(); // Record post-next() processing time when middleware did work after // the downstream chain resolved (e.g. adding headers, logging). if (nextResolvedAt !== undefined) { const postDur = performance.now() - nextResolvedAt; if (postDur > POST_METRIC_MIN_DURATION_MS) { appendMetric( _getRequestContext()?._metricsStore, `${metricLabel}:post`, nextResolvedAt, postDur, MIDDLEWARE_METRIC_DEPTH, ); } } // Explicit return takes precedence (middleware short-circuit). // Merge stub headers (from ctx.header before this point) and // RequestContext stub headers (from ctx.setCookie) into the // returned Response so they are not lost. if (result instanceof Response) { if (isWebSocketUpgradeResponse(result)) { responseHolder.response = result; return result; } const mergedHeaders = new Headers(result.headers); mergeStubHeaders(mergedHeaders, stubResponse.headers, false); mergeReqCtxStub(mergedHeaders, _getRequestContext()); const merged = new Response(result.body, { status: result.status, statusText: result.statusText, headers: mergedHeaders, }); responseHolder.response = merged; return merged; } // Warn about unexpected return values (non-Response, non-undefined) // This catches common mistakes like returning strings or objects if (result !== undefined) { const fnName = entry.handler.name || "(anonymous)"; console.warn( `[Middleware] "${fnName}" returned ${typeof result} instead of Response or undefined. ` + `This return value will be ignored. Did you mean to return a Response?`, ); } // If middleware called next(), await it and return the response if (nextPromise) { await nextPromise; return responseHolder.response!; } // Middleware didn't call next() and didn't return a Response - that's an error // (Note: responseHolder.response is the stub, but we require next() or explicit return) const fnName = entry.handler.name || "(anonymous)"; throw new Error( `Middleware must call next() or return a Response. ` + `Function: ${fnName}, Pattern: ${entry.pattern ?? "(all)"} Source: ${import.meta.env.DEV ? entry.handler.toString().slice(0, 200) : "(source hidden in production)"}`, { cause: { url: request.url, fn: entry.handler } }, ); }; await next(); // Use the final response from responseHolder (may have been modified by middleware) const finalResponse = responseHolder.response; if (!finalResponse) { throw new Error("No response generated by middleware chain"); } // Final re-merge: capture any RequestContext stub headers added after the // last merge point (e.g. cookies().set() called after await next()). // The reqCtx stub may have already been partially merged during finalHandler // or early-return paths; only append *new* Set-Cookie entries to avoid dupes. // // Skip for upgrade responses: upgrade headers are semantically immutable and // set-cookie on an upgrade is not meaningful. const reqCtx = _getRequestContext(); if (reqCtx && !isWebSocketUpgradeResponse(finalResponse)) { mergeReqCtxStub(finalResponse.headers, reqCtx); } return finalResponse; } /** * Execute middleware for intercepts (simplified execution) * * Intercepts use a shared stubResponse from the request context. This function: * - Runs middleware in sequence with a simple next() chain * - Returns Response if any middleware short-circuits (returns Response or redirects BEFORE next()) * - Returns null if all middleware calls next() - headers set after next() remain on stubResponse * * @param middlewares - Array of middleware functions * @param request - Original request * @param env - Environment bindings * @param params - Route params * @param variables - Shared variables object * @param stubResponse - Response from request context for collecting headers/cookies */ export async function executeInterceptMiddleware( middlewares: MiddlewareFn[], request: Request, env: TEnv, params: Record, variables: Record, stubResponse: Response, reverse?: ( name: string, params?: Record, search?: Record, ) => string, ): Promise { if (middlewares.length === 0) { return null; } let index = 0; let earlyResponse: Response | null = null; // Use provided stubResponse - headers/cookies set here will be merged by the caller const responseHolder: ResponseHolder = { response: stubResponse }; const next = async (): Promise => { if (index >= middlewares.length || earlyResponse) { return stubResponse; } const middleware = middlewares[index++]; const ctx = createMiddlewareContext( request, env, params, variables, responseHolder, reverse, ); let nextCalled = false; const guardedNext = (): Promise => { if (nextCalled) { throw new Error( `[@rangojs/router] Intercept middleware called next() more than once.`, ); } nextCalled = true; return next(); }; let result: Response | void; try { result = await middleware(ctx, guardedNext); } catch (error) { // Thrown Response is short-circuit control flow, parity with the // explicit-return path below. Real errors propagate. if (error instanceof Response) { result = error; } else { throw error; } } if (result instanceof Response) { earlyResponse = result; return result; } return stubResponse; }; await next(); // Return early response if middleware short-circuited (returned Response BEFORE next()) if (earlyResponse) { // Capture in const for TypeScript narrowing (earlyResponse is `let` which loses narrowing in callbacks) const response: Response = earlyResponse; // Merge any headers/cookies set on stub into the early response let hasStubHeaders = false; stubResponse.headers.forEach(() => { hasStubHeaders = true; }); if (hasStubHeaders) { // Clone and merge headers from stub into early response. // Only fill in missing headers — the returned Response's explicit // headers take precedence, matching executeMiddleware behavior. const mergedHeaders = new Headers(response.headers); mergeStubHeaders(mergedHeaders, stubResponse.headers, false); return new Response(response.body, { status: response.status, statusText: response.statusText, headers: mergedHeaders, }); } return response; } // All middleware completed without short-circuit // Headers/cookies set on stubResponse will be merged into the final response by the caller return null; } /** * Execute middleware chain for loaders (simpler signature) * * Takes an array of MiddlewareFn directly (no entry wrapper needed). * Used for fetchable loader middleware execution. */ export async function executeLoaderMiddleware( middlewares: MiddlewareFn[], request: Request, env: TEnv, params: Record, variables: Record, finalHandler: () => Promise, reverse?: ( name: string, params?: Record, search?: Record, ) => string, ): Promise { if (middlewares.length === 0) { return finalHandler(); } // Convert to the format executeMiddleware expects const middlewareEntries = middlewares.map((handler) => ({ entry: { pattern: null, regex: null, paramNames: [], handler, mountPrefix: null, } as MiddlewareEntry, params, })); return executeMiddleware( middlewareEntries, request, env, variables, finalHandler, reverse, ); } /** * Collect route-level middleware from an entry tree * * Recursively collects middleware from entries and their orphan layouts. * Used by match(), matchPartial(), and previewMatch() to gather route middleware. * * @param entries - Iterable of entries to collect middleware from (typically from traverseBack) * @param params - Route params to attach to each middleware entry * @returns Array of collected middleware with params */ export function collectRouteMiddleware( entries: Iterable, params: Record, ): CollectedMiddleware[] { const result: CollectedMiddleware[] = []; const collect = (entry: MiddlewareCollectableEntry): void => { // Collect entry's own middleware if (entry.middleware && entry.middleware.length > 0) { for (const mw of entry.middleware) { result.push({ handler: mw, params }); } } // Collect middleware from orphan layouts (recursive) if (entry.layout && entry.layout.length > 0) { for (const orphan of entry.layout) { collect(orphan); } } }; for (const entry of entries) { collect(entry); } return result; }