/** * Router Handler Context * * Creates the handler context object passed to route handlers, middleware, and loaders. */ import type { HandlerContext, InternalHandlerContext } from "../types"; import { _getRequestContext } from "../server/request-context.js"; import { getSearchSchema, isRouteRootScoped } from "../route-map-builder.js"; import { parseSearchParams, serializeSearchParams } from "../search-params.js"; import { contextGet, contextSet, isNonCacheable, type ContextSetOptions, } from "../context-var.js"; import { isInsideCacheScope } from "../server/context.js"; import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js"; import { isAutoGeneratedRouteName } from "../route-name.js"; import { PRERENDER_PASSTHROUGH } from "../prerender.js"; import { substitutePatternParams } from "./substitute-pattern-params.js"; import { fireAndForgetWaitUntil } from "../types/request-scope.js"; /** * Strip internal _rsc* query params from a URL. * Returns a new URL with only user-facing params. */ export function stripInternalParams(url: URL): URL { const clean = new URL(url); for (const key of [...clean.searchParams.keys()]) { if (key.startsWith("_rsc")) { clean.searchParams.delete(key); } } return clean; } /** * Resolve route name with namespace prefix support. * Supports local names (dot-prefixed) and absolute names (global lookup). * * @param rootScoped - Explicit override for root-scope check. When undefined, * falls back to the global scope registry, then to a heuristic. */ function resolveRouteName( name: string, routeMap: Record, currentRoutePrefix?: string, rootScoped?: boolean, ): string | undefined { // 1. Dot-prefixed (".article", ".author.posts") — local resolution only. // Resolves within the current include() scope using the mount prefix. if (name.startsWith(".")) { const lookupName = name.slice(1); if (!currentRoutePrefix) return undefined; // Extract the include prefix from current route name // e.g., "magazine.author" -> prefix is "magazine" const lastDot = currentRoutePrefix.lastIndexOf("."); const prefix = lastDot > 0 ? currentRoutePrefix.substring(0, lastDot) : currentRoutePrefix; // Try prefixed name at current level const prefixedName = `${prefix}.${lookupName}`; if (routeMap[prefixedName] !== undefined) { return routeMap[prefixedName]; } // Walk up parent prefixes for nested includes let currentPrefix = prefix; while (currentPrefix.includes(".")) { const parentDot = currentPrefix.lastIndexOf("."); currentPrefix = currentPrefix.substring(0, parentDot); const parentPrefixedName = `${currentPrefix}.${lookupName}`; if (routeMap[parentPrefixedName] !== undefined) { return routeMap[parentPrefixedName]; } } // Fallback: try bare name at root scope only. // Routes inside { name: "" } mounts are at root scope — their dot-local // names can fall back to bare names (e.g., "sub.detail" reaching "flatIndex"). // Routes inside named mounts (e.g., { name: "magazine" }) are NOT at root // scope — dot-local must not leak into unrelated global names. // // Resolution order: explicit param > scope registry > heuristic. const isRootScoped = rootScoped ?? isRouteRootScoped(currentRoutePrefix) ?? !currentRoutePrefix.includes("."); if (isRootScoped) { if (routeMap[lookupName] !== undefined) { return routeMap[lookupName]; } } return undefined; } // 2. Unprefixed ("magazine.index", "blog.post") — global resolution only. // Direct lookup in the full named-routes map. return routeMap[name]; } function createPrerenderPassthroughFn( build: boolean, isPassthroughRoute: boolean, ): () => typeof PRERENDER_PASSTHROUGH { return () => { if (!build) { throw new Error( "ctx.passthrough() can only be called during build-time prerendering.", ); } if (!isPassthroughRoute) { throw new Error( "ctx.passthrough() is only available on routes wrapped with " + "Passthrough(). Remove the passthrough() call or wrap the " + "Prerender definition with Passthrough(prerenderDef, liveHandler).", ); } return PRERENDER_PASSTHROUGH; }; } /** * Create a reverse function for URL generation from route names. * Used by both HandlerContext and MiddlewareContext. * * When currentParams is provided, those params are used as defaults for URL * generation. This enables auto-filling mount params from include() prefixes: * inner handlers can call ctx.reverse(".sibling") without explicitly passing * params that are already known from the current URL match. * Explicitly passed hrefParams take priority over currentParams. */ export function createReverseFunction( routeMap: Record, currentRoutePrefix?: string, currentParams?: Record, rootScoped?: boolean, ): ( name: string, hrefParams?: Record, search?: Record, ) => string { return (name, hrefParams, search) => { // Resolve route name with namespace support const pattern = resolveRouteName( name, routeMap, currentRoutePrefix, rootScoped, ); if (pattern === undefined) { throw new Error( `Unknown route: "${name}"${currentRoutePrefix ? ` (current route: ${currentRoutePrefix})` : ""}`, ); } // Merge current request params as defaults, explicit params override const effectiveParams = currentParams ? { ...currentParams, ...hrefParams } : hrefParams; let result = effectiveParams ? substitutePatternParams(pattern, effectiveParams, name) : pattern; // Append search params as query string if (search) { const qs = serializeSearchParams(search); if (qs) result += `?${qs}`; } return result; }; } /** * Create HandlerContext with typed env/var/get/set */ export function createHandlerContext( params: Record, request: Request, searchParams: URLSearchParams, pathname: string, url: URL, bindings: TEnv = {} as TEnv, routeMap: Record = {}, routeName?: string, responseType?: string, isPassthroughRoute: boolean = false, ): InternalHandlerContext { // Get variables from request context - this is the unified context // shared between middleware and route handlers const requestContext = _getRequestContext(); const variables: any = requestContext?._variables ?? {}; // If route has a search schema, parse URLSearchParams into typed object const searchSchema = routeName ? getSearchSchema(routeName) : undefined; const resolvedSearchParams = searchSchema ? parseSearchParams(searchParams, searchSchema) : searchParams; // Get stub response from request context for setting headers const stubResponse = requestContext?.res ?? new Response(null, { status: 200 }); // Guard mutating Headers methods so they throw inside "use cache" or cache() scope. // Uses lazy `ctx` reference (assigned below) — only the specific handler ctx // is stamped by cache-runtime, not the shared request context. const MUTATING_HEADERS_METHODS = new Set(["set", "append", "delete"]); let ctx: InternalHandlerContext; const guardedHeaders = new Proxy(stubResponse.headers, { get(target, prop, receiver) { const value = Reflect.get(target, prop, receiver); if (typeof value === "function") { if (MUTATING_HEADERS_METHODS.has(prop as string)) { return (...args: any[]) => { assertNotInsideCacheExec(ctx, "headers"); if (isInsideCacheScope()) { throw new Error( `ctx.headers.${String(prop)}() cannot be called inside a cache() boundary. ` + `On cache hit the handler is skipped, so this side effect would be lost. ` + `Move header mutations to a middleware or layout outside the cache() scope.`, ); } return value.apply(target, args); }; } return value.bind(target); } return value; }, }); ctx = { params, build: false, dev: false, request, searchParams, search: searchSchema ? resolvedSearchParams : {}, pathname, url, originalUrl: requestContext?.originalUrl ?? new URL(request.url), env: bindings, waitUntil: requestContext ? requestContext.waitUntil.bind(requestContext) : fireAndForgetWaitUntil, executionContext: requestContext?.executionContext, _variables: variables, get: ((keyOrVar: any) => { // Read-time guard: non-cacheable var inside cache() → throw. // Works for both ContextVar tokens and string keys. if (isNonCacheable(variables, keyOrVar) && isInsideCacheScope()) { throw new Error( `ctx.get() for a non-cacheable variable cannot be called inside a cache() boundary. ` + `The variable was created with { cache: false } or set with { cache: false }, ` + `and its value would be stale on cache hit. Move the read outside the cached scope.`, ); } return contextGet(variables, keyOrVar); }) as HandlerContext["get"], set: ((keyOrVar: any, value: any, options?: ContextSetOptions) => { assertNotInsideCacheExec(ctx, "set"); // Write is dumb: store value + non-cacheable metadata. // Enforcement happens at read time via ctx.get(). contextSet(variables, keyOrVar, value, options); }) as HandlerContext["set"], res: stubResponse, // Stub response for setting headers headers: guardedHeaders, // Guarded shorthand for res.headers // Placeholder use() - will be replaced with actual implementation during request use: () => { throw new Error("ctx.use() called before loaders were initialized"); }, // Theme support (when enabled via router config) theme: requestContext?.theme, setTheme: requestContext?.setTheme, // Location state support (delegates to request context) setLocationState(entries) { if (!requestContext) { throw new Error( "setLocationState() is not available outside a request context", ); } requestContext.setLocationState(entries); }, routeName: (routeName && !isAutoGeneratedRouteName(routeName) ? routeName : undefined) as HandlerContext["routeName"], // Scoped reverse for URL generation (auto-fills current request params). // Resolve rootScoped eagerly so the reverse function is self-contained // and does not depend on the global rootScopeRoutes registry at call time. reverse: createReverseFunction( routeMap, routeName, params, routeName ? isRouteRootScoped(routeName) : undefined, ), passthrough: createPrerenderPassthroughFn(false, isPassthroughRoute), _responseType: responseType, _routeName: routeName, }; // Brand with taint symbol so "use cache" excludes ctx from cache keys (ctx as any)[NOCACHE_SYMBOL] = true; return ctx; } /** * Create a PrerenderContext for Prerender() handlers at build time. * * Returns an InternalHandlerContext where params, pathname, url, searchParams, * search, reverse, and use(handle) work. Request-time properties * (request, env, headers, cookies, get, set, res) throw with a clear error. */ export function createPrerenderContext( params: Record, pathname: string, routeMap: Record, routeName?: string, buildVars?: Record, isPassthroughRoute?: boolean, buildEnv?: TEnv, devMode?: boolean, ): InternalHandlerContext { const syntheticUrl = new URL(`http://prerender${pathname}`); const variables = buildVars ?? {}; function throwUnavailable(prop: string): never { throw new Error( `Property "${prop}" is not available during pre-rendering. ` + `Fetch data directly in the handler or use a passthrough prerender handler.`, ); } return { params, build: true, dev: devMode ?? false, get request(): Request { return throwUnavailable("request"); }, searchParams: syntheticUrl.searchParams, search: {}, pathname, url: syntheticUrl, originalUrl: syntheticUrl, get env(): TEnv { if (buildEnv !== undefined) return buildEnv; throw new Error( "ctx.env is not available during pre-rendering. " + "Configure buildEnv in your rango() plugin options to enable build-time env access.", ); }, // Build-time prerender has no live request. waitUntil is a true no-op // (running fn() here would fire side effects during build, which is // incorrect — these are meant to outlive the live response). // executionContext is absent for the same reason. waitUntil: () => {}, executionContext: undefined, _variables: variables, get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any, set: ((keyOrVar: any, value: any) => { contextSet(variables, keyOrVar, value); }) as any, get res(): Response { return throwUnavailable("res"); }, get headers(): Headers { return throwUnavailable("headers"); }, // Placeholder use() - replaced by setupBuildUse use: () => { throw new Error("ctx.use() called before build context was initialized"); }, theme: undefined, setTheme: undefined, routeName: (routeName && !isAutoGeneratedRouteName(routeName) ? routeName : undefined) as HandlerContext["routeName"], setLocationState: () => { throwUnavailable("setLocationState"); }, reverse: createReverseFunction( routeMap, routeName, params, routeName ? isRouteRootScoped(routeName) : undefined, ), passthrough: createPrerenderPassthroughFn( true, isPassthroughRoute === true, ), _routeName: routeName, } as InternalHandlerContext; } /** * Create a StaticContext for Static() handlers at build time. * * Returns an InternalHandlerContext where only reverse and use(handle) work. * Static handlers have no URL, no params, no pathname — everything else throws. */ export function createStaticContext( routeMap: Record, routeName?: string, buildEnv?: TEnv, devMode?: boolean, ): InternalHandlerContext { const variables: Record = {}; function throwUnavailable(prop: string): never { throw new Error( `Property "${prop}" is not available in Static() handlers. ` + `Static handlers render content without request context.`, ); } return { get params(): any { return throwUnavailable("params"); }, build: true, dev: devMode ?? false, get request(): Request { return throwUnavailable("request"); }, get searchParams(): URLSearchParams { return throwUnavailable("searchParams"); }, get search(): any { return throwUnavailable("search"); }, get pathname(): string { return throwUnavailable("pathname"); }, get url(): URL { return throwUnavailable("url"); }, get originalUrl(): URL { return throwUnavailable("originalUrl"); }, get env(): TEnv { if (buildEnv !== undefined) return buildEnv; throw new Error( "ctx.env is not available in Static() handlers. " + "Configure buildEnv in your rango() plugin options to enable build-time env access.", ); }, // Static() handlers have no live request. waitUntil is a true no-op // (running fn() here would fire side effects during build, which is // incorrect). executionContext is absent for the same reason. waitUntil: () => {}, executionContext: undefined, _variables: variables, get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any, set: ((keyOrVar: any, value: any) => { contextSet(variables, keyOrVar, value); }) as any, get res(): Response { return throwUnavailable("res"); }, get headers(): Headers { return throwUnavailable("headers"); }, // Placeholder use() - replaced by setupBuildUse use: () => { throw new Error("ctx.use() called before build context was initialized"); }, theme: undefined, setTheme: undefined, routeName: (routeName && !isAutoGeneratedRouteName(routeName) ? routeName : undefined) as HandlerContext["routeName"], setLocationState: () => { throwUnavailable("setLocationState"); }, reverse: createReverseFunction( routeMap, routeName, undefined, routeName ? isRouteRootScoped(routeName) : undefined, ), _routeName: routeName, } as InternalHandlerContext; }