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

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 (45) hide show
  1. package/dist/bin/rango.js +7 -0
  2. package/dist/vite/index.js +823 -235
  3. package/package.json +6 -1
  4. package/skills/mime-routes/SKILL.md +25 -17
  5. package/skills/ppr/SKILL.md +20 -10
  6. package/src/browser/event-controller.ts +16 -2
  7. package/src/browser/rsc-router.tsx +11 -0
  8. package/src/cache/cache-scope.ts +11 -2
  9. package/src/cache/cf/cf-cache-store.ts +60 -2
  10. package/src/cache/memory-segment-store.ts +32 -0
  11. package/src/cache/segment-codec.ts +47 -0
  12. package/src/cache/types.ts +14 -0
  13. package/src/cache/vercel/vercel-cache-store.ts +71 -2
  14. package/src/index.rsc.ts +6 -0
  15. package/src/prerender/build-shell-capture.ts +253 -0
  16. package/src/prerender/shell-manifest-key.ts +20 -0
  17. package/src/prerender/store.ts +10 -1
  18. package/src/router/content-negotiation.ts +47 -5
  19. package/src/router/match-middleware/cache-lookup.ts +12 -1
  20. package/src/router/metrics.ts +17 -2
  21. package/src/router/prerender-match.ts +21 -0
  22. package/src/router/router-interfaces.ts +7 -0
  23. package/src/router/router-options.ts +13 -0
  24. package/src/router.ts +5 -0
  25. package/src/rsc/capture-queue.ts +67 -0
  26. package/src/rsc/handler.ts +4 -2
  27. package/src/rsc/rsc-rendering.ts +131 -23
  28. package/src/rsc/shell-build-manifest.ts +274 -0
  29. package/src/rsc/shell-capture.ts +486 -63
  30. package/src/rsc/shell-serve.ts +44 -0
  31. package/src/rsc/ssr-setup.ts +54 -22
  32. package/src/segment-fragments.ts +124 -0
  33. package/src/server/context.ts +1 -0
  34. package/src/server/request-context.ts +65 -11
  35. package/src/ssr/index.tsx +47 -9
  36. package/src/ssr/ssr-root.tsx +35 -2
  37. package/src/urls/pattern-types.ts +27 -0
  38. package/src/vite/discovery/discover-routers.ts +27 -0
  39. package/src/vite/discovery/prerender-collection.ts +16 -0
  40. package/src/vite/discovery/shell-prerender-phase.ts +397 -0
  41. package/src/vite/discovery/state.ts +44 -0
  42. package/src/vite/plugins/version-plugin.ts +8 -0
  43. package/src/vite/rango.ts +1 -0
  44. package/src/vite/router-discovery.ts +310 -8
  45. package/src/vite/utils/prerender-utils.ts +25 -6
@@ -19,11 +19,16 @@ import {
19
19
  createScanFilter,
20
20
  } from "../build/generate-route-types.js";
21
21
  import { firstCodeMatchIndex } from "../build/route-types/source-scan.js";
22
+ import {
23
+ DEV_SHELL_PROBE_TIMEOUT_MS,
24
+ normalizeCaptureTimeout,
25
+ } from "../rsc/shell-serve.js";
22
26
  import {
23
27
  injectClientDebugFlag,
24
28
  internalDebugNoCacheMiddleware,
25
29
  } from "./inject-client-debug.js";
26
30
  import { createVersionPlugin } from "./plugins/version-plugin.js";
31
+ import { getVirtualEntrySSR, VIRTUAL_IDS } from "./plugins/virtual-entries.js";
27
32
  import { createVirtualStubPlugin } from "./plugins/virtual-stub-plugin.js";
28
33
  import {
29
34
  BUILD_ENV_GLOBAL_KEY,
@@ -46,6 +51,7 @@ import {
46
51
  peekSelfGenWrite,
47
52
  } from "./discovery/self-gen-tracking.js";
48
53
  import { discoverRouters } from "./discovery/discover-routers.js";
54
+ import { runShellPrerenderPhase } from "./discovery/shell-prerender-phase.js";
49
55
  import { describeDiscoveryFailure } from "./discovery/discovery-errors.js";
50
56
  import {
51
57
  createDevPrerenderCache,
@@ -138,7 +144,21 @@ function ensureCloudflareProtocolLoaderRegistered(): void {
138
144
  */
139
145
  async function createTempRscServer(
140
146
  state: DiscoveryState,
141
- options: { forceBuild?: boolean; cacheDir?: string } = {},
147
+ options: {
148
+ forceBuild?: boolean;
149
+ cacheDir?: string;
150
+ /**
151
+ * Serve the REAL rango SSR entry (getVirtualEntrySSR) for
152
+ * "virtual:entry-ssr" instead of the discovery stub, so the dev
153
+ * /__rsc_shell endpoint can drive captureShellHTML in this server's SSR
154
+ * realm. Dev-correct by construction: the entry's bootstrap resolves to
155
+ * plugin-rsc's stable virtual browser-entry URL, which the MAIN dev
156
+ * server serves to the browser. Loaded lazily — discovery never imports
157
+ * the SSR entry, so the temp server stays as light as before until a
158
+ * shell capture actually runs.
159
+ */
160
+ realSsrEntry?: boolean;
161
+ } = {},
142
162
  ) {
143
163
  // Install the Node ESM loader hook before any module evaluation so
144
164
  // `cloudflare:*` specifiers in externalized/loader-delegated modules
@@ -178,6 +198,26 @@ async function createTempRscServer(
178
198
  // hashClientRefs only in build mode — production bundles need hashed refs
179
199
  ...(options.forceBuild ? [hashClientRefs(state.projectRoot)] : []),
180
200
  createVersionPlugin(),
201
+ // Before the stub plugin, so "virtual:entry-ssr" resolves to the real
202
+ // SSR entry when the shell endpoint needs it (see the option doc).
203
+ ...(options.realSsrEntry
204
+ ? [
205
+ {
206
+ name: "@rangojs/router:temp-real-ssr-entry",
207
+ enforce: "pre" as const,
208
+ resolveId(id: string) {
209
+ return id === "virtual:entry-ssr"
210
+ ? "\0rango-temp-real-ssr-entry"
211
+ : null;
212
+ },
213
+ load(id: string) {
214
+ return id === "\0rango-temp-real-ssr-entry"
215
+ ? getVirtualEntrySSR(state.opts?.headScripts)
216
+ : null;
217
+ },
218
+ } satisfies import("vite").Plugin,
219
+ ]
220
+ : []),
181
221
  createVirtualStubPlugin(),
182
222
  createCloudflareProtocolStubPlugin(),
183
223
  // Dev prerender must use dev-mode IDs (path-based) to match the workerd
@@ -281,6 +321,19 @@ async function acquireBuildEnv(
281
321
  return true;
282
322
  }
283
323
 
324
+ /**
325
+ * Reset the per-build prerender collection state. A helper (not inline
326
+ * assignments in buildStart) so TS's property narrowing does not pin
327
+ * `s.shellCandidates` to `null` across the discovery call that repopulates
328
+ * it — the finally block re-reads it to decide the temp-server keep-alive.
329
+ */
330
+ function resetPrerenderCollection(s: DiscoveryState): void {
331
+ s.prerenderManifestEntries = null;
332
+ s.staticManifestEntries = null;
333
+ s.shellCandidates = null;
334
+ s.prerenderPayloadValues = null;
335
+ }
336
+
284
337
  /**
285
338
  * Release build-time env resources and clear state.
286
339
  */
@@ -541,6 +594,10 @@ export function createRouterDiscoveryPlugin(
541
594
  try {
542
595
  prerenderTempServer = await createTempRscServer(s, {
543
596
  cacheDir: "node_modules/.vite_prerender",
597
+ // The dev /__rsc_shell endpoint drives captureShellHTML in this
598
+ // server's SSR realm; the entry is only imported when a shell
599
+ // capture runs, so discovery cost is unchanged.
600
+ realSsrEntry: true,
544
601
  });
545
602
 
546
603
  const tempRscEnv = (prerenderTempServer.environments as any)?.rsc;
@@ -1057,6 +1114,204 @@ export function createRouterDiscoveryPlugin(
1057
1114
  logResult(404, "no match");
1058
1115
  });
1059
1116
 
1117
+ // Dev on-demand PPR shell production (producer B, #699). There is no
1118
+ // build manifest in dev, so the serve path's read-through
1119
+ // (rsc/shell-build-manifest.ts) fetches the shell entry from here on a
1120
+ // Prerender+ppr route's first request — dev serves x-rango-shell: HIT
1121
+ // from request one, mirroring production. Memoized per router HMR
1122
+ // generation AND per caller version (a client-module edit bumps the
1123
+ // version without rotating the router instance; the stale entry would
1124
+ // fail the serve gate forever). The endpoint is policy-free: the caller
1125
+ // (the serve gate, which resolved the route's ppr option) sends
1126
+ // ttl/swr/tags/version. Only prerender-backed routes produce entries —
1127
+ // the /__rsc_prerender pre-flight below both warms the payload memo the
1128
+ // capture's dev store fetch will hit AND refuses non-prerenderable
1129
+ // routes (a live-handler render must never be served as a baked shell).
1130
+ server.middlewares.use("/__rsc_shell", async (req: any, res: any) => {
1131
+ await s.discoveryDone;
1132
+ const url = new URL(req.url ?? "", "http://localhost");
1133
+ const pathname = url.searchParams.get("pathname");
1134
+ const routeName = url.searchParams.get("routeName");
1135
+ const version = url.searchParams.get("version");
1136
+ // ttl is required like the identifiers: the endpoint is policy-free
1137
+ // (the serve gate resolved the route's ppr option and always sends
1138
+ // it), so there is deliberately no default to drift from
1139
+ // resolvePprConfig's.
1140
+ const ttlRaw = url.searchParams.get("ttl");
1141
+ if (!pathname || !routeName || !version || !ttlRaw) {
1142
+ res.statusCode = 400;
1143
+ res.end("Missing pathname/routeName/version/ttl");
1144
+ return;
1145
+ }
1146
+ const ttl = Number(ttlRaw);
1147
+ const swrRaw = url.searchParams.get("swr");
1148
+ const swr = swrRaw === null ? undefined : Number(swrRaw);
1149
+ const tagsRaw = url.searchParams.get("tags");
1150
+ const tags = tagsRaw ? tagsRaw.split(",") : undefined;
1151
+ const maxSnapshotBytesRaw = url.searchParams.get("maxSnapshotBytes");
1152
+ const maxSnapshotBytes =
1153
+ maxSnapshotBytesRaw === null
1154
+ ? undefined
1155
+ : Number(maxSnapshotBytesRaw);
1156
+ // Boundary revalidation via the SHARED normalizer (shell-serve.ts):
1157
+ // the param crossed an HTTP query string, and a garbage value must
1158
+ // fall back to the capture default, never reach setTimeout as NaN
1159
+ // (which Node clamps to ~1ms — an instant abort).
1160
+ const captureTimeout = normalizeCaptureTimeout(
1161
+ Number(url.searchParams.get("captureTimeout")),
1162
+ );
1163
+
1164
+ // Resolve the capture realms: main-server envs (Node preset) or the
1165
+ // shared temp Node server (Cloudflare preset — no main RSC runner).
1166
+ // Entry re-import per request picks up HMR edits, exactly like the
1167
+ // prerender endpoint above.
1168
+ const rscEnvMain = (server.environments as any)?.rsc;
1169
+ let rscRealm: any = null;
1170
+ let ssrRealm: any = null;
1171
+ let ssrEntryId: string;
1172
+ if (rscEnvMain?.runner && s.resolvedEntryPath) {
1173
+ try {
1174
+ await rscEnvMain.runner.import(s.resolvedEntryPath);
1175
+ } catch (err: any) {
1176
+ res.statusCode = 500;
1177
+ res.end(`Shell capture module refresh failed: ${err.message}`);
1178
+ return;
1179
+ }
1180
+ rscRealm = rscEnvMain;
1181
+ ssrRealm = (server.environments as any)?.ssr;
1182
+ ssrEntryId =
1183
+ (server.environments as any)?.ssr?.config?.build?.rollupOptions
1184
+ ?.input?.index ?? VIRTUAL_IDS.ssr;
1185
+ } else {
1186
+ const tempRscEnv = await getOrCreateTempServer();
1187
+ if (tempRscEnv) {
1188
+ try {
1189
+ await importEntryAndRegistry(tempRscEnv);
1190
+ } catch (err: any) {
1191
+ res.statusCode = 500;
1192
+ res.end(`Shell capture module refresh failed: ${err.message}`);
1193
+ return;
1194
+ }
1195
+ }
1196
+ rscRealm = tempRscEnv;
1197
+ ssrRealm = (prerenderTempServer?.environments as any)?.ssr;
1198
+ ssrEntryId = "virtual:entry-ssr";
1199
+ }
1200
+ if (!rscRealm?.runner || !ssrRealm?.runner) {
1201
+ res.statusCode = 503;
1202
+ res.end("Shell capture runners not available");
1203
+ return;
1204
+ }
1205
+ let registry: Map<string, any> | null = null;
1206
+ try {
1207
+ const serverMod = await rscRealm.runner.import(
1208
+ "@rangojs/router/server",
1209
+ );
1210
+ registry = serverMod.RouterRegistry ?? null;
1211
+ } catch {
1212
+ registry = null;
1213
+ }
1214
+ if (!registry || registry.size === 0) {
1215
+ res.statusCode = 503;
1216
+ res.end("Shell capture registry not available");
1217
+ return;
1218
+ }
1219
+
1220
+ // Memo sweep FIRST: after request one the common case is a memo HIT
1221
+ // (this fetch blocks a foreground document request), and the memoized
1222
+ // body needs neither the pre-flight round-trip nor a capture. Keyed
1223
+ // per router instance (= HMR generation) like the prerender memo.
1224
+ const cacheKey = `shell|${pathname}|r=${routeName}|t=${ttl}|s=${swr ?? ""}|g=${(tags ?? []).join("+")}|c=${captureTimeout ?? ""}|v=${version}`;
1225
+ for (const [, routerInstance] of registry) {
1226
+ if (typeof routerInstance.match !== "function") continue;
1227
+ const cached = devPrerenderCache.get(routerInstance, cacheKey);
1228
+ if (cached !== undefined) {
1229
+ res.setHeader("content-type", "application/json");
1230
+ res.setHeader("x-rango-shell-dev", "HIT");
1231
+ res.end(cached);
1232
+ return;
1233
+ }
1234
+ }
1235
+
1236
+ // Pre-flight: the route must be prerender-backed. Warms the payload
1237
+ // memo the capture's dev prerender store will fetch, and closes the
1238
+ // live-handler-bake hole (a non-pr route 404s here).
1239
+ if (s.devServerOrigin) {
1240
+ try {
1241
+ const probe = await fetch(
1242
+ `${s.devServerOrigin}/__rsc_prerender?pathname=${encodeURIComponent(pathname)}&routeName=${encodeURIComponent(routeName)}`,
1243
+ { signal: AbortSignal.timeout(DEV_SHELL_PROBE_TIMEOUT_MS) },
1244
+ );
1245
+ if (!probe.ok) {
1246
+ res.statusCode = 404;
1247
+ res.end("Route is not prerenderable");
1248
+ return;
1249
+ }
1250
+ } catch {
1251
+ res.statusCode = 404;
1252
+ res.end("Prerender pre-flight failed");
1253
+ return;
1254
+ }
1255
+ }
1256
+
1257
+ for (const [, routerInstance] of registry) {
1258
+ if (typeof routerInstance.match !== "function") continue;
1259
+ try {
1260
+ const ssrModule = await ssrRealm.runner.import(ssrEntryId);
1261
+ if (typeof ssrModule?.captureShellHTML !== "function") {
1262
+ res.statusCode = 404;
1263
+ res.end("SSR entry has no captureShellHTML");
1264
+ return;
1265
+ }
1266
+ const captureMod = await rscRealm.runner.import(
1267
+ "@rangojs/router/build/shell-capture",
1268
+ );
1269
+ const result = await captureMod.captureShellForBuild({
1270
+ router: routerInstance,
1271
+ urlPath: pathname,
1272
+ routeName,
1273
+ key: `${pathname}:shell`,
1274
+ ttl,
1275
+ swr,
1276
+ tags,
1277
+ maxSnapshotBytes,
1278
+ captureTimeout,
1279
+ buildEnv: s.resolvedBuildEnv,
1280
+ buildVersion: version,
1281
+ captureShellHTML: ssrModule.captureShellHTML,
1282
+ debug: !!debugDiscovery,
1283
+ });
1284
+ if (result.outcome === "route-mismatch") continue;
1285
+ if (result.outcome !== "stored" || !result.entry) {
1286
+ res.statusCode = 404;
1287
+ res.end(`Shell capture ${result.outcome}`);
1288
+ return;
1289
+ }
1290
+ const body = JSON.stringify({
1291
+ entry: result.entry,
1292
+ ttl,
1293
+ swr,
1294
+ tags: result.tags,
1295
+ routeName,
1296
+ });
1297
+ devPrerenderCache.set(routerInstance, cacheKey, body);
1298
+ res.setHeader("content-type", "application/json");
1299
+ res.setHeader("x-rango-shell-dev", "MISS");
1300
+ res.end(body);
1301
+ return;
1302
+ } catch (err: any) {
1303
+ console.warn(
1304
+ `[rango] Dev shell capture error for ${pathname} (route keeps runtime capture): ${err.message}`,
1305
+ );
1306
+ res.statusCode = 404;
1307
+ res.end(`Shell capture error: ${err.message}`);
1308
+ return;
1309
+ }
1310
+ }
1311
+ res.statusCode = 404;
1312
+ res.end("No router matched");
1313
+ });
1314
+
1060
1315
  // Watch url module and router files for changes and regenerate named-routes.gen.ts.
1061
1316
  // Process files containing urls( or createRouter( to update the combined route map.
1062
1317
  if (opts?.staticRouteTypesGeneration !== false) {
@@ -1430,8 +1685,7 @@ export function createRouterDiscoveryPlugin(
1430
1685
  const buildStartTime = performance.now();
1431
1686
  debugDiscovery?.("build: start (env=%s)", this.environment?.name ?? "?");
1432
1687
  resetStagedBuildAssets(s.projectRoot);
1433
- s.prerenderManifestEntries = null;
1434
- s.staticManifestEntries = null;
1688
+ resetPrerenderCollection(s);
1435
1689
 
1436
1690
  // Acquire build-time env bindings if configured
1437
1691
  await timed(debugDiscovery, "build acquireBuildEnv", () =>
@@ -1502,12 +1756,23 @@ export function createRouterDiscoveryPlugin(
1502
1756
  );
1503
1757
  } finally {
1504
1758
  delete (globalThis as any).__rscRouterDiscoveryActive;
1505
- if (tempServer) {
1506
- await timed(debugDiscovery, "build tempServer.close", () =>
1507
- tempServer.close(),
1508
- );
1759
+ if (tempServer && s.shellCandidates?.length) {
1760
+ // Prerender+ppr candidates exist: keep the temp server (and its
1761
+ // realm — tries installed, registry populated) alive for the
1762
+ // post-build shell capture phase (buildApp post, producer B #699).
1763
+ // The prelude embeds built client asset URLs, so the capture can
1764
+ // only run after the client build; that phase closes the server.
1765
+ // buildEnv release is deferred with it — a bake-lane loader
1766
+ // executing during the capture may read ctx.env.
1767
+ s.shellPhaseTempServer = tempServer;
1768
+ } else {
1769
+ if (tempServer) {
1770
+ await timed(debugDiscovery, "build tempServer.close", () =>
1771
+ tempServer.close(),
1772
+ );
1773
+ }
1774
+ await releaseBuildEnv(s);
1509
1775
  }
1510
- await releaseBuildEnv(s);
1511
1776
  debugDiscovery?.(
1512
1777
  "build discovery done (%sms)",
1513
1778
  (performance.now() - buildStartTime).toFixed(1),
@@ -1515,6 +1780,43 @@ export function createRouterDiscoveryPlugin(
1515
1780
  }
1516
1781
  },
1517
1782
 
1783
+ // Post-build PPR shell capture (producer B, #699): runs after EVERY
1784
+ // environment bundle is written — the shell prelude embeds built client
1785
+ // asset URLs (bootstrap entry), which do not exist at buildStart. The
1786
+ // kept temp server and the buildEnv were deferred AS A PAIR in
1787
+ // buildStart's finally; this finally is the pair's success-path owner
1788
+ // (buildEnd below owns the aborted-build path) — the phase itself is a
1789
+ // pure producer and tears down only the globals it installs.
1790
+ buildApp: {
1791
+ order: "post",
1792
+ async handler(builder) {
1793
+ try {
1794
+ await runShellPrerenderPhase(s, builder as any);
1795
+ } finally {
1796
+ if (s.isBuildMode) {
1797
+ const tempServer = s.shellPhaseTempServer;
1798
+ s.shellPhaseTempServer = null;
1799
+ if (tempServer) await tempServer.close();
1800
+ await releaseBuildEnv(s);
1801
+ }
1802
+ }
1803
+ },
1804
+ },
1805
+
1806
+ // An environment build failure aborts the builder before the buildApp
1807
+ // post hook — never leak the kept temp server (open handles hang the CLI)
1808
+ // or the deferred buildEnv (a live miniflare proxy).
1809
+ async buildEnd(error) {
1810
+ if (!error || !s.shellPhaseTempServer) return;
1811
+ const tempServer = s.shellPhaseTempServer;
1812
+ s.shellPhaseTempServer = null;
1813
+ try {
1814
+ await tempServer.close();
1815
+ } finally {
1816
+ await releaseBuildEnv(s);
1817
+ }
1818
+ },
1819
+
1518
1820
  // Suppress vite's HMR cascade for our own gen-file writes.
1519
1821
  //
1520
1822
  // 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>,