@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.
- package/dist/bin/rango.js +8 -40
- package/dist/vite/index.js +840 -243
- package/package.json +6 -1
- package/src/browser/event-controller.ts +16 -2
- package/src/browser/rsc-router.tsx +11 -0
- package/src/cache/cache-scope.ts +41 -3
- package/src/cache/cf/cf-cache-store.ts +23 -0
- package/src/cache/handle-snapshot.ts +22 -1
- package/src/cache/memory-segment-store.ts +32 -0
- package/src/cache/segment-codec.ts +47 -0
- package/src/cache/shell-snapshot.ts +47 -0
- package/src/cache/types.ts +27 -0
- package/src/cache/vercel/vercel-cache-store.ts +71 -2
- package/src/deps/ssr.ts +4 -1
- package/src/prerender/build-shell-capture.ts +237 -0
- package/src/prerender/shell-manifest-key.ts +20 -0
- package/src/prerender/store.ts +10 -1
- package/src/router/loader-resolution.ts +16 -0
- package/src/router/match-api.ts +9 -2
- package/src/router/match-handlers.ts +13 -0
- package/src/router/match-middleware/cache-lookup.ts +12 -1
- package/src/router/prerender-match.ts +21 -0
- package/src/router/segment-resolution/mask-nested.ts +19 -3
- package/src/rsc/capture-queue.ts +67 -0
- package/src/rsc/rsc-rendering.ts +136 -25
- package/src/rsc/shell-build-manifest.ts +244 -0
- package/src/rsc/shell-capture.ts +194 -43
- package/src/segment-fragments.ts +124 -0
- package/src/segment-system.tsx +49 -19
- package/src/server/request-context.ts +112 -11
- package/src/ssr/index.tsx +151 -22
- package/src/ssr/inject-rsc-eager.ts +2 -2
- package/src/ssr/preinit-client-references.ts +106 -0
- package/src/ssr/ssr-root.tsx +35 -2
- package/src/vite/discovery/discover-routers.ts +27 -0
- package/src/vite/discovery/prerender-collection.ts +16 -0
- package/src/vite/discovery/shell-prerender-phase.ts +395 -0
- package/src/vite/discovery/state.ts +42 -0
- package/src/vite/index.ts +1 -0
- package/src/vite/plugin-types.ts +33 -0
- package/src/vite/plugins/version-plugin.ts +8 -0
- package/src/vite/plugins/virtual-entries.ts +37 -4
- package/src/vite/rango.ts +11 -2
- package/src/vite/router-discovery.ts +292 -8
- package/src/vite/utils/prerender-utils.ts +25 -6
- 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
package/src/vite/plugin-types.ts
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
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(
|
|
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(
|
|
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
|
|