@rangojs/router 0.0.0-experimental.a769fbe7 → 0.0.0-experimental.b02a2fec

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