@rangojs/router 0.0.0-experimental.8678bb02 → 0.0.0-experimental.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +126 -38
  2. package/dist/bin/rango.js +130 -47
  3. package/dist/vite/index.js +847 -384
  4. package/dist/vite/index.js.bak +5448 -0
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +5 -5
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/handler-use/SKILL.md +362 -0
  9. package/skills/hooks/SKILL.md +28 -20
  10. package/skills/intercept/SKILL.md +20 -0
  11. package/skills/layout/SKILL.md +22 -0
  12. package/skills/links/SKILL.md +91 -17
  13. package/skills/loader/SKILL.md +35 -2
  14. package/skills/middleware/SKILL.md +34 -3
  15. package/skills/migrate-nextjs/SKILL.md +560 -0
  16. package/skills/migrate-react-router/SKILL.md +765 -0
  17. package/skills/parallel/SKILL.md +59 -0
  18. package/skills/prerender/SKILL.md +110 -68
  19. package/skills/rango/SKILL.md +24 -22
  20. package/skills/response-routes/SKILL.md +8 -0
  21. package/skills/route/SKILL.md +24 -0
  22. package/skills/router-setup/SKILL.md +35 -0
  23. package/skills/streams-and-websockets/SKILL.md +283 -0
  24. package/skills/typesafety/SKILL.md +3 -1
  25. package/src/__internal.ts +1 -1
  26. package/src/browser/app-shell.ts +52 -0
  27. package/src/browser/app-version.ts +14 -0
  28. package/src/browser/navigation-bridge.ts +87 -6
  29. package/src/browser/navigation-client.ts +128 -77
  30. package/src/browser/navigation-store.ts +68 -9
  31. package/src/browser/partial-update.ts +60 -7
  32. package/src/browser/prefetch/cache.ts +129 -21
  33. package/src/browser/prefetch/fetch.ts +156 -18
  34. package/src/browser/prefetch/queue.ts +36 -5
  35. package/src/browser/rango-state.ts +53 -13
  36. package/src/browser/react/Link.tsx +72 -8
  37. package/src/browser/react/NavigationProvider.tsx +57 -11
  38. package/src/browser/react/context.ts +7 -2
  39. package/src/browser/react/use-handle.ts +9 -58
  40. package/src/browser/react/use-navigation.ts +22 -2
  41. package/src/browser/react/use-params.ts +11 -1
  42. package/src/browser/react/use-router.ts +29 -9
  43. package/src/browser/rsc-router.tsx +60 -9
  44. package/src/browser/scroll-restoration.ts +10 -8
  45. package/src/browser/segment-reconciler.ts +36 -14
  46. package/src/browser/server-action-bridge.ts +8 -18
  47. package/src/browser/types.ts +33 -5
  48. package/src/build/generate-manifest.ts +6 -6
  49. package/src/build/generate-route-types.ts +3 -0
  50. package/src/build/route-trie.ts +50 -24
  51. package/src/build/route-types/include-resolution.ts +8 -1
  52. package/src/build/route-types/router-processing.ts +211 -72
  53. package/src/build/route-types/scan-filter.ts +8 -1
  54. package/src/cache/cf/cf-cache-store.ts +5 -7
  55. package/src/client.tsx +84 -230
  56. package/src/deps/browser.ts +0 -1
  57. package/src/handle.ts +40 -0
  58. package/src/index.rsc.ts +6 -1
  59. package/src/index.ts +49 -6
  60. package/src/outlet-context.ts +1 -1
  61. package/src/prerender/store.ts +5 -4
  62. package/src/prerender.ts +138 -77
  63. package/src/response-utils.ts +28 -0
  64. package/src/reverse.ts +27 -2
  65. package/src/route-definition/dsl-helpers.ts +210 -35
  66. package/src/route-definition/helpers-types.ts +61 -14
  67. package/src/route-definition/index.ts +3 -0
  68. package/src/route-definition/redirect.ts +9 -1
  69. package/src/route-definition/resolve-handler-use.ts +155 -0
  70. package/src/route-types.ts +18 -0
  71. package/src/router/content-negotiation.ts +100 -1
  72. package/src/router/handler-context.ts +70 -17
  73. package/src/router/intercept-resolution.ts +9 -4
  74. package/src/router/lazy-includes.ts +6 -6
  75. package/src/router/loader-resolution.ts +153 -21
  76. package/src/router/manifest.ts +22 -13
  77. package/src/router/match-api.ts +127 -192
  78. package/src/router/match-middleware/cache-lookup.ts +28 -8
  79. package/src/router/match-middleware/segment-resolution.ts +53 -0
  80. package/src/router/match-result.ts +82 -4
  81. package/src/router/middleware-types.ts +2 -28
  82. package/src/router/middleware.ts +32 -7
  83. package/src/router/navigation-snapshot.ts +182 -0
  84. package/src/router/pattern-matching.ts +60 -9
  85. package/src/router/prerender-match.ts +110 -10
  86. package/src/router/preview-match.ts +30 -102
  87. package/src/router/request-classification.ts +310 -0
  88. package/src/router/route-snapshot.ts +245 -0
  89. package/src/router/router-interfaces.ts +36 -4
  90. package/src/router/router-options.ts +37 -11
  91. package/src/router/segment-resolution/fresh.ts +70 -5
  92. package/src/router/segment-resolution/revalidation.ts +87 -9
  93. package/src/router/trie-matching.ts +10 -4
  94. package/src/router/url-params.ts +49 -0
  95. package/src/router.ts +54 -7
  96. package/src/rsc/handler.ts +478 -399
  97. package/src/rsc/helpers.ts +69 -41
  98. package/src/rsc/loader-fetch.ts +18 -3
  99. package/src/rsc/manifest-init.ts +5 -1
  100. package/src/rsc/progressive-enhancement.ts +14 -3
  101. package/src/rsc/response-route-handler.ts +14 -1
  102. package/src/rsc/rsc-rendering.ts +15 -2
  103. package/src/rsc/server-action.ts +10 -2
  104. package/src/rsc/ssr-setup.ts +2 -2
  105. package/src/rsc/types.ts +6 -4
  106. package/src/segment-content-promise.ts +67 -0
  107. package/src/segment-loader-promise.ts +122 -0
  108. package/src/segment-system.tsx +11 -61
  109. package/src/server/context.ts +65 -5
  110. package/src/server/handle-store.ts +19 -0
  111. package/src/server/loader-registry.ts +9 -8
  112. package/src/server/request-context.ts +142 -55
  113. package/src/ssr/index.tsx +3 -0
  114. package/src/static-handler.ts +18 -6
  115. package/src/types/cache-types.ts +4 -4
  116. package/src/types/handler-context.ts +17 -43
  117. package/src/types/loader-types.ts +37 -11
  118. package/src/types/request-scope.ts +126 -0
  119. package/src/types/route-entry.ts +12 -1
  120. package/src/types/segments.ts +1 -1
  121. package/src/urls/include-helper.ts +24 -14
  122. package/src/urls/path-helper-types.ts +39 -6
  123. package/src/urls/path-helper.ts +47 -12
  124. package/src/urls/pattern-types.ts +12 -0
  125. package/src/urls/response-types.ts +18 -16
  126. package/src/use-loader.tsx +77 -5
  127. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  128. package/src/vite/discovery/discover-routers.ts +5 -1
  129. package/src/vite/discovery/prerender-collection.ts +128 -74
  130. package/src/vite/discovery/state.ts +13 -4
  131. package/src/vite/index.ts +4 -0
  132. package/src/vite/plugin-types.ts +60 -5
  133. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  134. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  135. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  136. package/src/vite/plugins/expose-id-utils.ts +12 -0
  137. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  138. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  139. package/src/vite/plugins/performance-tracks.ts +64 -206
  140. package/src/vite/plugins/refresh-cmd.ts +88 -26
  141. package/src/vite/rango.ts +40 -18
  142. package/src/vite/router-discovery.ts +237 -37
  143. package/src/vite/utils/banner.ts +1 -1
  144. package/src/vite/utils/package-resolution.ts +1 -1
  145. package/src/vite/utils/prerender-utils.ts +37 -5
  146. package/src/vite/utils/shared-utils.ts +3 -2
  147. package/src/browser/debug-channel.ts +0 -93
@@ -15,14 +15,10 @@ import {
15
15
  setRequestContextParams,
16
16
  requireRequestContext,
17
17
  getRequestContext,
18
+ _getRequestContext,
18
19
  createRequestContext,
19
20
  } from "../server/request-context.js";
20
21
  import * as rscDeps from "@vitejs/plugin-rsc/rsc";
21
- import {
22
- DEBUG_ID_HEADER,
23
- createServerDebugChannel,
24
- } from "../vite/plugins/performance-tracks.js";
25
-
26
22
  import type {
27
23
  RscPayload,
28
24
  CreateRSCHandlerOptions,
@@ -35,6 +31,7 @@ import {
35
31
  interceptRedirectForPartial,
36
32
  buildRouteMiddlewareEntries,
37
33
  } from "./helpers.js";
34
+ import { isWebSocketUpgradeResponse } from "../response-utils.js";
38
35
  import {
39
36
  handleResponseRoute,
40
37
  type ResponseRouteMatch,
@@ -60,6 +57,7 @@ import {
60
57
  getRouterTrie,
61
58
  } from "../route-map-builder.js";
62
59
  import type { HandlerContext } from "./handler-context.js";
60
+ import type { SegmentCacheStore } from "../cache/types.js";
63
61
  import { buildRouterTrieFromUrlpatterns } from "./manifest-init.js";
64
62
  import { handleProgressiveEnhancement } from "./progressive-enhancement.js";
65
63
  import {
@@ -87,6 +85,11 @@ import {
87
85
  mayNeedSSR,
88
86
  SSR_SETUP_VAR,
89
87
  } from "./ssr-setup.js";
88
+ import {
89
+ classifyRequest,
90
+ type RequestPlan,
91
+ type ExecutableRequestPlan,
92
+ } from "../router/request-classification.js";
90
93
 
91
94
  /**
92
95
  * Create an RSC request handler.
@@ -166,10 +169,13 @@ export function createRSCHandler<
166
169
  phase: ErrorPhase,
167
170
  context: Parameters<typeof invokeOnError<TEnv>>[3],
168
171
  ): void {
169
- if (error != null && typeof error === "object") {
170
- const reportedErrors = requireRequestContext()._reportedErrors;
171
- if (reportedErrors.has(error)) return;
172
- reportedErrors.add(error);
172
+ // Guard: abort signal handlers fire asynchronously outside the ALS
173
+ // request scope, so the context may be gone. Skip dedup in that
174
+ // case — the error is from a cancelled stream, not a real failure.
175
+ const reqCtx = _getRequestContext();
176
+ if (error != null && typeof error === "object" && reqCtx) {
177
+ if (reqCtx._reportedErrors.has(error)) return;
178
+ reqCtx._reportedErrors.add(error);
173
179
  }
174
180
  invokeOnError(router.onError, error, phase, context, "RSC");
175
181
  }
@@ -267,10 +273,7 @@ export function createRSCHandler<
267
273
  ...(locationState && { locationState }),
268
274
  },
269
275
  };
270
- const debugChannel = getRequestContext()?._debugChannel;
271
- const rscStream = renderToReadableStream<RscPayload>(redirectPayload, {
272
- ...(debugChannel && { debugChannel }),
273
- });
276
+ const rscStream = renderToReadableStream<RscPayload>(redirectPayload);
274
277
  return createResponseWithMergedHeaders(rscStream, {
275
278
  status: 200,
276
279
  headers: { "content-type": "text/x-component;charset=utf-8" },
@@ -351,7 +354,7 @@ export function createRSCHandler<
351
354
  // Resolve cache store configuration
352
355
  // Priority: options.cache (handler override) > router.cache (router default)
353
356
  // Store is enabled only if: config provided, enabled, and no ?__no_cache query param
354
- let cacheStore = undefined;
357
+ let cacheStore: SegmentCacheStore | undefined;
355
358
  const cacheOption = options.cache ?? router.cache;
356
359
  if (cacheOption && !url.searchParams.has("__no_cache")) {
357
360
  const cacheConfig =
@@ -426,20 +429,6 @@ export function createRSCHandler<
426
429
  requestContext._debugPerformance = true;
427
430
  requestContext._metricsStore = earlyMetricsStore;
428
431
  }
429
- // Dev-only: wire debug channel for React Performance Tracks
430
- if (process.env.NODE_ENV !== "production") {
431
- // Client navigations send the debugId as a header.
432
- // SSR requests have no client — generate one and create the channel directly.
433
- const clientDebugId = request.headers.get(DEBUG_ID_HEADER);
434
- const debugId = clientDebugId || crypto.randomUUID();
435
- const channel = clientDebugId
436
- ? createServerDebugChannel(debugId)
437
- : createServerDebugChannel(debugId);
438
- if (channel) {
439
- requestContext._debugChannel = channel;
440
- console.log("[perf-tracks] debug channel attached for", debugId);
441
- }
442
- }
443
432
  // Wire background error reporting so "use cache" and other subsystems
444
433
  // can surface non-fatal errors through the router's onError callback.
445
434
  requestContext._reportBackgroundError = (
@@ -474,6 +463,9 @@ export function createRSCHandler<
474
463
  // - Server components during rendering
475
464
  // - Error boundaries
476
465
  // - Streaming
466
+ // Store basename on request context (scoped per-request via existing ALS)
467
+ requestContext._basename = router.basename;
468
+
477
469
  return runWithRequestContext(requestContext, async () => {
478
470
  // Core handler logic (wrapped by middleware)
479
471
  const coreHandler = async (): Promise<Response> => {
@@ -543,13 +535,17 @@ export function createRSCHandler<
543
535
  }
544
536
 
545
537
  const fullTiming = timingParts.join(", ");
546
- if (fullTiming) response.headers.set("Server-Timing", fullTiming);
538
+ if (fullTiming && !isWebSocketUpgradeResponse(response)) {
539
+ response.headers.set("Server-Timing", fullTiming);
540
+ }
547
541
 
548
542
  return response;
549
543
  });
550
544
  };
551
545
 
552
- // Core request handling logic (separated for middleware wrapping)
546
+ // Core request handling logic (separated for middleware wrapping).
547
+ // Uses the classify → execute model: classifyRequest produces a RequestPlan,
548
+ // then execution dispatches on the plan mode.
553
549
  async function coreRequestHandler(
554
550
  request: Request,
555
551
  env: TEnv,
@@ -557,71 +553,112 @@ export function createRSCHandler<
557
553
  variables: Record<string, any>,
558
554
  nonce: string | undefined,
559
555
  ): Promise<Response> {
560
- const previewStart = performance.now();
561
- const preview = await router.previewMatch(request, { env });
562
- const previewDur = performance.now() - previewStart;
563
556
  const handlerTiming: string[] = variables.__handlerTiming || [];
564
- handlerTiming.push(`handler-preview-match;dur=${previewDur.toFixed(2)}`);
565
- // Response route short-circuit: skip entire RSC pipeline
566
- if (preview?.responseType && preview.handler) {
567
- const responseOutcome = await withTimeout(
568
- handleResponseRoute(
569
- handlerCtx,
570
- preview as ResponseRouteMatch,
571
- request,
572
- env,
573
- url,
574
- variables,
557
+
558
+ // Debug manifest endpoint: handled before classification since it
559
+ // doesn't need a route match and needs trie access from the closure.
560
+ const isDev = process.env.NODE_ENV !== "production";
561
+ if (
562
+ url.searchParams.has("__debug_manifest") &&
563
+ (isDev || router.allowDebugManifest)
564
+ ) {
565
+ const trie = getRouterTrie(router.id) ?? getRouteTrie();
566
+ const routeManifest = getRequiredRouteMap();
567
+ const { extractAncestryFromTrie } =
568
+ await import("../build/route-trie.js");
569
+ return new Response(
570
+ JSON.stringify(
571
+ {
572
+ routerId: router.id,
573
+ routeManifest,
574
+ routeAncestry: trie ? extractAncestryFromTrie(trie) : {},
575
+ routeTrie: trie,
576
+ precomputedEntries: getPrecomputedEntries(),
577
+ },
578
+ null,
579
+ 2,
575
580
  ),
576
- router.timeouts.renderStartMs,
577
- "render-start",
581
+ {
582
+ headers: { "Content-Type": "application/json" },
583
+ },
578
584
  );
579
- if (responseOutcome.timedOut) {
580
- return handleTimeoutResponse(
581
- request,
582
- env,
583
- url,
584
- "render-start",
585
- responseOutcome.durationMs,
586
- preview?.routeKey,
587
- );
585
+ }
586
+
587
+ // ---- 1. Classify ----
588
+ // classifyRequest may throw RouteNotFoundError for unknown routes.
589
+ // In that case, fall through to a full-render plan so the pipeline
590
+ // can render the 404 page via the existing error handling path.
591
+ const classifyStart = performance.now();
592
+ let plan: RequestPlan<TEnv>;
593
+ try {
594
+ plan = await classifyRequest<TEnv>(request, url, {
595
+ findMatch: router.findMatch,
596
+ routerVersion: version,
597
+ routerId: router.id,
598
+ });
599
+ } catch (error) {
600
+ if (
601
+ error instanceof RouteNotFoundError ||
602
+ (error instanceof Error && error.name === "RouteNotFoundError")
603
+ ) {
604
+ // Let the render path handle 404 — match()/matchPartial() will
605
+ // re-throw RouteNotFoundError and the catch block in
606
+ // executeRenderWithMiddleware renders the not-found page.
607
+ plan = {
608
+ mode: "full-render",
609
+ route: {
610
+ matched: null as any,
611
+ manifestEntry: null as any,
612
+ entries: [],
613
+ routeKey: "",
614
+ localRouteName: "",
615
+ params: {},
616
+ routeMiddleware: [],
617
+ cacheScope: null,
618
+ isPassthrough: false,
619
+ },
620
+ negotiated: false,
621
+ };
622
+ } else {
623
+ throw error;
624
+ }
625
+ }
626
+ const classifyDur = performance.now() - classifyStart;
627
+ handlerTiming.push(`handler-classify;dur=${classifyDur.toFixed(2)}`);
628
+
629
+ // ---- 2. Terminal plans (no execution needed) ----
630
+ if (plan.mode === "redirect") {
631
+ // Redirects are handled by the pipeline (match/matchPartial),
632
+ // but for partial requests we short-circuit with a Flight redirect.
633
+ if (url.searchParams.has("_rsc_partial")) {
634
+ return createRedirectFlightResponse(plan.redirectUrl);
588
635
  }
589
- return responseOutcome.result;
636
+ // Full requests: let the pipeline handle the redirect via match()
637
+ // which returns { redirect: url }. Fall through to full-render.
590
638
  }
591
639
 
592
- // Kick off SSR module loading + stream mode resolution in parallel with
593
- // segment resolution. Placed after the response-route short-circuit so
594
- // response/mime routes never pay for SSR work.
595
- if (mayNeedSSR(request, url)) {
596
- variables[SSR_SETUP_VAR] = startSSRSetup(
597
- handlerCtx,
598
- request,
599
- env,
600
- url,
601
- router.debugPerformance
602
- ? () => requireRequestContext()._metricsStore
603
- : undefined,
640
+ if (plan.mode === "version-mismatch") {
641
+ console.log(
642
+ `[RSC] Version mismatch: client=${url.searchParams.get("_rsc_v")}, server=${version}. Forcing reload.`,
604
643
  );
644
+ return createResponseWithMergedHeaders(null, {
645
+ status: 200,
646
+ headers: {
647
+ "X-RSC-Reload": plan.reloadUrl,
648
+ "content-type": "text/x-component;charset=utf-8",
649
+ },
650
+ });
605
651
  }
606
652
 
607
- const routeReverse = createReverseFunction(getRequiredRouteMap());
608
-
609
- const isAction =
610
- request.headers.has("rsc-action") || url.searchParams.has("_rsc_action");
611
- const isLoaderFetch = url.searchParams.has("_rsc_loader");
612
- const actionId =
613
- request.headers.get("rsc-action") || url.searchParams.get("_rsc_action");
614
-
615
- // Origin guard: reject cross-origin actions, loader fetches, and
616
- // PE form submissions before any execution. Regular page navigations
617
- // (GET without _rsc_loader/_rsc_action) are not affected.
618
- const originPhase: OriginCheckPhase | null = isAction
619
- ? "action"
620
- : isLoaderFetch
621
- ? "loader"
622
- : request.method === "POST"
623
- ? "pe-form"
624
- : null;
653
+ // ---- 3. Origin guard (gate for action/loader/PE modes) ----
654
+ const originPhase: OriginCheckPhase | null =
655
+ plan.mode === "action"
656
+ ? "action"
657
+ : plan.mode === "loader"
658
+ ? "loader"
659
+ : plan.mode === "pe-render"
660
+ ? "pe-form"
661
+ : null;
625
662
  if (originPhase) {
626
663
  const originResult = await checkRequestOrigin(
627
664
  request,
@@ -671,13 +708,33 @@ export function createRSCHandler<
671
708
  }
672
709
  }
673
710
 
674
- // Get handle store from request context
711
+ // ---- 4. Execute ----
712
+ return executeRequest(
713
+ plan as ExecutableRequestPlan<TEnv>,
714
+ request,
715
+ env,
716
+ url,
717
+ variables,
718
+ nonce,
719
+ );
720
+ }
721
+
722
+ // Execute a classified request plan. Dispatches to the appropriate handler
723
+ // based on plan.mode. Lives in the createRSCHandler closure for access to
724
+ // handlerCtx, router, callOnError, etc.
725
+ // Only receives executable plans (version-mismatch is handled above).
726
+ async function executeRequest(
727
+ plan: ExecutableRequestPlan<TEnv>,
728
+ request: Request,
729
+ env: TEnv,
730
+ url: URL,
731
+ variables: Record<string, any>,
732
+ nonce: string | undefined,
733
+ ): Promise<Response> {
734
+ // Common setup
675
735
  const handleStore = requireRequestContext()._handleStore;
676
736
 
677
737
  // Wire up error reporting for late streaming-handle failures
678
- // (LateHandlePushError: handle pushed after stream completion).
679
- // Without this, these errors are only caught by React's error boundary
680
- // and never reach the router's onError callback or telemetry.
681
738
  handleStore.onError = (error: Error) => {
682
739
  const reqCtx = requireRequestContext();
683
740
  callOnError(error, "handler", {
@@ -707,37 +764,106 @@ export function createRSCHandler<
707
764
  };
708
765
 
709
766
  // Set route params early so all execution paths can access ctx.params.
710
- if (preview?.params) {
711
- setRequestContextParams(preview.params, preview.routeKey);
767
+ // Also store the classified snapshot so match/matchPartial can reuse it
768
+ // instead of calling resolveRoute again.
769
+ if (plan.mode !== "redirect") {
770
+ setRequestContextParams(plan.route.params, plan.route.routeKey);
771
+ requireRequestContext()._classifiedRoute = plan.route;
712
772
  }
713
773
 
714
- // Progressive enhancement runs before the normal action/render paths.
715
- // Route middleware wraps the PE re-render so handlers see the same
716
- // context variables regardless of JS/no-JS transport.
717
- const progressiveResult = await handleProgressiveEnhancement(
718
- handlerCtx,
719
- request,
720
- env,
721
- url,
722
- isAction,
723
- handleStore,
724
- nonce,
725
- {
726
- routeMiddleware: preview?.routeMiddleware,
774
+ const routeReverse = createReverseFunction(getRequiredRouteMap());
775
+
776
+ // ---- Response route: skip entire RSC pipeline ----
777
+ if (plan.mode === "response") {
778
+ // Build ResponseRouteMatch from plan fields. handleResponseRoute
779
+ // expects a flat object with params at the top level.
780
+ const responseMatch: ResponseRouteMatch = {
781
+ responseType: plan.responseType,
782
+ handler: plan.handler,
783
+ params: plan.route.params,
784
+ negotiated: plan.negotiated,
785
+ manifestEntry: plan.manifestEntry,
786
+ routeMiddleware: plan.routeMiddleware,
787
+ };
788
+ const responseOutcome = await withTimeout(
789
+ handleResponseRoute(
790
+ handlerCtx,
791
+ responseMatch,
792
+ request,
793
+ env,
794
+ url,
795
+ variables,
796
+ ),
797
+ router.timeouts.renderStartMs,
798
+ "render-start",
799
+ );
800
+ if (responseOutcome.timedOut) {
801
+ return handleTimeoutResponse(
802
+ request,
803
+ env,
804
+ url,
805
+ "render-start",
806
+ responseOutcome.durationMs,
807
+ plan.route.routeKey,
808
+ );
809
+ }
810
+ const response = responseOutcome.result;
811
+ if (plan.negotiated && !isWebSocketUpgradeResponse(response)) {
812
+ response.headers.append("Vary", "Accept");
813
+ }
814
+ return response;
815
+ }
816
+
817
+ // SSR setup: kick off in parallel for modes that need HTML rendering.
818
+ // Placed after response-route short-circuit so response/mime routes
819
+ // never pay for SSR work.
820
+ if (plan.mode !== "loader" && mayNeedSSR(request, url)) {
821
+ variables[SSR_SETUP_VAR] = startSSRSetup(
822
+ handlerCtx,
823
+ request,
824
+ env,
825
+ url,
826
+ router.debugPerformance
827
+ ? () => requireRequestContext()._metricsStore
828
+ : undefined,
829
+ );
830
+ }
831
+
832
+ // ---- Loader fetch ----
833
+ if (plan.mode === "loader") {
834
+ return handleLoaderFetch(
835
+ handlerCtx,
836
+ request,
837
+ env,
838
+ url,
727
839
  variables,
728
- routeReverse,
729
- },
730
- );
731
- if (progressiveResult) {
732
- return progressiveResult;
840
+ plan.route.params,
841
+ );
733
842
  }
734
843
 
735
- // --- Action execution: runs BEFORE route middleware ---
736
- // Route middleware wraps rendering only. For actions, the action runs
737
- // first in the global middleware context, then route middleware wraps
738
- // the revalidation pass (identical to a normal render).
739
- let actionContinuation: ActionContinuation | undefined;
740
- if (isAction && actionId) {
844
+ // ---- Progressive enhancement ----
845
+ if (plan.mode === "pe-render") {
846
+ const peResult = await handleProgressiveEnhancement(
847
+ handlerCtx,
848
+ request,
849
+ env,
850
+ url,
851
+ false, // isAction = false for PE
852
+ handleStore,
853
+ nonce,
854
+ {
855
+ routeMiddleware: plan.route.routeMiddleware,
856
+ variables,
857
+ routeReverse,
858
+ },
859
+ );
860
+ if (peResult) return peResult;
861
+ // PE handler returned null (not a PE form) — fall through to render
862
+ }
863
+
864
+ // ---- Action: execute action, then revalidate wrapped in route middleware ----
865
+ if (plan.mode === "action") {
866
+ let actionContinuation: ActionContinuation | undefined;
741
867
  try {
742
868
  const actionOutcome = await withTimeout(
743
869
  executeServerAction(
@@ -745,7 +871,7 @@ export function createRSCHandler<
745
871
  request,
746
872
  env,
747
873
  url,
748
- actionId,
874
+ plan.actionId,
749
875
  handleStore,
750
876
  ),
751
877
  router.timeouts.actionMs,
@@ -758,8 +884,8 @@ export function createRSCHandler<
758
884
  url,
759
885
  "action",
760
886
  actionOutcome.durationMs,
761
- preview?.routeKey,
762
- actionId,
887
+ plan.route.routeKey,
888
+ plan.actionId,
763
889
  );
764
890
  }
765
891
  const result = actionOutcome.result;
@@ -771,344 +897,297 @@ export function createRSCHandler<
771
897
  request,
772
898
  url,
773
899
  env,
774
- actionId,
900
+ actionId: plan.actionId,
775
901
  handledByBoundary: false,
776
902
  });
777
903
  console.error(`[RSC] Action error:`, error);
778
904
  throw error;
779
905
  }
780
- }
781
906
 
782
- // --- Rendering (action revalidation or navigation) ---
783
- // Route middleware wraps this same code path for both cases.
784
- const renderHandler = async () => {
785
- const response = await coreRequestHandlerInner(
907
+ // Revalidation render wrapped in route middleware.
908
+ // Actions from client-side navigation include _rsc_partial preserve
909
+ // the partial flag so the revalidation returns a Flight stream, not HTML.
910
+ // App-switch is already excluded by classifyRequest (would be full-render).
911
+ const isPartialAction = url.searchParams.has("_rsc_partial");
912
+ return executeRenderWithMiddleware(
913
+ plan.route.routeMiddleware,
914
+ plan.negotiated,
915
+ plan.route.routeKey,
916
+ routeReverse,
786
917
  request,
787
918
  env,
788
919
  url,
789
920
  variables,
790
921
  nonce,
791
- preview?.params,
792
- preview?.routeKey,
793
922
  handleStore,
923
+ isPartialAction,
794
924
  actionContinuation,
795
925
  );
796
- if (preview?.negotiated) {
797
- response.headers.append("Vary", "Accept");
798
- }
799
- return response;
800
- };
801
-
802
- // Wrap the render path (with or without route middleware) in a
803
- // renderStartMs timeout so slow renders are caught before output.
804
- const executeRender = async (): Promise<Response> => {
805
- if (preview?.routeMiddleware && preview.routeMiddleware.length > 0) {
806
- const mwResponse = await executeMiddleware(
807
- buildRouteMiddlewareEntries<TEnv>(preview.routeMiddleware),
808
- request,
809
- env,
810
- variables,
811
- renderHandler,
812
- routeReverse,
813
- );
814
-
815
- if (
816
- url.searchParams.has("_rsc_partial") ||
817
- url.searchParams.has("_rsc_action")
818
- ) {
819
- const intercepted = interceptRedirectForPartial(
820
- mwResponse,
821
- createRedirectFlightResponse,
822
- );
823
- if (intercepted) return intercepted;
824
- }
825
-
826
- return finalizeResponse(mwResponse);
827
- }
926
+ }
828
927
 
829
- // No route middleware, proceed directly
830
- return renderHandler();
831
- };
928
+ // ---- Full render / Partial render (or PE that fell through) ----
929
+ if (plan.mode === "full-render" || plan.mode === "partial-render") {
930
+ const isPartial = plan.mode === "partial-render";
931
+ return executeRenderWithMiddleware(
932
+ plan.route.routeMiddleware,
933
+ plan.negotiated,
934
+ plan.route.routeKey,
935
+ routeReverse,
936
+ request,
937
+ env,
938
+ url,
939
+ variables,
940
+ nonce,
941
+ handleStore,
942
+ isPartial,
943
+ );
944
+ }
832
945
 
833
- const renderOutcome = await withTimeout(
834
- executeRender(),
835
- router.timeouts.renderStartMs,
836
- "render-start",
837
- );
838
- if (renderOutcome.timedOut) {
839
- return handleTimeoutResponse(
946
+ // PE that fell through (handleProgressiveEnhancement returned null)
947
+ // falls back to full render
948
+ if (plan.mode === "pe-render") {
949
+ return executeRenderWithMiddleware(
950
+ plan.route.routeMiddleware,
951
+ false,
952
+ plan.route.routeKey,
953
+ routeReverse,
840
954
  request,
841
955
  env,
842
956
  url,
843
- "render-start",
844
- renderOutcome.durationMs,
845
- preview?.routeKey,
957
+ variables,
958
+ nonce,
959
+ handleStore,
960
+ false,
846
961
  );
847
962
  }
848
- return renderOutcome.result;
963
+
964
+ // Redirect plan that wasn't handled above (full-page redirect — let
965
+ // the pipeline handle it via match() which returns { redirect: url })
966
+ return executeRenderWithMiddleware(
967
+ plan.route.routeMiddleware,
968
+ false,
969
+ plan.route.routeKey,
970
+ routeReverse,
971
+ request,
972
+ env,
973
+ url,
974
+ variables,
975
+ nonce,
976
+ handleStore,
977
+ false,
978
+ );
849
979
  }
850
980
 
851
- // Inner request handler: rendering logic wrapped by route middleware.
852
- // Handles action revalidation (when actionContinuation is present),
853
- // loader fetches, and regular RSC rendering.
854
- async function coreRequestHandlerInner(
981
+ // Shared render execution: wraps handleRscRendering (or revalidateAfterAction)
982
+ // in route middleware and timeout handling. Consolidates the pattern used by
983
+ // action-revalidate, full-render, and partial-render modes.
984
+ async function executeRenderWithMiddleware(
985
+ routeMiddleware: import("../router/middleware-types.js").CollectedMiddleware[],
986
+ negotiated: boolean,
987
+ routeKey: string,
988
+ routeReverse: ReturnType<typeof createReverseFunction>,
855
989
  request: Request,
856
990
  env: TEnv,
857
991
  url: URL,
858
992
  variables: Record<string, any>,
859
993
  nonce: string | undefined,
860
- routeParams?: Record<string, string>,
861
- routeKey?: string,
862
- handleStore?: ReturnType<typeof requireRequestContext>["_handleStore"],
994
+ handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
995
+ isPartial: boolean,
863
996
  actionContinuation?: ActionContinuation,
864
997
  ): Promise<Response> {
865
- const isPartial = url.searchParams.has("_rsc_partial");
866
- const isAction =
867
- request.headers.has("rsc-action") || url.searchParams.has("_rsc_action");
868
-
869
- // Version mismatch detection - client may have stale code after HMR/deployment
870
- // If versions don't match, tell the client to reload
871
- const clientVersion = url.searchParams.get("_rsc_v");
872
- if (version && clientVersion && clientVersion !== version) {
873
- console.log(
874
- `[RSC] Version mismatch: client=${clientVersion}, server=${version}. Forcing reload.`,
875
- );
998
+ const renderHandler = async (): Promise<Response> => {
999
+ try {
1000
+ let response: Response;
1001
+ if (actionContinuation) {
1002
+ response = await revalidateAfterAction(
1003
+ handlerCtx,
1004
+ request,
1005
+ env,
1006
+ url,
1007
+ handleStore,
1008
+ actionContinuation,
1009
+ );
1010
+ } else {
1011
+ response = await handleRscRendering(
1012
+ handlerCtx,
1013
+ request,
1014
+ env,
1015
+ url,
1016
+ isPartial,
1017
+ handleStore,
1018
+ nonce,
1019
+ );
1020
+ }
1021
+ if (negotiated && !isWebSocketUpgradeResponse(response)) {
1022
+ response.headers.append("Vary", "Accept");
1023
+ }
1024
+ return response;
1025
+ } catch (error) {
1026
+ // Check if middleware/handler returned Response
1027
+ if (error instanceof Response) {
1028
+ // During partial (client-side navigation), a 200 Response from a handler
1029
+ // means the route serves raw content (JSON, text, etc.), not JSX.
1030
+ // Signal the browser to hard-navigate so it renders the raw response.
1031
+ if (isPartial && error.status === 200) {
1032
+ console.warn(
1033
+ `[RSC] Route handler at ${url.pathname} returned a Response during client-side navigation. ` +
1034
+ `Falling back to hard navigation. Use data-external on the <Link> to avoid the extra round-trip.`,
1035
+ );
1036
+ return createResponseWithMergedHeaders(null, {
1037
+ status: 200,
1038
+ headers: {
1039
+ "X-RSC-Reload": stripInternalParams(url).toString(),
1040
+ "content-type": "text/x-component;charset=utf-8",
1041
+ },
1042
+ });
1043
+ }
876
1044
 
877
- // For actions, reload current page (referer) if same origin.
878
- // For navigation, load the target URL.
879
- // Validate referer origin to prevent open redirect via crafted header.
880
- let reloadUrl = stripInternalParams(url).toString();
881
- if (isAction) {
882
- const referer = request.headers.get("referer");
883
- if (referer) {
884
- try {
885
- const refererUrl = new URL(referer);
886
- if (refererUrl.origin === url.origin) {
887
- reloadUrl = referer;
888
- }
889
- } catch {
890
- // Malformed referer, fall back to cleanUrl
1045
+ if (isPartial) {
1046
+ const intercepted = interceptRedirectForPartial(
1047
+ error,
1048
+ createRedirectFlightResponse,
1049
+ );
1050
+ if (intercepted) return intercepted;
891
1051
  }
1052
+
1053
+ return error;
892
1054
  }
893
- }
894
1055
 
895
- // Return special response that tells client to reload
896
- return createResponseWithMergedHeaders(null, {
897
- status: 200,
898
- headers: {
899
- "X-RSC-Reload": reloadUrl,
900
- "content-type": "text/x-component;charset=utf-8",
901
- },
902
- });
903
- }
904
- // Debug manifest endpoint: ?__debug_manifest on any route.
905
- // Always available in dev, requires allowDebugManifest option in production.
906
- const isDev = process.env.NODE_ENV !== "production";
907
- if (
908
- url.searchParams.has("__debug_manifest") &&
909
- (isDev || router.allowDebugManifest)
910
- ) {
911
- const trie = getRouterTrie(router.id) ?? getRouteTrie();
912
- const routeManifest = getRequiredRouteMap();
913
- const { extractAncestryFromTrie } =
914
- await import("../build/route-trie.js");
915
- return new Response(
916
- JSON.stringify(
917
- {
918
- routerId: router.id,
919
- routeManifest,
920
- routeAncestry: trie ? extractAncestryFromTrie(trie) : {},
921
- routeTrie: trie,
922
- precomputedEntries: getPrecomputedEntries(),
923
- },
924
- null,
925
- 2,
926
- ),
927
- {
928
- headers: { "Content-Type": "application/json" },
929
- },
930
- );
931
- }
1056
+ // Render 404 page for unmatched routes
1057
+ const isRouteNotFound =
1058
+ error instanceof RouteNotFoundError ||
1059
+ (error instanceof Error && error.name === "RouteNotFoundError");
1060
+ if (isRouteNotFound) {
1061
+ callOnError(error, "routing", {
1062
+ request,
1063
+ url,
1064
+ env,
1065
+ handledByBoundary: true,
1066
+ });
932
1067
 
933
- const store = handleStore ?? requireRequestContext()._handleStore;
1068
+ const notFoundOption = router.notFound;
1069
+ const notFoundComponent =
1070
+ typeof notFoundOption === "function"
1071
+ ? notFoundOption({ pathname: url.pathname })
1072
+ : (notFoundOption ?? createElement("h1", null, "Not Found"));
1073
+
1074
+ const notFoundSegment = {
1075
+ id: "notFound",
1076
+ namespace: "notFound",
1077
+ type: "route" as const,
1078
+ index: 0,
1079
+ component: notFoundComponent,
1080
+ params: {},
1081
+ };
1082
+
1083
+ const payload: RscPayload = {
1084
+ metadata: {
1085
+ pathname: url.pathname,
1086
+ routerId: router.id,
1087
+ basename: router.basename,
1088
+ segments: [notFoundSegment],
1089
+ matched: [],
1090
+ diff: [],
1091
+ isPartial: false,
1092
+ rootLayout: router.rootLayout,
1093
+ handles: handleStore.stream(),
1094
+ version,
1095
+ themeConfig: router.themeConfig,
1096
+ warmupEnabled: router.warmupEnabled,
1097
+ initialTheme: requireRequestContext().theme,
1098
+ },
1099
+ };
934
1100
 
935
- try {
936
- // Route params were already set in coreRequestHandler, but set again
937
- // for callers that enter coreRequestHandlerInner directly.
938
- if (routeParams) {
939
- setRequestContextParams(routeParams, routeKey);
940
- }
1101
+ const rscStream = renderToReadableStream(payload, {
1102
+ onError: (error: unknown) => {
1103
+ callOnError(error, "rendering", { request, url, env });
1104
+ },
1105
+ });
941
1106
 
942
- // ============================================================================
943
- // ACTION REVALIDATION (action already executed, revalidate segments)
944
- // ============================================================================
945
- if (actionContinuation) {
946
- return await revalidateAfterAction(
947
- handlerCtx,
948
- request,
949
- env,
950
- url,
951
- store,
952
- actionContinuation,
953
- );
954
- }
1107
+ const isRscRequest =
1108
+ isPartial ||
1109
+ (!request.headers.get("accept")?.includes("text/html") &&
1110
+ !url.searchParams.has("__html")) ||
1111
+ url.searchParams.has("__rsc");
955
1112
 
956
- // ============================================================================
957
- // LOADER FETCH EXECUTION (data fetching with RSC serialization)
958
- // ============================================================================
959
- const isLoaderRequest = url.searchParams.has("_rsc_loader");
960
- if (isLoaderRequest) {
961
- return handleLoaderFetch(
962
- handlerCtx,
963
- request,
964
- env,
965
- url,
966
- variables,
967
- routeParams,
968
- );
969
- }
1113
+ if (isRscRequest) {
1114
+ return createResponseWithMergedHeaders(rscStream, {
1115
+ status: 404,
1116
+ headers: { "content-type": "text/x-component;charset=utf-8" },
1117
+ });
1118
+ }
970
1119
 
971
- // ============================================================================
972
- // REGULAR RSC RENDERING (Navigation)
973
- // ============================================================================
974
- // Note: Must use "return await" for try/catch to catch async rejections
975
- return await handleRscRendering(
976
- handlerCtx,
977
- request,
978
- env,
979
- url,
980
- isPartial,
981
- store,
982
- nonce,
983
- );
984
- } catch (error) {
985
- // Check if middleware/handler returned Response
986
- if (error instanceof Response) {
987
- // During partial (client-side navigation), a 200 Response from a handler
988
- // means the route serves raw content (JSON, text, etc.), not JSX.
989
- // Signal the browser to hard-navigate so it renders the raw response.
990
- // Only for 200 — redirects (3xx) work already because the browser follows
991
- // them automatically to a URL that serves Flight data.
992
- if (isPartial && error.status === 200) {
993
- console.warn(
994
- `[RSC] Route handler at ${url.pathname} returned a Response during client-side navigation. ` +
995
- `Falling back to hard navigation. Use data-external on the <Link> to avoid the extra round-trip.`,
1120
+ const [ssrModule, streamMode] = await getSSRSetup(
1121
+ handlerCtx,
1122
+ request,
1123
+ env,
1124
+ url,
1125
+ requireRequestContext()._metricsStore,
996
1126
  );
997
- return createResponseWithMergedHeaders(null, {
998
- status: 200,
999
- headers: {
1000
- "X-RSC-Reload": stripInternalParams(url).toString(),
1001
- "content-type": "text/x-component;charset=utf-8",
1002
- },
1127
+ const htmlStream = await ssrModule.renderHTML(rscStream, {
1128
+ nonce,
1129
+ streamMode,
1003
1130
  });
1004
- }
1005
1131
 
1006
- if (isPartial) {
1007
- const intercepted = interceptRedirectForPartial(
1008
- error,
1009
- createRedirectFlightResponse,
1010
- );
1011
- if (intercepted) return intercepted;
1132
+ return createResponseWithMergedHeaders(htmlStream, {
1133
+ status: 404,
1134
+ headers: { "content-type": "text/html;charset=utf-8" },
1135
+ });
1012
1136
  }
1013
1137
 
1014
- return error;
1015
- }
1016
-
1017
- // Render 404 page for unmatched routes
1018
- // Check both instanceof and error.name for cross-bundle compatibility
1019
- const isRouteNotFound =
1020
- error instanceof RouteNotFoundError ||
1021
- (error instanceof Error && error.name === "RouteNotFoundError");
1022
- if (isRouteNotFound) {
1138
+ // Report unhandled errors
1023
1139
  callOnError(error, "routing", {
1024
1140
  request,
1025
1141
  url,
1026
1142
  env,
1027
- handledByBoundary: true, // Handled by notFound component
1028
- });
1029
-
1030
- // Get notFound component from router options or use default
1031
- const notFoundOption = router.notFound;
1032
- const notFoundComponent =
1033
- typeof notFoundOption === "function"
1034
- ? notFoundOption({ pathname: url.pathname })
1035
- : (notFoundOption ?? createElement("h1", null, "Not Found"));
1036
-
1037
- // Create a simple segment for the 404 page
1038
- const notFoundSegment = {
1039
- id: "notFound",
1040
- namespace: "notFound",
1041
- type: "route" as const,
1042
- index: 0,
1043
- component: notFoundComponent,
1044
- params: {},
1045
- };
1046
-
1047
- const payload: RscPayload = {
1048
- metadata: {
1049
- pathname: url.pathname,
1050
- segments: [notFoundSegment],
1051
- matched: [],
1052
- diff: [],
1053
- isPartial: false,
1054
- rootLayout: router.rootLayout,
1055
- handles: store.stream(),
1056
- version,
1057
- themeConfig: router.themeConfig,
1058
- warmupEnabled: router.warmupEnabled,
1059
- initialTheme: requireRequestContext().theme,
1060
- // No routeName for not-found routes
1061
- },
1062
- };
1063
-
1064
- const debugChannel = requireRequestContext()._debugChannel;
1065
- const rscStream = renderToReadableStream(payload, {
1066
- ...(debugChannel && { debugChannel }),
1143
+ handledByBoundary: false,
1067
1144
  });
1145
+ console.error(`[RSC] Error:`, error);
1146
+ throw error;
1147
+ }
1148
+ };
1068
1149
 
1069
- // Determine if this is an RSC request or HTML request.
1070
- // Partial requests are always RSC (see main isRscRequest comment).
1071
- const isRscRequest =
1072
- isPartial ||
1073
- (!request.headers.get("accept")?.includes("text/html") &&
1074
- !url.searchParams.has("__html")) ||
1075
- url.searchParams.has("__rsc");
1076
-
1077
- if (isRscRequest) {
1078
- return createResponseWithMergedHeaders(rscStream, {
1079
- status: 404,
1080
- headers: { "content-type": "text/x-component;charset=utf-8" },
1081
- });
1082
- }
1083
-
1084
- // Delegate to SSR for HTML response (reuse early setup if available)
1085
- const [ssrModule, streamMode] = await getSSRSetup(
1086
- handlerCtx,
1150
+ // Wrap the render path in a renderStartMs timeout
1151
+ const executeRender = async (): Promise<Response> => {
1152
+ if (routeMiddleware.length > 0) {
1153
+ const mwResponse = await executeMiddleware(
1154
+ buildRouteMiddlewareEntries<TEnv>(routeMiddleware),
1087
1155
  request,
1088
1156
  env,
1089
- url,
1090
- requireRequestContext()._metricsStore,
1157
+ variables,
1158
+ renderHandler,
1159
+ routeReverse,
1091
1160
  );
1092
- const htmlStream = await ssrModule.renderHTML(rscStream, {
1093
- nonce,
1094
- streamMode,
1095
- });
1096
1161
 
1097
- return createResponseWithMergedHeaders(htmlStream, {
1098
- status: 404,
1099
- headers: { "content-type": "text/html;charset=utf-8" },
1100
- });
1162
+ if (isPartial || actionContinuation) {
1163
+ const intercepted = interceptRedirectForPartial(
1164
+ mwResponse,
1165
+ createRedirectFlightResponse,
1166
+ );
1167
+ if (intercepted) return intercepted;
1168
+ }
1169
+
1170
+ return finalizeResponse(mwResponse);
1101
1171
  }
1102
1172
 
1103
- // Report unhandled errors
1104
- callOnError(error, "routing", {
1173
+ return renderHandler();
1174
+ };
1175
+
1176
+ const renderOutcome = await withTimeout(
1177
+ executeRender(),
1178
+ router.timeouts.renderStartMs,
1179
+ "render-start",
1180
+ );
1181
+ if (renderOutcome.timedOut) {
1182
+ return handleTimeoutResponse(
1105
1183
  request,
1106
- url,
1107
1184
  env,
1108
- handledByBoundary: false,
1109
- });
1110
- console.error(`[RSC] Error:`, error);
1111
- throw error;
1185
+ url,
1186
+ "render-start",
1187
+ renderOutcome.durationMs,
1188
+ routeKey,
1189
+ );
1112
1190
  }
1191
+ return renderOutcome.result;
1113
1192
  }
1114
1193
  }