@rangojs/router 0.0.0-experimental.145 → 0.0.0-experimental.147

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 (46) hide show
  1. package/dist/bin/rango.js +8 -40
  2. package/dist/vite/index.js +840 -243
  3. package/package.json +6 -1
  4. package/src/browser/event-controller.ts +16 -2
  5. package/src/browser/rsc-router.tsx +11 -0
  6. package/src/cache/cache-scope.ts +41 -3
  7. package/src/cache/cf/cf-cache-store.ts +23 -0
  8. package/src/cache/handle-snapshot.ts +22 -1
  9. package/src/cache/memory-segment-store.ts +32 -0
  10. package/src/cache/segment-codec.ts +47 -0
  11. package/src/cache/shell-snapshot.ts +47 -0
  12. package/src/cache/types.ts +27 -0
  13. package/src/cache/vercel/vercel-cache-store.ts +71 -2
  14. package/src/deps/ssr.ts +4 -1
  15. package/src/prerender/build-shell-capture.ts +237 -0
  16. package/src/prerender/shell-manifest-key.ts +20 -0
  17. package/src/prerender/store.ts +10 -1
  18. package/src/router/loader-resolution.ts +16 -0
  19. package/src/router/match-api.ts +9 -2
  20. package/src/router/match-handlers.ts +13 -0
  21. package/src/router/match-middleware/cache-lookup.ts +12 -1
  22. package/src/router/prerender-match.ts +21 -0
  23. package/src/router/segment-resolution/mask-nested.ts +19 -3
  24. package/src/rsc/capture-queue.ts +67 -0
  25. package/src/rsc/rsc-rendering.ts +136 -25
  26. package/src/rsc/shell-build-manifest.ts +244 -0
  27. package/src/rsc/shell-capture.ts +194 -43
  28. package/src/segment-fragments.ts +124 -0
  29. package/src/segment-system.tsx +49 -19
  30. package/src/server/request-context.ts +112 -11
  31. package/src/ssr/index.tsx +151 -22
  32. package/src/ssr/inject-rsc-eager.ts +2 -2
  33. package/src/ssr/preinit-client-references.ts +106 -0
  34. package/src/ssr/ssr-root.tsx +35 -2
  35. package/src/vite/discovery/discover-routers.ts +27 -0
  36. package/src/vite/discovery/prerender-collection.ts +16 -0
  37. package/src/vite/discovery/shell-prerender-phase.ts +395 -0
  38. package/src/vite/discovery/state.ts +42 -0
  39. package/src/vite/index.ts +1 -0
  40. package/src/vite/plugin-types.ts +33 -0
  41. package/src/vite/plugins/version-plugin.ts +8 -0
  42. package/src/vite/plugins/virtual-entries.ts +37 -4
  43. package/src/vite/rango.ts +11 -2
  44. package/src/vite/router-discovery.ts +292 -8
  45. package/src/vite/utils/prerender-utils.ts +25 -6
  46. package/src/vite/utils/shared-utils.ts +4 -2
@@ -0,0 +1,395 @@
1
+ /**
2
+ * Post-build PPR shell capture phase (producer B, issue #699).
3
+ *
4
+ * Runs from the buildApp post hook, AFTER every environment bundle is written
5
+ * — the shell prelude embeds built client asset URLs (the bootstrap entry),
6
+ * which do not exist during buildStart discovery. Reuses the buildStart temp
7
+ * server kept alive on DiscoveryState (its realm already has tries installed
8
+ * and routers registered), seeds an in-realm prerender store from the
9
+ * retained phase-A Flight payloads, and drives the shared capture core
10
+ * (prerender/build-shell-capture.ts) once per Prerender+ppr candidate. The
11
+ * SSR half is composed from the temp server's SSR environment runner with
12
+ * the BUILT client entry as bootstrap.
13
+ *
14
+ * Stored entries are staged as __ps-*.js asset modules under the RSC out
15
+ * dir with a lazy __shell-manifest.js (mirroring the prerender manifest);
16
+ * the runtime read-through (rsc/shell-build-manifest.ts) serves them on a
17
+ * shell-store MISS. A candidate whose capture is refused, produces no shell,
18
+ * or never consulted the prerender store is SKIPPED loudly — the route keeps
19
+ * producer A (runtime capture) semantics, never a wrong-lane bake.
20
+ */
21
+
22
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
23
+ import { join, resolve } from "node:path";
24
+ import { jsonParseExpression } from "../utils/manifest-utils.js";
25
+ import { writeBuildAssetModule } from "../utils/prerender-utils.js";
26
+ import { buildShellManifestKey } from "../../prerender/shell-manifest-key.js";
27
+ // Type-only: the producer stages exactly the record shape the runtime
28
+ // read-through consumes, so the contract cannot drift (the KEY half of that
29
+ // contract is shell-manifest-key.ts). No runtime coupling — the RSC-runtime
30
+ // module is never imported by plugin code.
31
+ import type { BuildShellEntry } from "../../rsc/shell-build-manifest.js";
32
+ import type { DiscoveryState, ShellPrerenderCandidate } from "./state.js";
33
+ import { createRangoDebugger, NS } from "../debug.js";
34
+
35
+ const debug = createRangoDebugger(NS.prerender);
36
+
37
+ /**
38
+ * Minimal builder surface the phase reads: resolved plugins (for the main
39
+ * build's version plugin) and per-environment outDirs.
40
+ */
41
+ interface BuilderLike {
42
+ config?: {
43
+ base?: string;
44
+ plugins?: readonly unknown[];
45
+ };
46
+ environments?: Record<
47
+ string,
48
+ { config?: { build?: { outDir?: string } } } | undefined
49
+ >;
50
+ }
51
+
52
+ export async function runShellPrerenderPhase(
53
+ s: DiscoveryState,
54
+ builder: BuilderLike | undefined,
55
+ ): Promise<void> {
56
+ // The kept temp server (and the buildEnv deferred with it) is OWNED by the
57
+ // callers — the buildApp post hook's finally on success, buildEnd on an
58
+ // aborted build — so this function stays a pure producer: it only tears
59
+ // down the globals it installs itself.
60
+ const tempServer = s.shellPhaseTempServer;
61
+ if (!s.isBuildMode || !s.shellCandidates?.length || !tempServer) return;
62
+
63
+ const candidates: ShellPrerenderCandidate[] = s.shellCandidates;
64
+ const startTotal = performance.now();
65
+ console.log(`[rango] Shell-prerendering ${candidates.length} URL(s)...`);
66
+
67
+ let restoreClientRequire: (() => void) | undefined;
68
+ try {
69
+ // Runner access needs the RunnableDevEnvironment surface; the environments
70
+ // map types as DevEnvironment (same cast the dev endpoints use).
71
+ const rscEnv = (tempServer.environments as any)?.rsc;
72
+ const ssrEnv = (tempServer.environments as any)?.ssr;
73
+ if (!rscEnv?.runner || !ssrEnv?.runner) {
74
+ console.warn(
75
+ "[rango] shell prerender: temp server runners unavailable " +
76
+ `(rsc=${String(!!rscEnv?.runner)}, ssr=${String(!!ssrEnv?.runner)}); ` +
77
+ "skipping — routes keep runtime shell capture.",
78
+ );
79
+ return;
80
+ }
81
+
82
+ // The MAIN build's version (the value folded into the shipped worker) —
83
+ // never the temp server's own version-plugin stamp, which is a different
84
+ // Date.now() and would fail the serve-side isValidShellHit gate forever.
85
+ const versionPlugin = (builder?.config?.plugins ?? []).find(
86
+ (p: any) => p?.name === "@rangojs/router:version",
87
+ ) as { api?: { getBuildVersion?: () => string } } | undefined;
88
+ const buildVersion = versionPlugin?.api?.getBuildVersion?.();
89
+ if (!buildVersion) {
90
+ console.warn(
91
+ "[rango] shell prerender: main build version unavailable; skipping — " +
92
+ "routes keep runtime shell capture.",
93
+ );
94
+ return;
95
+ }
96
+
97
+ // In-realm prerender store over the retained phase-A payloads, so the
98
+ // capture's match() re-enters withCacheLookup and REPLAYS the build-time
99
+ // segments (globalThis is shared between the plugin process and the temp
100
+ // server's module-runner realms). `fetchedKeys` doubles as the replay
101
+ // assertion: a capture whose candidate key was never fetched rendered
102
+ // something else (live handler, wrong route) and must not be baked.
103
+ const payloads = s.prerenderPayloadValues ?? new Map<string, string>();
104
+ const fetchedKeys = new Set<string>();
105
+ (globalThis as any).__loadPrerenderManifestModule = async () => ({
106
+ default: Object.fromEntries([...payloads.keys()].map((k) => [k, k])),
107
+ loadPrerenderAsset: async (spec: string) => {
108
+ fetchedKeys.add(spec);
109
+ return { default: JSON.parse(payloads.get(spec)!) };
110
+ },
111
+ });
112
+
113
+ // SSR half from the temp server's SSR environment runner. react-dom's
114
+ // edge builds surface as default-only namespaces through the runner.
115
+ const ssrRunner = ssrEnv.runner;
116
+ const ssrPkg = await ssrRunner.import("@rangojs/router/ssr");
117
+ const ssrDeps = await ssrRunner.import("@rangojs/router/internal/deps/ssr");
118
+ const reactDomServer = await ssrRunner.import("react-dom/server.edge");
119
+ const reactDomStatic = await ssrRunner.import("react-dom/static.edge");
120
+ const htmlStream = await ssrRunner.import(
121
+ "@rangojs/router/internal/deps/html-stream-server",
122
+ );
123
+
124
+ // Built client bootstrap: the prelude must embed the BUILT entry URL.
125
+ const clientOutDir =
126
+ builder?.environments?.client?.config?.build?.outDir ??
127
+ resolve(s.projectRoot, "dist/client");
128
+ const clientAssetsDir = join(clientOutDir, "assets");
129
+ const entryFile = existsSync(clientAssetsDir)
130
+ ? readdirSync(clientAssetsDir).find((f) => /^index-.*\.js$/.test(f))
131
+ : undefined;
132
+ if (!entryFile) {
133
+ console.warn(
134
+ `[rango] shell prerender: built client entry not found under ${clientAssetsDir}; ` +
135
+ "skipping — routes keep runtime shell capture.",
136
+ );
137
+ return;
138
+ }
139
+ const base = builder?.config?.base ?? "/";
140
+ const normalizedBase = base.endsWith("/") ? base : `${base}/`;
141
+ const bootstrapContent = `import("${normalizedBase}assets/${entryFile}")`;
142
+ debug?.("shell prerender bootstrap: %s", bootstrapContent);
143
+
144
+ // The Flight payloads carry PRODUCTION-HASHED client reference ids
145
+ // (hashClientRefs, forceBuild) — resolvable only in the built bundles.
146
+ // The temp server's SSR loader receives those hashes and can neither
147
+ // validate nor import them (dev refKeys are module URLs). Bridge: wrap
148
+ // the SSR realm's late-bound client require (__vite_rsc_client_require__,
149
+ // read per call by plugin-rsc's __vite_rsc_require__) with a hash ->
150
+ // dev-refKey reverse map computed from plugin-rsc's manager using the
151
+ // SAME hashing (computeProductionHash). Lazy rebuild on miss: a client
152
+ // module first transformed DURING capture registers after the map was
153
+ // built. The RSC realm needs no bridge — its $$decode-client lane
154
+ // re-registers references by id without loading modules.
155
+ const minimalPlugin = (tempServer.config?.plugins ?? []).find(
156
+ (p: any) => p?.name === "rsc:minimal",
157
+ );
158
+ const rscManager = minimalPlugin?.api?.manager;
159
+ const { computeProductionHash } =
160
+ await import("../plugins/client-ref-hashing.js");
161
+ let hashToDevKey = new Map<string, string>();
162
+ const rebuildHashMap = (): void => {
163
+ hashToDevKey = new Map();
164
+ const metaMap = rscManager?.clientReferenceMetaMap ?? {};
165
+ for (const meta of Object.values(metaMap) as Array<{
166
+ referenceKey: string;
167
+ }>) {
168
+ hashToDevKey.set(
169
+ computeProductionHash(s.projectRoot, meta.referenceKey),
170
+ meta.referenceKey,
171
+ );
172
+ }
173
+ };
174
+ const origClientRequire = (globalThis as any).__vite_rsc_client_require__;
175
+ if (typeof origClientRequire === "function") {
176
+ restoreClientRequire = () => {
177
+ (globalThis as any).__vite_rsc_client_require__ = origClientRequire;
178
+ };
179
+ (globalThis as any).__vite_rsc_client_require__ = (id: string) => {
180
+ const base = id.split("$$cache=")[0]!;
181
+ let mapped = hashToDevKey.get(base);
182
+ if (mapped === undefined) {
183
+ rebuildHashMap();
184
+ mapped = hashToDevKey.get(base);
185
+ }
186
+ return origClientRequire(mapped ?? id);
187
+ };
188
+ }
189
+
190
+ const captureShellHTML = ssrPkg.createShellCaptureHandler({
191
+ createFromReadableStream: ssrDeps.createFromReadableStream,
192
+ renderToReadableStream:
193
+ reactDomServer.renderToReadableStream ??
194
+ reactDomServer.default?.renderToReadableStream,
195
+ resume: reactDomServer.resume ?? reactDomServer.default?.resume,
196
+ prerender: reactDomStatic.prerender ?? reactDomStatic.default?.prerender,
197
+ injectRSCPayload: htmlStream.injectRSCPayload,
198
+ headScripts: "preinit",
199
+ loadBootstrapScriptContent: async () => bootstrapContent,
200
+ });
201
+
202
+ // RSC half: the shared capture core in the RSC realm.
203
+ const rscRunner = rscEnv.runner;
204
+ const captureMod = await rscRunner.import(
205
+ "@rangojs/router/build/shell-capture",
206
+ );
207
+ const serverMod = await rscRunner.import("@rangojs/router/server");
208
+ const registry: Map<string, any> = serverMod.RouterRegistry;
209
+
210
+ const staged: Array<{ key: string; value: string }> = [];
211
+ let skipCount = 0;
212
+
213
+ // Manifest keys are pathname-only (see shell-manifest-key.ts): two
214
+ // candidates on one pathname (two routers prerendering the same path)
215
+ // would be ambiguous at serve time — decline ALL of that pathname's
216
+ // entries rather than bake a wrong-router shell.
217
+ const pathCounts = new Map<string, number>();
218
+ for (const cand of candidates) {
219
+ pathCounts.set(cand.urlPath, (pathCounts.get(cand.urlPath) ?? 0) + 1);
220
+ }
221
+
222
+ for (const cand of candidates) {
223
+ if ((pathCounts.get(cand.urlPath) ?? 0) > 1) {
224
+ console.warn(
225
+ `[rango] SHELL SKIP ${cand.urlPath.padEnd(34)} - pathname claimed by multiple prerender routes; routes keep runtime capture`,
226
+ );
227
+ skipCount++;
228
+ continue;
229
+ }
230
+ const startUrl = performance.now();
231
+ const policy = captureMod.resolveBuildPprConfig(cand.ppr);
232
+ const mainKey = `${cand.routeName}/${cand.paramHash}`;
233
+ let handled = false;
234
+ const mismatches: string[] = [];
235
+ for (const [, routerInstance] of registry) {
236
+ if (typeof routerInstance.match !== "function") continue;
237
+ try {
238
+ const res = await captureMod.captureShellForBuild({
239
+ router: routerInstance,
240
+ urlPath: cand.urlPath,
241
+ routeName: cand.routeName,
242
+ key: `${cand.urlPath}:shell`,
243
+ ttl: policy.ttl,
244
+ swr: policy.swr,
245
+ tags: policy.tags,
246
+ buildEnv: s.resolvedBuildEnv,
247
+ buildVersion,
248
+ captureShellHTML,
249
+ debug: !!debug,
250
+ });
251
+ if (res.outcome === "route-mismatch") {
252
+ mismatches.push(res.matchedRouteName ?? "(no match)");
253
+ continue;
254
+ }
255
+ handled = true;
256
+ const elapsed = (performance.now() - startUrl).toFixed(0);
257
+ if (res.outcome !== "stored" || !res.entry) {
258
+ console.warn(
259
+ `[rango] SHELL SKIP ${cand.urlPath.padEnd(34)} (${elapsed}ms) - capture ${res.outcome}; route keeps runtime capture`,
260
+ );
261
+ skipCount++;
262
+ break;
263
+ }
264
+ // Replay assertion: the capture must have fetched THIS candidate's
265
+ // prerender payload — otherwise it rendered outside the store
266
+ // (live handler in the source realm) and baking it would encode a
267
+ // lane production never serves.
268
+ if (!fetchedKeys.has(mainKey)) {
269
+ console.warn(
270
+ `[rango] SHELL SKIP ${cand.urlPath.padEnd(34)} (${elapsed}ms) - capture did not replay the prerender store; route keeps runtime capture`,
271
+ );
272
+ skipCount++;
273
+ break;
274
+ }
275
+ const value: BuildShellEntry = {
276
+ entry: res.entry,
277
+ ttl: policy.ttl,
278
+ swr: policy.swr,
279
+ tags: res.tags,
280
+ routeName: cand.routeName,
281
+ };
282
+ staged.push({
283
+ key: buildShellManifestKey(cand.urlPath),
284
+ value: JSON.stringify(value),
285
+ });
286
+ console.log(
287
+ `[rango] SHELL OK ${cand.urlPath.padEnd(34)} (${elapsed}ms)`,
288
+ );
289
+ break;
290
+ } catch (err: any) {
291
+ handled = true;
292
+ const elapsed = (performance.now() - startUrl).toFixed(0);
293
+ console.warn(
294
+ `[rango] SHELL SKIP ${cand.urlPath.padEnd(34)} (${elapsed}ms) - ${err?.message ?? err}; route keeps runtime capture`,
295
+ );
296
+ skipCount++;
297
+ break;
298
+ }
299
+ }
300
+ if (!handled) {
301
+ console.warn(
302
+ `[rango] SHELL SKIP ${cand.urlPath.padEnd(34)} - no router matched "${cand.routeName}"` +
303
+ (mismatches.length > 0
304
+ ? ` (matched: ${mismatches.join(", ")})`
305
+ : "") +
306
+ "; route keeps runtime capture",
307
+ );
308
+ skipCount++;
309
+ }
310
+ }
311
+
312
+ if (staged.length > 0) {
313
+ const rscOutDir =
314
+ builder?.environments?.rsc?.config?.build?.outDir ??
315
+ resolve(s.projectRoot, "dist/rsc");
316
+ writeShellManifest(s, rscOutDir, staged);
317
+ }
318
+
319
+ const totalElapsed = (performance.now() - startTotal).toFixed(0);
320
+ const parts = [`${staged.length} done`];
321
+ if (skipCount > 0) parts.push(`${skipCount} skipped`);
322
+ console.log(
323
+ `[rango] Shell prerender complete: ${parts.join(", ")} (${totalElapsed}ms total)`,
324
+ );
325
+ } catch (err: any) {
326
+ // The shell phase is an optimization over runtime capture — a failure
327
+ // must never fail an otherwise-good build. Loud, so a first-request-HIT
328
+ // expectation is never silently degraded.
329
+ console.warn(
330
+ `[rango] shell prerender phase failed: ${err?.message ?? err}; ` +
331
+ "routes keep runtime shell capture.",
332
+ );
333
+ } finally {
334
+ // Tear down only what THIS function installed (its globals). The kept
335
+ // temp server + deferred buildEnv pair is owned by the callers: the
336
+ // buildApp post hook's finally on success, buildEnd on an aborted build.
337
+ delete (globalThis as any).__loadPrerenderManifestModule;
338
+ restoreClientRequire?.();
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Write staged shell entries as content-hashed asset modules + the lazy
344
+ * manifest, and inject the loader global into the built RSC entry. Mirrors
345
+ * postprocessBundle's prerender manifest mechanics, but runs POST-buildApp
346
+ * (the RSC entry on disk was already postprocessed — read-modify-write with
347
+ * an idempotence guard).
348
+ */
349
+ function writeShellManifest(
350
+ s: DiscoveryState,
351
+ rscOutDir: string,
352
+ staged: Array<{ key: string; value: string }>,
353
+ ): void {
354
+ const rscEntryPath = resolve(rscOutDir, s.rscEntryFileName ?? "index.js");
355
+ if (!existsSync(rscEntryPath)) {
356
+ console.warn(
357
+ `[rango] shell prerender: RSC entry not found at ${rscEntryPath}; ` +
358
+ "entries not persisted — routes keep runtime capture.",
359
+ );
360
+ return;
361
+ }
362
+ const assetsDir = resolve(rscOutDir, "assets");
363
+
364
+ let totalBytes = 0;
365
+ const manifestMap: Record<string, string> = {};
366
+ const countedFiles = new Set<string>();
367
+ for (const { key, value } of staged) {
368
+ const fileName = writeBuildAssetModule(assetsDir, "__ps", value);
369
+ if (!countedFiles.has(fileName)) {
370
+ countedFiles.add(fileName);
371
+ totalBytes += Buffer.byteLength(value) + "export default ;\n".length;
372
+ }
373
+ manifestMap[key] = `./assets/${fileName}`;
374
+ }
375
+
376
+ const manifestCode = [
377
+ `const m=${jsonParseExpression(manifestMap)};`,
378
+ `export function loadShellAsset(s){return import(s)}`,
379
+ `export default m;`,
380
+ "",
381
+ ].join("\n");
382
+ writeFileSync(resolve(rscOutDir, "__shell-manifest.js"), manifestCode);
383
+ totalBytes += Buffer.byteLength(manifestCode);
384
+
385
+ const rscCode = readFileSync(rscEntryPath, "utf-8");
386
+ if (!rscCode.includes("__shell-manifest.js")) {
387
+ const injection = `globalThis.__loadShellManifestModule = () => import("./__shell-manifest.js");\n`;
388
+ writeFileSync(rscEntryPath, injection + rscCode);
389
+ }
390
+
391
+ const totalKB = (totalBytes / 1024).toFixed(1);
392
+ console.log(
393
+ `[rango] Wrote shell assets (${totalKB} KB total, ${staged.length} entries)`,
394
+ );
395
+ }
@@ -38,6 +38,12 @@ export interface PluginOptions {
38
38
  * (`clientChunks: true`/default); undefined for `false` or a custom function.
39
39
  */
40
40
  clientChunkCtx?: import("../utils/client-chunks.js").ClientChunkContext;
41
+ /**
42
+ * rango({ headScripts }) — threaded so the dev temp server can serve the
43
+ * REAL virtual SSR entry (getVirtualEntrySSR) for the on-demand shell
44
+ * capture endpoint with the app's configured head-script strategy.
45
+ */
46
+ headScripts?: import("../plugin-types.js").HeadScriptsOption;
41
47
  }
42
48
 
43
49
  export interface PrecomputedEntry {
@@ -58,6 +64,18 @@ export interface PerRouterManifestEntry {
58
64
  factoryOnlyPrefixes?: Set<string>;
59
65
  }
60
66
 
67
+ /**
68
+ * One Prerender+ppr URL the build must additionally produce a PPR shell entry
69
+ * for (issue #699 producer B). `ppr` is the route's raw path option, already
70
+ * filtered to truthy by the collector.
71
+ */
72
+ export interface ShellPrerenderCandidate {
73
+ urlPath: string;
74
+ routeName: string;
75
+ paramHash: string;
76
+ ppr: true | { ttl?: number; swr?: number; tags?: string[] };
77
+ }
78
+
61
79
  export interface DiscoveryState {
62
80
  resolvedEntryPath: string | undefined;
63
81
  projectRoot: string;
@@ -93,6 +111,27 @@ export interface DiscoveryState {
93
111
 
94
112
  prerenderManifestEntries: Record<string, string> | null;
95
113
  staticManifestEntries: Record<string, string> | null;
114
+ /**
115
+ * Build-time PPR shell candidates: prerender routes that ALSO declare the
116
+ * `ppr` path option (issue #699 producer B). Collected by
117
+ * expandPrerenderRoutes; consumed by the post-build shell capture phase.
118
+ */
119
+ shellCandidates: ShellPrerenderCandidate[] | null;
120
+ /**
121
+ * Raw prerender payload JSON strings keyed by manifest key, retained in
122
+ * memory so the shell capture phase can seed an in-realm prerender store
123
+ * without re-reading staged asset files.
124
+ */
125
+ prerenderPayloadValues: Map<string, string> | null;
126
+ /**
127
+ * The buildStart temp RSC server, kept alive past discovery when shell
128
+ * candidates exist: the shell capture phase (buildApp post) reuses its
129
+ * realm — tries installed, registry populated — after the client build has
130
+ * produced the asset URLs the prelude must embed. Deferred as a PAIR with
131
+ * the buildEnv; closed by the buildApp post hook's finally (success) or
132
+ * buildEnd (aborted build).
133
+ */
134
+ shellPhaseTempServer: import("vite").ViteDevServer | null;
96
135
  handlerChunkInfoMap: Map<string, ChunkInfo>;
97
136
  staticHandlerChunkInfoMap: Map<string, ChunkInfo>;
98
137
  rscEntryFileName: string | null;
@@ -145,6 +184,9 @@ export function createDiscoveryState(
145
184
 
146
185
  prerenderManifestEntries: null,
147
186
  staticManifestEntries: null,
187
+ shellCandidates: null,
188
+ prerenderPayloadValues: null,
189
+ shellPhaseTempServer: null,
148
190
  handlerChunkInfoMap: new Map(),
149
191
  staticHandlerChunkInfoMap: new Map(),
150
192
  rscEntryFileName: null,
package/src/vite/index.ts CHANGED
@@ -24,6 +24,7 @@ export type {
24
24
  RangoOptions,
25
25
  ClientChunks,
26
26
  ClientChunkMeta,
27
+ HeadScriptsOption,
27
28
  BuildEnvOption,
28
29
  BuildEnvFactory,
29
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
@@ -142,6 +142,14 @@ export function createVersionPlugin(): Plugin {
142
142
  name: "@rangojs/router:version",
143
143
  enforce: "pre",
144
144
 
145
+ // The build-time shell capture phase (producer B, #699) stamps entries
146
+ // with THIS instance's version — the value folded into the shipped worker
147
+ // — never the discovery temp server's own version-plugin value (a
148
+ // different Date.now() stamp that would fail the serve-side gate).
149
+ api: {
150
+ getBuildVersion: (): string => currentVersion,
151
+ },
152
+
145
153
  configResolved(config) {
146
154
  isDev = config.command === "serve";
147
155
  resolvedCacheDir = config.cacheDir
@@ -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({
@@ -536,6 +544,7 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
536
544
  prerenderOnError: options?.prerender?.onError,
537
545
  discovery: options?.discovery,
538
546
  clientChunkCtx,
547
+ headScripts: resolvedOptions.headScripts,
539
548
  }),
540
549
  );
541
550