///
/**
* 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;
}