@rangojs/router 0.0.0-experimental.fa8a383a → 0.0.0-experimental.fad716ff

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