@rangojs/router 0.1.0 → 0.1.1

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 (89) hide show
  1. package/dist/bin/rango.js +11 -1
  2. package/dist/types/browser/dev-discovery.d.ts +11 -0
  3. package/dist/types/browser/navigation-store.d.ts +3 -13
  4. package/dist/types/browser/prefetch/loader.d.ts +10 -0
  5. package/dist/types/browser/prefetch/runtime.d.ts +3 -0
  6. package/dist/types/browser/types.d.ts +4 -16
  7. package/dist/types/build/generate-manifest.d.ts +6 -0
  8. package/dist/types/cache/cache-scope.d.ts +7 -5
  9. package/dist/types/cache/cf/cf-cache-store.d.ts +10 -9
  10. package/dist/types/cache/memory-segment-store.d.ts +6 -6
  11. package/dist/types/cache/shell-snapshot.d.ts +21 -8
  12. package/dist/types/cache/types.d.ts +24 -7
  13. package/dist/types/cache/vercel/vercel-cache-store.d.ts +9 -8
  14. package/dist/types/dev-discovery-protocol.d.ts +5 -0
  15. package/dist/types/prerender/store.d.ts +1 -0
  16. package/dist/types/route-map-builder.d.ts +4 -29
  17. package/dist/types/router/prerender-match.d.ts +4 -1
  18. package/dist/types/router/router-interfaces.d.ts +3 -1
  19. package/dist/types/rsc/handler-context.d.ts +1 -0
  20. package/dist/types/rsc/render-pipeline.d.ts +3 -2
  21. package/dist/types/rsc/rsc-rendering.d.ts +5 -10
  22. package/dist/types/rsc/shell-capture.d.ts +4 -8
  23. package/dist/types/rsc/types.d.ts +2 -0
  24. package/dist/types/server/context.d.ts +16 -0
  25. package/dist/types/server/request-context.d.ts +18 -8
  26. package/dist/types/server.d.ts +1 -1
  27. package/dist/types/types/route-entry.d.ts +3 -0
  28. package/dist/types/vite/discovery/state.d.ts +3 -2
  29. package/dist/types/vite/utils/manifest-utils.d.ts +1 -1
  30. package/dist/vite/index.js +194 -107
  31. package/package.json +7 -2
  32. package/skills/caching/SKILL.md +1 -1
  33. package/skills/ppr/SKILL.md +48 -4
  34. package/src/browser/dev-discovery.ts +66 -0
  35. package/src/browser/navigation-client.ts +6 -0
  36. package/src/browser/navigation-store.ts +4 -271
  37. package/src/browser/partial-update.ts +7 -14
  38. package/src/browser/prefetch/cache.ts +1 -1
  39. package/src/browser/prefetch/loader.ts +110 -0
  40. package/src/browser/prefetch/runtime.ts +7 -0
  41. package/src/browser/react/Link.tsx +7 -10
  42. package/src/browser/react/NavigationProvider.tsx +1 -1
  43. package/src/browser/react/use-router.ts +1 -1
  44. package/src/browser/rsc-router.tsx +4 -2
  45. package/src/browser/types.ts +4 -27
  46. package/src/build/generate-manifest.ts +11 -0
  47. package/src/cache/cache-scope.ts +17 -6
  48. package/src/cache/cf/cf-cache-store.ts +63 -39
  49. package/src/cache/memory-segment-store.ts +17 -6
  50. package/src/cache/shell-snapshot.ts +45 -15
  51. package/src/cache/types.ts +23 -6
  52. package/src/cache/vercel/vercel-cache-store.ts +52 -22
  53. package/src/dev-discovery-protocol.ts +11 -0
  54. package/src/prerender/build-shell-capture.ts +7 -1
  55. package/src/prerender/store.ts +5 -0
  56. package/src/route-definition/dsl-helpers.ts +7 -2
  57. package/src/route-map-builder.ts +56 -49
  58. package/src/router/handler-context.ts +13 -5
  59. package/src/router/lazy-includes.ts +1 -0
  60. package/src/router/match-api.ts +8 -4
  61. package/src/router/prerender-match.ts +19 -2
  62. package/src/router/router-interfaces.ts +4 -0
  63. package/src/router/segment-resolution/static-store.ts +36 -10
  64. package/src/router.ts +34 -3
  65. package/src/rsc/handler-context.ts +1 -0
  66. package/src/rsc/handler.ts +3 -1
  67. package/src/rsc/loader-fetch.ts +2 -2
  68. package/src/rsc/manifest-init.ts +4 -11
  69. package/src/rsc/render-pipeline.ts +10 -2
  70. package/src/rsc/rsc-rendering.ts +207 -146
  71. package/src/rsc/shell-capture.ts +34 -11
  72. package/src/rsc/types.ts +2 -0
  73. package/src/server/context.ts +28 -0
  74. package/src/server/request-context.ts +32 -10
  75. package/src/server.ts +0 -2
  76. package/src/testing/dispatch.ts +1 -0
  77. package/src/types/route-entry.ts +3 -0
  78. package/src/urls/include-helper.ts +1 -0
  79. package/src/urls/path-helper.ts +8 -4
  80. package/src/vite/discovery/bundle-postprocess.ts +10 -7
  81. package/src/vite/discovery/discover-routers.ts +7 -66
  82. package/src/vite/discovery/prerender-collection.ts +13 -2
  83. package/src/vite/discovery/state.ts +4 -4
  84. package/src/vite/discovery/virtual-module-codegen.ts +75 -42
  85. package/src/vite/plugins/version-injector.ts +0 -1
  86. package/src/vite/plugins/virtual-entries.ts +11 -1
  87. package/src/vite/router-discovery.ts +132 -22
  88. package/src/vite/utils/manifest-utils.ts +1 -4
  89. package/src/vite/utils/shared-utils.ts +7 -0
@@ -24,6 +24,19 @@ import type { DiscoveryState } from "./state.js";
24
24
  */
25
25
  const MANIFEST_EXTERNALIZE_THRESHOLD = 512 * 1024;
26
26
 
27
+ function devDiscoveryBootstrap(state: DiscoveryState): string[] {
28
+ if (
29
+ state.isBuildMode ||
30
+ state.opts?.preset !== "cloudflare" ||
31
+ state.devDiscoveryEpoch === undefined
32
+ ) {
33
+ return [];
34
+ }
35
+
36
+ const epoch = state.devDiscoveryEpoch;
37
+ return [`globalThis.__RANGO_DEV_DISCOVERY_EPOCH = ${epoch};`];
38
+ }
39
+
27
40
  /**
28
41
  * Generate the code for the main virtual:rsc-router/routes-manifest module.
29
42
  */
@@ -39,11 +52,9 @@ export function generateRoutesManifestModule(state: DiscoveryState): string {
39
52
  // module and re-evaluates it on the next request, calling
40
53
  // setCachedManifest() with fresh data. No manual sync needed.
41
54
  const genFileImports: string[] = [];
42
- const genFileVars: string[] = [];
43
- const routersWithoutGenFile: Array<{
44
- id: string;
45
- manifest: Record<string, string>;
46
- }> = [];
55
+ // Per-entry NamedRoutes import var, aligned with perRouterManifests
56
+ // (null for routers without a gen file).
57
+ const genFileVarByEntry: Array<string | null> = [];
47
58
  let varIdx = 0;
48
59
 
49
60
  for (const entry of state.perRouterManifests) {
@@ -61,17 +72,20 @@ export function generateRoutesManifestModule(state: DiscoveryState): string {
61
72
  genFileImports.push(
62
73
  `import { NamedRoutes as ${varName} } from ${JSON.stringify(genPath)};`,
63
74
  );
64
- genFileVars.push(varName);
75
+ genFileVarByEntry.push(varName);
65
76
  } else {
66
- routersWithoutGenFile.push({
67
- id: entry.id,
68
- manifest: entry.routeManifest,
69
- });
77
+ genFileVarByEntry.push(null);
70
78
  }
71
79
  }
72
80
 
81
+ const serverImports = [
82
+ "setCachedManifest",
83
+ "setRouterManifest",
84
+ ...(state.isBuildMode ? ["registerRouterManifestLoader"] : []),
85
+ "clearAllRouterData",
86
+ ];
73
87
  const lines = [
74
- `import { setCachedManifest, setRouterManifest, registerRouterManifestLoader, clearAllRouterData } from "@rangojs/router/server";`,
88
+ `import { ${serverImports.join(", ")} } from "@rangojs/router/server";`,
75
89
  ...genFileImports,
76
90
  // Clear stale per-router cached data (manifest, trie, precomputed entries)
77
91
  // before re-populating. In Cloudflare dev mode, program reloads re-evaluate
@@ -81,62 +95,80 @@ export function generateRoutesManifestModule(state: DiscoveryState): string {
81
95
  `clearAllRouterData();`,
82
96
  ];
83
97
 
84
- if (genFileVars.length > 0) {
98
+ if (varIdx > 0) {
85
99
  lines.push(
86
100
  `function __flat(r) { const o = {}; for (const [k, v] of Object.entries(r)) o[k] = typeof v === "string" ? v : v.path; return o; }`,
87
101
  );
88
102
  }
89
103
 
90
- if (genFileVars.length === 1 && routersWithoutGenFile.length === 0) {
91
- lines.push(`setCachedManifest(__flat(${genFileVars[0]}));`);
104
+ // Each router's flattened map is materialized ONCE and the same object is
105
+ // shared between the global cachedManifest and the per-router map (a
106
+ // single-router app previously held two byte-identical maps, and __flat
107
+ // ran twice per router on cold start). Sharing is safe: every consumer
108
+ // reads these maps; lazy-include deltas mutate globalRouteMap, a different
109
+ // object (see registerRouteMap in route-map-builder.ts).
110
+ const routerMapVars: string[] = [];
111
+ for (const [i, entry] of state.perRouterManifests.entries()) {
112
+ const mapVar = `__m${i}`;
113
+ const genVar = genFileVarByEntry[i];
114
+ const init = genVar
115
+ ? `__flat(${genVar})`
116
+ : jsonParseExpression(entry.routeManifest);
117
+ lines.push(`const ${mapVar} = ${init};`);
118
+ routerMapVars.push(mapVar);
119
+ }
120
+
121
+ if (routerMapVars.length === 1) {
122
+ lines.push(`setCachedManifest(${routerMapVars[0]});`);
92
123
  } else {
93
- const parts: string[] = [];
94
- for (const v of genFileVars) parts.push(`...__flat(${v})`);
95
- for (const { manifest } of routersWithoutGenFile)
96
- parts.push(`...${jsonParseExpression(manifest)}`);
97
- lines.push(`setCachedManifest({ ${parts.join(", ")} });`);
124
+ lines.push(
125
+ `setCachedManifest({ ${routerMapVars.map((v) => `...${v}`).join(", ")} });`,
126
+ );
98
127
  }
99
128
 
100
- let genVarIdx = 0;
101
- for (const entry of state.perRouterManifests) {
102
- if (entry.sourceFile) {
103
- const varName = genFileVars[genVarIdx++];
104
- lines.push(
105
- `setRouterManifest(${JSON.stringify(entry.id)}, __flat(${varName}));`,
106
- );
107
- } else {
108
- lines.push(
109
- `setRouterManifest(${JSON.stringify(entry.id)}, ${jsonParseExpression(entry.routeManifest)});`,
110
- );
111
- }
129
+ for (const [i, entry] of state.perRouterManifests.entries()) {
130
+ lines.push(
131
+ `setRouterManifest(${JSON.stringify(entry.id)}, ${routerMapVars[i]});`,
132
+ );
112
133
  }
113
134
 
114
135
  // Per-router trie and precomputedEntries are NOT inlined eagerly.
115
136
  // They live in the per-router lazy chunks (generatePerRouterModule) and
116
137
  // are loaded via ensureRouterManifest(routerId), which is awaited before
117
- // every request in router.fetch() and before findMatch is reached.
118
- // Inlining the merged versions here would duplicate the per-router data
119
- // (the merged trie/precomputedEntries equal the per-router data for
120
- // single-router apps; for multi-router, the merged trie is dead code
121
- // because find-match.ts only consumes per-router tries).
138
+ // every request in router.fetch() and before findMatch is reached
139
+ // (Bundle Hygiene rule #1: route data in exactly one lazy chunk).
122
140
  //
123
141
  // In dev mode, the handler also falls back to Phase 2 regex matching
124
142
  // against live router.urlpatterns, which is always correct after a
125
143
  // program reload.
126
144
 
127
- for (const routerId of state.perRouterManifestDataMap.keys()) {
128
- lines.push(
129
- `registerRouterManifestLoader(${JSON.stringify(routerId)}, () => import(${JSON.stringify(VIRTUAL_ROUTES_MANIFEST_ID + "/" + routerId)}));`,
130
- );
145
+ // Loaders are registered in BUILD only. ensureRouterManifest() marks a
146
+ // loader-supplied trie authoritative (misses are hard 404s), which is only
147
+ // valid for a build-time trie from complete discovery. In dev the loader
148
+ // would preempt the handler's buildRouterTrieFromUrlpatterns fallback
149
+ // (rsc/handler.ts) and can install a STALE trie after a Cloudflare program
150
+ // reload: the per-router virtual module's only invalidation edge is the
151
+ // gen-file import, which lags the urls.tsx edit by one reload cycle, so a
152
+ // browser full-reload landing in that window renders a removed route from
153
+ // the stale-but-authoritative trie and never converges. Dev must always
154
+ // rebuild from live router.urlpatterns instead.
155
+ if (state.isBuildMode) {
156
+ for (const routerId of state.perRouterManifestDataMap.keys()) {
157
+ lines.push(
158
+ `registerRouterManifestLoader(${JSON.stringify(routerId)}, () => import(${JSON.stringify(VIRTUAL_ROUTES_MANIFEST_ID + "/" + routerId)}));`,
159
+ );
160
+ }
131
161
  }
132
162
  if (!state.isBuildMode && state.devServerOrigin) {
133
163
  lines.push(
134
164
  `globalThis.__PRERENDER_DEV_URL = ${JSON.stringify(state.devServerOrigin)};`,
135
165
  );
136
166
  }
167
+ lines.push(...devDiscoveryBootstrap(state));
137
168
  return lines.join("\n");
138
169
  }
139
170
 
171
+ const lines: string[] = [];
140
172
  if (!state.isBuildMode) {
141
173
  const origin =
142
174
  state.devServerOrigin ||
@@ -145,10 +177,11 @@ export function generateRoutesManifestModule(state: DiscoveryState): string {
145
177
  `http://localhost:${state.devServer.config.server.port || 5173}`);
146
178
  if (origin) {
147
179
  state.devServerOrigin = origin;
148
- return `globalThis.__PRERENDER_DEV_URL = ${JSON.stringify(origin)};`;
180
+ lines.push(`globalThis.__PRERENDER_DEV_URL = ${JSON.stringify(origin)};`);
149
181
  }
150
182
  }
151
- return `// Route manifest will be populated at runtime`;
183
+ lines.push(...devDiscoveryBootstrap(state));
184
+ return lines.join("\n") || `// Route manifest will be populated at runtime`;
152
185
  }
153
186
 
154
187
  /**
@@ -54,7 +54,6 @@ export function createVersionInjectorPlugin(
54
54
  const prepend: string[] = RSC_ENTRY_BOOTSTRAP_IMPORTS.map(
55
55
  (id) => `import "${id}";`,
56
56
  );
57
-
58
57
  let newCode = code;
59
58
  const needsVersion =
60
59
  code.includes("createRSCHandler") &&
@@ -26,7 +26,17 @@ async function initializeApp() {
26
26
  // context, including strictMode (default true) from createRouter. StrictMode
27
27
  // is the default; createRouter({ strictMode: false }) ships the opt-out in the
28
28
  // payload metadata. StrictMode emits no DOM, so toggling never changes markup.
29
- const { strictMode } = await initBrowserApp({ rscStream, deps });
29
+ const { strictMode, initialPayload } = await initBrowserApp({ rscStream, deps });
30
+
31
+ if (import.meta.hot) {
32
+ const { startDevDiscoveryHandshake } = await import(
33
+ "@rangojs/router/internal/browser/dev-discovery"
34
+ );
35
+ startDevDiscoveryHandshake(
36
+ initialPayload.metadata?.devDiscoveryEpoch,
37
+ import.meta.hot
38
+ );
39
+ }
30
40
 
31
41
  const app = createElement(Rango);
32
42
  hydrateRoot(
@@ -19,6 +19,12 @@ 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_DISCOVERY_EPOCH_HEADER,
24
+ DEV_DISCOVERY_PROBE_HEADER,
25
+ DEV_DISCOVERY_QUERY_EVENT,
26
+ DEV_DISCOVERY_READY_EVENT,
27
+ } from "../dev-discovery-protocol.js";
22
28
  import {
23
29
  DEV_SHELL_PROBE_TIMEOUT_MS,
24
30
  normalizeCaptureTimeout,
@@ -472,6 +478,45 @@ export function createRouterDiscoveryPlugin(
472
478
  if ((globalThis as any).__rscRouterDiscoveryActive) return;
473
479
  s.devServer = server;
474
480
 
481
+ let workerReadyEpoch: number | undefined;
482
+ const publishDevDiscoveryReady = (epoch: number) => {
483
+ if (epoch <= (workerReadyEpoch ?? -1)) return;
484
+ workerReadyEpoch = epoch;
485
+ (server.environments as any)?.client?.hot?.send({
486
+ type: "custom",
487
+ event: DEV_DISCOVERY_READY_EVENT,
488
+ data: { epoch },
489
+ });
490
+ debugDiscovery?.("hmr: workerd ready at epoch %d", epoch);
491
+ };
492
+ if (opts?.preset === "cloudflare") {
493
+ const registeredClientHotChannels = new Set<any>();
494
+ const registerDevDiscoveryHotChannels = () => {
495
+ const clientHot = (server.environments as any)?.client?.hot;
496
+ if (clientHot && !registeredClientHotChannels.has(clientHot)) {
497
+ registeredClientHotChannels.add(clientHot);
498
+ clientHot.on(
499
+ DEV_DISCOVERY_QUERY_EVENT,
500
+ (_payload: unknown, client: any) => {
501
+ if (workerReadyEpoch === undefined) return;
502
+ client.send(DEV_DISCOVERY_READY_EVENT, {
503
+ epoch: workerReadyEpoch,
504
+ });
505
+ },
506
+ );
507
+ }
508
+ debugDiscovery?.(
509
+ "hmr: dev discovery browser channel registered (client=%s)",
510
+ !!clientHot,
511
+ );
512
+ };
513
+
514
+ registerDevDiscoveryHotChannels();
515
+ if (server.httpServer && !server.httpServer.listening) {
516
+ server.httpServer.once("listening", registerDevDiscoveryHotChannels);
517
+ }
518
+ }
519
+
475
520
  // Serve the internal-debug module no-cache: consumers resolve it into
476
521
  // node_modules, where dev's immutable `?v=` caching pinned browsers to a
477
522
  // stale baked INTERNAL_RANGO_DEBUG. See internalDebugNoCacheMiddleware.
@@ -500,6 +545,7 @@ export function createRouterDiscoveryPlugin(
500
545
  const getDevServerOrigin = () =>
501
546
  server.resolvedUrls?.local?.[0]?.replace(/\/$/, "") ||
502
547
  `http://localhost:${server.config.server.port || 5173}`;
548
+ let devServerClosed = false;
503
549
 
504
550
  // Shared temp server for Cloudflare dev (no module runner in workerd).
505
551
  // Used by both discover() (route type generation) and the prerender
@@ -510,6 +556,7 @@ export function createRouterDiscoveryPlugin(
510
556
 
511
557
  // Clean up the temporary server and build env when the dev server shuts down
512
558
  server.httpServer?.on("close", () => {
559
+ devServerClosed = true;
513
560
  if (prerenderTempServer) {
514
561
  prerenderTempServer.close().catch(() => {});
515
562
  prerenderTempServer = null;
@@ -953,16 +1000,6 @@ export function createRouterDiscoveryPlugin(
953
1000
  if (s.mergedRouteManifest && serverMod.setCachedManifest) {
954
1001
  serverMod.setCachedManifest(s.mergedRouteManifest);
955
1002
  }
956
- if (
957
- s.mergedPrecomputedEntries &&
958
- s.mergedPrecomputedEntries.length > 0 &&
959
- serverMod.setPrecomputedEntries
960
- ) {
961
- serverMod.setPrecomputedEntries(s.mergedPrecomputedEntries);
962
- }
963
- if (s.mergedRouteTrie && serverMod.setRouteTrie) {
964
- serverMod.setRouteTrie(s.mergedRouteTrie);
965
- }
966
1003
  const perRouterSetters: Array<[Map<string, any>, string]> = [
967
1004
  [s.perRouterManifestDataMap, "setRouterManifest"],
968
1005
  [s.perRouterTrieMap, "setRouterTrie"],
@@ -1436,6 +1473,22 @@ export function createRouterDiscoveryPlugin(
1436
1473
  writeCombinedRouteTypesWithTracking(s);
1437
1474
  }
1438
1475
  };
1476
+ const routeShapeSignature = () =>
1477
+ JSON.stringify(
1478
+ s.perRouterManifests.map(
1479
+ ({
1480
+ id,
1481
+ routeManifest,
1482
+ routeTrailingSlash,
1483
+ routeSearchSchemas,
1484
+ }) => ({
1485
+ id,
1486
+ routeManifest,
1487
+ routeTrailingSlash,
1488
+ routeSearchSchemas,
1489
+ }),
1490
+ ),
1491
+ );
1439
1492
 
1440
1493
  const maybeHandleGeneratedRouteFileMutation = (
1441
1494
  filePath: string,
@@ -1474,6 +1527,7 @@ export function createRouterDiscoveryPlugin(
1474
1527
  // populated manifest there's nothing useful to do, so bail
1475
1528
  // before involving the gate machine at all.
1476
1529
  if (!hasMainRunner && s.perRouterManifests.length === 0) return;
1530
+ let previousRouteShape = routeShapeSignature();
1477
1531
  await gate.runRefreshCycle(async () => {
1478
1532
  const hmrStart = performance.now();
1479
1533
  try {
@@ -1528,7 +1582,19 @@ export function createRouterDiscoveryPlugin(
1528
1582
  // original call already resolved on the failed cycle. A failed
1529
1583
  // cycle throws above and never reaches here, so a broken edit
1530
1584
  // never reloads the worker onto bad source.
1531
- if (rscEnv && !rscEnv.runner) forceCloudflareWorkerReload(rscEnv);
1585
+ if (rscEnv && !rscEnv.runner) {
1586
+ const nextRouteShape = routeShapeSignature();
1587
+ let expectedEpoch: number | undefined;
1588
+ if (nextRouteShape !== previousRouteShape) {
1589
+ expectedEpoch = Math.max(
1590
+ Date.now(),
1591
+ (s.devDiscoveryEpoch ?? 0) + 1,
1592
+ );
1593
+ s.devDiscoveryEpoch = expectedEpoch;
1594
+ }
1595
+ previousRouteShape = nextRouteShape;
1596
+ forceCloudflareWorkerReload(rscEnv, expectedEpoch);
1597
+ }
1532
1598
  } catch (err: any) {
1533
1599
  s.lastDiscoveryError = {
1534
1600
  message: err?.message ?? String(err),
@@ -1562,7 +1628,8 @@ export function createRouterDiscoveryPlugin(
1562
1628
  // HMR update for them and the entry chain is never evicted.
1563
1629
  //
1564
1630
  // Fix: after discovery completes, (1) invalidate the worker env's
1565
- // Node-side module graph, then (2) send a full-reload to the worker.
1631
+ // Node-side module graph, (2) send a full-reload to the worker, then
1632
+ // (3) probe until the active router reports the new discovery epoch.
1566
1633
  // Step (2) alone is insufficient: the full-reload handler clears the
1567
1634
  // runner's evaluatedModules and re-imports entrypoints, but each
1568
1635
  // re-import fetches the module back through this Node-side graph, which
@@ -1570,13 +1637,19 @@ export function createRouterDiscoveryPlugin(
1570
1637
  // rebuilds the stale route table and the new route 404s/hits the
1571
1638
  // catch-all. Invalidating the graph forces a fresh transform on
1572
1639
  // re-fetch (the same mechanism refreshTempRscEnv uses for discovery),
1573
- // so the re-import re-runs createRouter() with the new routes. This is
1574
- // the programmatic equivalent of the dev-server "r + enter" restart,
1575
- // scoped to the worker environment instead of tearing down the server.
1576
- const forceCloudflareWorkerReload = (rscEnv: any) => {
1640
+ // so the re-import re-runs createRouter() with the new routes. The probe
1641
+ // is detached because the refresh callback still holds the discovery
1642
+ // gate; awaiting it here would deadlock the request that proves the new
1643
+ // router is active. Once it succeeds, the client hot channel broadcasts
1644
+ // readiness to open and newly-booted stale documents.
1645
+ const forceCloudflareWorkerReload = (
1646
+ rscEnv: any,
1647
+ expectedEpoch: number | undefined,
1648
+ ) => {
1577
1649
  if (!rscEnv?.hot) return;
1578
- try {
1579
- const graph = rscEnv.moduleGraph;
1650
+
1651
+ const graph = rscEnv.moduleGraph;
1652
+ const reloadWorkerd = () => {
1580
1653
  if (graph?.invalidateAll) {
1581
1654
  graph.invalidateAll();
1582
1655
  debugDiscovery?.("hmr: invalidated workerd rsc module graph");
@@ -1585,12 +1658,49 @@ export function createRouterDiscoveryPlugin(
1585
1658
  debugDiscovery?.(
1586
1659
  "hmr: forced workerd rsc env reload (full-reload)",
1587
1660
  );
1588
- } catch (err: any) {
1661
+ };
1662
+ reloadWorkerd();
1663
+
1664
+ if (expectedEpoch === undefined) return;
1665
+ void (async () => {
1666
+ const deadline = Date.now() + 15_000;
1667
+ do {
1668
+ if (devServerClosed || expectedEpoch !== s.devDiscoveryEpoch) {
1669
+ return;
1670
+ }
1671
+ await new Promise<void>((resolve) => setTimeout(resolve, 25));
1672
+ if (devServerClosed || expectedEpoch !== s.devDiscoveryEpoch) {
1673
+ return;
1674
+ }
1675
+ try {
1676
+ const response = await fetch(getDevServerOrigin() + "/", {
1677
+ cache: "no-store",
1678
+ headers: {
1679
+ [DEV_DISCOVERY_PROBE_HEADER]: String(expectedEpoch),
1680
+ },
1681
+ signal: AbortSignal.timeout(1_000),
1682
+ });
1683
+ if (
1684
+ !devServerClosed &&
1685
+ expectedEpoch === s.devDiscoveryEpoch &&
1686
+ response.headers.get(DEV_DISCOVERY_EPOCH_HEADER) ===
1687
+ String(expectedEpoch)
1688
+ ) {
1689
+ publishDevDiscoveryReady(expectedEpoch);
1690
+ return;
1691
+ }
1692
+ // A response without the expected epoch proves an older worker
1693
+ // evaluation won the race after the initial invalidation. Clear
1694
+ // that completed evaluation and retry the reload.
1695
+ reloadWorkerd();
1696
+ } catch {}
1697
+ } while (Date.now() < deadline);
1698
+
1589
1699
  debugDiscovery?.(
1590
- "hmr: workerd reload failed: %s",
1591
- err?.message ?? err,
1700
+ "hmr: workerd readiness probe timed out at epoch %d",
1701
+ expectedEpoch,
1592
1702
  );
1593
- }
1703
+ })();
1594
1704
  };
1595
1705
 
1596
1706
  const scheduleRouteRegeneration = () => {
@@ -1,7 +1,4 @@
1
- export {
2
- flattenLeafEntries,
3
- buildRouteToStaticPrefix,
4
- } from "../../build/prefix-tree-utils.js";
1
+ export { flattenLeafEntries } from "../../build/prefix-tree-utils.js";
5
2
 
6
3
  /**
7
4
  * Wrap a value as `JSON.parse('...')` instead of a JS object literal.
@@ -252,6 +252,13 @@ export function onwarn(
252
252
  export function getManualChunks(id: string): string | undefined {
253
253
  const normalized = Vite.normalizePath(id);
254
254
 
255
+ if (
256
+ /\/browser\/prefetch\/(?:runtime|fetch|queue|observer|policy|resource-ready)\.[cm]?[jt]sx?(?:[?#].*)?$/.test(
257
+ normalized,
258
+ )
259
+ ) {
260
+ return undefined;
261
+ }
255
262
  if (
256
263
  normalized.includes("node_modules/react/") ||
257
264
  normalized.includes("node_modules/react-dom/") ||