/** * Request Context - AsyncLocalStorage for passing request-scoped data throughout rendering * * This is the unified context used everywhere: * - Middleware execution * - Route handlers and loaders * - Server components during rendering * - Error boundaries and streaming * * Available via getRequestContext() anywhere in the request lifecycle. */ import { AsyncLocalStorage } from "node:async_hooks"; import type { CookieOptions } from "../router/middleware.js"; import type { LoaderDefinition, LoaderContext } from "../types.js"; import type { ScopedReverseFunction } from "../reverse.js"; import type { DefaultEnv, DefaultReverseRouteMap, DefaultRouteName, } from "../types/global-namespace.js"; import type { Handle } from "../handle.js"; import { type ContextVar, contextGet, contextSet, isNonCacheable, } from "../context-var.js"; import { createHandleStore, buildHandleSnapshot, type HandleStore, type HandleData, } from "./handle-store.js"; import { isHandle } from "../handle.js"; import { track, type MetricsStore } from "./context.js"; import { getFetchableLoader } from "./fetchable-loader-store.js"; import type { SegmentCacheStore } from "../cache/types.js"; import type { Theme, ResolvedThemeConfig } from "../theme/types.js"; import type { ExecutionContext, RequestScope } from "../types/request-scope.js"; import { fireAndForgetWaitUntil } from "../types/request-scope.js"; import { THEME_COOKIE } from "../theme/constants.js"; import type { LocationStateEntry } from "../browser/react/location-state-shared.js"; import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js"; import { isInsideCacheScope } from "./context.js"; import { createReverseFunction, stripInternalParams, } from "../router/handler-context.js"; import { getGlobalRouteMap, isRouteRootScoped } from "../route-map-builder.js"; import { invariant } from "../errors.js"; import { isAutoGeneratedRouteName } from "../route-name.js"; /** * Unified request context available via getRequestContext() * * This is the same context passed to middleware and handlers. * Use this when you need access to request data outside of route handlers. */ export interface RequestContext< TEnv = DefaultEnv, TParams = Record, > extends RequestScope { /** @internal Shared variable backing store for ctx.get()/ctx.set(). */ _variables: Record; /** Get a variable set by middleware */ get: { (contextVar: ContextVar): T | undefined; (key: K): any; }; /** Set a variable (shared with middleware and handlers) */ set: { ( contextVar: ContextVar, value: T, options?: { cache?: boolean }, ): void; (key: K, value: any, options?: { cache?: boolean }): void; }; /** * Route params (populated after route matching) * Initially empty, then set to matched params */ params: TParams; /** @internal Stub response for collecting headers/cookies. Use ctx.headers or ctx.header() instead. */ readonly res: Response; /** @internal Get a cookie value (effective: request + response mutations). Use cookies().get() instead. */ cookie(name: string): string | undefined; /** @internal Get all cookies (effective merged view). Use cookies().getAll() instead. */ cookies(): Record; /** @internal Set a cookie on the response. Use cookies().set() instead. */ setCookie(name: string, value: string, options?: CookieOptions): void; /** @internal Delete a cookie. Use cookies().delete() instead. */ deleteCookie( name: string, options?: Pick, ): void; /** Set a response header */ header(name: string, value: string): void; /** Set the response status code */ setStatus(status: number): void; /** @internal Set status bypassing cache-exec guard (for framework error handling) */ _setStatus(status: number): void; /** * Access loader data or push handle data. * * For loaders: Returns a promise that resolves to the loader data. * Loaders are executed in parallel and memoized per request. * * For handles: Returns a push function to add data for this segment. * Handle data accumulates across all matched route segments. * * @example * ```typescript * // Loader usage * const cart = await ctx.use(CartLoader); * * // Handle usage * const push = ctx.use(Breadcrumbs); * push({ label: "Shop", href: "/shop" }); * ``` */ use: { ( loader: LoaderDefinition, ): Promise; ( handle: Handle, ): (data: TData | Promise | (() => Promise)) => void; }; /** HTTP method (GET, POST, PUT, PATCH, DELETE, etc.) */ method: string; /** @internal Handle store for tracking handle data across segments */ _handleStore: HandleStore; /** @internal Cache store for segment caching (optional, used by CacheScope) */ _cacheStore?: SegmentCacheStore; /** @internal Cache profiles for "use cache" profile resolution (per-router) */ _cacheProfiles?: Record< string, import("../cache/profile-registry.js").CacheProfile >; /** * Register a callback to run when the response is created. * Callbacks are sync and receive the response. They can: * - Inspect response status/headers * - Return a modified response * - Schedule async work via waitUntil * * @example * ```typescript * ctx.onResponse((res) => { * if (res.status === 200) { * ctx.waitUntil(async () => await cacheIt()); * } * return res; * }); * ``` */ onResponse(callback: (response: Response) => Response): void; /** @internal Registered onResponse callbacks */ _onResponseCallbacks: Array<(response: Response) => Response>; /** * Current theme setting (only available when theme is enabled in router config) * * Returns the theme value from the cookie, or the default theme if not set. * This is the user's preference ("light", "dark", or "system"), not the resolved value. * * @example * ```typescript * route("settings", (ctx) => { * const currentTheme = ctx.theme; // "light" | "dark" | "system" | undefined * return ; * }); * ``` */ theme?: Theme; /** * Set the theme (only available when theme is enabled in router config) * * Sets a cookie with the new theme value. The change takes effect on the next request. * * @example * ```typescript * route("settings", (ctx) => { * if (ctx.method === "POST") { * const formData = await ctx.request.formData(); * const newTheme = formData.get("theme") as Theme; * ctx.setTheme(newTheme); * } * return ; * }); * ``` */ setTheme?: (theme: Theme) => void; /** @internal Theme configuration (null if theme not enabled) */ _themeConfig?: ResolvedThemeConfig | null; /** * Attach location state entries to the current response. * * For partial (SPA) requests, the state is included in the RSC payload * metadata and merged into history.pushState on the client. For redirect * responses, the state travels through the redirect payload so the target * page can read it via useLocationState. * * Multiple calls accumulate entries. * * @example * ```typescript * ctx.setLocationState(Flash({ text: "Item saved!" })); * ``` */ setLocationState(entries: LocationStateEntry | LocationStateEntry[]): void; /** @internal Accumulated location state entries */ _locationState?: LocationStateEntry[]; /** * The matched route name, if the route has an explicit name. * Undefined before route matching or for unnamed routes. * Includes the namespace prefix from include() (e.g., "blog.post"). */ routeName?: DefaultRouteName; /** * Generate URLs from route names. * Uses the global route map. After route matching, scoped (`.name`) resolution * works within the matched include() scope. */ reverse: ScopedReverseFunction< Record, DefaultReverseRouteMap >; /** @internal Route name from route matching, used for scoped reverse resolution */ _routeName?: string; /** @internal Previous route key (from the navigation source), used for revalidation */ _prevRouteKey?: string; /** * @internal Render barrier for experimental `rendered()` API. * Resolves when all non-loader segments have settled and handle data * is available. Used by DSL loaders that call `ctx.rendered()`. */ _renderBarrier: Promise; /** * @internal Resolve the render barrier. Accepts resolved segments, filters * out loaders, and captures non-loader segment IDs as the handle ordering. * Called after segment resolution (fresh) or handle replay (cache/prerender). */ _resolveRenderBarrier: ( segments: Array<{ type: string; id: string }>, ) => void; /** * @internal Segment order at barrier resolution time, used by loader * ctx.use(handle) to collect handle data in correct order. */ _renderBarrierSegmentOrder?: string[]; /** * @internal Set to true when the matched entry tree contains any `loading()` * entries (streaming). Used by rendered() to fail fast. */ _treeHasStreaming?: boolean; /** * @internal Loader IDs that have called rendered() and are waiting for the * barrier. Used to detect deadlocks when a handler tries to await the same * loader via ctx.use(Loader). */ _renderBarrierWaiters?: Set; /** * @internal Loader IDs that handlers have started awaiting via ctx.use(). * Used for bidirectional deadlock detection: if a loader later calls * rendered() and a handler already awaits it, we can detect the deadlock. */ _handlerLoaderDeps?: Set; /** * @internal Cached HandleData snapshot built at barrier resolution time. * Avoids rebuilding the snapshot on every loader ctx.use(handle) call. */ _renderBarrierHandleSnapshot?: HandleData; /** @internal Per-request error dedup set for onError reporting */ _reportedErrors: WeakSet; /** * @internal Report a non-fatal background error through the router's * onError callback. Wired by the RSC handler / router during request * creation. Cache-runtime and other subsystems call this to surface * errors without failing the response. */ _reportBackgroundError?: (error: unknown, category: string) => void; /** @internal Per-request debug performance override (set via ctx.debugPerformance()) */ _debugPerformance?: boolean; /** @internal Request-scoped performance metrics store */ _metricsStore?: MetricsStore; /** @internal Router basename for this request (used by redirect()) */ _basename?: string; /** * @internal RouteSnapshot from classifyRequest, reused by match/matchPartial * to avoid a second resolveRoute call. Cleared on HMR invalidation. */ _classifiedRoute?: import("../router/route-snapshot.js").RouteSnapshot; } /** * Public view of RequestContext, without internal methods and fields. * * This is the type exported to library consumers. Internal code should * use the full RequestContext interface directly. */ export type PublicRequestContext< TEnv = DefaultEnv, TParams = Record, > = Omit< RequestContext, | "cookie" | "cookies" | "setCookie" | "deleteCookie" | "_handleStore" | "_cacheStore" | "_cacheProfiles" | "_onResponseCallbacks" | "_themeConfig" | "_locationState" | "_routeName" | "_prevRouteKey" | "_reportedErrors" | "_renderBarrier" | "_resolveRenderBarrier" | "_renderBarrierSegmentOrder" | "_treeHasStreaming" | "_renderBarrierWaiters" | "_handlerLoaderDeps" | "_renderBarrierHandleSnapshot" | "_reportBackgroundError" | "_debugPerformance" | "_metricsStore" | "_basename" | "_setStatus" | "_variables" | "_classifiedRoute" | "res" >; // AsyncLocalStorage instance for request context const requestContextStorage = new AsyncLocalStorage>(); /** * Run a function within a request context * Used by the RSC handler to provide context to server actions */ export function runWithRequestContext( context: RequestContext, fn: () => T, ): T { return requestContextStorage.run(context, fn); } /** * Get the current request context * Throws if called outside of a request context */ export function getRequestContext(): RequestContext { const ctx = requestContextStorage.getStore() as | RequestContext | undefined; invariant( ctx, "getRequestContext() called outside of a request context. " + "This function must be called from within a route handler, loader, middleware, " + "server action, or server component.", ); return ctx; } /** * @internal Get the request context without throwing — for internal code that * may run outside a request context (cache stores, optional handle lookups, etc.) */ export function _getRequestContext(): | RequestContext | undefined { return requestContextStorage.getStore() as RequestContext | undefined; } /** * Update params on the current request context * Called after route matching to populate route params and route name */ export function setRequestContextParams( params: Record, routeName?: string, ): void { const ctx = requestContextStorage.getStore(); if (ctx) { ctx.params = params; if (routeName !== undefined) { ctx._routeName = routeName; ctx.routeName = ( routeName && !isAutoGeneratedRouteName(routeName) ? routeName : undefined ) as DefaultRouteName | undefined; } // Update reverse with scoped resolution now that route is known ctx.reverse = createReverseFunction( getGlobalRouteMap(), routeName, params, routeName ? isRouteRootScoped(routeName) : undefined, ); } } /** * Store the previous route key on the request context. * Called during partial-match context creation to make the navigation source * route key available for revalidation and intercept evaluation. * @internal */ export function setRequestContextPrevRouteKey( prevRouteKey: string | undefined, ): void { const ctx = requestContextStorage.getStore(); if (ctx && prevRouteKey !== undefined) { ctx._prevRouteKey = prevRouteKey; } } /** * Get accumulated location state entries from the current request context. * Returns undefined if no state has been set. * * @internal Used by the RSC handler to include state in payload metadata. */ export function getLocationState(): LocationStateEntry[] | undefined { const ctx = getRequestContext(); return ctx?._locationState; } /** * Get the current request context, throwing if not available * @deprecated Use getRequestContext() directly — it now throws if outside context */ export function requireRequestContext< TEnv = DefaultEnv, >(): RequestContext { return getRequestContext(); } export type { ExecutionContext }; /** * Options for creating a request context */ export interface CreateRequestContextOptions { env: TEnv; request: Request; url: URL; variables: Record; /** Optional initial response stub headers/status to seed effective cookie reads */ initialResponse?: Response; /** Optional cache store for segment caching (used by CacheScope) */ cacheStore?: SegmentCacheStore; /** Optional cache profiles for "use cache" resolution (per-router) */ cacheProfiles?: Record< string, import("../cache/profile-registry.js").CacheProfile >; /** Optional Cloudflare execution context for waitUntil support */ executionContext?: ExecutionContext; /** Optional theme configuration (enables ctx.theme and ctx.setTheme) */ themeConfig?: ResolvedThemeConfig | null; } /** * Create a full request context with all methods implemented * * This is used by the RSC handler to create the unified context that's: * - Available via getRequestContext() throughout the request * - Passed to middleware as ctx * - Passed to handlers as ctx */ export function createRequestContext( options: CreateRequestContextOptions, ): RequestContext { const { env, request, url, variables, initialResponse, cacheStore, cacheProfiles, executionContext, themeConfig, } = options; const cookieHeader = request.headers.get("Cookie"); let parsedCookies: Record | null = null; // Create stub response for collecting headers/cookies. // All cookie/header mutations go here; cookie reads derive from it. let stubResponse = initialResponse ? new Response(null, { status: initialResponse.status, statusText: initialResponse.statusText, headers: new Headers(initialResponse.headers), }) : new Response(null, { status: 200 }); // Create handle store and loader memoization for this request const handleStore = createHandleStore(); const loaderPromises = new Map>(); // Lazy parse cookies from the original Cookie header const getParsedCookies = (): Record => { if (!parsedCookies) { parsedCookies = parseCookiesFromHeader(cookieHeader); } return parsedCookies; }; // Cached response cookie mutations — invalidated on setCookie/deleteCookie/setTheme let responseCookieCache: Map | null = null; const getResponseCookies = (): Map => { if (!responseCookieCache) { responseCookieCache = parseResponseCookies(stubResponse); } return responseCookieCache; }; const invalidateResponseCookieCache = () => { responseCookieCache = null; }; // Guard: throw if a response-level side effect is called inside a cache() scope. // Uses ALS to detect the scope (set during segment resolution). function assertNotInsideCacheScopeALS(methodName: string): void { if (isInsideCacheScope()) { throw new Error( `ctx.${methodName}() cannot be called inside a cache() boundary. ` + `On cache hit the handler is skipped, so this side effect would be lost. ` + `Move ctx.${methodName}() to a middleware or layout outside the cache() scope.`, ); } } // Effective cookie read: response stub Set-Cookie wins, then original header. // The stub IS the source of truth for same-request mutations. const effectiveCookie = (name: string): string | undefined => { const mutations = getResponseCookies(); if (mutations.has(name)) { const v = mutations.get(name); return v === null ? undefined : v; } return getParsedCookies()[name]; }; // Theme helpers (only used when themeConfig is provided) const getTheme = (): Theme | undefined => { if (!themeConfig) return undefined; // Use overlay-aware read so setTheme() in the same request is reflected const stored = effectiveCookie(themeConfig.storageKey); if (stored) { // Validate stored value if (stored === "system" && themeConfig.enableSystem) { return "system"; } if (themeConfig.themes.includes(stored)) { return stored as Theme; } } return themeConfig.defaultTheme; }; const setTheme = (theme: Theme): void => { if (!themeConfig) return; // Validate theme value if (theme !== "system" && !themeConfig.themes.includes(theme)) { console.warn( `[Theme] Invalid theme value: "${theme}". Valid values: system, ${themeConfig.themes.join(", ")}`, ); return; } // Write to stub — effectiveCookie() will pick it up on next read stubResponse.headers.append( "Set-Cookie", serializeCookieValue(themeConfig.storageKey, theme, { path: THEME_COOKIE.path, maxAge: THEME_COOKIE.maxAge, sameSite: THEME_COOKIE.sameSite, }), ); invalidateResponseCookieCache(); }; // Strip internal _rsc* params so userland sees a clean URL. const cleanUrl = stripInternalParams(url); // Build the context object first (without use), then add use const ctx: RequestContext = { env, request, url: cleanUrl, originalUrl: new URL(request.url), pathname: url.pathname, searchParams: cleanUrl.searchParams, _variables: variables, get: ((keyOrVar: any) => { 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 RequestContext["get"], set: ((keyOrVar: any, value: any, options?: any) => { assertNotInsideCacheExec(ctx, "set"); contextSet(variables, keyOrVar, value, options); }) as RequestContext["set"], params: {} as Record, get res(): Response { return stubResponse; }, set res(_: Response) { throw new Error( "ctx.res is read-only. Use ctx.header() to set response headers, or cookies() for cookie mutations.", ); }, cookie(name: string): string | undefined { return effectiveCookie(name); }, cookies(): Record { const parsed = getParsedCookies(); const mutations = getResponseCookies(); if (mutations.size === 0) return { ...parsed }; // Build result without delete (avoids V8 dictionary-mode de-opt) const deleted = new Set(); for (const [k, v] of mutations) { if (v === null) deleted.add(k); } const result: Record = {}; for (const key of Object.keys(parsed)) { if (!deleted.has(key)) result[key] = parsed[key]; } for (const [k, v] of mutations) { if (v !== null) result[k] = v; } return result; }, setCookie(name: string, value: string, options?: CookieOptions): void { assertNotInsideCacheExec(ctx, "setCookie"); assertNotInsideCacheScopeALS("setCookie"); stubResponse.headers.append( "Set-Cookie", serializeCookieValue(name, value, options), ); invalidateResponseCookieCache(); }, deleteCookie( name: string, options?: Pick, ): void { assertNotInsideCacheExec(ctx, "deleteCookie"); assertNotInsideCacheScopeALS("deleteCookie"); stubResponse.headers.append( "Set-Cookie", serializeCookieValue(name, "", { ...options, maxAge: 0 }), ); invalidateResponseCookieCache(); }, header(name: string, value: string): void { assertNotInsideCacheExec(ctx, "header"); assertNotInsideCacheScopeALS("header"); stubResponse.headers.set(name, value); }, setStatus(status: number): void { assertNotInsideCacheExec(ctx, "setStatus"); assertNotInsideCacheScopeALS("setStatus"); stubResponse = new Response(null, { status, headers: stubResponse.headers, }); }, _setStatus(status: number): void { stubResponse = new Response(null, { status, headers: stubResponse.headers, }); }, // Placeholder - will be replaced below use: null as any, method: request.method, _handleStore: handleStore, _cacheStore: cacheStore, _cacheProfiles: cacheProfiles, waitUntil(fn: () => Promise): void { if (executionContext?.waitUntil) { executionContext.waitUntil(fn()); } else { fireAndForgetWaitUntil(fn); } }, executionContext, _onResponseCallbacks: [], onResponse(callback: (response: Response) => Response): void { assertNotInsideCacheExec(ctx, "onResponse"); assertNotInsideCacheScopeALS("onResponse"); this._onResponseCallbacks.push(callback); }, // Theme properties (only set when themeConfig is provided) get theme() { return themeConfig ? getTheme() : undefined; }, setTheme: themeConfig ? (theme: Theme) => { assertNotInsideCacheExec(ctx, "setTheme"); setTheme(theme); } : undefined, _themeConfig: themeConfig, setLocationState(entries: LocationStateEntry | LocationStateEntry[]): void { assertNotInsideCacheExec(ctx, "setLocationState"); const arr = Array.isArray(entries) ? entries : [entries]; this._locationState = this._locationState ? [...this._locationState, ...arr] : arr; }, _locationState: undefined, _reportedErrors: new WeakSet(), _metricsStore: undefined, // Render barrier: deferred promise resolved after non-loader segments settle. _renderBarrier: null as any, // set below _resolveRenderBarrier: null as any, // set below _renderBarrierSegmentOrder: undefined, reverse: createReverseFunction(getGlobalRouteMap(), undefined, {}), }; // Lazy render barrier: only allocate the Promise when a loader actually // calls rendered(). Requests that don't use rendered() pay zero cost. let barrierResolved = false; let resolveBarrier: (() => void) | undefined; ctx._renderBarrier = null as any; // lazy — created on first access ctx._resolveRenderBarrier = ( segments: Array<{ type: string; id: string }>, ) => { if (barrierResolved) return; barrierResolved = true; const segOrder = segments .filter((s) => s.type !== "loader") .map((s) => s.id); ctx._renderBarrierSegmentOrder = segOrder; // Build and cache handle snapshot so loader ctx.use(handle) calls // don't rebuild it on every invocation. ctx._renderBarrierHandleSnapshot = buildHandleSnapshot( handleStore, segOrder, ); ctx._renderBarrierWaiters = undefined; ctx._handlerLoaderDeps = undefined; if (resolveBarrier) resolveBarrier(); }; Object.defineProperty(ctx, "_renderBarrier", { get() { // Barrier already resolved (cache/prerender hit) or first lazy access. // Either way, replace the getter with a concrete value to avoid // repeated Promise.resolve() allocations on subsequent reads. const p = barrierResolved ? Promise.resolve() : new Promise((resolve) => { resolveBarrier = resolve; }); Object.defineProperty(ctx, "_renderBarrier", { value: p, writable: false, configurable: false, }); return p; }, configurable: true, }); // Now create use() with access to ctx ctx.use = createUseFunction({ handleStore, loaderPromises, getContext: () => ctx, }); // Brand with taint symbol so "use cache" excludes ctx from cache keys (ctx as any)[NOCACHE_SYMBOL] = true; return ctx; } /** * Parse Set-Cookie headers from a response into effective cookie state. * Returns a map of cookie name -> value (string) or name -> null (deleted). * Last-write-wins: later Set-Cookie entries for the same name overwrite earlier ones. * Max-Age=0 is treated as a delete. */ const MAX_AGE_ZERO_RE = /;\s*Max-Age\s*=\s*0/i; function parseResponseCookies(response: Response): Map { const result = new Map(); const setCookies = response.headers.getSetCookie(); for (const header of setCookies) { // First segment before ';' is the name=value pair const semiIdx = header.indexOf(";"); const pair = semiIdx === -1 ? header : header.substring(0, semiIdx); const eqIdx = pair.indexOf("="); if (eqIdx === -1) continue; let name: string; let value: string; try { name = decodeURIComponent(pair.substring(0, eqIdx).trim()); value = decodeURIComponent(pair.substring(eqIdx + 1).trim()); } catch { // Malformed encoding — skip this entry continue; } // Max-Age=0 means the cookie is being deleted const isDeleted = MAX_AGE_ZERO_RE.test(header); result.set(name, isDeleted ? null : value); } return result; } /** * Parse cookies from Cookie header */ function parseCookiesFromHeader( cookieHeader: string | null, ): Record { if (!cookieHeader) return {}; const cookies: Record = {}; const pairs = cookieHeader.split(";"); for (const pair of pairs) { const [name, ...rest] = pair.trim().split("="); if (name) { const raw = rest.join("="); try { cookies[name] = decodeURIComponent(raw); } catch { // Malformed percent-encoded value (e.g. %zz, %2) - fall back to raw value cookies[name] = raw; } } } return cookies; } /** * Serialize a cookie for Set-Cookie header */ function serializeCookieValue( name: string, value: string, options: CookieOptions = {}, ): string { let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`; if (options.domain) cookie += `; Domain=${options.domain}`; if (options.path) cookie += `; Path=${options.path}`; if (options.maxAge !== undefined) cookie += `; Max-Age=${options.maxAge}`; if (options.expires) cookie += `; Expires=${options.expires.toUTCString()}`; if (options.httpOnly) cookie += "; HttpOnly"; if (options.secure) cookie += "; Secure"; if (options.sameSite) cookie += `; SameSite=${options.sameSite}`; return cookie; } /** * Options for creating the use() function */ export interface CreateUseFunctionOptions { handleStore: HandleStore; loaderPromises: Map>; getContext: () => RequestContext; } /** * Create the use() function for loader and handle composition. * * This is the unified implementation used by both RequestContext and HandlerContext. * - For loaders: executes and memoizes loader functions * - For handles: returns a push function to add handle data */ export function createUseFunction( options: CreateUseFunctionOptions, ): RequestContext["use"] { const { handleStore, loaderPromises, getContext } = options; return ((item: LoaderDefinition | Handle) => { // Handle case: return a push function if (isHandle(item)) { const handle = item; const ctx = getContext(); const segmentId = (ctx as any)._currentSegmentId; if (!segmentId) { throw new Error( `Handle "${handle.$$id}" used outside of handler context. ` + `Handles must be used within route/layout handlers.`, ); } // Return a push function bound to this handle and segment return ( dataOrFn: unknown | Promise | (() => Promise), ) => { // If it's a function, call it immediately to get the promise const valueOrPromise = typeof dataOrFn === "function" ? (dataOrFn as () => Promise)() : dataOrFn; // Push directly - promises will be serialized by RSC and streamed handleStore.push(handle.$$id, segmentId, valueOrPromise); }; } // Loader case const loader = item as LoaderDefinition; // Return cached promise if already started if (loaderPromises.has(loader.$$id)) { return loaderPromises.get(loader.$$id); } // Get loader function - either from loader object or fetchable registry let loaderFn = loader.fn; if (!loaderFn) { const fetchable = getFetchableLoader(loader.$$id); if (fetchable) { loaderFn = fetchable.fn; } } if (!loaderFn) { throw new Error( `Loader "${loader.$$id}" has no function. This usually means the loader was defined without "use server" and the function was not included in the build.`, ); } const ctx = getContext(); // Create loader context with recursive use() support const loaderCtx: LoaderContext, TEnv> = { params: ctx.params, routeParams: (ctx.params ?? {}) as Record, request: ctx.request, searchParams: ctx.searchParams, search: (ctx as any).search ?? {}, pathname: ctx.pathname, url: ctx.url, originalUrl: ctx.originalUrl, env: ctx.env as any, waitUntil: ctx.waitUntil.bind(ctx), executionContext: ctx.executionContext, get: ctx.get as any, use: (( dep: LoaderDefinition, ): Promise => { // Recursive call - will start dep loader if not already started return ctx.use(dep); }) as LoaderContext["use"], method: "GET", body: undefined, reverse: createReverseFunction( getGlobalRouteMap(), ctx._routeName, ctx.params as Record, ctx._routeName ? isRouteRootScoped(ctx._routeName) : undefined, ), rendered: () => { throw new Error( `ctx.rendered() is only available in DSL loaders (registered via loader() in urls()). ` + `It cannot be used from request-context loaders or server actions.`, ); }, }; const doneLoader = track(`loader:${loader.$$id}`, 2); const promise = Promise.resolve(loaderFn(loaderCtx)).finally(() => { doneLoader(); }); // Memoize for subsequent calls loaderPromises.set(loader.$$id, promise); return promise; }) as RequestContext["use"]; }