///
///
/**
* RSC Request Handler
*
* Main request handler for RSC rendering, server actions, loader fetching,
* and progressive enhancement (no-JS form submissions).
*/
import { createElement } from "react";
import { isRouteNotFoundError } from "../errors.js";
import { matchMiddleware, executeMiddleware } from "../router/middleware.js";
import {
runWithRequestContext,
setRequestContextParams,
requireRequestContext,
getRequestContext,
_getRequestContext,
createRequestContext,
} from "../server/request-context.js";
import * as rscDeps from "@vitejs/plugin-rsc/rsc";
import type {
RscPayload,
CreateRSCHandlerOptions,
LoadSSRModule,
SSRModule,
} from "./types.js";
import {
createResponseWithMergedHeaders,
finalizeResponse,
interceptRedirectForPartial,
buildRouteMiddlewareEntries,
} from "./helpers.js";
import { isWebSocketUpgradeResponse } from "../response-utils.js";
import {
handleResponseRoute,
type ResponseRouteMatch,
} from "./response-route-handler.js";
import { generateNonce, nonce as nonceToken } from "./nonce.js";
import { VERSION } from "@rangojs/router:version";
import type { ErrorPhase } from "../types.js";
import type { RouterRequestInput } from "../router/router-interfaces.js";
import { invokeOnError } from "../router/error-handling.js";
import {
createReverseFunction,
stripInternalParams,
} from "../router/handler-context.js";
import { getRouterContext } from "../router/router-context.js";
import { resolveSink, safeEmit } from "../router/telemetry.js";
import { contextSet } from "../context-var.js";
import {
hasCachedManifest,
getRouteTrie,
getPrecomputedEntries,
waitForManifestReady,
getRouterManifest,
getRouterTrie,
} from "../route-map-builder.js";
import type { HandlerContext } from "./handler-context.js";
import type { SegmentCacheStore } from "../cache/types.js";
import { buildRouterTrieFromUrlpatterns } from "./manifest-init.js";
import { handleProgressiveEnhancement } from "./progressive-enhancement.js";
import {
executeServerAction,
revalidateAfterAction,
type ActionContinuation,
} from "./server-action.js";
import { handleLoaderFetch } from "./loader-fetch.js";
import {
checkRequestOrigin,
ORIGIN_CHECK_PHASE_BY_MODE,
} from "./origin-guard.js";
import { handleRscRendering } from "./rsc-rendering.js";
import {
withTimeout,
RouterTimeoutError,
createDefaultTimeoutResponse,
type TimeoutPhase,
} from "../router/timeout.js";
import {
createMetricsStore,
appendMetric,
buildMetricsTiming,
} from "../router/metrics.js";
import {
startSSRSetup,
getSSRSetup,
mayNeedSSR,
isRscRequest,
SSR_SETUP_VAR,
} from "./ssr-setup.js";
import {
classifyRequest,
type RequestPlan,
type ExecutableRequestPlan,
} from "../router/request-classification.js";
/**
* Create an RSC request handler.
*
* **Recommended:** Use `router.createHandler()` instead for simpler setup:
* ```tsx
* const router = createRouter({ document, urls, nonce: () => true });
* export const fetch = router.createHandler();
* ```
*
* This function is still useful for advanced cases like per-request cache
* configuration (e.g., Cloudflare Workers with ExecutionContext).
*
* @example Basic usage (deps and loadSSRModule have sensible defaults)
* ```tsx
* import { createRSCHandler } from "@rangojs/router/rsc";
* import { router } from "./router.js";
*
* export default createRSCHandler({ router });
* ```
*
* @example With custom deps (advanced)
* ```tsx
* import { createRSCHandler } from "@rangojs/router/rsc";
* import * as rsc from "@vitejs/plugin-rsc/rsc";
* import { router } from "./router.js";
*
* export default createRSCHandler({
* router,
* deps: rsc,
* loadSSRModule: () => import.meta.viteRsc.loadModule("ssr", "index"),
* });
* ```
*/
export function createRSCHandler<
TEnv = unknown,
TRoutes extends Record = Record,
>(options: CreateRSCHandlerOptions) {
const { router, version = VERSION, nonce: nonceProvider } = options;
// Use provided deps or default to @vitejs/plugin-rsc/rsc exports
const deps = options.deps ?? rscDeps;
const {
renderToReadableStream,
decodeReply,
createTemporaryReferenceSet,
loadServerAction,
decodeAction,
decodeFormState,
} = deps;
// Use provided loadSSRModule or default to vite RSC module loader.
// In production the SSR module is stable across requests, so memoize
// the dynamic import to avoid repeated module resolution overhead.
// In dev mode Vite may hot-reload the module, so skip memoization.
const rawLoadSSRModule: LoadSSRModule =
options.loadSSRModule ??
(() => import.meta.viteRsc.loadModule("ssr", "index"));
let _ssrModulePromise: Promise | undefined;
const loadSSRModule: LoadSSRModule =
process.env.NODE_ENV === "production"
? () =>
(_ssrModulePromise ??= rawLoadSSRModule().catch((err) => {
_ssrModulePromise = undefined;
throw err;
}))
: rawLoadSSRModule;
/**
* Per-request error reporter that deduplicates via the ALS request context.
*
* Uses the same _reportedErrors WeakSet as the router layer so errors
* that propagate across layers are only reported once per request.
*/
function callOnError(
error: unknown,
phase: ErrorPhase,
context: Parameters>[3],
): void {
// Guard: abort signal handlers fire asynchronously outside the ALS
// request scope, so the context may be gone. Skip dedup in that
// case — the error is from a cancelled stream, not a real failure.
const reqCtx = _getRequestContext();
if (error != null && typeof error === "object" && reqCtx) {
if (reqCtx._reportedErrors.has(error)) return;
reqCtx._reportedErrors.add(error);
}
invokeOnError(router.onError, error, phase, context, "RSC");
}
function getRequiredRouteMap(): Record {
const routeMap = getRouterManifest(router.id);
if (!routeMap) {
throw new Error(
`Route manifest for router "${router.id}" is not available.`,
);
}
return routeMap;
}
/**
* Handle a timeout by reporting the error, emitting telemetry,
* and returning either the custom onTimeout response or a default 504.
*/
async function handleTimeoutResponse(
request: Request,
env: TEnv,
url: URL,
phase: TimeoutPhase,
durationMs: number,
routeKey?: string,
actionId?: string,
): Promise {
const timeoutError = new RouterTimeoutError(phase, durationMs);
callOnError(timeoutError, phase === "action" ? "action" : "handler", {
request,
url,
env,
routeKey,
actionId,
handledByBoundary: false,
metadata: { timeout: true, phase, durationMs },
});
try {
const routerCtx = getRouterContext();
if (routerCtx?.telemetry) {
safeEmit(resolveSink(routerCtx.telemetry), {
type: "request.timeout" as const,
timestamp: performance.now(),
requestId: routerCtx.requestId,
phase,
pathname: url.pathname,
routeKey,
actionId,
durationMs,
customHandler: !!router.onTimeout,
});
}
} catch {
// Router context may not be available
}
if (router.onTimeout) {
try {
return await router.onTimeout({
phase,
request,
url,
env,
routeKey,
actionId,
durationMs,
});
} catch (e) {
if (process.env.NODE_ENV !== "production") {
console.error("[RSC] onTimeout callback error:", e);
}
return createDefaultTimeoutResponse(phase);
}
}
return createDefaultTimeoutResponse(phase);
}
/**
* Build a 200 Flight response that carries a redirect URL and optional state.
* Used when a partial/action request results in a redirect -- fetch
* auto-follows 3xx so we send the redirect as payload metadata instead.
*/
function createRedirectFlightResponse(
redirectUrl: string,
locationState?: Record,
): Response {
const redirectPayload: RscPayload = {
metadata: {
pathname: redirectUrl,
segments: [],
redirect: { url: redirectUrl },
...(locationState && { locationState }),
},
};
const rscStream = renderToReadableStream(redirectPayload);
return createResponseWithMergedHeaders(rscStream, {
status: 200,
headers: { "content-type": "text/x-component;charset=utf-8" },
});
}
// Bundle shared dependencies for extracted handler functions.
// callOnError reads from ALS so it's inherently per-request scoped.
const handlerCtx: HandlerContext = {
router,
version,
renderToReadableStream,
decodeReply,
createTemporaryReferenceSet,
loadServerAction,
decodeAction,
decodeFormState,
loadSSRModule,
callOnError,
getRequiredRouteMap,
createRedirectFlightResponse,
resolveStreamMode: async (request, env, url) => {
const resolver = router.ssr?.resolveStreaming;
if (!resolver) return "stream";
return resolver({ request, env, url });
},
};
return async function handler(
request: Request,
input: RouterRequestInput = {},
): Promise {
const handlerStart = performance.now();
// Create the metrics store at handler start so handler:total has startTime=0
// and all metrics are relative to the request entry point.
const earlyMetricsStore = router.debugPerformance
? createMetricsStore(true, handlerStart)
: undefined;
const { env = {} as TEnv, vars: initialVars, ctx: executionCtx } = input;
// Connection warmup: return 204 immediately before any processing
if (router?.warmupEnabled && request.method === "HEAD") {
const warmupUrl = new URL(request.url);
if (warmupUrl.searchParams.has("_rsc_warmup")) {
return new Response(null, { status: 204 });
}
}
// Resolve nonce if provider is set
const nonceStart = performance.now();
let nonce: string | undefined;
if (nonceProvider) {
const result = await nonceProvider(request, env);
nonce = result === true ? generateNonce() : result;
}
const nonceDur = performance.now() - nonceStart;
const url = new URL(request.url);
// Match global middleware
const mwMatchStart = performance.now();
const matchedMiddleware = matchMiddleware(url.pathname, router.middleware);
const mwMatchDur = performance.now() - mwMatchStart;
// Shared variables between middleware and route handlers
// Initialize from input.vars if provided (allows pre-seeding from worker entry)
const variables: Record = initialVars
? { ...initialVars }
: {};
// Store nonce via ContextVar token and string key for backward compat
if (nonce) {
contextSet(variables, nonceToken, nonce);
variables.nonce = nonce;
}
// Resolve cache store configuration
// Priority: options.cache (handler override) > router.cache (router default)
// Store is enabled only if: config provided, enabled, and no ?__no_cache query param
let cacheStore: SegmentCacheStore | undefined;
const cacheOption = options.cache ?? router.cache;
if (cacheOption && !url.searchParams.has("__no_cache")) {
const cacheConfig =
typeof cacheOption === "function"
? cacheOption(env, executionCtx)
: cacheOption;
if (cacheConfig.enabled !== false) {
cacheStore = cacheConfig.store;
}
}
// Route manifest is populated at startup via the virtual module
// (virtual:rsc-router/routes-manifest). In build/production, it's inlined
// into the bundle. In dev mode (Node), the discovery plugin populates it
// via setManifestReadyPromise(). In dev mode (Cloudflare), Miniflare runs
// in a separate isolate where module-level state doesn't carry over, so
// we generate inline from the router's urlpatterns.
//
// In multi-router setups (e.g. createHostRouter), each router must have
// its own per-router manifest. We check per-router data first: even if
// the global manifest was set by a different router, this router still
// needs its own trie and manifest for correct matching.
const manifestCacheStart = performance.now();
const hasRouterData = getRouterManifest(router.id) !== undefined;
if (!hasRouterData) {
if (!hasCachedManifest()) {
const readyPromise = waitForManifestReady();
if (readyPromise) {
await readyPromise;
}
}
if (!getRouterManifest(router.id) && router.urlpatterns) {
// Cloudflare dev: generate manifest inline for this router.
// Each router generates its own manifest independently so
// multi-router setups (host routing) work correctly.
await buildRouterTrieFromUrlpatterns(router);
}
if (!getRouterManifest(router.id) && !hasCachedManifest()) {
throw new Error(
'Route manifest not available. Ensure "virtual:rsc-router/routes-manifest" is imported in your entry file.',
);
}
}
// Rebuild the trie when the manifest exists but the per-router trie is
// missing. This happens in dev mode after HMR: the virtual module sets
// the manifest (from fresh gen files) but the trie is intentionally not
// injected to avoid stale discovery-time data. Without the trie, route
// matching falls back to regex iteration which does not handle wildcard
// priority correctly (catch-all patterns match before specific routes).
if (!getRouterTrie(router.id) && router.urlpatterns) {
await buildRouterTrieFromUrlpatterns(router);
}
const manifestCacheDur = performance.now() - manifestCacheStart;
// Create unified request context with all methods
// Includes: stub response, handle store, loader memoization, use(), cookies, headers, cache store
// params starts empty, populated after route matching via setRequestContextParams
const ctxCreateStart = performance.now();
const requestContext = createRequestContext({
env,
request,
url,
variables,
cacheStore,
cacheProfiles: router.cacheProfiles,
executionContext: executionCtx,
themeConfig: router.themeConfig,
});
if (earlyMetricsStore) {
requestContext._debugPerformance = true;
requestContext._metricsStore = earlyMetricsStore;
}
// Wire background error reporting so "use cache" and other subsystems
// can surface non-fatal errors through the router's onError callback.
requestContext._reportBackgroundError = (
error: unknown,
category: string,
) => {
callOnError(error, "cache", {
request,
url,
metadata: { category },
});
};
const ctxCreateDur = performance.now() - ctxCreateStart;
// Accumulate handler-level timing for Server-Timing header
const handlerTiming = [
`handler-nonce;dur=${nonceDur.toFixed(2)}`,
`handler-mw-match;dur=${mwMatchDur.toFixed(2)}`,
`handler-manifest-cache;dur=${manifestCacheDur.toFixed(2)}`,
`handler-ctx-create;dur=${ctxCreateDur.toFixed(2)}`,
];
// Store timing data in variables for downstream access
variables.__handlerTiming = handlerTiming;
variables.__handlerStart = handlerStart;
// Wrap entire request handling in request context
// Makes context available via getRequestContext() throughout:
// - Middleware execution
// - Route handlers and loaders
// - Server components during rendering
// - Error boundaries
// - Streaming
// Store basename on request context (scoped per-request via existing ALS)
requestContext._basename = router.basename;
return runWithRequestContext(requestContext, async () => {
// Core handler logic (wrapped by middleware)
const coreHandler = async (): Promise => {
return coreRequestHandler(request, env, url, variables, nonce);
};
// Execute middleware chain if any, otherwise call core handler directly
let response: Response;
if (matchedMiddleware.length > 0) {
const mwResponse = await executeMiddleware(
matchedMiddleware,
request,
env,
variables,
coreHandler,
createReverseFunction(getRequiredRouteMap()),
);
if (
url.searchParams.has("_rsc_partial") ||
url.searchParams.has("_rsc_action")
) {
const intercepted = interceptRedirectForPartial(
mwResponse,
createRedirectFlightResponse,
);
response = intercepted ?? finalizeResponse(mwResponse);
} else {
response = finalizeResponse(mwResponse);
}
} else {
response = await coreHandler();
}
// Finalize metrics after all middleware (including post-next work)
// has completed so :post spans are captured in the timeline.
// Handler timing parts are always emitted (even without debug metrics)
// so non-debug requests still get bootstrap Server-Timing entries.
const handlerTimingArr: string[] = variables.__handlerTiming || [];
// Preserve any existing Server-Timing set by response routes or middleware
const existingTiming = response.headers.get("Server-Timing");
const timingParts = existingTiming
? [existingTiming, ...handlerTimingArr]
: [...handlerTimingArr];
const metricsStore = requestContext._metricsStore;
if (metricsStore) {
// When the store was created at handler start (earlyMetricsStore),
// handler:total covers the full request. When ctx.debugPerformance()
// created the store mid-request, use its requestStart to avoid a
// negative startTime offset.
const totalStart = earlyMetricsStore
? handlerStart
: metricsStore.requestStart;
appendMetric(
metricsStore,
"handler:total",
totalStart,
performance.now() - totalStart,
);
const metricsTiming = buildMetricsTiming(
request.method,
url.pathname,
metricsStore,
);
if (metricsTiming) timingParts.push(metricsTiming);
}
const fullTiming = timingParts.join(", ");
if (fullTiming && !isWebSocketUpgradeResponse(response)) {
response.headers.set("Server-Timing", fullTiming);
}
return response;
});
};
// Core request handling logic (separated for middleware wrapping).
// Uses the classify → execute model: classifyRequest produces a RequestPlan,
// then execution dispatches on the plan mode.
async function coreRequestHandler(
request: Request,
env: TEnv,
url: URL,
variables: Record,
nonce: string | undefined,
): Promise {
const handlerTiming: string[] = variables.__handlerTiming || [];
// Debug manifest endpoint: handled before classification since it
// doesn't need a route match and needs trie access from the closure.
const isDev = process.env.NODE_ENV !== "production";
if (
url.searchParams.has("__debug_manifest") &&
(isDev || router.allowDebugManifest)
) {
const trie = getRouterTrie(router.id) ?? getRouteTrie();
const routeManifest = getRequiredRouteMap();
const { extractAncestryFromTrie } =
await import("../build/route-trie.js");
return new Response(
JSON.stringify(
{
routerId: router.id,
routeManifest,
routeAncestry: trie ? extractAncestryFromTrie(trie) : {},
routeTrie: trie,
precomputedEntries: getPrecomputedEntries(),
},
null,
2,
),
{
headers: { "Content-Type": "application/json" },
},
);
}
// ---- 1. Classify ----
// classifyRequest may throw RouteNotFoundError for unknown routes.
// In that case, fall through to a full-render plan so the pipeline
// can render the 404 page via the existing error handling path.
const classifyStart = performance.now();
let plan: RequestPlan;
try {
plan = await classifyRequest(request, url, {
findMatch: router.findMatch,
routerVersion: version,
routerId: router.id,
});
} catch (error) {
if (isRouteNotFoundError(error)) {
// Let the render path handle 404 — match()/matchPartial() will
// re-throw RouteNotFoundError and the catch block in
// executeRenderWithMiddleware renders the not-found page.
plan = {
mode: "full-render",
route: {
matched: null as any,
manifestEntry: null as any,
entries: [],
routeKey: "",
localRouteName: "",
params: {},
routeMiddleware: [],
cacheScope: null,
isPassthrough: false,
},
negotiated: false,
};
} else {
throw error;
}
}
const classifyDur = performance.now() - classifyStart;
handlerTiming.push(`handler-classify;dur=${classifyDur.toFixed(2)}`);
// ---- 2. Terminal plans (no execution needed) ----
if (plan.mode === "redirect") {
// Redirects are handled by the pipeline (match/matchPartial),
// but for partial requests we short-circuit with a Flight redirect.
if (url.searchParams.has("_rsc_partial")) {
return createRedirectFlightResponse(plan.redirectUrl);
}
// Full requests: let the pipeline handle the redirect via match()
// which returns { redirect: url }. Fall through to full-render.
}
if (plan.mode === "version-mismatch") {
console.log(
`[RSC] Version mismatch: client=${url.searchParams.get("_rsc_v")}, server=${version}. Forcing reload.`,
);
return createResponseWithMergedHeaders(null, {
status: 200,
headers: {
"X-RSC-Reload": plan.reloadUrl,
"content-type": "text/x-component;charset=utf-8",
},
});
}
// ---- 3. Origin guard (gate for action/loader/PE modes) ----
const originPhase = ORIGIN_CHECK_PHASE_BY_MODE[plan.mode];
if (originPhase) {
const originResult = await checkRequestOrigin(
request,
url,
router.originCheck,
env,
router.id,
originPhase,
);
if (originResult) {
const originError = new Error(
`Origin check rejected: ${request.headers.get("origin") ?? "none"} vs ${request.headers.get("host") ?? "none"}`,
);
originError.name = "OriginCheckError";
callOnError(originError, "origin", {
request,
url,
env,
handledByBoundary: false,
metadata: {
phase: originPhase,
origin: request.headers.get("origin"),
host: request.headers.get("host"),
},
});
try {
const routerCtx = getRouterContext();
if (routerCtx?.telemetry) {
safeEmit(resolveSink(routerCtx.telemetry), {
type: "request.origin-rejected" as const,
timestamp: performance.now(),
requestId: routerCtx.requestId,
method: request.method,
pathname: url.pathname,
phase: originPhase,
origin: request.headers.get("origin"),
host: request.headers.get("host"),
});
}
} catch {
// Router context may not be available
}
return originResult;
}
}
// ---- 4. Execute ----
return executeRequest(
plan as ExecutableRequestPlan,
request,
env,
url,
variables,
nonce,
);
}
// Execute a classified request plan. Dispatches to the appropriate handler
// based on plan.mode. Lives in the createRSCHandler closure for access to
// handlerCtx, router, callOnError, etc.
// Only receives executable plans (version-mismatch is handled above).
async function executeRequest(
plan: ExecutableRequestPlan,
request: Request,
env: TEnv,
url: URL,
variables: Record,
nonce: string | undefined,
): Promise {
// Common setup
const handleStore = requireRequestContext()._handleStore;
// Wire up error reporting for late streaming-handle failures
handleStore.onError = (error: Error) => {
const reqCtx = requireRequestContext();
callOnError(error, "handler", {
request,
url,
routeKey: reqCtx._routeName,
params: reqCtx.params as Record,
handledByBoundary: true,
});
try {
const routerCtx = getRouterContext();
if (routerCtx?.telemetry) {
safeEmit(resolveSink(routerCtx.telemetry), {
type: "handler.error" as const,
timestamp: performance.now(),
requestId: routerCtx.requestId,
error,
handledByBoundary: true,
pathname: url.pathname,
routeKey: reqCtx._routeName,
params: reqCtx.params as Record,
});
}
} catch {
// Router context may not be available (e.g. prerender path)
}
};
// Set route params early so all execution paths can access ctx.params.
// Also store the classified snapshot so match/matchPartial can reuse it
// instead of calling resolveRoute again.
if (plan.mode !== "redirect") {
setRequestContextParams(plan.route.params, plan.route.routeKey);
requireRequestContext()._classifiedRoute = plan.route;
}
const routeReverse = createReverseFunction(getRequiredRouteMap());
// ---- Response route: skip entire RSC pipeline ----
if (plan.mode === "response") {
// Build ResponseRouteMatch from plan fields. handleResponseRoute
// expects a flat object with params at the top level.
const responseMatch: ResponseRouteMatch = {
responseType: plan.responseType,
handler: plan.handler,
params: plan.route.params,
negotiated: plan.negotiated,
manifestEntry: plan.manifestEntry,
routeMiddleware: plan.routeMiddleware,
};
const responseOutcome = await withTimeout(
handleResponseRoute(
handlerCtx,
responseMatch,
request,
env,
url,
variables,
),
router.timeouts.renderStartMs,
"render-start",
);
if (responseOutcome.timedOut) {
return handleTimeoutResponse(
request,
env,
url,
"render-start",
responseOutcome.durationMs,
plan.route.routeKey,
);
}
const response = responseOutcome.result;
if (plan.negotiated && !isWebSocketUpgradeResponse(response)) {
response.headers.append("Vary", "Accept");
}
return response;
}
// SSR setup: kick off in parallel for modes that need HTML rendering.
// Placed after response-route short-circuit so response/mime routes
// never pay for SSR work.
if (plan.mode !== "loader" && mayNeedSSR(request, url)) {
variables[SSR_SETUP_VAR] = startSSRSetup(
handlerCtx,
request,
env,
url,
router.debugPerformance
? () => requireRequestContext()._metricsStore
: undefined,
);
}
// ---- Loader fetch ----
if (plan.mode === "loader") {
return handleLoaderFetch(
handlerCtx,
request,
env,
url,
variables,
plan.route.params,
);
}
// ---- Progressive enhancement ----
if (plan.mode === "pe-render") {
const peResult = await handleProgressiveEnhancement(
handlerCtx,
request,
env,
url,
false, // isAction = false for PE
handleStore,
nonce,
{
routeMiddleware: plan.route.routeMiddleware,
variables,
routeReverse,
},
);
if (peResult) return peResult;
// PE handler returned null (not a PE form) — fall through to render
}
// ---- Action: execute action, then revalidate wrapped in route middleware ----
if (plan.mode === "action") {
let actionContinuation: ActionContinuation | undefined;
try {
const actionOutcome = await withTimeout(
executeServerAction(
handlerCtx,
request,
env,
url,
plan.actionId,
handleStore,
),
router.timeouts.actionMs,
"action",
);
if (actionOutcome.timedOut) {
return handleTimeoutResponse(
request,
env,
url,
"action",
actionOutcome.durationMs,
plan.route.routeKey,
plan.actionId,
);
}
const result = actionOutcome.result;
// Response means redirect or error boundary — done.
if (result instanceof Response) return result;
actionContinuation = result;
} catch (error) {
callOnError(error, "action", {
request,
url,
env,
actionId: plan.actionId,
handledByBoundary: false,
});
console.error(`[RSC] Action error:`, error);
throw error;
}
// Revalidation render wrapped in route middleware.
// Actions from client-side navigation include _rsc_partial — preserve
// the partial flag so the revalidation returns a Flight stream, not HTML.
// App-switch is already excluded by classifyRequest (would be full-render).
const isPartialAction = url.searchParams.has("_rsc_partial");
return executeRenderWithMiddleware(
plan.route.routeMiddleware,
plan.negotiated,
plan.route.routeKey,
routeReverse,
request,
env,
url,
variables,
nonce,
handleStore,
isPartialAction,
actionContinuation,
);
}
// Full render, partial render, fallen-through PE, and full-page redirect all
// render through the same middleware-wrapped path. Only full/partial-render
// carry negotiation + the partial flag; pe/redirect render plainly.
const isPartial = plan.mode === "partial-render";
const negotiated =
plan.mode === "full-render" || plan.mode === "partial-render"
? plan.negotiated
: false;
return executeRenderWithMiddleware(
plan.route.routeMiddleware,
negotiated,
plan.route.routeKey,
routeReverse,
request,
env,
url,
variables,
nonce,
handleStore,
isPartial,
);
}
// Shared render execution: wraps handleRscRendering (or revalidateAfterAction)
// in route middleware and timeout handling. Consolidates the pattern used by
// action-revalidate, full-render, and partial-render modes.
async function executeRenderWithMiddleware(
routeMiddleware: import("../router/middleware-types.js").CollectedMiddleware[],
negotiated: boolean,
routeKey: string,
routeReverse: ReturnType,
request: Request,
env: TEnv,
url: URL,
variables: Record,
nonce: string | undefined,
handleStore: ReturnType["_handleStore"],
isPartial: boolean,
actionContinuation?: ActionContinuation,
): Promise {
const renderHandler = async (): Promise => {
try {
let response: Response;
if (actionContinuation) {
response = await revalidateAfterAction(
handlerCtx,
request,
env,
url,
handleStore,
actionContinuation,
);
} else {
response = await handleRscRendering(
handlerCtx,
request,
env,
url,
isPartial,
handleStore,
nonce,
);
}
if (negotiated && !isWebSocketUpgradeResponse(response)) {
response.headers.append("Vary", "Accept");
}
return response;
} catch (error) {
// Check if middleware/handler returned Response
if (error instanceof Response) {
// During partial (client-side navigation), a 200 Response from a handler
// means the route serves raw content (JSON, text, etc.), not JSX.
// Signal the browser to hard-navigate so it renders the raw response.
if (isPartial && error.status === 200) {
console.warn(
`[RSC] Route handler at ${url.pathname} returned a Response during client-side navigation. ` +
`Falling back to hard navigation. Use data-external on the to avoid the extra round-trip.`,
);
return createResponseWithMergedHeaders(null, {
status: 200,
headers: {
"X-RSC-Reload": stripInternalParams(url).toString(),
"content-type": "text/x-component;charset=utf-8",
},
});
}
if (isPartial) {
const intercepted = interceptRedirectForPartial(
error,
createRedirectFlightResponse,
);
if (intercepted) return intercepted;
}
return error;
}
// Render 404 page for unmatched routes
if (isRouteNotFoundError(error)) {
callOnError(error, "routing", {
request,
url,
env,
handledByBoundary: true,
});
const notFoundOption = router.notFound;
const notFoundComponent =
typeof notFoundOption === "function"
? notFoundOption({ pathname: url.pathname })
: (notFoundOption ?? createElement("h1", null, "Not Found"));
const notFoundSegment = {
id: "notFound",
namespace: "notFound",
type: "route" as const,
index: 0,
component: notFoundComponent,
params: {},
};
const payload: RscPayload = {
metadata: {
pathname: url.pathname,
routerId: router.id,
basename: router.basename,
segments: [notFoundSegment],
matched: [],
diff: [],
isPartial: false,
rootLayout: router.rootLayout,
handles: handleStore.stream(),
version,
themeConfig: router.themeConfig,
warmupEnabled: router.warmupEnabled,
initialTheme: requireRequestContext().theme,
},
};
const rscStream = renderToReadableStream(payload, {
onError: (error: unknown) => {
callOnError(error, "rendering", { request, url, env });
},
});
if (isRscRequest(request, url, isPartial)) {
return createResponseWithMergedHeaders(rscStream, {
status: 404,
headers: { "content-type": "text/x-component;charset=utf-8" },
});
}
const [ssrModule, streamMode] = await getSSRSetup(
handlerCtx,
request,
env,
url,
requireRequestContext()._metricsStore,
);
const htmlStream = await ssrModule.renderHTML(rscStream, {
nonce,
streamMode,
});
return createResponseWithMergedHeaders(htmlStream, {
status: 404,
headers: { "content-type": "text/html;charset=utf-8" },
});
}
// Report unhandled errors
callOnError(error, "routing", {
request,
url,
env,
handledByBoundary: false,
});
console.error(`[RSC] Error:`, error);
throw error;
}
};
// Wrap the render path in a renderStartMs timeout
const executeRender = async (): Promise => {
if (routeMiddleware.length > 0) {
const mwResponse = await executeMiddleware(
buildRouteMiddlewareEntries(routeMiddleware),
request,
env,
variables,
renderHandler,
routeReverse,
);
if (isPartial || actionContinuation) {
const intercepted = interceptRedirectForPartial(
mwResponse,
createRedirectFlightResponse,
);
if (intercepted) return intercepted;
}
return finalizeResponse(mwResponse);
}
return renderHandler();
};
const renderOutcome = await withTimeout(
executeRender(),
router.timeouts.renderStartMs,
"render-start",
);
if (renderOutcome.timedOut) {
return handleTimeoutResponse(
request,
env,
url,
"render-start",
renderOutcome.durationMs,
routeKey,
);
}
return renderOutcome.result;
}
}