@rangojs/router 0.0.0-experimental.19 → 0.0.0-experimental.1fa245e2

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 (160) hide show
  1. package/{CLAUDE.md → AGENTS.md} +4 -0
  2. package/README.md +122 -30
  3. package/dist/bin/rango.js +245 -63
  4. package/dist/vite/index.js +859 -418
  5. package/package.json +3 -3
  6. package/skills/breadcrumbs/SKILL.md +250 -0
  7. package/skills/cache-guide/SKILL.md +32 -0
  8. package/skills/caching/SKILL.md +49 -8
  9. package/skills/document-cache/SKILL.md +2 -2
  10. package/skills/hooks/SKILL.md +33 -31
  11. package/skills/host-router/SKILL.md +218 -0
  12. package/skills/links/SKILL.md +3 -1
  13. package/skills/loader/SKILL.md +72 -22
  14. package/skills/middleware/SKILL.md +2 -0
  15. package/skills/parallel/SKILL.md +126 -0
  16. package/skills/prerender/SKILL.md +112 -70
  17. package/skills/rango/SKILL.md +0 -1
  18. package/skills/route/SKILL.md +34 -4
  19. package/skills/router-setup/SKILL.md +95 -5
  20. package/skills/typesafety/SKILL.md +35 -23
  21. package/src/__internal.ts +92 -0
  22. package/src/bin/rango.ts +18 -0
  23. package/src/browser/app-version.ts +14 -0
  24. package/src/browser/event-controller.ts +5 -0
  25. package/src/browser/link-interceptor.ts +4 -0
  26. package/src/browser/navigation-bridge.ts +114 -18
  27. package/src/browser/navigation-client.ts +126 -44
  28. package/src/browser/navigation-store.ts +43 -8
  29. package/src/browser/navigation-transaction.ts +11 -9
  30. package/src/browser/partial-update.ts +80 -15
  31. package/src/browser/prefetch/cache.ts +166 -27
  32. package/src/browser/prefetch/fetch.ts +52 -39
  33. package/src/browser/prefetch/policy.ts +6 -0
  34. package/src/browser/prefetch/queue.ts +92 -20
  35. package/src/browser/prefetch/resource-ready.ts +77 -0
  36. package/src/browser/react/Link.tsx +70 -14
  37. package/src/browser/react/NavigationProvider.tsx +40 -4
  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-router.ts +21 -8
  41. package/src/browser/rsc-router.tsx +143 -59
  42. package/src/browser/scroll-restoration.ts +41 -42
  43. package/src/browser/segment-reconciler.ts +6 -1
  44. package/src/browser/server-action-bridge.ts +454 -436
  45. package/src/browser/types.ts +60 -5
  46. package/src/build/generate-manifest.ts +6 -6
  47. package/src/build/generate-route-types.ts +5 -0
  48. package/src/build/route-trie.ts +19 -3
  49. package/src/build/route-types/include-resolution.ts +8 -1
  50. package/src/build/route-types/router-processing.ts +346 -87
  51. package/src/build/route-types/scan-filter.ts +8 -1
  52. package/src/cache/cache-runtime.ts +15 -11
  53. package/src/cache/cache-scope.ts +48 -7
  54. package/src/cache/cf/cf-cache-store.ts +453 -11
  55. package/src/cache/cf/index.ts +5 -1
  56. package/src/cache/document-cache.ts +17 -7
  57. package/src/cache/index.ts +1 -0
  58. package/src/cache/taint.ts +55 -0
  59. package/src/client.rsc.tsx +2 -1
  60. package/src/client.tsx +3 -102
  61. package/src/context-var.ts +72 -2
  62. package/src/debug.ts +2 -2
  63. package/src/handle.ts +40 -0
  64. package/src/handles/breadcrumbs.ts +66 -0
  65. package/src/handles/index.ts +1 -0
  66. package/src/host/index.ts +0 -3
  67. package/src/index.rsc.ts +8 -37
  68. package/src/index.ts +40 -66
  69. package/src/prerender/store.ts +57 -15
  70. package/src/prerender.ts +138 -77
  71. package/src/reverse.ts +22 -1
  72. package/src/route-definition/dsl-helpers.ts +73 -25
  73. package/src/route-definition/helpers-types.ts +10 -6
  74. package/src/route-definition/index.ts +3 -3
  75. package/src/route-definition/redirect.ts +11 -3
  76. package/src/route-definition/resolve-handler-use.ts +149 -0
  77. package/src/route-map-builder.ts +7 -1
  78. package/src/route-types.ts +11 -0
  79. package/src/router/content-negotiation.ts +100 -1
  80. package/src/router/find-match.ts +4 -2
  81. package/src/router/handler-context.ts +108 -25
  82. package/src/router/intercept-resolution.ts +11 -4
  83. package/src/router/lazy-includes.ts +4 -1
  84. package/src/router/loader-resolution.ts +123 -11
  85. package/src/router/logging.ts +5 -2
  86. package/src/router/manifest.ts +9 -3
  87. package/src/router/match-api.ts +125 -190
  88. package/src/router/match-middleware/background-revalidation.ts +30 -2
  89. package/src/router/match-middleware/cache-lookup.ts +88 -16
  90. package/src/router/match-middleware/cache-store.ts +53 -10
  91. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  92. package/src/router/match-middleware/segment-resolution.ts +61 -5
  93. package/src/router/match-result.ts +22 -15
  94. package/src/router/metrics.ts +238 -13
  95. package/src/router/middleware-types.ts +53 -12
  96. package/src/router/middleware.ts +172 -85
  97. package/src/router/navigation-snapshot.ts +182 -0
  98. package/src/router/pattern-matching.ts +20 -5
  99. package/src/router/prerender-match.ts +114 -10
  100. package/src/router/preview-match.ts +30 -102
  101. package/src/router/request-classification.ts +310 -0
  102. package/src/router/revalidation.ts +27 -7
  103. package/src/router/route-snapshot.ts +245 -0
  104. package/src/router/router-context.ts +6 -1
  105. package/src/router/router-interfaces.ts +50 -5
  106. package/src/router/router-options.ts +50 -19
  107. package/src/router/segment-resolution/fresh.ts +200 -19
  108. package/src/router/segment-resolution/helpers.ts +30 -25
  109. package/src/router/segment-resolution/loader-cache.ts +1 -0
  110. package/src/router/segment-resolution/revalidation.ts +429 -301
  111. package/src/router/segment-wrappers.ts +2 -0
  112. package/src/router/trie-matching.ts +20 -2
  113. package/src/router/types.ts +1 -0
  114. package/src/router.ts +88 -15
  115. package/src/rsc/handler.ts +546 -359
  116. package/src/rsc/index.ts +0 -20
  117. package/src/rsc/manifest-init.ts +5 -1
  118. package/src/rsc/progressive-enhancement.ts +25 -8
  119. package/src/rsc/rsc-rendering.ts +35 -43
  120. package/src/rsc/server-action.ts +16 -10
  121. package/src/rsc/ssr-setup.ts +128 -0
  122. package/src/rsc/types.ts +10 -1
  123. package/src/search-params.ts +16 -13
  124. package/src/segment-system.tsx +140 -4
  125. package/src/server/context.ts +148 -16
  126. package/src/server/loader-registry.ts +9 -8
  127. package/src/server/request-context.ts +182 -34
  128. package/src/server.ts +6 -0
  129. package/src/ssr/index.tsx +4 -0
  130. package/src/static-handler.ts +18 -6
  131. package/src/theme/index.ts +4 -13
  132. package/src/types/cache-types.ts +4 -4
  133. package/src/types/handler-context.ts +149 -49
  134. package/src/types/loader-types.ts +36 -9
  135. package/src/types/route-config.ts +17 -8
  136. package/src/types/route-entry.ts +8 -1
  137. package/src/types/segments.ts +2 -5
  138. package/src/urls/path-helper-types.ts +9 -2
  139. package/src/urls/path-helper.ts +48 -13
  140. package/src/urls/pattern-types.ts +12 -0
  141. package/src/urls/response-types.ts +16 -6
  142. package/src/use-loader.tsx +73 -4
  143. package/src/vite/discovery/bundle-postprocess.ts +61 -89
  144. package/src/vite/discovery/discover-routers.ts +23 -5
  145. package/src/vite/discovery/prerender-collection.ts +48 -15
  146. package/src/vite/discovery/state.ts +17 -13
  147. package/src/vite/index.ts +8 -3
  148. package/src/vite/plugin-types.ts +51 -79
  149. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  150. package/src/vite/plugins/expose-action-id.ts +1 -3
  151. package/src/vite/plugins/performance-tracks.ts +88 -0
  152. package/src/vite/plugins/refresh-cmd.ts +127 -0
  153. package/src/vite/plugins/version-plugin.ts +13 -1
  154. package/src/vite/rango.ts +174 -211
  155. package/src/vite/router-discovery.ts +169 -42
  156. package/src/vite/utils/banner.ts +3 -3
  157. package/src/vite/utils/prerender-utils.ts +78 -0
  158. package/src/vite/utils/shared-utils.ts +3 -2
  159. package/skills/testing/SKILL.md +0 -226
  160. package/src/route-definition/route-function.ts +0 -119
@@ -14,11 +14,16 @@ 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
- import type { RscPayload, CreateRSCHandlerOptions } from "./types.js";
21
+ import type {
22
+ RscPayload,
23
+ CreateRSCHandlerOptions,
24
+ LoadSSRModule,
25
+ SSRModule,
26
+ } from "./types.js";
22
27
  import {
23
28
  createResponseWithMergedHeaders,
24
29
  finalizeResponse,
@@ -66,6 +71,22 @@ import {
66
71
  createDefaultTimeoutResponse,
67
72
  type TimeoutPhase,
68
73
  } from "../router/timeout.js";
74
+ import {
75
+ createMetricsStore,
76
+ appendMetric,
77
+ buildMetricsTiming,
78
+ } from "../router/metrics.js";
79
+ import {
80
+ startSSRSetup,
81
+ getSSRSetup,
82
+ mayNeedSSR,
83
+ SSR_SETUP_VAR,
84
+ } from "./ssr-setup.js";
85
+ import {
86
+ classifyRequest,
87
+ type RequestPlan,
88
+ type ExecutableRequestPlan,
89
+ } from "../router/request-classification.js";
69
90
 
70
91
  /**
71
92
  * Create an RSC request handler.
@@ -117,10 +138,22 @@ export function createRSCHandler<
117
138
  decodeFormState,
118
139
  } = deps;
119
140
 
120
- // Use provided loadSSRModule or default to vite RSC module loader
121
- const loadSSRModule =
141
+ // Use provided loadSSRModule or default to vite RSC module loader.
142
+ // In production the SSR module is stable across requests, so memoize
143
+ // the dynamic import to avoid repeated module resolution overhead.
144
+ // In dev mode Vite may hot-reload the module, so skip memoization.
145
+ const rawLoadSSRModule: LoadSSRModule =
122
146
  options.loadSSRModule ??
123
147
  (() => import.meta.viteRsc.loadModule("ssr", "index"));
148
+ let _ssrModulePromise: Promise<SSRModule> | undefined;
149
+ const loadSSRModule: LoadSSRModule =
150
+ process.env.NODE_ENV === "production"
151
+ ? () =>
152
+ (_ssrModulePromise ??= rawLoadSSRModule().catch((err) => {
153
+ _ssrModulePromise = undefined;
154
+ throw err;
155
+ }))
156
+ : rawLoadSSRModule;
124
157
 
125
158
  /**
126
159
  * Per-request error reporter that deduplicates via the ALS request context.
@@ -268,6 +301,11 @@ export function createRSCHandler<
268
301
  input: RouterRequestInput<TEnv> = {},
269
302
  ): Promise<Response> {
270
303
  const handlerStart = performance.now();
304
+ // Create the metrics store at handler start so handler:total has startTime=0
305
+ // and all metrics are relative to the request entry point.
306
+ const earlyMetricsStore = router.debugPerformance
307
+ ? createMetricsStore(true, handlerStart)
308
+ : undefined;
271
309
 
272
310
  const { env = {} as TEnv, vars: initialVars, ctx: executionCtx } = input;
273
311
 
@@ -381,6 +419,10 @@ export function createRSCHandler<
381
419
  executionContext: executionCtx,
382
420
  themeConfig: router.themeConfig,
383
421
  });
422
+ if (earlyMetricsStore) {
423
+ requestContext._debugPerformance = true;
424
+ requestContext._metricsStore = earlyMetricsStore;
425
+ }
384
426
  // Wire background error reporting so "use cache" and other subsystems
385
427
  // can surface non-fatal errors through the router's onError callback.
386
428
  requestContext._reportBackgroundError = (
@@ -415,6 +457,9 @@ export function createRSCHandler<
415
457
  // - Server components during rendering
416
458
  // - Error boundaries
417
459
  // - Streaming
460
+ // Store basename on request context (scoped per-request via existing ALS)
461
+ requestContext._basename = router.basename;
462
+
418
463
  return runWithRequestContext(requestContext, async () => {
419
464
  // Core handler logic (wrapped by middleware)
420
465
  const coreHandler = async (): Promise<Response> => {
@@ -422,6 +467,7 @@ export function createRSCHandler<
422
467
  };
423
468
 
424
469
  // Execute middleware chain if any, otherwise call core handler directly
470
+ let response: Response;
425
471
  if (matchedMiddleware.length > 0) {
426
472
  const mwResponse = await executeMiddleware(
427
473
  matchedMiddleware,
@@ -440,17 +486,58 @@ export function createRSCHandler<
440
486
  mwResponse,
441
487
  createRedirectFlightResponse,
442
488
  );
443
- if (intercepted) return intercepted;
489
+ response = intercepted ?? finalizeResponse(mwResponse);
490
+ } else {
491
+ response = finalizeResponse(mwResponse);
444
492
  }
493
+ } else {
494
+ response = await coreHandler();
495
+ }
445
496
 
446
- return finalizeResponse(mwResponse);
497
+ // Finalize metrics after all middleware (including post-next work)
498
+ // has completed so :post spans are captured in the timeline.
499
+ // Handler timing parts are always emitted (even without debug metrics)
500
+ // so non-debug requests still get bootstrap Server-Timing entries.
501
+ const handlerTimingArr: string[] = variables.__handlerTiming || [];
502
+ // Preserve any existing Server-Timing set by response routes or middleware
503
+ const existingTiming = response.headers.get("Server-Timing");
504
+ const timingParts = existingTiming
505
+ ? [existingTiming, ...handlerTimingArr]
506
+ : [...handlerTimingArr];
507
+
508
+ const metricsStore = requestContext._metricsStore;
509
+ if (metricsStore) {
510
+ // When the store was created at handler start (earlyMetricsStore),
511
+ // handler:total covers the full request. When ctx.debugPerformance()
512
+ // created the store mid-request, use its requestStart to avoid a
513
+ // negative startTime offset.
514
+ const totalStart = earlyMetricsStore
515
+ ? handlerStart
516
+ : metricsStore.requestStart;
517
+ appendMetric(
518
+ metricsStore,
519
+ "handler:total",
520
+ totalStart,
521
+ performance.now() - totalStart,
522
+ );
523
+ const metricsTiming = buildMetricsTiming(
524
+ request.method,
525
+ url.pathname,
526
+ metricsStore,
527
+ );
528
+ if (metricsTiming) timingParts.push(metricsTiming);
447
529
  }
448
530
 
449
- return coreHandler();
531
+ const fullTiming = timingParts.join(", ");
532
+ if (fullTiming) response.headers.set("Server-Timing", fullTiming);
533
+
534
+ return response;
450
535
  });
451
536
  };
452
537
 
453
- // 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.
454
541
  async function coreRequestHandler(
455
542
  request: Request,
456
543
  env: TEnv,
@@ -458,56 +545,112 @@ export function createRSCHandler<
458
545
  variables: Record<string, any>,
459
546
  nonce: string | undefined,
460
547
  ): Promise<Response> {
461
- const previewStart = performance.now();
462
- const preview = await router.previewMatch(request, { env });
463
- const previewDur = performance.now() - previewStart;
464
548
  const handlerTiming: string[] = variables.__handlerTiming || [];
465
- handlerTiming.push(`handler-preview-match;dur=${previewDur.toFixed(2)}`);
466
- // Response route short-circuit: skip entire RSC pipeline
467
- if (preview?.responseType && preview.handler) {
468
- const responseOutcome = await withTimeout(
469
- handleResponseRoute(
470
- handlerCtx,
471
- preview as ResponseRouteMatch,
472
- request,
473
- env,
474
- url,
475
- 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,
476
572
  ),
477
- router.timeouts.renderStartMs,
478
- "render-start",
573
+ {
574
+ headers: { "Content-Type": "application/json" },
575
+ },
479
576
  );
480
- if (responseOutcome.timedOut) {
481
- return handleTimeoutResponse(
482
- request,
483
- env,
484
- url,
485
- "render-start",
486
- responseOutcome.durationMs,
487
- preview?.routeKey,
488
- );
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;
489
616
  }
490
- 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.
491
630
  }
492
631
 
493
- const routeReverse = createReverseFunction(getRequiredRouteMap());
632
+ if (plan.mode === "version-mismatch") {
633
+ console.log(
634
+ `[RSC] Version mismatch: client=${url.searchParams.get("_rsc_v")}, server=${version}. Forcing reload.`,
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
+ });
643
+ }
494
644
 
495
- const isAction =
496
- request.headers.has("rsc-action") || url.searchParams.has("_rsc_action");
497
- const isLoaderFetch = url.searchParams.has("_rsc_loader");
498
- const actionId =
499
- request.headers.get("rsc-action") || url.searchParams.get("_rsc_action");
500
-
501
- // Origin guard: reject cross-origin actions, loader fetches, and
502
- // PE form submissions before any execution. Regular page navigations
503
- // (GET without _rsc_loader/_rsc_action) are not affected.
504
- const originPhase: OriginCheckPhase | null = isAction
505
- ? "action"
506
- : isLoaderFetch
507
- ? "loader"
508
- : request.method === "POST"
509
- ? "pe-form"
510
- : 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;
511
654
  if (originPhase) {
512
655
  const originResult = await checkRequestOrigin(
513
656
  request,
@@ -557,13 +700,33 @@ export function createRSCHandler<
557
700
  }
558
701
  }
559
702
 
560
- // 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
561
727
  const handleStore = requireRequestContext()._handleStore;
562
728
 
563
729
  // Wire up error reporting for late streaming-handle failures
564
- // (LateHandlePushError: handle pushed after stream completion).
565
- // Without this, these errors are only caught by React's error boundary
566
- // and never reach the router's onError callback or telemetry.
567
730
  handleStore.onError = (error: Error) => {
568
731
  const reqCtx = requireRequestContext();
569
732
  callOnError(error, "handler", {
@@ -593,37 +756,106 @@ export function createRSCHandler<
593
756
  };
594
757
 
595
758
  // Set route params early so all execution paths can access ctx.params.
596
- if (preview?.params) {
597
- 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;
598
764
  }
599
765
 
600
- // Progressive enhancement runs before the normal action/render paths.
601
- // Route middleware wraps the PE re-render so handlers see the same
602
- // context variables regardless of JS/no-JS transport.
603
- const progressiveResult = await handleProgressiveEnhancement(
604
- handlerCtx,
605
- request,
606
- env,
607
- url,
608
- isAction,
609
- handleStore,
610
- nonce,
611
- {
612
- 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,
613
831
  variables,
614
- routeReverse,
615
- },
616
- );
617
- if (progressiveResult) {
618
- return progressiveResult;
832
+ plan.route.params,
833
+ );
834
+ }
835
+
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
619
854
  }
620
855
 
621
- // --- Action execution: runs BEFORE route middleware ---
622
- // Route middleware wraps rendering only. For actions, the action runs
623
- // first in the global middleware context, then route middleware wraps
624
- // the revalidation pass (identical to a normal render).
625
- let actionContinuation: ActionContinuation | undefined;
626
- if (isAction && actionId) {
856
+ // ---- Action: execute action, then revalidate wrapped in route middleware ----
857
+ if (plan.mode === "action") {
858
+ let actionContinuation: ActionContinuation | undefined;
627
859
  try {
628
860
  const actionOutcome = await withTimeout(
629
861
  executeServerAction(
@@ -631,7 +863,7 @@ export function createRSCHandler<
631
863
  request,
632
864
  env,
633
865
  url,
634
- actionId,
866
+ plan.actionId,
635
867
  handleStore,
636
868
  ),
637
869
  router.timeouts.actionMs,
@@ -644,8 +876,8 @@ export function createRSCHandler<
644
876
  url,
645
877
  "action",
646
878
  actionOutcome.durationMs,
647
- preview?.routeKey,
648
- actionId,
879
+ plan.route.routeKey,
880
+ plan.actionId,
649
881
  );
650
882
  }
651
883
  const result = actionOutcome.result;
@@ -657,338 +889,293 @@ export function createRSCHandler<
657
889
  request,
658
890
  url,
659
891
  env,
660
- actionId,
892
+ actionId: plan.actionId,
661
893
  handledByBoundary: false,
662
894
  });
663
895
  console.error(`[RSC] Action error:`, error);
664
896
  throw error;
665
897
  }
666
- }
667
898
 
668
- // --- Rendering (action revalidation or navigation) ---
669
- // Route middleware wraps this same code path for both cases.
670
- const renderHandler = async () => {
671
- 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,
672
909
  request,
673
910
  env,
674
911
  url,
675
912
  variables,
676
913
  nonce,
677
- preview?.params,
678
- preview?.routeKey,
679
914
  handleStore,
915
+ isPartialAction,
680
916
  actionContinuation,
681
917
  );
682
- if (preview?.negotiated) {
683
- response.headers.append("Vary", "Accept");
684
- }
685
- return response;
686
- };
687
-
688
- // Wrap the render path (with or without route middleware) in a
689
- // renderStartMs timeout so slow renders are caught before output.
690
- const executeRender = async (): Promise<Response> => {
691
- if (preview?.routeMiddleware && preview.routeMiddleware.length > 0) {
692
- const mwResponse = await executeMiddleware(
693
- buildRouteMiddlewareEntries<TEnv>(preview.routeMiddleware),
694
- request,
695
- env,
696
- variables,
697
- renderHandler,
698
- routeReverse,
699
- );
700
-
701
- if (
702
- url.searchParams.has("_rsc_partial") ||
703
- url.searchParams.has("_rsc_action")
704
- ) {
705
- const intercepted = interceptRedirectForPartial(
706
- mwResponse,
707
- createRedirectFlightResponse,
708
- );
709
- if (intercepted) return intercepted;
710
- }
711
-
712
- return finalizeResponse(mwResponse);
713
- }
918
+ }
714
919
 
715
- // No route middleware, proceed directly
716
- return renderHandler();
717
- };
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
+ }
718
937
 
719
- const renderOutcome = await withTimeout(
720
- executeRender(),
721
- router.timeouts.renderStartMs,
722
- "render-start",
723
- );
724
- if (renderOutcome.timedOut) {
725
- 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,
726
946
  request,
727
947
  env,
728
948
  url,
729
- "render-start",
730
- renderOutcome.durationMs,
731
- preview?.routeKey,
949
+ variables,
950
+ nonce,
951
+ handleStore,
952
+ false,
732
953
  );
733
954
  }
734
- 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
+ );
735
971
  }
736
972
 
737
- // Inner request handler: rendering logic wrapped by route middleware.
738
- // Handles action revalidation (when actionContinuation is present),
739
- // loader fetches, and regular RSC rendering.
740
- 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>,
741
981
  request: Request,
742
982
  env: TEnv,
743
983
  url: URL,
744
984
  variables: Record<string, any>,
745
985
  nonce: string | undefined,
746
- routeParams?: Record<string, string>,
747
- routeKey?: string,
748
- handleStore?: ReturnType<typeof requireRequestContext>["_handleStore"],
986
+ handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
987
+ isPartial: boolean,
749
988
  actionContinuation?: ActionContinuation,
750
989
  ): Promise<Response> {
751
- const isPartial = url.searchParams.has("_rsc_partial");
752
- const isAction =
753
- request.headers.has("rsc-action") || url.searchParams.has("_rsc_action");
754
-
755
- // Version mismatch detection - client may have stale code after HMR/deployment
756
- // If versions don't match, tell the client to reload
757
- const clientVersion = url.searchParams.get("_rsc_v");
758
- if (version && clientVersion && clientVersion !== version) {
759
- console.log(
760
- `[RSC] Version mismatch: client=${clientVersion}, server=${version}. Forcing reload.`,
761
- );
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
+ }
762
1036
 
763
- // For actions, reload current page (referer) if same origin.
764
- // For navigation, load the target URL.
765
- // Validate referer origin to prevent open redirect via crafted header.
766
- let reloadUrl = stripInternalParams(url).toString();
767
- if (isAction) {
768
- const referer = request.headers.get("referer");
769
- if (referer) {
770
- try {
771
- const refererUrl = new URL(referer);
772
- if (refererUrl.origin === url.origin) {
773
- reloadUrl = referer;
774
- }
775
- } catch {
776
- // Malformed referer, fall back to cleanUrl
1037
+ if (isPartial) {
1038
+ const intercepted = interceptRedirectForPartial(
1039
+ error,
1040
+ createRedirectFlightResponse,
1041
+ );
1042
+ if (intercepted) return intercepted;
777
1043
  }
1044
+
1045
+ return error;
778
1046
  }
779
- }
780
1047
 
781
- // Return special response that tells client to reload
782
- return createResponseWithMergedHeaders(null, {
783
- status: 200,
784
- headers: {
785
- "X-RSC-Reload": reloadUrl,
786
- "content-type": "text/x-component;charset=utf-8",
787
- },
788
- });
789
- }
790
- // Debug manifest endpoint: ?__debug_manifest on any route.
791
- // Always available in dev, requires allowDebugManifest option in production.
792
- const isDev = process.env.NODE_ENV !== "production";
793
- if (
794
- url.searchParams.has("__debug_manifest") &&
795
- (isDev || router.allowDebugManifest)
796
- ) {
797
- const trie = getRouterTrie(router.id) ?? getRouteTrie();
798
- const routeManifest = getRequiredRouteMap();
799
- const { extractAncestryFromTrie } =
800
- await import("../build/route-trie.js");
801
- return new Response(
802
- JSON.stringify(
803
- {
804
- routerId: router.id,
805
- routeManifest,
806
- routeAncestry: trie ? extractAncestryFromTrie(trie) : {},
807
- routeTrie: trie,
808
- precomputedEntries: getPrecomputedEntries(),
809
- },
810
- null,
811
- 2,
812
- ),
813
- {
814
- headers: { "Content-Type": "application/json" },
815
- },
816
- );
817
- }
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
+ });
818
1059
 
819
- 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
+ };
820
1092
 
821
- try {
822
- // Route params were already set in coreRequestHandler, but set again
823
- // for callers that enter coreRequestHandlerInner directly.
824
- if (routeParams) {
825
- setRequestContextParams(routeParams, routeKey);
826
- }
1093
+ const rscStream = renderToReadableStream(payload);
827
1094
 
828
- // ============================================================================
829
- // ACTION REVALIDATION (action already executed, revalidate segments)
830
- // ============================================================================
831
- if (actionContinuation) {
832
- return await revalidateAfterAction(
833
- handlerCtx,
834
- request,
835
- env,
836
- url,
837
- store,
838
- actionContinuation,
839
- );
840
- }
1095
+ const isRscRequest =
1096
+ isPartial ||
1097
+ (!request.headers.get("accept")?.includes("text/html") &&
1098
+ !url.searchParams.has("__html")) ||
1099
+ url.searchParams.has("__rsc");
841
1100
 
842
- // ============================================================================
843
- // LOADER FETCH EXECUTION (data fetching with RSC serialization)
844
- // ============================================================================
845
- const isLoaderRequest = url.searchParams.has("_rsc_loader");
846
- if (isLoaderRequest) {
847
- return handleLoaderFetch(
848
- handlerCtx,
849
- request,
850
- env,
851
- url,
852
- variables,
853
- routeParams,
854
- );
855
- }
1101
+ if (isRscRequest) {
1102
+ return createResponseWithMergedHeaders(rscStream, {
1103
+ status: 404,
1104
+ headers: { "content-type": "text/x-component;charset=utf-8" },
1105
+ });
1106
+ }
856
1107
 
857
- // ============================================================================
858
- // REGULAR RSC RENDERING (Navigation)
859
- // ============================================================================
860
- // Note: Must use "return await" for try/catch to catch async rejections
861
- return await handleRscRendering(
862
- handlerCtx,
863
- request,
864
- env,
865
- url,
866
- isPartial,
867
- store,
868
- nonce,
869
- );
870
- } catch (error) {
871
- // Check if middleware/handler returned Response
872
- if (error instanceof Response) {
873
- // During partial (client-side navigation), a 200 Response from a handler
874
- // means the route serves raw content (JSON, text, etc.), not JSX.
875
- // Signal the browser to hard-navigate so it renders the raw response.
876
- // Only for 200 — redirects (3xx) work already because the browser follows
877
- // them automatically to a URL that serves Flight data.
878
- if (isPartial && error.status === 200) {
879
- console.warn(
880
- `[RSC] Route handler at ${url.pathname} returned a Response during client-side navigation. ` +
881
- `Falling back to hard navigation. Use data-external on the <Link> to avoid the extra round-trip.`,
1108
+ const [ssrModule, streamMode] = await getSSRSetup(
1109
+ handlerCtx,
1110
+ request,
1111
+ env,
1112
+ url,
1113
+ requireRequestContext()._metricsStore,
882
1114
  );
883
- return createResponseWithMergedHeaders(null, {
884
- status: 200,
885
- headers: {
886
- "X-RSC-Reload": stripInternalParams(url).toString(),
887
- "content-type": "text/x-component;charset=utf-8",
888
- },
1115
+ const htmlStream = await ssrModule.renderHTML(rscStream, {
1116
+ nonce,
1117
+ streamMode,
889
1118
  });
890
- }
891
1119
 
892
- if (isPartial) {
893
- const intercepted = interceptRedirectForPartial(
894
- error,
895
- createRedirectFlightResponse,
896
- );
897
- if (intercepted) return intercepted;
1120
+ return createResponseWithMergedHeaders(htmlStream, {
1121
+ status: 404,
1122
+ headers: { "content-type": "text/html;charset=utf-8" },
1123
+ });
898
1124
  }
899
1125
 
900
- return error;
901
- }
902
-
903
- // Render 404 page for unmatched routes
904
- // Check both instanceof and error.name for cross-bundle compatibility
905
- const isRouteNotFound =
906
- error instanceof RouteNotFoundError ||
907
- (error instanceof Error && error.name === "RouteNotFoundError");
908
- if (isRouteNotFound) {
1126
+ // Report unhandled errors
909
1127
  callOnError(error, "routing", {
910
1128
  request,
911
1129
  url,
912
1130
  env,
913
- handledByBoundary: true, // Handled by notFound component
1131
+ handledByBoundary: false,
914
1132
  });
1133
+ console.error(`[RSC] Error:`, error);
1134
+ throw error;
1135
+ }
1136
+ };
915
1137
 
916
- // Get notFound component from router options or use default
917
- const notFoundOption = router.notFound;
918
- const notFoundComponent =
919
- typeof notFoundOption === "function"
920
- ? notFoundOption({ pathname: url.pathname })
921
- : (notFoundOption ?? createElement("h1", null, "Not Found"));
922
-
923
- // Create a simple segment for the 404 page
924
- const notFoundSegment = {
925
- id: "notFound",
926
- namespace: "notFound",
927
- type: "route" as const,
928
- index: 0,
929
- component: notFoundComponent,
930
- params: {},
931
- };
932
-
933
- const payload: RscPayload = {
934
- metadata: {
935
- pathname: url.pathname,
936
- segments: [notFoundSegment],
937
- matched: [],
938
- diff: [],
939
- isPartial: false,
940
- rootLayout: router.rootLayout,
941
- handles: store.stream(),
942
- version,
943
- themeConfig: router.themeConfig,
944
- warmupEnabled: router.warmupEnabled,
945
- initialTheme: requireRequestContext().theme,
946
- // No routeName for not-found routes
947
- },
948
- };
949
-
950
- const rscStream = renderToReadableStream(payload);
951
-
952
- // Determine if this is an RSC request or HTML request.
953
- // Partial requests are always RSC (see main isRscRequest comment).
954
- const isRscRequest =
955
- isPartial ||
956
- (!request.headers.get("accept")?.includes("text/html") &&
957
- !url.searchParams.has("__html")) ||
958
- url.searchParams.has("__rsc");
1138
+ // Wrap the render path in a renderStartMs timeout
1139
+ const executeRender = async (): Promise<Response> => {
1140
+ if (routeMiddleware.length > 0) {
1141
+ const mwResponse = await executeMiddleware(
1142
+ buildRouteMiddlewareEntries<TEnv>(routeMiddleware),
1143
+ request,
1144
+ env,
1145
+ variables,
1146
+ renderHandler,
1147
+ routeReverse,
1148
+ );
959
1149
 
960
- if (isRscRequest) {
961
- return createResponseWithMergedHeaders(rscStream, {
962
- status: 404,
963
- headers: { "content-type": "text/x-component;charset=utf-8" },
964
- });
1150
+ if (isPartial || actionContinuation) {
1151
+ const intercepted = interceptRedirectForPartial(
1152
+ mwResponse,
1153
+ createRedirectFlightResponse,
1154
+ );
1155
+ if (intercepted) return intercepted;
965
1156
  }
966
1157
 
967
- // Delegate to SSR for HTML response
968
- const [ssrModule, streamMode] = await Promise.all([
969
- loadSSRModule(),
970
- handlerCtx.resolveStreamMode(request, env, url),
971
- ]);
972
- const htmlStream = await ssrModule.renderHTML(rscStream, {
973
- nonce,
974
- streamMode,
975
- });
976
-
977
- return createResponseWithMergedHeaders(htmlStream, {
978
- status: 404,
979
- headers: { "content-type": "text/html;charset=utf-8" },
980
- });
1158
+ return finalizeResponse(mwResponse);
981
1159
  }
982
1160
 
983
- // Report unhandled errors
984
- callOnError(error, "routing", {
1161
+ return renderHandler();
1162
+ };
1163
+
1164
+ const renderOutcome = await withTimeout(
1165
+ executeRender(),
1166
+ router.timeouts.renderStartMs,
1167
+ "render-start",
1168
+ );
1169
+ if (renderOutcome.timedOut) {
1170
+ return handleTimeoutResponse(
985
1171
  request,
986
- url,
987
1172
  env,
988
- handledByBoundary: false,
989
- });
990
- console.error(`[RSC] Error:`, error);
991
- throw error;
1173
+ url,
1174
+ "render-start",
1175
+ renderOutcome.durationMs,
1176
+ routeKey,
1177
+ );
992
1178
  }
1179
+ return renderOutcome.result;
993
1180
  }
994
1181
  }