@rangojs/router 0.0.0-experimental.146 → 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.
@@ -24,6 +24,7 @@ import {
24
24
  internalDebugNoCacheMiddleware,
25
25
  } from "./inject-client-debug.js";
26
26
  import { createVersionPlugin } from "./plugins/version-plugin.js";
27
+ import { getVirtualEntrySSR, VIRTUAL_IDS } from "./plugins/virtual-entries.js";
27
28
  import { createVirtualStubPlugin } from "./plugins/virtual-stub-plugin.js";
28
29
  import {
29
30
  BUILD_ENV_GLOBAL_KEY,
@@ -46,6 +47,7 @@ import {
46
47
  peekSelfGenWrite,
47
48
  } from "./discovery/self-gen-tracking.js";
48
49
  import { discoverRouters } from "./discovery/discover-routers.js";
50
+ import { runShellPrerenderPhase } from "./discovery/shell-prerender-phase.js";
49
51
  import { describeDiscoveryFailure } from "./discovery/discovery-errors.js";
50
52
  import {
51
53
  createDevPrerenderCache,
@@ -138,7 +140,21 @@ function ensureCloudflareProtocolLoaderRegistered(): void {
138
140
  */
139
141
  async function createTempRscServer(
140
142
  state: DiscoveryState,
141
- options: { forceBuild?: boolean; cacheDir?: string } = {},
143
+ options: {
144
+ forceBuild?: boolean;
145
+ cacheDir?: string;
146
+ /**
147
+ * Serve the REAL rango SSR entry (getVirtualEntrySSR) for
148
+ * "virtual:entry-ssr" instead of the discovery stub, so the dev
149
+ * /__rsc_shell endpoint can drive captureShellHTML in this server's SSR
150
+ * realm. Dev-correct by construction: the entry's bootstrap resolves to
151
+ * plugin-rsc's stable virtual browser-entry URL, which the MAIN dev
152
+ * server serves to the browser. Loaded lazily — discovery never imports
153
+ * the SSR entry, so the temp server stays as light as before until a
154
+ * shell capture actually runs.
155
+ */
156
+ realSsrEntry?: boolean;
157
+ } = {},
142
158
  ) {
143
159
  // Install the Node ESM loader hook before any module evaluation so
144
160
  // `cloudflare:*` specifiers in externalized/loader-delegated modules
@@ -178,6 +194,26 @@ async function createTempRscServer(
178
194
  // hashClientRefs only in build mode — production bundles need hashed refs
179
195
  ...(options.forceBuild ? [hashClientRefs(state.projectRoot)] : []),
180
196
  createVersionPlugin(),
197
+ // Before the stub plugin, so "virtual:entry-ssr" resolves to the real
198
+ // SSR entry when the shell endpoint needs it (see the option doc).
199
+ ...(options.realSsrEntry
200
+ ? [
201
+ {
202
+ name: "@rangojs/router:temp-real-ssr-entry",
203
+ enforce: "pre" as const,
204
+ resolveId(id: string) {
205
+ return id === "virtual:entry-ssr"
206
+ ? "\0rango-temp-real-ssr-entry"
207
+ : null;
208
+ },
209
+ load(id: string) {
210
+ return id === "\0rango-temp-real-ssr-entry"
211
+ ? getVirtualEntrySSR(state.opts?.headScripts)
212
+ : null;
213
+ },
214
+ } satisfies import("vite").Plugin,
215
+ ]
216
+ : []),
181
217
  createVirtualStubPlugin(),
182
218
  createCloudflareProtocolStubPlugin(),
183
219
  // Dev prerender must use dev-mode IDs (path-based) to match the workerd
@@ -281,6 +317,19 @@ async function acquireBuildEnv(
281
317
  return true;
282
318
  }
283
319
 
320
+ /**
321
+ * Reset the per-build prerender collection state. A helper (not inline
322
+ * assignments in buildStart) so TS's property narrowing does not pin
323
+ * `s.shellCandidates` to `null` across the discovery call that repopulates
324
+ * it — the finally block re-reads it to decide the temp-server keep-alive.
325
+ */
326
+ function resetPrerenderCollection(s: DiscoveryState): void {
327
+ s.prerenderManifestEntries = null;
328
+ s.staticManifestEntries = null;
329
+ s.shellCandidates = null;
330
+ s.prerenderPayloadValues = null;
331
+ }
332
+
284
333
  /**
285
334
  * Release build-time env resources and clear state.
286
335
  */
@@ -541,6 +590,10 @@ export function createRouterDiscoveryPlugin(
541
590
  try {
542
591
  prerenderTempServer = await createTempRscServer(s, {
543
592
  cacheDir: "node_modules/.vite_prerender",
593
+ // The dev /__rsc_shell endpoint drives captureShellHTML in this
594
+ // server's SSR realm; the entry is only imported when a shell
595
+ // capture runs, so discovery cost is unchanged.
596
+ realSsrEntry: true,
544
597
  });
545
598
 
546
599
  const tempRscEnv = (prerenderTempServer.environments as any)?.rsc;
@@ -1057,6 +1110,190 @@ export function createRouterDiscoveryPlugin(
1057
1110
  logResult(404, "no match");
1058
1111
  });
1059
1112
 
1113
+ // Dev on-demand PPR shell production (producer B, #699). There is no
1114
+ // build manifest in dev, so the serve path's read-through
1115
+ // (rsc/shell-build-manifest.ts) fetches the shell entry from here on a
1116
+ // Prerender+ppr route's first request — dev serves x-rango-shell: HIT
1117
+ // from request one, mirroring production. Memoized per router HMR
1118
+ // generation AND per caller version (a client-module edit bumps the
1119
+ // version without rotating the router instance; the stale entry would
1120
+ // fail the serve gate forever). The endpoint is policy-free: the caller
1121
+ // (the serve gate, which resolved the route's ppr option) sends
1122
+ // ttl/swr/tags/version. Only prerender-backed routes produce entries —
1123
+ // the /__rsc_prerender pre-flight below both warms the payload memo the
1124
+ // capture's dev store fetch will hit AND refuses non-prerenderable
1125
+ // routes (a live-handler render must never be served as a baked shell).
1126
+ server.middlewares.use("/__rsc_shell", async (req: any, res: any) => {
1127
+ await s.discoveryDone;
1128
+ const url = new URL(req.url ?? "", "http://localhost");
1129
+ const pathname = url.searchParams.get("pathname");
1130
+ const routeName = url.searchParams.get("routeName");
1131
+ const version = url.searchParams.get("version");
1132
+ // ttl is required like the identifiers: the endpoint is policy-free
1133
+ // (the serve gate resolved the route's ppr option and always sends
1134
+ // it), so there is deliberately no default to drift from
1135
+ // resolvePprConfig's.
1136
+ const ttlRaw = url.searchParams.get("ttl");
1137
+ if (!pathname || !routeName || !version || !ttlRaw) {
1138
+ res.statusCode = 400;
1139
+ res.end("Missing pathname/routeName/version/ttl");
1140
+ return;
1141
+ }
1142
+ const ttl = Number(ttlRaw);
1143
+ const swrRaw = url.searchParams.get("swr");
1144
+ const swr = swrRaw === null ? undefined : Number(swrRaw);
1145
+ const tagsRaw = url.searchParams.get("tags");
1146
+ const tags = tagsRaw ? tagsRaw.split(",") : undefined;
1147
+
1148
+ // Resolve the capture realms: main-server envs (Node preset) or the
1149
+ // shared temp Node server (Cloudflare preset — no main RSC runner).
1150
+ // Entry re-import per request picks up HMR edits, exactly like the
1151
+ // prerender endpoint above.
1152
+ const rscEnvMain = (server.environments as any)?.rsc;
1153
+ let rscRealm: any = null;
1154
+ let ssrRealm: any = null;
1155
+ let ssrEntryId: string;
1156
+ if (rscEnvMain?.runner && s.resolvedEntryPath) {
1157
+ try {
1158
+ await rscEnvMain.runner.import(s.resolvedEntryPath);
1159
+ } catch (err: any) {
1160
+ res.statusCode = 500;
1161
+ res.end(`Shell capture module refresh failed: ${err.message}`);
1162
+ return;
1163
+ }
1164
+ rscRealm = rscEnvMain;
1165
+ ssrRealm = (server.environments as any)?.ssr;
1166
+ ssrEntryId =
1167
+ (server.environments as any)?.ssr?.config?.build?.rollupOptions
1168
+ ?.input?.index ?? VIRTUAL_IDS.ssr;
1169
+ } else {
1170
+ const tempRscEnv = await getOrCreateTempServer();
1171
+ if (tempRscEnv) {
1172
+ try {
1173
+ await importEntryAndRegistry(tempRscEnv);
1174
+ } catch (err: any) {
1175
+ res.statusCode = 500;
1176
+ res.end(`Shell capture module refresh failed: ${err.message}`);
1177
+ return;
1178
+ }
1179
+ }
1180
+ rscRealm = tempRscEnv;
1181
+ ssrRealm = (prerenderTempServer?.environments as any)?.ssr;
1182
+ ssrEntryId = "virtual:entry-ssr";
1183
+ }
1184
+ if (!rscRealm?.runner || !ssrRealm?.runner) {
1185
+ res.statusCode = 503;
1186
+ res.end("Shell capture runners not available");
1187
+ return;
1188
+ }
1189
+ let registry: Map<string, any> | null = null;
1190
+ try {
1191
+ const serverMod = await rscRealm.runner.import(
1192
+ "@rangojs/router/server",
1193
+ );
1194
+ registry = serverMod.RouterRegistry ?? null;
1195
+ } catch {
1196
+ registry = null;
1197
+ }
1198
+ if (!registry || registry.size === 0) {
1199
+ res.statusCode = 503;
1200
+ res.end("Shell capture registry not available");
1201
+ return;
1202
+ }
1203
+
1204
+ // Memo sweep FIRST: after request one the common case is a memo HIT
1205
+ // (this fetch blocks a foreground document request), and the memoized
1206
+ // body needs neither the pre-flight round-trip nor a capture. Keyed
1207
+ // per router instance (= HMR generation) like the prerender memo.
1208
+ const cacheKey = `shell|${pathname}|r=${routeName}|t=${ttl}|s=${swr ?? ""}|g=${(tags ?? []).join("+")}|v=${version}`;
1209
+ for (const [, routerInstance] of registry) {
1210
+ if (typeof routerInstance.match !== "function") continue;
1211
+ const cached = devPrerenderCache.get(routerInstance, cacheKey);
1212
+ if (cached !== undefined) {
1213
+ res.setHeader("content-type", "application/json");
1214
+ res.setHeader("x-rango-shell-dev", "HIT");
1215
+ res.end(cached);
1216
+ return;
1217
+ }
1218
+ }
1219
+
1220
+ // Pre-flight: the route must be prerender-backed. Warms the payload
1221
+ // memo the capture's dev prerender store will fetch, and closes the
1222
+ // live-handler-bake hole (a non-pr route 404s here).
1223
+ if (s.devServerOrigin) {
1224
+ try {
1225
+ const probe = await fetch(
1226
+ `${s.devServerOrigin}/__rsc_prerender?pathname=${encodeURIComponent(pathname)}&routeName=${encodeURIComponent(routeName)}`,
1227
+ { signal: AbortSignal.timeout(10_000) },
1228
+ );
1229
+ if (!probe.ok) {
1230
+ res.statusCode = 404;
1231
+ res.end("Route is not prerenderable");
1232
+ return;
1233
+ }
1234
+ } catch {
1235
+ res.statusCode = 404;
1236
+ res.end("Prerender pre-flight failed");
1237
+ return;
1238
+ }
1239
+ }
1240
+
1241
+ for (const [, routerInstance] of registry) {
1242
+ if (typeof routerInstance.match !== "function") continue;
1243
+ try {
1244
+ const ssrModule = await ssrRealm.runner.import(ssrEntryId);
1245
+ if (typeof ssrModule?.captureShellHTML !== "function") {
1246
+ res.statusCode = 404;
1247
+ res.end("SSR entry has no captureShellHTML");
1248
+ return;
1249
+ }
1250
+ const captureMod = await rscRealm.runner.import(
1251
+ "@rangojs/router/build/shell-capture",
1252
+ );
1253
+ const result = await captureMod.captureShellForBuild({
1254
+ router: routerInstance,
1255
+ urlPath: pathname,
1256
+ routeName,
1257
+ key: `${pathname}:shell`,
1258
+ ttl,
1259
+ swr,
1260
+ tags,
1261
+ buildEnv: s.resolvedBuildEnv,
1262
+ buildVersion: version,
1263
+ captureShellHTML: ssrModule.captureShellHTML,
1264
+ debug: !!debugDiscovery,
1265
+ });
1266
+ if (result.outcome === "route-mismatch") continue;
1267
+ if (result.outcome !== "stored" || !result.entry) {
1268
+ res.statusCode = 404;
1269
+ res.end(`Shell capture ${result.outcome}`);
1270
+ return;
1271
+ }
1272
+ const body = JSON.stringify({
1273
+ entry: result.entry,
1274
+ ttl,
1275
+ swr,
1276
+ tags: result.tags,
1277
+ routeName,
1278
+ });
1279
+ devPrerenderCache.set(routerInstance, cacheKey, body);
1280
+ res.setHeader("content-type", "application/json");
1281
+ res.setHeader("x-rango-shell-dev", "MISS");
1282
+ res.end(body);
1283
+ return;
1284
+ } catch (err: any) {
1285
+ console.warn(
1286
+ `[rango] Dev shell capture error for ${pathname} (route keeps runtime capture): ${err.message}`,
1287
+ );
1288
+ res.statusCode = 404;
1289
+ res.end(`Shell capture error: ${err.message}`);
1290
+ return;
1291
+ }
1292
+ }
1293
+ res.statusCode = 404;
1294
+ res.end("No router matched");
1295
+ });
1296
+
1060
1297
  // Watch url module and router files for changes and regenerate named-routes.gen.ts.
1061
1298
  // Process files containing urls( or createRouter( to update the combined route map.
1062
1299
  if (opts?.staticRouteTypesGeneration !== false) {
@@ -1430,8 +1667,7 @@ export function createRouterDiscoveryPlugin(
1430
1667
  const buildStartTime = performance.now();
1431
1668
  debugDiscovery?.("build: start (env=%s)", this.environment?.name ?? "?");
1432
1669
  resetStagedBuildAssets(s.projectRoot);
1433
- s.prerenderManifestEntries = null;
1434
- s.staticManifestEntries = null;
1670
+ resetPrerenderCollection(s);
1435
1671
 
1436
1672
  // Acquire build-time env bindings if configured
1437
1673
  await timed(debugDiscovery, "build acquireBuildEnv", () =>
@@ -1502,12 +1738,23 @@ export function createRouterDiscoveryPlugin(
1502
1738
  );
1503
1739
  } finally {
1504
1740
  delete (globalThis as any).__rscRouterDiscoveryActive;
1505
- if (tempServer) {
1506
- await timed(debugDiscovery, "build tempServer.close", () =>
1507
- tempServer.close(),
1508
- );
1741
+ if (tempServer && s.shellCandidates?.length) {
1742
+ // Prerender+ppr candidates exist: keep the temp server (and its
1743
+ // realm — tries installed, registry populated) alive for the
1744
+ // post-build shell capture phase (buildApp post, producer B #699).
1745
+ // The prelude embeds built client asset URLs, so the capture can
1746
+ // only run after the client build; that phase closes the server.
1747
+ // buildEnv release is deferred with it — a bake-lane loader
1748
+ // executing during the capture may read ctx.env.
1749
+ s.shellPhaseTempServer = tempServer;
1750
+ } else {
1751
+ if (tempServer) {
1752
+ await timed(debugDiscovery, "build tempServer.close", () =>
1753
+ tempServer.close(),
1754
+ );
1755
+ }
1756
+ await releaseBuildEnv(s);
1509
1757
  }
1510
- await releaseBuildEnv(s);
1511
1758
  debugDiscovery?.(
1512
1759
  "build discovery done (%sms)",
1513
1760
  (performance.now() - buildStartTime).toFixed(1),
@@ -1515,6 +1762,43 @@ export function createRouterDiscoveryPlugin(
1515
1762
  }
1516
1763
  },
1517
1764
 
1765
+ // Post-build PPR shell capture (producer B, #699): runs after EVERY
1766
+ // environment bundle is written — the shell prelude embeds built client
1767
+ // asset URLs (bootstrap entry), which do not exist at buildStart. The
1768
+ // kept temp server and the buildEnv were deferred AS A PAIR in
1769
+ // buildStart's finally; this finally is the pair's success-path owner
1770
+ // (buildEnd below owns the aborted-build path) — the phase itself is a
1771
+ // pure producer and tears down only the globals it installs.
1772
+ buildApp: {
1773
+ order: "post",
1774
+ async handler(builder) {
1775
+ try {
1776
+ await runShellPrerenderPhase(s, builder as any);
1777
+ } finally {
1778
+ if (s.isBuildMode) {
1779
+ const tempServer = s.shellPhaseTempServer;
1780
+ s.shellPhaseTempServer = null;
1781
+ if (tempServer) await tempServer.close();
1782
+ await releaseBuildEnv(s);
1783
+ }
1784
+ }
1785
+ },
1786
+ },
1787
+
1788
+ // An environment build failure aborts the builder before the buildApp
1789
+ // post hook — never leak the kept temp server (open handles hang the CLI)
1790
+ // or the deferred buildEnv (a live miniflare proxy).
1791
+ async buildEnd(error) {
1792
+ if (!error || !s.shellPhaseTempServer) return;
1793
+ const tempServer = s.shellPhaseTempServer;
1794
+ s.shellPhaseTempServer = null;
1795
+ try {
1796
+ await tempServer.close();
1797
+ } finally {
1798
+ await releaseBuildEnv(s);
1799
+ }
1800
+ },
1801
+
1518
1802
  // Suppress vite's HMR cascade for our own gen-file writes.
1519
1803
  //
1520
1804
  // After every cf HMR cycle, refreshTempRscEnv → writeRouteTypesFiles
@@ -207,20 +207,27 @@ export function resetStagedBuildAssets(projectRoot: string): void {
207
207
  rmSync(getStagedAssetDir(projectRoot), { recursive: true, force: true });
208
208
  }
209
209
 
210
- export function stageBuildAssetModule(
211
- projectRoot: string,
212
- prefix: "__pr" | "__st",
210
+ /**
211
+ * Write one content-hashed `export default <value>;` asset module into `dir`
212
+ * (created if needed) and return its file name. Identical payloads dedupe to
213
+ * one file (the hash is the content). Shared by the staged prerender/static
214
+ * flow (stageBuildAssetModule below) and the shell prerender phase, which
215
+ * writes directly into the final RSC assets dir — it runs after buildApp,
216
+ * past the staging/copy window.
217
+ */
218
+ export function writeBuildAssetModule(
219
+ dir: string,
220
+ prefix: "__pr" | "__st" | "__ps",
213
221
  exportValue: string,
214
222
  ): string {
215
- const stagedDir = getStagedAssetDir(projectRoot);
216
- mkdirSync(stagedDir, { recursive: true });
223
+ mkdirSync(dir, { recursive: true });
217
224
 
218
225
  const contentHash = createHash("sha256")
219
226
  .update(exportValue)
220
227
  .digest("hex")
221
228
  .slice(0, 8);
222
229
  const fileName = `${prefix}-${contentHash}.js`;
223
- const filePath = resolve(stagedDir, fileName);
230
+ const filePath = resolve(dir, fileName);
224
231
 
225
232
  if (!existsSync(filePath)) {
226
233
  writeFileSync(filePath, `export default ${exportValue};\n`);
@@ -229,6 +236,18 @@ export function stageBuildAssetModule(
229
236
  return fileName;
230
237
  }
231
238
 
239
+ export function stageBuildAssetModule(
240
+ projectRoot: string,
241
+ prefix: "__pr" | "__st",
242
+ exportValue: string,
243
+ ): string {
244
+ return writeBuildAssetModule(
245
+ getStagedAssetDir(projectRoot),
246
+ prefix,
247
+ exportValue,
248
+ );
249
+ }
250
+
232
251
  export function copyStagedBuildAssets(
233
252
  projectRoot: string,
234
253
  fileNames: Iterable<string>,