@rangojs/router 0.0.0-experimental.144 → 0.0.0-experimental.146

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 (35) hide show
  1. package/dist/bin/rango.js +1 -40
  2. package/dist/vite/index.js +35 -9
  3. package/package.json +1 -1
  4. package/skills/ppr/SKILL.md +29 -23
  5. package/src/browser/logging.ts +18 -0
  6. package/src/browser/rsc-router.tsx +43 -0
  7. package/src/cache/cache-runtime.ts +41 -51
  8. package/src/cache/cache-scope.ts +30 -1
  9. package/src/cache/cf/cf-cache-store.ts +4 -0
  10. package/src/cache/handle-snapshot.ts +22 -1
  11. package/src/cache/shell-snapshot.ts +47 -0
  12. package/src/cache/types.ts +31 -4
  13. package/src/cache/vercel/vercel-cache-store.ts +6 -1
  14. package/src/deps/ssr.ts +4 -1
  15. package/src/router/loader-resolution.ts +16 -0
  16. package/src/router/match-api.ts +9 -2
  17. package/src/router/match-handlers.ts +13 -0
  18. package/src/router/segment-resolution/loader-cache.ts +19 -3
  19. package/src/router/segment-resolution/loader-mask.ts +4 -11
  20. package/src/router/segment-resolution/loader-snapshot.ts +14 -6
  21. package/src/router/segment-resolution/mask-nested.ts +99 -0
  22. package/src/rsc/rsc-rendering.ts +139 -16
  23. package/src/rsc/shell-capture.ts +122 -0
  24. package/src/rsc/shell-serve.ts +37 -6
  25. package/src/segment-loader-promise.ts +18 -0
  26. package/src/segment-system.tsx +123 -9
  27. package/src/server/request-context.ts +47 -0
  28. package/src/ssr/index.tsx +118 -18
  29. package/src/ssr/inject-rsc-eager.ts +167 -0
  30. package/src/ssr/preinit-client-references.ts +106 -0
  31. package/src/vite/index.ts +8 -0
  32. package/src/vite/plugin-types.ts +33 -0
  33. package/src/vite/plugins/virtual-entries.ts +37 -4
  34. package/src/vite/rango.ts +10 -2
  35. package/src/vite/utils/shared-utils.ts +4 -2
@@ -0,0 +1,106 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { preinitModule } from "react-dom";
3
+
4
+ /**
5
+ * JS/CSS asset deps plugin-rsc resolves for a client reference. Structural
6
+ * mirror of @vitejs/plugin-rsc's ResolvedAssetDeps — deliberately not imported:
7
+ * @rangojs/router/ssr never imports plugin-rsc directly; every plugin binding
8
+ * is injected by the virtual SSR entry (see SSRDependencies).
9
+ */
10
+ export interface ClientReferenceDeps {
11
+ js: string[];
12
+ css: string[];
13
+ }
14
+
15
+ /**
16
+ * Callback shape of @vitejs/plugin-rsc/ssr's setOnClientReference. Fired
17
+ * (synchronously, inside the Fizz render) whenever a client reference module
18
+ * is accessed during SSR — the same moment plugin-rsc issues its own
19
+ * ReactDOM.preloadModule calls for the reference's chunks.
20
+ */
21
+ export type OnClientReference = (reference: {
22
+ id: string;
23
+ deps: ClientReferenceDeps;
24
+ }) => void;
25
+
26
+ /** setOnClientReference from @vitejs/plugin-rsc/ssr (injected). */
27
+ export type SetOnClientReference = (
28
+ callback: OnClientReference | undefined,
29
+ ) => void;
30
+
31
+ /**
32
+ * Per-request CSP nonce channel for the preinit hook. The hook is installed
33
+ * once per isolate while the nonce is per request, and Fizz interleaves
34
+ * concurrent renders at task granularity — a module-scoped variable would race
35
+ * across requests and stamp request A's scripts with request B's nonce. ALS is
36
+ * the only channel that survives from the render call into the client-reference
37
+ * proxy access. A lost context degrades to nonce-less preinit (CSP blocks the
38
+ * head script; hydration still works through the nonce'd bootstrap), never to a
39
+ * wrong nonce.
40
+ */
41
+ const preinitNonceStorage = new AsyncLocalStorage<string | undefined>();
42
+
43
+ /**
44
+ * Run a Fizz render (renderToReadableStream / prerender / resume) with the
45
+ * request's nonce visible to the client-reference preinit hook. Nonce-less
46
+ * requests (the common non-CSP configuration) skip the ALS frame entirely —
47
+ * getStore() on an unentered storage already returns undefined, so the hook
48
+ * reads the same value either way.
49
+ */
50
+ export function runWithPreinitNonce<T>(
51
+ nonce: string | undefined,
52
+ fn: () => T,
53
+ ): T {
54
+ return nonce === undefined ? fn() : preinitNonceStorage.run(nonce, fn);
55
+ }
56
+
57
+ /**
58
+ * Upgrade plugin-rsc's client-reference modulepreload hints to executing
59
+ * scripts: for every JS chunk a client reference needs, emit
60
+ * `<script type="module" src async>` hoisted into the document head instead of
61
+ * only `<link rel="modulepreload">`.
62
+ *
63
+ * Why: modulepreload fetches + compiles but never executes; the chunks then
64
+ * execute only when the entry's hydration import walks the graph — after the
65
+ * whole document has streamed. preinitModule starts execution as soon as each
66
+ * chunk arrives, overlapping it with body streaming (the pattern Next.js uses
67
+ * via ReactDOM.preinit for all non-bootstrap chunks). Under PPR the preinits
68
+ * run during shell capture, so the executing tags live in the stored prelude
69
+ * and chunk execution starts on the first flushed bytes.
70
+ *
71
+ * No duplicate tags: plugin-rsc's preloadModule fires first in the same
72
+ * synchronous block; Fizz's preinitModuleScript then clears the queued preload
73
+ * chunks for that URL and adopts its credentials (ReactFizzConfigDOM,
74
+ * renderState.preloads.moduleScripts). Capture→resume double-emission is
75
+ * prevented the same way: the `moduleScriptResources[src] = null` markers
76
+ * serialize inside the postponed state, so the resume pass preinits only
77
+ * references the shell never saw.
78
+ *
79
+ * crossOrigin "" matches plugin-rsc's preloadModule creds so the upgrade path
80
+ * reuses the same resource instead of forking on credential mismatch.
81
+ *
82
+ * Known trades (deliberate, measured neutral-to-positive on the e2e apps —
83
+ * PR #694 has the Lighthouse/hydration numbers):
84
+ * - Fetch priority: an executing async module script fetches at Chromium's
85
+ * async-script priority, below a bare modulepreload hint; preinitModule
86
+ * forwards no fetchPriority (react-dom's public API drops it — only
87
+ * `preinit` forwards it). Execution-overlap is bought with hint priority,
88
+ * the same trade Next.js ships via ReactDOM.preinit.
89
+ * - Build only: plugin-rsc's dev load path reports `js: []` per reference, so
90
+ * dev documents have no head chunk scripts — a client module whose module
91
+ * scope assumes body-parsed DOM can break in production only. The
92
+ * `rango({ headScripts: "preload" })` escape hatch restores hint-only.
93
+ * - plugin-rsc's setOnClientReference is a single-slot, last-write-wins
94
+ * setter: another registrant in the SSR environment silently replaces this
95
+ * hook (or is replaced by it). No composition API exists upstream yet.
96
+ */
97
+ export function installClientReferencePreinit(
98
+ setOnClientReference: SetOnClientReference,
99
+ ): void {
100
+ setOnClientReference(({ deps }) => {
101
+ const nonce = preinitNonceStorage.getStore();
102
+ for (const href of deps.js) {
103
+ preinitModule(href, { as: "script", crossOrigin: "", nonce });
104
+ }
105
+ });
106
+ }
package/src/vite/index.ts CHANGED
@@ -8,6 +8,13 @@
8
8
 
9
9
  export { rango } from "./rango.js";
10
10
  export { poke } from "./plugins/refresh-cmd.js";
11
+ // The built-in clientChunks strategy, exported so a custom `clientChunks`
12
+ // function can OVERLAY it (route a few modules to a dedicated chunk, delegate
13
+ // the rest) instead of replacing the whole route/marker grouping. Without this
14
+ // a consumer override silently loses app-fallback/route splitting for the
15
+ // entire app. Note: called without a ClientChunkContext the fallbackRefs-based
16
+ // `app-fallback` split is inactive — discovery wires it only for the built-in.
17
+ export { directoryClientChunks } from "./utils/client-chunks.js";
11
18
 
12
19
  export type {
13
20
  RangoNodeOptions,
@@ -17,6 +24,7 @@ export type {
17
24
  RangoOptions,
18
25
  ClientChunks,
19
26
  ClientChunkMeta,
27
+ HeadScriptsOption,
20
28
  BuildEnvOption,
21
29
  BuildEnvFactory,
22
30
  BuildEnvFactoryContext,
@@ -107,6 +107,11 @@ export type ClientChunks =
107
107
 
108
108
  // -- Plugin options ---------------------------------------------------------
109
109
 
110
+ /**
111
+ * Document script strategy. See {@link RangoBaseOptions.headScripts}.
112
+ */
113
+ export type HeadScriptsOption = "preinit" | "preload";
114
+
110
115
  /**
111
116
  * Base options shared by all presets
112
117
  */
@@ -126,6 +131,34 @@ interface RangoBaseOptions {
126
131
  */
127
132
  clientChunks?: ClientChunks;
128
133
 
134
+ /**
135
+ * How the document ships its JavaScript.
136
+ *
137
+ * - `"preinit"` (**default**): client-reference chunks render as EXECUTING
138
+ * `<script type="module" async>` tags hoisted into `<head>` (upgrading
139
+ * plugin-rsc's modulepreload hints in place), and the browser entry ships
140
+ * as Fizz `bootstrapModules` — a head `modulepreload fetchpriority=low`
141
+ * hint plus the executing end-of-shell `id="_R_"` module script. Chunk
142
+ * execution overlaps body streaming instead of waiting for the hydration
143
+ * import walk; under PPR everything lands in the stored shell prelude.
144
+ * - `"preload"`: the previous behavior — `<link rel="modulepreload">` hints
145
+ * only, entry as an inline `import()` script at end of shell. Chunks
146
+ * fetch+compile early but execute only when hydration imports them.
147
+ *
148
+ * Build-only for the chunk half: plugin-rsc resolves no JS deps per client
149
+ * reference in dev, so dev documents carry no head chunk scripts in either
150
+ * mode (the bootstrap conversion does apply in dev). Trades and upstream
151
+ * limits are documented in src/ssr/preinit-client-references.ts.
152
+ *
153
+ * Wired in the generated virtual SSR entry
154
+ * (`src/ssr/preinit-client-references.ts` has the mechanism); apps with a
155
+ * custom SSR entry choose per-handler via `SSRDependencies.headScripts` and
156
+ * `installClientReferencePreinit`.
157
+ *
158
+ * @default "preinit"
159
+ */
160
+ headScripts?: HeadScriptsOption;
161
+
129
162
  /**
130
163
  * Filter which files route discovery scans, by glob. Paths are matched
131
164
  * root-relative (e.g. `src/routes/**`). `include` restricts discovery to
@@ -1,3 +1,5 @@
1
+ import type { HeadScriptsOption } from "../plugin-types.js";
2
+
1
3
  export const VIRTUAL_ENTRY_BROWSER: string = `
2
4
  import {
3
5
  createFromReadableStream,
@@ -36,21 +38,49 @@ async function initializeApp() {
36
38
  initializeApp().catch(console.error);
37
39
  `.trim();
38
40
 
39
- export const VIRTUAL_ENTRY_SSR: string = `
40
- import { createFromReadableStream } from "@rangojs/router/internal/deps/ssr";
41
+ /**
42
+ * Generate the virtual SSR entry. `headScripts` mirrors the rango() plugin
43
+ * option: "preinit" (default) installs the client-reference preinit hook and
44
+ * lets the SSR handlers convert the bootstrap to `bootstrapModules`;
45
+ * "preload" omits the hook and pins the handlers to the hint-only strategy.
46
+ */
47
+ export function getVirtualEntrySSR(
48
+ headScripts: HeadScriptsOption = "preinit",
49
+ ): string {
50
+ const preinit = headScripts !== "preload";
51
+ // The preload variant drops exactly three preinit-only lines, all built
52
+ // here so the template below stays a single unconditional shape.
53
+ const depsImportNames = preinit
54
+ ? "createFromReadableStream,\n setOnClientReference,"
55
+ : "createFromReadableStream,";
56
+ const ssrImportNames = preinit ? "\n installClientReferencePreinit," : "";
57
+ const install = preinit
58
+ ? `
59
+ // Upgrade client-reference modulepreload hints to executing module scripts in
60
+ // the document head, for every render pass (live SSR, shell capture, resume).
61
+ // See src/ssr/preinit-client-references.ts for the full rationale.
62
+ installClientReferencePreinit(setOnClientReference);
63
+ `
64
+ : "";
65
+ const hs = JSON.stringify(headScripts);
66
+ return `
67
+ import {
68
+ ${depsImportNames}
69
+ } from "@rangojs/router/internal/deps/ssr";
41
70
  import { renderToReadableStream, resume } from "react-dom/server.edge";
42
71
  import { prerender } from "react-dom/static.edge";
43
72
  import { injectRSCPayload } from "@rangojs/router/internal/deps/html-stream-server";
44
73
  import {
45
74
  createSSRHandler,
46
75
  createShellCaptureHandler,
47
- createShellResumeHandler,
76
+ createShellResumeHandler,${ssrImportNames}
48
77
  } from "@rangojs/router/ssr";
49
-
78
+ ${install}
50
79
  export const renderHTML = createSSRHandler({
51
80
  createFromReadableStream,
52
81
  renderToReadableStream,
53
82
  injectRSCPayload,
83
+ headScripts: ${hs},
54
84
  loadBootstrapScriptContent: () =>
55
85
  import.meta.viteRsc.loadBootstrapScriptContent("index"),
56
86
  });
@@ -61,6 +91,7 @@ export const captureShellHTML = createShellCaptureHandler({
61
91
  injectRSCPayload,
62
92
  prerender,
63
93
  resume,
94
+ headScripts: ${hs},
64
95
  loadBootstrapScriptContent: () =>
65
96
  import.meta.viteRsc.loadBootstrapScriptContent("index"),
66
97
  });
@@ -71,10 +102,12 @@ export const resumeShellHTML = createShellResumeHandler({
71
102
  injectRSCPayload,
72
103
  prerender,
73
104
  resume,
105
+ headScripts: ${hs},
74
106
  loadBootstrapScriptContent: () =>
75
107
  import.meta.viteRsc.loadBootstrapScriptContent("index"),
76
108
  });
77
109
  `.trim();
110
+ }
78
111
 
79
112
  /**
80
113
  * Virtual modules an RSC entry must import at startup to register the data the
package/src/vite/rango.ts CHANGED
@@ -239,7 +239,11 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
239
239
  },
240
240
  });
241
241
 
242
- plugins.push(createVirtualEntriesPlugin(finalEntries));
242
+ plugins.push(
243
+ createVirtualEntriesPlugin(finalEntries, undefined, {
244
+ headScripts: resolvedOptions.headScripts,
245
+ }),
246
+ );
243
247
  plugins.push(performanceTracksPlugin());
244
248
  plugins.push(
245
249
  rsc({
@@ -476,7 +480,11 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
476
480
  },
477
481
  });
478
482
 
479
- plugins.push(createVirtualEntriesPlugin(finalEntries, routerRef));
483
+ plugins.push(
484
+ createVirtualEntriesPlugin(finalEntries, routerRef, {
485
+ headScripts: resolvedOptions.headScripts,
486
+ }),
487
+ );
480
488
  plugins.push(performanceTracksPlugin());
481
489
  plugins.push(
482
490
  rsc({
@@ -5,12 +5,13 @@ import { getPublishedPackageName } from "./package-resolution.js";
5
5
  import { performanceTracksOptimizeDepsPlugin } from "../plugins/performance-tracks.js";
6
6
  import {
7
7
  VIRTUAL_ENTRY_BROWSER,
8
- VIRTUAL_ENTRY_SSR,
8
+ getVirtualEntrySSR,
9
9
  getVirtualEntryRSC,
10
10
  getVirtualEntryRSCHost,
11
11
  getVirtualVersionContent,
12
12
  VIRTUAL_IDS,
13
13
  } from "../plugins/virtual-entries.js";
14
+ import type { HeadScriptsOption } from "../plugin-types.js";
14
15
 
15
16
  // Cloudflare preset: @cloudflare/vite-plugin sets optimizeDeps.entries (string
16
17
  // or array) on the rsc environment. Single source for both the discovery plugin
@@ -109,6 +110,7 @@ export function normalizeHostRouterEntry(
109
110
  export function createVirtualEntriesPlugin(
110
111
  entries: { client: string; ssr: string; rsc?: string },
111
112
  routerPathRef?: { path?: string; kind?: "router" | "host" },
113
+ options?: { headScripts?: HeadScriptsOption },
112
114
  ): Plugin {
113
115
  // Build virtual modules map based on which entries use virtual IDs
114
116
  const virtualModules: Record<string, string> = {};
@@ -117,7 +119,7 @@ export function createVirtualEntriesPlugin(
117
119
  virtualModules[VIRTUAL_IDS.browser] = VIRTUAL_ENTRY_BROWSER;
118
120
  }
119
121
  if (entries.ssr === VIRTUAL_IDS.ssr) {
120
- virtualModules[VIRTUAL_IDS.ssr] = VIRTUAL_ENTRY_SSR;
122
+ virtualModules[VIRTUAL_IDS.ssr] = getVirtualEntrySSR(options?.headScripts);
121
123
  }
122
124
 
123
125
  // RSC entry is resolved lazily in load() because routerPath may be