@rangojs/router 0.27.0 → 0.29.0

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 (243) hide show
  1. package/dist/bin/rango.js +1 -1
  2. package/dist/types/__internal.d.ts +7 -1
  3. package/dist/types/browser/action-fence.d.ts +2 -1
  4. package/dist/types/browser/event-controller.d.ts +8 -2
  5. package/dist/types/browser/history-state.d.ts +3 -9
  6. package/dist/types/browser/navigation-store.d.ts +8 -6
  7. package/dist/types/browser/partial-update.d.ts +11 -0
  8. package/dist/types/browser/react/filter-segment-order.d.ts +2 -2
  9. package/dist/types/browser/react/use-action.d.ts +7 -2
  10. package/dist/types/browser/react/use-handle.d.ts +19 -16
  11. package/dist/types/browser/react/use-link-status.d.ts +5 -2
  12. package/dist/types/browser/segment-reconciler.d.ts +32 -4
  13. package/dist/types/browser/types.d.ts +24 -5
  14. package/dist/types/browser/wire-protocol.d.ts +26 -0
  15. package/dist/types/cache/cache-key-utils.d.ts +11 -5
  16. package/dist/types/cache/cache-scope.d.ts +34 -22
  17. package/dist/types/cache/cf/cf-cache-constants.d.ts +2 -2
  18. package/dist/types/cache/cf/cf-cache-store.d.ts +47 -65
  19. package/dist/types/cache/cf/cf-kv-utils.d.ts +1 -1
  20. package/dist/types/cache/handle-snapshot.d.ts +9 -4
  21. package/dist/types/cache/segment-codec.d.ts +14 -0
  22. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  23. package/dist/types/cache/taint.d.ts +5 -3
  24. package/dist/types/cache/types.d.ts +7 -3
  25. package/dist/types/cache/vercel/vercel-cache-store.d.ts +8 -6
  26. package/dist/types/prefetch-markers.d.ts +5 -14
  27. package/dist/types/prerender/store.d.ts +31 -0
  28. package/dist/types/prerender/warm-request.d.ts +3 -3
  29. package/dist/types/prerender.d.ts +2 -2
  30. package/dist/types/route-name.d.ts +5 -0
  31. package/dist/types/router/error-handling.d.ts +11 -5
  32. package/dist/types/router/instrument.d.ts +4 -3
  33. package/dist/types/router/intercept-resolution.d.ts +25 -9
  34. package/dist/types/router/match-context.d.ts +6 -11
  35. package/dist/types/router/match-handlers.d.ts +2 -6
  36. package/dist/types/router/match-middleware/background-revalidation.d.ts +13 -22
  37. package/dist/types/router/match-middleware/cache-lookup.d.ts +34 -86
  38. package/dist/types/router/match-middleware/segment-resolution.d.ts +8 -6
  39. package/dist/types/router/navigation-snapshot.d.ts +7 -0
  40. package/dist/types/router/prerender-match.d.ts +21 -6
  41. package/dist/types/router/preview-match.d.ts +2 -7
  42. package/dist/types/router/request-classification.d.ts +5 -0
  43. package/dist/types/router/router-context.d.ts +7 -46
  44. package/dist/types/router/router-interfaces.d.ts +8 -14
  45. package/dist/types/router/segment-resolution/helpers.d.ts +19 -3
  46. package/dist/types/router/segment-resolution/loader-cache.d.ts +3 -3
  47. package/dist/types/router/segment-resolution/loader-mask.d.ts +4 -4
  48. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +20 -18
  49. package/dist/types/router/segment-resolution/resolution-pass.d.ts +95 -0
  50. package/dist/types/router/segment-resolution/revalidation.d.ts +82 -31
  51. package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +2 -0
  52. package/dist/types/router/segment-resolution.d.ts +1 -2
  53. package/dist/types/router/segment-wrappers.d.ts +11 -48
  54. package/dist/types/router/telemetry.d.ts +2 -0
  55. package/dist/types/router/types.d.ts +11 -1
  56. package/dist/types/rsc/full-payload.d.ts +2 -2
  57. package/dist/types/rsc/handler-context.d.ts +6 -6
  58. package/dist/types/rsc/json-route-result.d.ts +5 -9
  59. package/dist/types/rsc/request-stages.d.ts +203 -0
  60. package/dist/types/rsc/response-cache-serve.d.ts +7 -12
  61. package/dist/types/rsc/response-route-handler.d.ts +2 -2
  62. package/dist/types/rsc/routine-plan.d.ts +1 -1
  63. package/dist/types/rsc/server-action.d.ts +2 -2
  64. package/dist/types/rsc/shell-capture-constants.d.ts +1 -1
  65. package/dist/types/rsc/shell-capture.d.ts +132 -15
  66. package/dist/types/rsc/shell-serve.d.ts +37 -12
  67. package/dist/types/server/context.d.ts +9 -8
  68. package/dist/types/server/handle-store.d.ts +16 -0
  69. package/dist/types/server/render-pass.d.ts +72 -0
  70. package/dist/types/server/request-context.d.ts +53 -104
  71. package/dist/types/server/rewrite-trail.d.ts +2 -2
  72. package/dist/types/testing/dispatch.d.ts +35 -18
  73. package/dist/types/testing/render-route.d.ts +43 -8
  74. package/dist/types/testing/serve-shell-request.d.ts +8 -1
  75. package/dist/types/types/cache-types.d.ts +6 -3
  76. package/dist/types/types/error-types.d.ts +30 -3
  77. package/dist/types/types/handler-context.d.ts +9 -1
  78. package/dist/types/types/segments.d.ts +1 -1
  79. package/dist/types/vite/discovery/build-discovery-phase.d.ts +40 -0
  80. package/dist/types/vite/discovery/build-env.d.ts +22 -0
  81. package/dist/types/vite/discovery/bundle-scan-phase.d.ts +12 -0
  82. package/dist/types/vite/discovery/dev-prerender-cache.d.ts +3 -2
  83. package/dist/types/vite/discovery/dev-server-phase.d.ts +26 -0
  84. package/dist/types/vite/discovery/discovery-errors.d.ts +1 -1
  85. package/dist/types/vite/discovery/state.d.ts +12 -0
  86. package/dist/types/vite/plugins/version-plugin.d.ts +1 -1
  87. package/dist/types/vite/plugins/virtual-entries.d.ts +1 -1
  88. package/dist/types/vite/router-discovery.d.ts +8 -47
  89. package/dist/types/vite/temp-rsc-server.d.ts +39 -0
  90. package/dist/types/vite/utils/prerender-utils.d.ts +9 -0
  91. package/dist/types/wire-params.d.ts +39 -0
  92. package/dist/vite/index.js +3822 -3727
  93. package/package.json +1 -1
  94. package/skills/cache-guide/SKILL.md +8 -5
  95. package/skills/caching/SKILL.md +37 -18
  96. package/skills/client-urls/SKILL.md +4 -2
  97. package/skills/document-cache/SKILL.md +13 -1
  98. package/skills/handler-use/SKILL.md +1 -1
  99. package/skills/hooks/SKILL.md +1 -1
  100. package/skills/hooks/data.md +8 -1
  101. package/skills/hooks/handle-and-actions.md +33 -2
  102. package/skills/hooks/navigation.md +121 -15
  103. package/skills/hooks/state.md +6 -4
  104. package/skills/hooks/urls.md +16 -5
  105. package/skills/intercept/SKILL.md +23 -7
  106. package/skills/middleware/SKILL.md +18 -0
  107. package/skills/observability/SKILL.md +30 -0
  108. package/skills/ppr/SKILL.md +18 -3
  109. package/skills/prerender/SKILL.md +39 -15
  110. package/skills/rango/SKILL.md +5 -1
  111. package/skills/server-actions/SKILL.md +15 -1
  112. package/skills/testing/client-components.md +25 -18
  113. package/skills/testing/response-routes.md +6 -4
  114. package/skills/testing/serve-shell-request.md +7 -7
  115. package/skills/use-cache/SKILL.md +46 -8
  116. package/skills/view-transitions/SKILL.md +1 -1
  117. package/src/__internal.ts +7 -1
  118. package/src/browser/action-fence.ts +2 -1
  119. package/src/browser/connection-warmup.ts +3 -1
  120. package/src/browser/event-controller.ts +37 -13
  121. package/src/browser/history-state.ts +6 -12
  122. package/src/browser/navigation-bridge.ts +59 -26
  123. package/src/browser/navigation-client.ts +20 -25
  124. package/src/browser/navigation-store.ts +25 -27
  125. package/src/browser/navigation-transaction.ts +4 -1
  126. package/src/browser/network-error-handler.ts +4 -0
  127. package/src/browser/partial-update.ts +33 -6
  128. package/src/browser/prefetch/cache.ts +2 -1
  129. package/src/browser/prefetch/fetch.ts +8 -16
  130. package/src/browser/prefetch/queue.ts +4 -4
  131. package/src/browser/react/NavigationProvider.tsx +2 -1
  132. package/src/browser/react/filter-segment-order.ts +2 -2
  133. package/src/browser/react/use-action.ts +24 -48
  134. package/src/browser/react/use-handle.ts +22 -18
  135. package/src/browser/react/use-link-status.ts +48 -60
  136. package/src/browser/react/use-navigation.ts +92 -41
  137. package/src/browser/rsc-router.tsx +1 -0
  138. package/src/browser/segment-reconciler.ts +109 -53
  139. package/src/browser/server-action-bridge.ts +81 -61
  140. package/src/browser/types.ts +25 -5
  141. package/src/browser/wire-protocol.ts +43 -0
  142. package/src/cache/cache-key-utils.ts +11 -5
  143. package/src/cache/cache-runtime.ts +132 -75
  144. package/src/cache/cache-scope.ts +110 -64
  145. package/src/cache/cf/cf-cache-constants.ts +2 -2
  146. package/src/cache/cf/cf-cache-store.ts +486 -874
  147. package/src/cache/cf/cf-kv-utils.ts +1 -1
  148. package/src/cache/document-cache.ts +64 -46
  149. package/src/cache/handle-snapshot.ts +19 -7
  150. package/src/cache/read-through-swr.ts +20 -18
  151. package/src/cache/segment-codec.ts +55 -0
  152. package/src/cache/shell-snapshot.ts +2 -2
  153. package/src/cache/tag-invalidation.ts +6 -2
  154. package/src/cache/taint.ts +5 -3
  155. package/src/cache/types.ts +7 -3
  156. package/src/cache/vercel/vercel-cache-store.ts +243 -310
  157. package/src/handles/pushed-by.ts +1 -1
  158. package/src/prefetch-markers.ts +5 -16
  159. package/src/prerender/store.ts +76 -0
  160. package/src/prerender/warm-request.ts +8 -4
  161. package/src/prerender.ts +2 -2
  162. package/src/route-name.ts +8 -0
  163. package/src/router/error-handling.ts +22 -10
  164. package/src/router/instrument.ts +4 -3
  165. package/src/router/intercept-resolution.ts +82 -75
  166. package/src/router/loader-resolution.ts +5 -4
  167. package/src/router/match-api.ts +5 -5
  168. package/src/router/match-context.ts +23 -12
  169. package/src/router/match-handlers.ts +8 -20
  170. package/src/router/match-middleware/background-revalidation.ts +46 -57
  171. package/src/router/match-middleware/cache-lookup.ts +677 -726
  172. package/src/router/match-middleware/cache-store.ts +4 -8
  173. package/src/router/match-middleware/intercept-resolution.ts +3 -23
  174. package/src/router/match-middleware/segment-resolution.ts +34 -61
  175. package/src/router/middleware.ts +69 -11
  176. package/src/router/navigation-snapshot.ts +14 -4
  177. package/src/router/prerender-match.ts +100 -44
  178. package/src/router/preview-match.ts +6 -41
  179. package/src/router/request-classification.ts +13 -7
  180. package/src/router/route-snapshot.ts +2 -5
  181. package/src/router/router-context.ts +10 -95
  182. package/src/router/router-interfaces.ts +8 -14
  183. package/src/router/segment-resolution/helpers.ts +49 -13
  184. package/src/router/segment-resolution/loader-cache.ts +15 -9
  185. package/src/router/segment-resolution/loader-mask.ts +5 -5
  186. package/src/router/segment-resolution/prefetch-deferral.ts +30 -27
  187. package/src/router/segment-resolution/resolution-pass.ts +139 -0
  188. package/src/router/segment-resolution/revalidation.ts +939 -789
  189. package/src/router/segment-resolution/streamed-handler-boundary.ts +3 -0
  190. package/src/router/segment-resolution.ts +4 -13
  191. package/src/router/segment-wrappers.ts +51 -269
  192. package/src/router/telemetry.ts +5 -0
  193. package/src/router/types.ts +11 -1
  194. package/src/router.ts +19 -17
  195. package/src/rsc/full-payload.ts +6 -5
  196. package/src/rsc/handler-context.ts +8 -14
  197. package/src/rsc/handler.ts +126 -507
  198. package/src/rsc/json-route-result.ts +5 -9
  199. package/src/rsc/loader-fetch.ts +6 -5
  200. package/src/rsc/progressive-enhancement.ts +5 -0
  201. package/src/rsc/request-stages.ts +759 -0
  202. package/src/rsc/response-cache-serve.ts +10 -14
  203. package/src/rsc/response-route-handler.ts +13 -5
  204. package/src/rsc/rewrite-passes.ts +2 -1
  205. package/src/rsc/routine-plan.ts +1 -1
  206. package/src/rsc/rsc-rendering.ts +164 -349
  207. package/src/rsc/server-action.ts +87 -113
  208. package/src/rsc/shell-capture-constants.ts +1 -1
  209. package/src/rsc/shell-capture.ts +275 -227
  210. package/src/rsc/shell-serve.ts +121 -18
  211. package/src/rsc/ssr-setup.ts +5 -4
  212. package/src/segment-system.tsx +3 -3
  213. package/src/server/context.ts +22 -25
  214. package/src/server/handle-store.ts +55 -0
  215. package/src/server/render-pass.ts +291 -0
  216. package/src/server/request-context.ts +75 -115
  217. package/src/server/rewrite-trail.ts +6 -4
  218. package/src/ssr/index.tsx +1 -1
  219. package/src/testing/dispatch.ts +226 -710
  220. package/src/testing/e2e/page-helpers.ts +2 -2
  221. package/src/testing/render-route.tsx +199 -13
  222. package/src/testing/serve-shell-request.ts +36 -40
  223. package/src/types/cache-types.ts +6 -3
  224. package/src/types/error-types.ts +30 -3
  225. package/src/types/handler-context.ts +12 -1
  226. package/src/types/segments.ts +1 -1
  227. package/src/vite/discovery/build-discovery-phase.ts +204 -0
  228. package/src/vite/discovery/build-env.ts +148 -0
  229. package/src/vite/discovery/bundle-scan-phase.ts +128 -0
  230. package/src/vite/discovery/dev-prerender-cache.ts +8 -3
  231. package/src/vite/discovery/dev-server-phase.ts +1776 -0
  232. package/src/vite/discovery/discover-routers.ts +1 -1
  233. package/src/vite/discovery/discovery-errors.ts +1 -1
  234. package/src/vite/discovery/prerender-collection.ts +24 -39
  235. package/src/vite/discovery/state.ts +15 -0
  236. package/src/vite/plugins/version-plugin.ts +1 -1
  237. package/src/vite/plugins/virtual-entries.ts +1 -1
  238. package/src/vite/router-discovery.ts +29 -2428
  239. package/src/vite/temp-rsc-server.ts +223 -0
  240. package/src/vite/utils/prerender-utils.ts +47 -0
  241. package/src/wire-params.ts +40 -0
  242. package/dist/types/router/segment-resolution/fresh.d.ts +0 -72
  243. package/src/router/segment-resolution/fresh.ts +0 -945
@@ -2,101 +2,40 @@
2
2
  * Router Discovery Plugin
3
3
  *
4
4
  * Vite plugin that discovers router instances at dev/build time via the RSC
5
- * environment. Delegates to extracted modules for discovery, route types
6
- * generation, virtual module codegen, and bundle post-processing.
5
+ * environment. Its hooks call the phase modules over one DiscoveryState:
6
+ * discovery/dev-server-phase.ts (configureServer),
7
+ * discovery/build-discovery-phase.ts (buildStart, buildApp post, buildEnd) and
8
+ * discovery/bundle-scan-phase.ts (generateBundle). Route types generation,
9
+ * virtual module codegen and bundle post-processing live in their own
10
+ * discovery/ modules.
7
11
  */
8
12
 
9
- import type { Plugin, ViteDevServer } from "vite";
10
- import { createServer as createViteServer, normalizePath } from "vite";
11
- import { dirname, join, resolve } from "node:path";
12
- import { existsSync, readFileSync } from "node:fs";
13
- import { createRequire, register } from "node:module";
14
- import { pathToFileURL } from "node:url";
13
+ import type { Plugin } from "vite";
15
14
  import {
16
- formatNestedRouterConflictError,
17
- findNestedRouterConflict,
18
15
  findRouterFiles,
19
16
  createScanFilter,
20
- isInSkippedScanDir,
21
17
  } from "../build/generate-route-types.js";
22
- import { firstCodeMatchIndex } from "../build/route-types/source-scan.js";
18
+ import { injectClientDebugFlag } from "./inject-client-debug.js";
23
19
  import {
24
- DEV_DISCOVERY_EPOCH_HEADER,
25
- DEV_DISCOVERY_PROBE_HEADER,
26
- DEV_DISCOVERY_QUERY_EVENT,
27
- DEV_DISCOVERY_READY_EVENT,
28
- } from "../dev-discovery-protocol.js";
29
- import {
30
- DEV_SHELL_PROBE_TIMEOUT_MS,
31
- normalizeCaptureTimeout,
32
- } from "../rsc/shell-serve.js";
33
- import {
34
- injectClientDebugFlag,
35
- internalDebugNoCacheMiddleware,
36
- } from "./inject-client-debug.js";
37
- import { createVersionPlugin } from "./plugins/version-plugin.js";
38
- import { uniformVersions } from "../router-versions.js";
39
- import { getVirtualEntrySSR, VIRTUAL_IDS } from "./plugins/virtual-entries.js";
40
- import { createVirtualStubPlugin } from "./plugins/virtual-stub-plugin.js";
41
- import {
42
- BUILD_ENV_GLOBAL_KEY,
43
- createCloudflareProtocolStubPlugin,
44
- } from "./plugins/cloudflare-protocol-stub.js";
45
- import {
46
- exposeInternalIds,
47
- exposeRouterId,
48
- } from "./plugins/expose-internal-ids.js";
49
- import { hashClientRefs } from "./plugins/client-ref-hashing.js";
50
- import { hashServerRefs } from "./plugins/server-ref-hashing.js";
51
- import { defineEncryptionKeyExpr } from "./encryption-key.js";
52
- import { extractHandlerExportsFromChunk } from "./utils/bundle-analysis.js";
53
- import {
54
- canRefreshRuntimeDiscovery,
55
20
  createDiscoveryState,
56
- recordDiscoveryFailure,
57
21
  VIRTUAL_ROUTES_MANIFEST_ID,
58
- type DiscoveryState,
59
22
  type PluginOptions,
60
23
  } from "./discovery/state.js";
61
- import {
62
- consumeSelfGenWrite,
63
- peekSelfGenWrite,
64
- } from "./discovery/self-gen-tracking.js";
65
- import { discoverRouters } from "./discovery/discover-routers.js";
66
- import {
67
- componentUseSignature,
68
- recordClientUrlsModule,
69
- refreshRecordedClientUrlProjections,
70
- } from "./discovery/client-urls-projection.js";
71
- import { runShellPrerenderPhase } from "./discovery/shell-prerender-phase.js";
72
- import { runRouterVersionsPhase } from "./discovery/router-versions-phase.js";
73
- import {
74
- recordBundleFiles,
75
- recordClientGraph,
76
- recordServerGraph,
77
- recordVersionModuleFiles,
78
- } from "./discovery/build-versions.js";
79
- import { describeDiscoveryFailure } from "./discovery/discovery-errors.js";
80
- import { findTransitionWhenError } from "../transition-when-ref.js";
81
- import { transitionWhenHoistPlugin } from "./plugins/transition-when-hoist.js";
82
- import { clientUrlsRevalidateHoistPlugin } from "./plugins/client-urls-revalidate-hoist.js";
83
- import {
84
- createDevPrerenderCache,
85
- devPrerenderCacheKey,
86
- payloadBodiesFromResult,
87
- } from "./discovery/dev-prerender-cache.js";
88
- import {
89
- writeCombinedRouteTypesWithTracking,
90
- writeRouteTypesFiles,
91
- supplementGenFilesWithRuntimeRoutes,
92
- } from "./discovery/route-types-writer.js";
24
+ import { peekSelfGenWrite } from "./discovery/self-gen-tracking.js";
25
+ import { recordClientUrlsModule } from "./discovery/client-urls-projection.js";
26
+ import { writeCombinedRouteTypesWithTracking } from "./discovery/route-types-writer.js";
93
27
  import {
94
28
  generateRoutesManifestModule,
95
29
  generatePerRouterModule,
96
30
  } from "./discovery/virtual-module-codegen.js";
97
31
  import { postprocessBundle } from "./discovery/bundle-postprocess.js";
98
- import { createDiscoveryGate } from "./discovery/gate-state.js";
99
- import { resetStagedBuildAssets } from "./utils/prerender-utils.js";
32
+ import { devServerPhase } from "./discovery/dev-server-phase.js";
33
+ import {
34
+ buildDiscoveryPhase,
35
+ buildEndPhase,
36
+ postBuildPhase,
37
+ } from "./discovery/build-discovery-phase.js";
38
+ import { bundleScanPhase } from "./discovery/bundle-scan-phase.js";
100
39
  import { resolveRscEntryFromConfig } from "./utils/shared-utils.js";
101
40
  import {
102
41
  pickForwardedRunnerConfig,
@@ -107,411 +46,9 @@ import { createRangoDebugger, timed, timedSync, NS } from "./debug.js";
107
46
  const debugDiscovery = createRangoDebugger(NS.discovery);
108
47
  const debugRoutes = createRangoDebugger(NS.routes);
109
48
  const debugBuild = createRangoDebugger(NS.build);
110
- const debugDev = createRangoDebugger(NS.dev);
111
49
 
112
50
  export { VIRTUAL_ROUTES_MANIFEST_ID };
113
51
 
114
- /** The slice of a Vite environment module graph the route-file watcher reads. */
115
- interface ModulesByFileGraph {
116
- getModulesByFile(file: string): ReadonlySet<unknown> | undefined;
117
- }
118
-
119
- /** Modules a Vite graph holds for `file`; Vite keys its file index by normalized path. */
120
- function modulesForFile(
121
- graph: ModulesByFileGraph,
122
- file: string,
123
- ): ReadonlySet<unknown> | undefined {
124
- return graph.getModulesByFile(normalizePath(file));
125
- }
126
-
127
- /**
128
- * Whether the dev route-file watcher ignores `file`: it sits in a directory
129
- * every discovery scan skips (isInSkippedScanDir) and the RSC graph has not
130
- * loaded it. See docs/manifests.md, "A reload no file of the app asked for".
131
- */
132
- export function isFileOutsideApp(
133
- root: string,
134
- file: string,
135
- rscGraph: ModulesByFileGraph | undefined,
136
- ): boolean {
137
- return (
138
- isInSkippedScanDir(root, file) &&
139
- !(rscGraph && modulesForFile(rscGraph, file)?.size)
140
- );
141
- }
142
-
143
- // ============================================================================
144
- // Node ESM Loader Hook Registration
145
- // ============================================================================
146
-
147
- /**
148
- * Registers a Node ESM loader hook that resolves `cloudflare:*` specifiers
149
- * to a data: URL stub. Defense-in-depth alongside the Vite transform in
150
- * `cloudflare-protocol-stub.ts`:
151
- *
152
- * - The Vite transform catches `cloudflare:*` imports in modules that flow
153
- * through Vite's plugin pipeline. That's the vast majority of cases.
154
- * - The Node loader catches imports in modules that Vite/Rollup externalize
155
- * (e.g. the `partyserver` package, which has a top-level
156
- * `import { DurableObject, env } from "cloudflare:workers"` and ships
157
- * shapes plugin-rsc marks as external). Externalized modules are loaded
158
- * via Node's native ESM loader, which rejects URL schemes.
159
- *
160
- * Registration is process-global and one-shot. The hook only intercepts
161
- * `cloudflare:*` specifiers; everything else passes through via
162
- * `nextResolve()`. It runs in a separate worker thread (Node ESM loader
163
- * architecture), so it can't read the `globalThis[BUILD_ENV_GLOBAL_KEY]`
164
- * bridge that the Vite transform uses — the stubs served here always
165
- * return `env = {}`. That's fine because externalized libraries don't
166
- * typically access `env` at module top level; user source (where real
167
- * `env` matters at build time) flows through the Vite transform.
168
- */
169
- let loaderHookRegistered = false;
170
- function ensureCloudflareProtocolLoaderRegistered(): void {
171
- if (loaderHookRegistered) return;
172
- loaderHookRegistered = true;
173
- try {
174
- register(
175
- new URL("./plugins/cloudflare-protocol-loader-hook.mjs", import.meta.url),
176
- );
177
- } catch (err: any) {
178
- // register() requires Node 18.19+ / 20.6+. Older Node still has the
179
- // Vite transform as primary defense.
180
- console.warn(
181
- `[rango] Could not register Node ESM loader hook for cloudflare:* imports (${err?.message ?? err}). Falling back to Vite transform only.`,
182
- );
183
- }
184
- }
185
-
186
- // ============================================================================
187
- // Temp Server Factory
188
- // ============================================================================
189
-
190
- /**
191
- * Outcome of getOrCreateTempServer. `env` is the temp RSC environment on
192
- * success, else null with `error` carrying WHY the create/import failed — or
193
- * null when createServer resolved cleanly but attached no runner (terminal, but
194
- * not an exception). The dev /__rsc_shell endpoint runs `error` through the
195
- * SAME reoptimization classifier the import sites use: only a transient Vite
196
- * re-optimization is signalled NOT-READY (client re-polls); every terminal
197
- * failure fails fast so the read-through MISSes immediately instead of
198
- * re-polling a permanently-broken realm for the full readiness deadline
199
- * (issue #719 P2).
200
- */
201
- type TempServerResult = { env: any; error: unknown };
202
-
203
- /**
204
- * Test-only one-shot boot-race injection (issue #719 P3). When
205
- * RANGO_E2E_INJECT_SHELL_NOTREADY=1, the dev /__rsc_shell endpoint emits a
206
- * single reoptimization-class NOT-READY per pathname before serving, so an e2e
207
- * can drive the REAL endpoint + real client re-poll deterministically — a
208
- * natural cold race settles too fast on quick machines to guard the regression.
209
- * Keyed per pathname so the read-through's re-poll finds it already fired and
210
- * gets the HIT. Never engaged without the env flag.
211
- */
212
- const injectedShellNotReadyPaths = new Set<string>();
213
-
214
- /**
215
- * Create a minimal Vite server for router discovery.
216
- *
217
- * Both dev-mode prerender and build-mode discovery need a temp RSC server
218
- * to import user router files via module runner. This factory centralizes
219
- * the shared config and the mode-specific differences:
220
- * - Dev: path-based IDs (no forceBuild), separate cacheDir
221
- * - Build: hashed IDs (forceBuild), hashClientRefs for production bundles
222
- *
223
- * Returns the ViteDevServer instance. Callers access .environments.rsc as needed.
224
- * @internal Exported for tests.
225
- */
226
- export async function createTempRscServer(
227
- state: DiscoveryState,
228
- options: {
229
- forceBuild?: boolean;
230
- cacheDir?: string;
231
- /**
232
- * Serve the REAL rango SSR entry (getVirtualEntrySSR) for
233
- * "virtual:entry-ssr" instead of the discovery stub, so the dev
234
- * /__rsc_shell endpoint can drive captureShellHTML in this server's SSR
235
- * realm. Dev-correct by construction: the entry's bootstrap resolves to
236
- * plugin-rsc's stable virtual browser-entry URL, which the MAIN dev
237
- * server serves to the browser. Loaded lazily — discovery never imports
238
- * the SSR entry, so the temp server stays as light as before until a
239
- * shell capture actually runs.
240
- */
241
- realSsrEntry?: boolean;
242
- } = {},
243
- ): Promise<ViteDevServer> {
244
- // Install the Node ESM loader hook before any module evaluation so
245
- // `cloudflare:*` specifiers in externalized/loader-delegated modules
246
- // (e.g. packages plugin-rsc marks as external) resolve to stubs
247
- // instead of crashing Node's native loader.
248
- ensureCloudflareProtocolLoaderRegistered();
249
- const { default: rsc } = await import("@vitejs/plugin-rsc");
250
- // Mirror the user's resolution config + plugins so discovery (and the
251
- // prerender/static rendering that shares this runner) resolves modules the
252
- // same way the real environment does. Falls back to the legacy alias-only
253
- // behavior if configResolved hasn't populated the parity slice yet.
254
- const runnerConfig = state.userRunnerConfig;
255
- const resolveConfig = runnerConfig?.resolve ?? {
256
- alias: state.userResolveAlias,
257
- };
258
- const oxcConfig = runnerConfig?.oxc ?? {
259
- jsx: { runtime: "automatic", importSource: "react" },
260
- };
261
- return createViteServer({
262
- root: state.projectRoot,
263
- configFile: false,
264
- server: {
265
- middlewareMode: true,
266
- // Build mode: no file watching. buildStart installs the per-router tries
267
- // into this realm and runShellPrerenderPhase reuses it after the bundles
268
- // are written. A watched write in between (writeRouteTypesFiles' own
269
- // <router>.named-routes.gen.ts, which every router file imports) can
270
- // make Vite full-reload the RSC runner; the shell phase then matches in
271
- // a fresh realm with no tries and a root path("/*") wins (#947).
272
- ...(options.forceBuild ? { watch: null } : {}),
273
- },
274
- appType: "custom",
275
- logLevel: "silent",
276
- resolve: resolveConfig,
277
- ...(runnerConfig?.define ? { define: runnerConfig.define } : {}),
278
- oxc: oxcConfig as any,
279
- ...(options.cacheDir && { cacheDir: options.cacheDir }),
280
- plugins: [
281
- rsc({
282
- entries: {
283
- client: "virtual:entry-client",
284
- ssr: "virtual:entry-ssr",
285
- rsc: state.resolvedEntryPath!,
286
- },
287
- // The temp server renders Static/Prerender output and encrypts inline-
288
- // action bound args. Give it the SAME key as the main build/dev runtime
289
- // (defineEncryptionKeyExpr is process-cached) so those args decrypt at
290
- // invocation. Unconditional, NOT build-only: under Cloudflare/workerd dev
291
- // the RSC env has no module runner, so prerender is rendered by THIS temp
292
- // server (via /__rsc_prerender), and the action decrypts in the main
293
- // runtime with the rango.ts key -- gating on forceBuild left dev encrypting
294
- // with the temp server's own random key, failing decryptActionBoundArgs.
295
- // (hashServerRefs stays build-only: dev keeps dev-style ids the dev runtime
296
- // resolves directly.)
297
- defineEncryptionKey: defineEncryptionKeyExpr(),
298
- }),
299
- // hashClientRefs/hashServerRefs only in build mode — production bundles
300
- // need hashed refs. hashServerRefs is the server-side analog: it rewrites
301
- // registerServerReference dev-style ids to production hashes so a
302
- // server-created action embedded in prerendered/static Flight resolves
303
- // against the production manifest on a build-time-cache hit.
304
- ...(options.forceBuild
305
- ? [hashClientRefs(state.projectRoot), hashServerRefs(state.projectRoot)]
306
- : []),
307
- createVersionPlugin(),
308
- // Before the stub plugin, so "virtual:entry-ssr" resolves to the real
309
- // SSR entry when the shell endpoint needs it (see the option doc).
310
- ...(options.realSsrEntry
311
- ? [
312
- {
313
- name: "@rangojs/router:temp-real-ssr-entry",
314
- enforce: "pre" as const,
315
- resolveId(id: string) {
316
- return id === "virtual:entry-ssr"
317
- ? "\0rango-temp-real-ssr-entry"
318
- : null;
319
- },
320
- load(id: string) {
321
- return id === "\0rango-temp-real-ssr-entry"
322
- ? getVirtualEntrySSR(
323
- state.opts?.headScripts,
324
- state.opts?.progressiveChunkSize,
325
- )
326
- : null;
327
- },
328
- } satisfies import("vite").Plugin,
329
- ]
330
- : []),
331
- createVirtualStubPlugin(),
332
- createCloudflareProtocolStubPlugin(),
333
- // Inline transition({ when }) literals become client references here
334
- // too, or discovery would see (and reject) the plain function.
335
- transitionWhenHoistPlugin(),
336
- // clientUrls() revalidate() predicates move to the server here too:
337
- // discovery serializes the SSR copy's markers and the RSC realm
338
- // registers the predicates the materialized stubs call. The index is
339
- // the main plugin's, so its group modules import the files assigned here.
340
- ...clientUrlsRevalidateHoistPlugin(state.clientUrlRevalidate),
341
- // Dev prerender must use dev-mode IDs (path-based) to match the workerd
342
- // runtime. forceBuild produces hashed IDs for production bundle consistency.
343
- exposeInternalIds(options.forceBuild ? { forceBuild: true } : undefined),
344
- exposeRouterId(),
345
- {
346
- name: "@rangojs/router:client-urls-source-tracking",
347
- enforce: "pre",
348
- transform(code, id) {
349
- recordClientUrlsModule(state, code, id);
350
- },
351
- },
352
- // Forwarded user resolution plugins (e.g. vite-tsconfig-paths). Stripped
353
- // to resolveId/load (plus transform for import rewriters, see
354
- // IMPORT_REWRITE_PLUGINS) and placed last so framework resolution runs first;
355
- // Vite re-sorts by `enforce`, so `enforce: "pre"` resolvers still lead.
356
- ...state.userResolvePlugins,
357
- ],
358
- });
359
- }
360
-
361
- // ============================================================================
362
- // Build-Time Env Resolution
363
- // ============================================================================
364
-
365
- import type {
366
- BuildEnvOption,
367
- BuildEnvFactoryContext,
368
- BuildEnvResult,
369
- } from "./plugin-types.js";
370
-
371
- const WRANGLER_CONFIG_NAMES: readonly string[] = [
372
- "wrangler.json",
373
- "wrangler.jsonc",
374
- "wrangler.toml",
375
- ];
376
-
377
- /**
378
- * getPlatformProxy() looks for the wrangler config from process.cwd() and
379
- * resolves its default persist path against cwd, so building from another
380
- * directory dropped the app's bindings (#1037). Search upward from the Vite
381
- * root with wrangler's own file names and order. Persisted state goes under
382
- * the Vite root, where @cloudflare/vite-plugin reads it in dev and preview
383
- * (its getPersistenceRoot), also when the config sits above the root. No
384
- * config found: no options, wrangler's own lookup.
385
- */
386
- function wranglerProxyOptions(
387
- root: string,
388
- ): Record<string, unknown> | undefined {
389
- const viteRoot = resolve(root);
390
- for (let dir = viteRoot; ; dir = dirname(dir)) {
391
- for (const name of WRANGLER_CONFIG_NAMES) {
392
- const configPath = join(dir, name);
393
- if (existsSync(configPath)) {
394
- return {
395
- configPath,
396
- persist: { path: join(viteRoot, ".wrangler", "state", "v3") },
397
- };
398
- }
399
- }
400
- if (dirname(dir) === dir) return undefined;
401
- }
402
- }
403
-
404
- /**
405
- * Resolve the buildEnv option into a concrete { env, dispose? } result.
406
- * Handles all four input shapes: false, "auto", factory, plain object.
407
- */
408
- export async function resolveBuildEnv(
409
- option: BuildEnvOption | undefined,
410
- factoryCtx: BuildEnvFactoryContext,
411
- ): Promise<BuildEnvResult | null> {
412
- if (!option) return null;
413
-
414
- if (option === "auto") {
415
- if (factoryCtx.preset !== "cloudflare") {
416
- throw new Error(
417
- '[rango] buildEnv: "auto" is only supported with preset: "cloudflare". ' +
418
- "Use a factory function or plain object for other presets.",
419
- );
420
- }
421
- try {
422
- // Resolve wrangler from the user's project root (not the router package)
423
- const userRequire = createRequire(
424
- resolve(factoryCtx.root, "package.json"),
425
- );
426
- const wranglerPath = userRequire.resolve("wrangler");
427
- const { getPlatformProxy } = (await import(
428
- pathToFileURL(wranglerPath).href
429
- )) as {
430
- getPlatformProxy: (opts?: any) => Promise<any>;
431
- };
432
- const proxy = await getPlatformProxy(
433
- wranglerProxyOptions(factoryCtx.root),
434
- );
435
- return {
436
- env: proxy.env as Record<string, unknown>,
437
- dispose: proxy.dispose,
438
- };
439
- } catch (err: any) {
440
- throw new Error(
441
- '[rango] buildEnv: "auto" requires wrangler to be installed.\n' +
442
- `Install it with: pnpm add -D wrangler\n${err.message}`,
443
- );
444
- }
445
- }
446
-
447
- if (typeof option === "function") {
448
- return await option(factoryCtx);
449
- }
450
-
451
- // Plain object
452
- return { env: option };
453
- }
454
-
455
- /**
456
- * Acquire build-time env bindings and store on discovery state.
457
- * Returns true if env was acquired, false if buildEnv is disabled.
458
- */
459
- async function acquireBuildEnv(
460
- s: DiscoveryState,
461
- command: "serve" | "build",
462
- mode: string,
463
- ): Promise<boolean> {
464
- const option = s.opts?.buildEnv;
465
- if (!option) return false;
466
-
467
- const result = await resolveBuildEnv(option, {
468
- root: s.projectRoot,
469
- mode,
470
- command,
471
- preset: s.opts?.preset ?? "node",
472
- });
473
- if (!result) return false;
474
-
475
- s.resolvedBuildEnv = result.env;
476
- s.buildEnvDispose = result.dispose ?? null;
477
- // Bridge the resolved env into `cloudflare:workers`'s stubbed `env`
478
- // export so user code that does `import { env } from "cloudflare:workers"`
479
- // sees the real bindings proxy during discovery + prerender instead of
480
- // an empty object. The stub reads this global at module-evaluation time.
481
- (globalThis as Record<string, unknown>)[BUILD_ENV_GLOBAL_KEY] = result.env;
482
- return true;
483
- }
484
-
485
- /**
486
- * Reset the per-build prerender collection state. A helper (not inline
487
- * assignments in buildStart) so TS's property narrowing does not pin
488
- * `s.shellCandidates` to `null` across the discovery call that repopulates
489
- * it — the finally block re-reads it to decide the temp-server keep-alive.
490
- */
491
- function resetPrerenderCollection(s: DiscoveryState): void {
492
- s.prerenderManifestEntries = null;
493
- s.staticManifestEntries = null;
494
- s.shellCandidates = null;
495
- s.prerenderPayloadValues = null;
496
- s.buildData = [];
497
- }
498
-
499
- /**
500
- * Release build-time env resources and clear state.
501
- */
502
- async function releaseBuildEnv(s: DiscoveryState): Promise<void> {
503
- if (s.buildEnvDispose) {
504
- try {
505
- await s.buildEnvDispose();
506
- } catch (err: any) {
507
- console.warn(`[rango] buildEnv dispose failed: ${err.message}`);
508
- }
509
- s.buildEnvDispose = null;
510
- }
511
- s.resolvedBuildEnv = undefined;
512
- delete (globalThis as Record<string, unknown>)[BUILD_ENV_GLOBAL_KEY];
513
- }
514
-
515
52
  /**
516
53
  * Plugin that discovers router instances at dev/build time via the RSC environment.
517
54
  *
@@ -529,39 +66,6 @@ export function createRouterDiscoveryPlugin(
529
66
  opts?: PluginOptions,
530
67
  ): Plugin {
531
68
  const s = createDiscoveryState(entryPath, opts);
532
- let viteCommand: "serve" | "build" = "build";
533
- let viteMode = "production";
534
-
535
- // Dev: an invalid transition({ when }) is a definition error the user must
536
- // see in the browser too, as the Vite error overlay. The overlay does not
537
- // survive on its own: a reload clears it, and plugin-rsc closes every
538
- // overlay on each "rsc:update" (the edit's own, the gen-file write's), each
539
- // followed by the client's RSC refetch. So until a discovery succeeds the
540
- // pending error is re-sent to every client that connects and after every
541
- // request the dev server answers (configureServer).
542
- let transitionWhenOverlay: {
543
- message: string;
544
- stack: string;
545
- plugin: string;
546
- } | null = null;
547
- const sendTransitionWhenOverlay = (): void => {
548
- if (!transitionWhenOverlay) return;
549
- (s.devServer?.environments as any)?.client?.hot?.send({
550
- type: "error",
551
- err: transitionWhenOverlay,
552
- });
553
- };
554
- const showTransitionWhenError = (err: unknown): Error | undefined => {
555
- const whenError = findTransitionWhenError(err);
556
- if (!whenError) return undefined;
557
- transitionWhenOverlay = {
558
- message: whenError.message,
559
- stack: whenError.stack ?? "",
560
- plugin: "@rangojs/router",
561
- };
562
- sendTransitionWhenOverlay();
563
- return whenError;
564
- };
565
69
 
566
70
  return {
567
71
  name: "@rangojs/router:discovery",
@@ -588,8 +92,8 @@ export function createRouterDiscoveryPlugin(
588
92
  ? createScanFilter(s.projectRoot, opts.discovery)
589
93
  : undefined;
590
94
  s.isBuildMode = config.command === "build";
591
- viteCommand = config.command as "serve" | "build";
592
- viteMode = config.mode;
95
+ s.viteCommand = config.command as "serve" | "build";
96
+ s.viteMode = config.mode;
593
97
  // Capture user's resolve aliases for the temp server
594
98
  s.userResolveAlias = config.resolve.alias;
595
99
  // Capture the data-only resolution config (resolve.*, define, oxc) and
@@ -638,1820 +142,23 @@ export function createRouterDiscoveryPlugin(
638
142
  }
639
143
  },
640
144
 
641
- // Dev mode: discover routers and populate manifest in memory.
642
- // Skipped in build mode (buildStart handles it).
643
145
  configureServer(server) {
644
- if (s.isBuildMode) return;
645
- // Skip if this is a temp server created by buildStart
646
- if ((globalThis as any).__rscRouterDiscoveryActive) return;
647
- s.devServer = server;
648
-
649
- (server.environments as any)?.client?.hot?.on?.(
650
- "vite:client:connect",
651
- (_payload: unknown, client: any) => {
652
- if (transitionWhenOverlay) {
653
- client?.send?.({ type: "error", err: transitionWhenOverlay });
654
- }
655
- },
656
- );
657
- // The http server's "request" event, not a middleware: plugin-rsc
658
- // answers page and RSC requests before a middleware added here runs.
659
- let overlayResendQueued = false;
660
- server.httpServer?.on("request", (req, res) => {
661
- // Documents and RSC fetches only (the refetch that follows each
662
- // "rsc:update"), one send per burst.
663
- const isPage =
664
- req.url?.includes("_rsc") === true ||
665
- req.headers.accept?.includes("text/html") === true;
666
- if (transitionWhenOverlay && isPage && !overlayResendQueued) {
667
- overlayResendQueued = true;
668
- res.once("close", () => {
669
- overlayResendQueued = false;
670
- sendTransitionWhenOverlay();
671
- });
672
- }
673
- });
674
-
675
- let workerReadyEpoch: number | undefined;
676
- const publishDevDiscoveryReady = (epoch: number) => {
677
- if (epoch <= (workerReadyEpoch ?? -1)) return;
678
- workerReadyEpoch = epoch;
679
- (server.environments as any)?.client?.hot?.send({
680
- type: "custom",
681
- event: DEV_DISCOVERY_READY_EVENT,
682
- data: { epoch },
683
- });
684
- debugDiscovery?.("hmr: workerd ready at epoch %d", epoch);
685
- };
686
- if (opts?.preset === "cloudflare") {
687
- const registeredClientHotChannels = new Set<any>();
688
- const registerDevDiscoveryHotChannels = () => {
689
- const clientHot = (server.environments as any)?.client?.hot;
690
- if (clientHot && !registeredClientHotChannels.has(clientHot)) {
691
- registeredClientHotChannels.add(clientHot);
692
- clientHot.on(
693
- DEV_DISCOVERY_QUERY_EVENT,
694
- (_payload: unknown, client: any) => {
695
- if (workerReadyEpoch === undefined) return;
696
- client.send(DEV_DISCOVERY_READY_EVENT, {
697
- epoch: workerReadyEpoch,
698
- });
699
- },
700
- );
701
- }
702
- debugDiscovery?.(
703
- "hmr: dev discovery browser channel registered (client=%s)",
704
- !!clientHot,
705
- );
706
- };
707
-
708
- registerDevDiscoveryHotChannels();
709
- if (server.httpServer && !server.httpServer.listening) {
710
- server.httpServer.once("listening", registerDevDiscoveryHotChannels);
711
- }
712
- }
713
-
714
- // Serve the internal-debug module no-cache: consumers resolve it into
715
- // node_modules, where dev's immutable `?v=` caching pinned browsers to a
716
- // stale baked INTERNAL_RANGO_DEBUG. See internalDebugNoCacheMiddleware.
717
- server.middlewares.use(internalDebugNoCacheMiddleware());
718
-
719
- // Discovery promise that the handler can await if requests arrive
720
- // before discovery completes
721
- let resolveDiscovery: () => void;
722
- const discoveryPromise = new Promise<void>((resolve) => {
723
- resolveDiscovery = resolve;
724
- });
725
-
726
- // Manifest-readiness gate + rediscovery scheduler.
727
- // The virtual:rsc-router/routes-manifest module's `load()` hook
728
- // awaits `s.discoveryDone`; the gate is reset on each discovery
729
- // cycle so workerd's HMR reloads block until the new gen file is
730
- // written. State machine + transitions are extracted into
731
- // ./discovery/gate-state.ts and unit-tested there — see the
732
- // module's JSDoc for the four-flag contract.
733
- const gate = createDiscoveryGate(s, debugDiscovery);
734
- const beginDiscoveryGate = gate.beginGate;
735
- const resolveDiscoveryGate = gate.resolveGate;
736
-
737
- // Compute dev server origin from resolved URLs (preferred) or config port (fallback).
738
- // Called after discovery (or in the load hook) when the server may be listening.
739
- const getDevServerOrigin = () =>
740
- server.resolvedUrls?.local?.[0]?.replace(/\/$/, "") ||
741
- `http://localhost:${server.config.server.port || 5173}`;
742
- let devServerClosed = false;
743
-
744
- // Shared temp server for Cloudflare dev (no module runner in workerd).
745
- // Used by both discover() (route type generation) and the prerender
746
- // middleware (on-demand prerender evaluation). Created lazily, closed on
747
- // server shutdown.
748
- let prerenderTempServer: any = null;
749
- let prerenderNodeRegistry: Map<string, any> | null = null;
750
-
751
- // Clean up the temporary server and build env when the dev server shuts down
752
- server.httpServer?.on("close", () => {
753
- devServerClosed = true;
754
- if (prerenderTempServer) {
755
- prerenderTempServer.close().catch(() => {});
756
- prerenderTempServer = null;
757
- }
758
- releaseBuildEnv(s).catch(() => {});
759
- });
760
-
761
- // Mirror the build-path contract (the buildStart hook below, which sets
762
- // __rscRouterDiscoveryActive before running user modules):
763
- // set __rscRouterDiscoveryActive before running user modules so any
764
- // module-level router.reverse() calls return a placeholder instead
765
- // of throwing. The temp Vite server's module runner has its own
766
- // module context; the flag must be on globalThis to cross that
767
- // boundary. Cleared in finally so the dev request handlers run with
768
- // strict reverse() semantics afterwards.
769
- async function importEntryAndRegistry(tempRscEnv: any): Promise<void> {
770
- const flagAlreadySet = !!(globalThis as any).__rscRouterDiscoveryActive;
771
- if (!flagAlreadySet) {
772
- (globalThis as any).__rscRouterDiscoveryActive = true;
773
- }
774
- try {
775
- debugDiscovery?.(
776
- "importEntryAndRegistry: importing entry (flag=%s)",
777
- (globalThis as any).__rscRouterDiscoveryActive ?? false,
778
- );
779
- await tempRscEnv.runner.import(s.resolvedEntryPath!);
780
- debugDiscovery?.(
781
- "importEntryAndRegistry: entry import OK, fetching RouterRegistry",
782
- );
783
- const serverMod = await tempRscEnv.runner.import(
784
- "@rangojs/router/server",
785
- );
786
- prerenderNodeRegistry = serverMod.RouterRegistry;
787
- debugDiscovery?.(
788
- "importEntryAndRegistry: registry size=%d",
789
- prerenderNodeRegistry?.size ?? 0,
790
- );
791
- } finally {
792
- if (!flagAlreadySet) {
793
- delete (globalThis as any).__rscRouterDiscoveryActive;
794
- debugDiscovery?.(
795
- "importEntryAndRegistry: cleared __rscRouterDiscoveryActive",
796
- );
797
- }
798
- }
799
- }
800
-
801
- async function getOrCreateTempServer(): Promise<TempServerResult> {
802
- // Reuse path: if a temp server is already alive, prefer reusing
803
- // it over orphaning the existing instance and spinning up a new
804
- // one. This handles two cases:
805
- //
806
- // 1. Steady-state cache hit (cold-start completed, registry
807
- // cached) — return the env immediately.
808
- // 2. Recovery from a failed refresh: refreshTempRscEnv() may
809
- // have invalidated and nulled the registry, then thrown
810
- // during importEntryAndRegistry. Without reuse, the next
811
- // call would `createTempRscServer` and overwrite the
812
- // handle, leaking the previous server. Try to re-import on
813
- // the existing runner first; only if THAT fails do we
814
- // close the orphan and create new.
815
- if (prerenderTempServer) {
816
- const existingEnv = (prerenderTempServer.environments as any)?.rsc;
817
- if (existingEnv?.runner) {
818
- if (prerenderNodeRegistry) {
819
- debugDiscovery?.(
820
- "getOrCreateTempServer: cached temp runner reused",
821
- );
822
- return { env: existingEnv, error: null };
823
- }
824
- // Server alive but registry missing — likely after a prior
825
- // refresh's invalidate + import threw. Try to re-import.
826
- debugDiscovery?.(
827
- "getOrCreateTempServer: server alive but registry missing — re-importing",
828
- );
829
- try {
830
- await importEntryAndRegistry(existingEnv);
831
- return { env: existingEnv, error: null };
832
- } catch (err: any) {
833
- debugDiscovery?.(
834
- "getOrCreateTempServer: reuse import failed (%s) — closing orphan and creating fresh",
835
- err?.message ?? String(err),
836
- );
837
- await prerenderTempServer.close().catch(() => {});
838
- prerenderTempServer = null;
839
- prerenderNodeRegistry = null;
840
- // Fall through to create-new path below.
841
- }
842
- } else {
843
- // Server reference exists but its rsc env is unhealthy
844
- // (no runner). Close and recreate.
845
- debugDiscovery?.(
846
- "getOrCreateTempServer: existing server has no rsc.runner — closing and recreating",
847
- );
848
- await prerenderTempServer.close().catch(() => {});
849
- prerenderTempServer = null;
850
- prerenderNodeRegistry = null;
851
- }
852
- }
853
-
854
- // Create path: no existing temp server (or just nullified above).
855
- debugDiscovery?.(
856
- "getOrCreateTempServer: creating new temp server, entry=%s",
857
- s.resolvedEntryPath ?? "(unset)",
858
- );
859
- // Surface the create/import cause to the caller (issue #719 P2): a
860
- // transient re-optimization is re-pollable, a terminal fault is not. A
861
- // clean createServer that yields no runner leaves this null — terminal,
862
- // but not an exception.
863
- let createError: unknown = null;
864
- try {
865
- prerenderTempServer = await createTempRscServer(s, {
866
- cacheDir: "node_modules/.vite_prerender",
867
- // The dev /__rsc_shell endpoint drives captureShellHTML in this
868
- // server's SSR realm; the entry is only imported when a shell
869
- // capture runs, so discovery cost is unchanged.
870
- realSsrEntry: true,
871
- });
872
-
873
- const tempRscEnv = (prerenderTempServer.environments as any)?.rsc;
874
- if (tempRscEnv?.runner) {
875
- await importEntryAndRegistry(tempRscEnv);
876
- return { env: tempRscEnv, error: null };
877
- }
878
- debugDiscovery?.(
879
- "getOrCreateTempServer: tempRscEnv.runner unavailable",
880
- );
881
- } catch (err: any) {
882
- createError = err;
883
- debugDiscovery?.(
884
- "getOrCreateTempServer: FAILED message=%s",
885
- err.message,
886
- );
887
- console.warn(`[rango] Failed to create temp runner: ${err.message}`);
888
- }
889
- // Reached only on failure (runner unavailable, or create/import threw
890
- // AFTER the server was created). Close the just-created server so a
891
- // failed discovery does not leak it until the next call or dev shutdown,
892
- // and null the refs so the reuse path above starts clean. Mirrors the
893
- // close pattern used when an existing server is discarded (above).
894
- await prerenderTempServer?.close().catch(() => {});
895
- prerenderTempServer = null;
896
- prerenderNodeRegistry = null;
897
- return { env: null, error: createError };
898
- }
899
-
900
- // Clear the package-level singleton registries that survive a Vite
901
- // moduleGraph.invalidateAll(). createRouter() / createHostRouter()
902
- // call .set(id, ...) on these Maps; for "router removed" or
903
- // "router id changed" edits, the OLD entry would persist after
904
- // re-import without an explicit .clear(), leaving ghost routes
905
- // in discoverRouters' output.
906
- //
907
- // We import the same module the runner imports, so the .clear()
908
- // here mutates the same Map the freshly re-imported entry will
909
- // populate.
910
- async function clearTempRegistries(tempRscEnv: any): Promise<void> {
911
- try {
912
- const serverMod = await tempRscEnv.runner.import(
913
- "@rangojs/router/server",
914
- );
915
- if (typeof serverMod?.RouterRegistry?.clear === "function") {
916
- serverMod.RouterRegistry.clear();
917
- }
918
- if (typeof serverMod?.HostRouterRegistry?.clear === "function") {
919
- serverMod.HostRouterRegistry.clear();
920
- }
921
- debugDiscovery?.(
922
- "clearTempRegistries: cleared RouterRegistry + HostRouterRegistry",
923
- );
924
- } catch (err: any) {
925
- // Non-fatal: if the import fails here, importEntryAndRegistry
926
- // below will fail loudly with the same root cause and the
927
- // caller will surface it.
928
- debugDiscovery?.(
929
- "clearTempRegistries: import @rangojs/router/server failed (%s)",
930
- err?.message ?? String(err),
931
- );
932
- }
933
- }
934
-
935
- // HMR refresh: keep the temp Vite server alive across HMR cycles and
936
- // invalidate its module graph instead of close+recreate. Closing the
937
- // temp server during workerd's first post-cold-start module-fetch
938
- // window disrupted the main dev server's transport — the user-visible
939
- // symptom was a `transport was disconnected, cannot call "fetchModule"`
940
- // error on the first urls.tsx edit (workerd's cache was cold, so its
941
- // eval was still in flight when our close() ran). Module-graph
942
- // invalidation is the architecturally cleaner refresh: same Vite
943
- // instance, same transport, fresh source.
944
- //
945
- // Falls back to close+recreate when neither the env-level nor
946
- // server-level moduleGraph exposes invalidateAll() (defensive — Vite
947
- // versions / preset configurations may differ in which graph carries
948
- // the module-runner cache).
949
- async function refreshTempRscEnv(): Promise<any | null> {
950
- const tempRscEnv = (await getOrCreateTempServer()).env;
951
- if (!tempRscEnv) return null;
952
-
953
- // Module-runner cache is on the per-environment graph in Vite 6+;
954
- // older / non-environments setups carry it on the server graph.
955
- // Try env first, server second.
956
- const envGraph = (tempRscEnv as any).moduleGraph;
957
- const serverGraph = (prerenderTempServer as any)?.moduleGraph;
958
- const target = envGraph?.invalidateAll
959
- ? envGraph
960
- : serverGraph?.invalidateAll
961
- ? serverGraph
962
- : null;
963
-
964
- if (!target) {
965
- // No invalidate method available — fall back to close+recreate.
966
- // This preserves the previous behavior in case a Vite version
967
- // doesn't expose invalidateAll on either graph.
968
- debugDiscovery?.(
969
- "refreshTempRscEnv: invalidateAll unavailable on env+server graphs, falling back to close+recreate",
970
- );
971
- if (prerenderTempServer) {
972
- await prerenderTempServer.close().catch(() => {});
973
- prerenderTempServer = null;
974
- prerenderNodeRegistry = null;
975
- }
976
- return (await getOrCreateTempServer()).env;
977
- }
978
-
979
- debugDiscovery?.(
980
- "refreshTempRscEnv: invalidating module graph (%s)",
981
- envGraph?.invalidateAll ? "env" : "server",
982
- );
983
- target.invalidateAll();
984
- // Drop the cached registry so importEntryAndRegistry re-reads it
985
- // through the now-invalidated module runner.
986
- prerenderNodeRegistry = null;
987
- // Clear singleton Maps that Vite's moduleGraph invalidation can't
988
- // reach (RouterRegistry / HostRouterRegistry). Without this, an
989
- // edit that REMOVES a createRouter() call or CHANGES a router id
990
- // would leave the old entry in the registry, and discoverRouters
991
- // would still emit its routes alongside whatever the new source
992
- // declares.
993
- await clearTempRegistries(tempRscEnv);
994
- await importEntryAndRegistry(tempRscEnv);
995
- return tempRscEnv;
996
- }
997
-
998
- // Surface a discovery failure on either dev path (Node RSC runner or the
999
- // Cloudflare temp Node server). `hashBefore`/`hashAfter` are the discovering
1000
- // environment's dep-optimizer browserHash snapshots: a change across the
1001
- // attempt means a reload-causing re-optimization landed mid-flight, so an
1002
- // empty registry was the transient race (downgraded to a warning) rather
1003
- // than a genuine misconfig (loud, actionable error). Shared so both catch
1004
- // sites frame the same failure identically.
1005
- const emitDiscoveryFailure = (
1006
- err: unknown,
1007
- hashBefore: string | undefined,
1008
- hashAfter: string | undefined,
1009
- ): void => {
1010
- const reoptimizeObserved =
1011
- hashBefore !== undefined &&
1012
- hashAfter !== undefined &&
1013
- hashBefore !== hashAfter;
1014
- const report = describeDiscoveryFailure(err, { reoptimizeObserved });
1015
- if (report.level === "warn") {
1016
- console.warn(report.message);
1017
- } else {
1018
- console.error(report.message);
1019
- }
1020
- showTransitionWhenError(err);
1021
- };
1022
-
1023
- const discover = async () => {
1024
- const discoverStart = performance.now();
1025
- const rscEnv = (server.environments as any)?.rsc;
1026
- if (!rscEnv?.runner) {
1027
- // Cloudflare dev: no module runner available (workerd-based RSC env).
1028
- // Set devServerOrigin so the virtual module can inject __PRERENDER_DEV_URL
1029
- // for on-demand prerender via the /__rsc_prerender endpoint.
1030
- debugDiscovery?.(
1031
- "dev: cloudflare path start, __rscRouterDiscoveryActive=%s",
1032
- (globalThis as any).__rscRouterDiscoveryActive ?? false,
1033
- );
1034
- s.devServerOrigin = getDevServerOrigin();
1035
- // The temp Node realm below shares this globalThis and runs the dev
1036
- // /__rsc_shell capture, which must replay the prerender store as
1037
- // the worker's HIT does: a capture that renders the Prerender
1038
- // handler live stores a doc record the HIT never reads, and the
1039
- // HIT loses the loader pushes its prelude rendered (#1057). That
1040
- // realm never evaluates the virtual module that sets this on the
1041
- // Node preset.
1042
- globalThis.__PRERENDER_DEV_URL = s.devServerOrigin;
1043
-
1044
- // Create a temp Node.js server to run runtime discovery and generate
1045
- // named route types (static parser can't resolve factory calls).
1046
- // The temp server is a separate Vite instance with its own dep
1047
- // optimizer; snapshot ITS browserHash (hoisted so the catch can tell a
1048
- // transient re-optimization apart from a genuine empty registry, the
1049
- // same way the Node path below does).
1050
- let tempRscEnv: any;
1051
- let optimizerHashBefore: string | undefined;
1052
- try {
1053
- // Acquire build-time env bindings for dev prerender
1054
- await timed(debugDiscovery, "acquireBuildEnv", () =>
1055
- acquireBuildEnv(s, viteCommand, viteMode),
1056
- );
1057
-
1058
- const temp = await timed(
1059
- debugDiscovery,
1060
- "getOrCreateTempServer",
1061
- () => getOrCreateTempServer(),
1062
- );
1063
- tempRscEnv = temp.env;
1064
- if (!tempRscEnv) {
1065
- // Recovery mode, as in the catch below. getOrCreateTempServer
1066
- // already logged why the temp runner failed.
1067
- recordDiscoveryFailure(
1068
- s,
1069
- temp.error ?? "temp runner unavailable for discovery",
1070
- );
1071
- } else {
1072
- optimizerHashBefore =
1073
- tempRscEnv.depsOptimizer?.metadata?.browserHash;
1074
- await timed(debugDiscovery, "discoverRouters (cloudflare)", () =>
1075
- discoverRouters(
1076
- s,
1077
- tempRscEnv,
1078
- (prerenderTempServer?.environments as any)?.ssr,
1079
- ),
1080
- );
1081
- timedSync(debugDiscovery, "writeRouteTypesFiles", () =>
1082
- writeRouteTypesFiles(s),
1083
- );
1084
- }
1085
- } catch (err: any) {
1086
- recordDiscoveryFailure(s, err);
1087
- emitDiscoveryFailure(
1088
- err,
1089
- optimizerHashBefore,
1090
- tempRscEnv?.depsOptimizer?.metadata?.browserHash,
1091
- );
1092
- }
1093
-
1094
- debugDiscovery?.(
1095
- "dev discovery done (%sms)",
1096
- (performance.now() - discoverStart).toFixed(1),
1097
- );
1098
- resolveDiscovery!();
1099
- return;
1100
- }
1101
-
1102
- // Snapshot the dep-optimizer hash before discovery so the catch can tell
1103
- // a transient re-optimization race apart from a genuine empty registry.
1104
- // A reload-causing re-optimization regenerates browserHash; if it changed
1105
- // across the attempt, an empty read was almost certainly the race below.
1106
- const optimizerHashBefore: string | undefined =
1107
- rscEnv.depsOptimizer?.metadata?.browserHash;
1108
-
1109
- try {
1110
- // Acquire build-time env bindings for dev prerender (Node.js path)
1111
- debugDiscovery?.("dev: node path start");
1112
- await timed(debugDiscovery, "acquireBuildEnv", () =>
1113
- acquireBuildEnv(s, viteCommand, viteMode),
1114
- );
1115
-
1116
- // Discover routers FIRST, then arm the manifest-readiness gate on the
1117
- // server module discovery actually read the registry from.
1118
- //
1119
- // We deliberately do NOT pre-import "@rangojs/router/server" here to
1120
- // arm the gate early. During a Vite dependency re-optimization (dev
1121
- // boot after a lockfile change, or `vite dev --force`) or any runner
1122
- // full reload, a module imported before the entry can be a stale copy
1123
- // of the runner's module graph: createRouter() populates RouterRegistry
1124
- // on the fresh copy and the stale one reads an empty Map, a spurious
1125
- // "No routers found" (#637). discoverRouters returns the instance it
1126
- // read the registry from and verified the runner still serves (it
1127
- // redoes a pass that a reload split). The virtual manifest module's
1128
- // own gate (s.discoveryDone, armed by beginDiscoveryGate) already
1129
- // blocks early requests during discovery on the Node path, so arming
1130
- // manifestReadyPromise after discovery is sufficient here.
1131
- const serverMod = await timed(debugDiscovery, "discoverRouters", () =>
1132
- discoverRouters(s, rscEnv, (server.environments as any)?.ssr),
1133
- );
1134
- if (serverMod?.setManifestReadyPromise) {
1135
- serverMod.setManifestReadyPromise(discoveryPromise);
1136
- }
1137
-
1138
- // Store server origin for dev prerender endpoint (virtual module injection)
1139
- s.devServerOrigin = getDevServerOrigin();
1140
-
1141
- // Update named-routes.gen.ts from runtime discovery.
1142
- // The runtime manifest is the source of truth: it evaluates dynamic
1143
- // routes (e.g. Array.from loops) that the static parser cannot see.
1144
- // writeRouteTypesFiles() only writes when content changes, so this
1145
- // won't cause unnecessary HMR triggers.
1146
- timedSync(debugDiscovery, "writeRouteTypesFiles", () =>
1147
- writeRouteTypesFiles(s),
1148
- );
1149
-
1150
- // Populate the route map and per-router data in the RSC env
1151
- await timed(debugDiscovery, "propagateDiscoveryState", () =>
1152
- propagateDiscoveryState(rscEnv),
1153
- );
1154
- } catch (err: any) {
1155
- // Recovery mode: the next in-scan source change re-runs discovery
1156
- // (canRefreshRuntimeDiscovery) instead of waiting for a restart.
1157
- recordDiscoveryFailure(s, err);
1158
- emitDiscoveryFailure(
1159
- err,
1160
- optimizerHashBefore,
1161
- rscEnv.depsOptimizer?.metadata?.browserHash,
1162
- );
1163
- } finally {
1164
- debugDiscovery?.(
1165
- "dev discovery done (%sms)",
1166
- (performance.now() - discoverStart).toFixed(1),
1167
- );
1168
- resolveDiscovery!();
1169
- }
1170
- };
1171
-
1172
- // Schedule after all plugins have finished configureServer.
1173
- // The gate (s.discoveryDone) is reset via beginDiscoveryGate() and
1174
- // resolved when discover() finishes, so the virtual manifest module's
1175
- // load() awaits the populated state.
1176
- beginDiscoveryGate();
1177
- setTimeout(
1178
- () => discover().then(resolveDiscoveryGate, resolveDiscoveryGate),
1179
- 0,
1180
- );
1181
-
1182
- // Dev-mode on-demand prerender endpoint.
1183
- // When workerd hits a prerender route, it fetches this endpoint instead of
1184
- // trying to run node:fs-dependent handlers in the Cloudflare environment.
1185
- //
1186
- // Node.js preset: uses the main server's RSC environment directly (router
1187
- // instances are already discovered and have matchForPrerender).
1188
- // Cloudflare preset: lazily creates a Node.js temp server because the main
1189
- // RSC environment uses workerd where node:fs can't access the host filesystem.
1190
-
1191
- // Registry from the main server's RSC environment (populated by discoverRouters)
1192
- let mainRegistry: Map<string, any> | null = null;
1193
-
1194
- // Memoized /__rsc_prerender render results, keyed by router-instance
1195
- // identity (#654). The per-request entry re-import below is what makes
1196
- // identity a valid freshness key: an HMR-invalidated chain re-runs
1197
- // createRouter() and replaces the registry instance, so cached bodies
1198
- // for the old instance become unreachable; an untouched chain returns
1199
- // the same instance and the cached body is byte-identical to a fresh
1200
- // render. See dev-prerender-cache.ts for the full invariant.
1201
- const devPrerenderCache = createDevPrerenderCache();
1202
-
1203
- // Push discovery state (manifest, trie, precomputed entries) to the
1204
- // server module so runtime request handling uses the current routes.
1205
- // Shared by initial discovery and HMR-triggered re-discovery.
1206
- const propagateDiscoveryState = async (rscEnv: any) => {
1207
- const serverMod = await rscEnv.runner.import("@rangojs/router/server");
1208
- if (!serverMod) return;
1209
- // Clear stale per-router and global route data before repopulating.
1210
- // Without this, removed routers/routes survive in the per-router maps
1211
- // and shrunk precomputed entries or tries are never purged.
1212
- if (serverMod.clearAllRouterData) {
1213
- serverMod.clearAllRouterData();
1214
- }
1215
- mainRegistry = serverMod.RouterRegistry ?? null;
1216
- if (s.mergedRouteManifest && serverMod.setCachedManifest) {
1217
- serverMod.setCachedManifest(s.mergedRouteManifest);
1218
- }
1219
- const perRouterSetters: Array<[Map<string, any>, string]> = [
1220
- [s.perRouterManifestDataMap, "setRouterManifest"],
1221
- [s.perRouterTrieMap, "setRouterTrie"],
1222
- [s.perRouterPrecomputedMap, "setRouterPrecomputedEntries"],
1223
- ];
1224
- for (const [map, fn] of perRouterSetters) {
1225
- const setter = serverMod[fn];
1226
- if (typeof setter !== "function") continue;
1227
- for (const [routerId, value] of map) setter(routerId, value);
1228
- }
1229
- };
1230
-
1231
- server.middlewares.use("/__rsc_prerender", async (req: any, res: any) => {
1232
- const reqStart = debugDev ? performance.now() : 0;
1233
- const logResult = (status: number, note: string) => {
1234
- debugDev?.(
1235
- "/__rsc_prerender %s -> %d %s (%sms)",
1236
- req.url,
1237
- status,
1238
- note,
1239
- (performance.now() - reqStart).toFixed(1),
1240
- );
1241
- };
1242
-
1243
- if (s.discoveryDone) await s.discoveryDone;
1244
-
1245
- const url = new URL(req.url || "/", "http://localhost");
1246
- const pathname = url.searchParams.get("pathname");
1247
- if (!pathname) {
1248
- res.statusCode = 400;
1249
- res.end("Missing pathname");
1250
- logResult(400, "missing pathname");
1251
- return;
1252
- }
1253
-
1254
- // Import the user's entry module to force re-evaluation of any
1255
- // HMR-invalidated modules in the chain (entry → router → urls → handlers).
1256
- // This ensures createRouter() re-runs with updated handler code before
1257
- // we read RouterRegistry. Without this, edits to prerender handler files
1258
- // produce stale content because the old router instance remains registered.
1259
- const rscEnv = (server.environments as any)?.rsc;
1260
- let registry: Map<string, any> | null = null;
1261
- if (rscEnv?.runner && s.resolvedEntryPath) {
1262
- try {
1263
- await rscEnv.runner.import(s.resolvedEntryPath);
1264
- const serverMod = await rscEnv.runner.import(
1265
- "@rangojs/router/server",
1266
- );
1267
- registry = serverMod.RouterRegistry ?? null;
1268
- } catch (err: any) {
1269
- console.warn(
1270
- `[rango] Dev prerender module refresh failed: ${err.message}`,
1271
- );
1272
- res.statusCode = 500;
1273
- res.end(`Prerender handler error: ${err.message}`);
1274
- logResult(500, "module refresh failed");
1275
- return;
1276
- }
1277
- } else {
1278
- registry = mainRegistry;
1279
- }
1280
-
1281
- if (!registry) {
1282
- // No main registry: the RSC env has no module runner (Cloudflare dev).
1283
- // Lazily create a Node.js temp server for prerender evaluation, and
1284
- // re-import the entry through it on EVERY request — the temp server
1285
- // has its own file watcher, so a handler-only edit (a file without
1286
- // urls()/createRouter() that the main watcher's route-file sniff
1287
- // ignores) invalidates its module graph; the re-import re-evaluates
1288
- // exactly the dirty subgraph and re-registers fresh router
1289
- // instances. Before #654 the cached registry was only refreshed on
1290
- // route-file edits, so handler-only edits served stale prerender
1291
- // content on this path. Warm-cache re-imports are module-cache hits.
1292
- const tempRscEnv = (await getOrCreateTempServer()).env;
1293
- if (tempRscEnv) {
1294
- try {
1295
- await importEntryAndRegistry(tempRscEnv);
1296
- } catch (err: any) {
1297
- console.warn(
1298
- `[rango] Dev prerender module refresh failed: ${err.message}`,
1299
- );
1300
- res.statusCode = 500;
1301
- res.end(`Prerender handler error: ${err.message}`);
1302
- logResult(500, "temp module refresh failed");
1303
- return;
1304
- }
1305
- }
1306
- registry = prerenderNodeRegistry;
1307
- }
1308
-
1309
- if (!registry || registry.size === 0) {
1310
- res.statusCode = 503;
1311
- res.end("Prerender runner not available");
1312
- logResult(503, "no registry");
1313
- return;
1314
- }
1315
-
1316
- const wantIntercept = url.searchParams.get("intercept") === "1";
1317
- const wantRouteName = url.searchParams.get("routeName");
1318
- const wantPassthrough = url.searchParams.get("passthrough") === "1";
1319
-
1320
- // One render warms BOTH variant keys (matchForPrerender computes the
1321
- // intercept segments unconditionally), so a route's main and modal
1322
- // variants cost a single render per HMR generation.
1323
- const variantDims = {
1324
- passthrough: wantPassthrough,
1325
- routeName: wantRouteName,
1326
- };
1327
- const keyMain = devPrerenderCacheKey(pathname, {
1328
- intercept: false,
1329
- ...variantDims,
1330
- });
1331
- const keyIntercept = devPrerenderCacheKey(pathname, {
1332
- intercept: true,
1333
- ...variantDims,
1334
- });
1335
- const requestedKey = wantIntercept ? keyIntercept : keyMain;
1336
-
1337
- for (const [, routerInstance] of registry) {
1338
- if (!routerInstance.matchForPrerender) continue;
1339
- // Cache is consulted per router IN LOOP ORDER so multi-router
1340
- // fall-through semantics are identical to the uncached path: a
1341
- // router that never produced a payload for this key still runs
1342
- // its matchForPrerender (cheap trie miss / intentionally-uncached
1343
- // error retry) before the next router is considered.
1344
- const cached = devPrerenderCache.get(routerInstance, requestedKey);
1345
- if (cached !== undefined) {
1346
- res.setHeader("content-type", "application/json");
1347
- res.setHeader("x-rango-prerender-cache", "HIT");
1348
- res.end(cached);
1349
- logResult(200, "cache hit");
1350
- return;
1351
- }
1352
- try {
1353
- const result = await routerInstance.matchForPrerender(
1354
- pathname,
1355
- {},
1356
- undefined,
1357
- wantPassthrough,
1358
- s.resolvedBuildEnv,
1359
- true, // devMode: check getParams for passthrough routes
1360
- );
1361
- if (!result) continue;
1362
- if (result.passthrough) continue;
1363
- // When routeName is specified, only accept a match for that route.
1364
- // This prevents returning the wrong entry when multiple routers
1365
- // have prerenderable routes sharing the same pathname.
1366
- if (wantRouteName && result.routeName !== wantRouteName) continue;
1367
- // Pre-encoded MERGED handle string in the intercept body comes
1368
- // from the producer (handles are Flight-encoded so
1369
- // Promise/ReactNode values survive the wire).
1370
- const bodies = payloadBodiesFromResult(result);
1371
- devPrerenderCache.set(routerInstance, keyMain, bodies.main);
1372
- devPrerenderCache.set(
1373
- routerInstance,
1374
- keyIntercept,
1375
- bodies.intercept,
1376
- );
1377
- res.setHeader("content-type", "application/json");
1378
- res.setHeader("x-rango-prerender-cache", "MISS");
1379
- res.end(wantIntercept ? bodies.intercept : bodies.main);
1380
- logResult(200, `match ${result.routeName}`);
1381
- return;
1382
- } catch (err: any) {
1383
- // matchForPrerender now re-throws render failures instead of baking
1384
- // an error page (issue #587). In dev there is no frozen artifact, so
1385
- // we fall through (404 -> live handler). A `throw new Skip()` is the
1386
- // expected "skip this URL" signal, not a failure, so it stays quiet.
1387
- if (err?.name !== "Skip") {
1388
- console.warn(
1389
- `[rango] Dev prerender error for ${pathname} (serving live instead): ${err.message}`,
1390
- );
1391
- }
1392
- }
1393
- }
1394
-
1395
- res.statusCode = 404;
1396
- res.end("No prerender match");
1397
- logResult(404, "no match");
1398
- });
1399
-
1400
- // Dev on-demand PPR shell production (producer B, #699). There is no
1401
- // build manifest in dev, so the serve path's read-through
1402
- // (rsc/shell-build-manifest.ts) fetches the shell entry from here on a
1403
- // Prerender+ppr route's first request — dev serves x-rango-shell: HIT
1404
- // from request one, mirroring production. Memoized per router HMR
1405
- // generation AND per caller version (a client-module edit bumps the
1406
- // version without rotating the router instance; the stale entry would
1407
- // fail the serve gate forever). The endpoint is policy-free: the caller
1408
- // (the serve gate, which resolved the route's ppr option) sends
1409
- // ttl/swr/tags/version. Only prerender-backed routes produce entries —
1410
- // the /__rsc_prerender pre-flight below both warms the payload memo the
1411
- // capture's dev store fetch will hit AND refuses non-prerenderable
1412
- // routes (a live-handler render must never be served as a baked shell).
1413
- server.middlewares.use("/__rsc_shell", async (req: any, res: any) => {
1414
- await s.discoveryDone;
1415
- const url = new URL(req.url ?? "", "http://localhost");
1416
- const pathname = url.searchParams.get("pathname");
1417
- const routeName = url.searchParams.get("routeName");
1418
- const version = url.searchParams.get("version");
1419
- // ttl is required like the identifiers: the endpoint is policy-free
1420
- // (the serve gate resolved the route's ppr option and always sends
1421
- // it), so there is deliberately no default to drift from
1422
- // resolvePprConfig's.
1423
- const ttlRaw = url.searchParams.get("ttl");
1424
- if (!pathname || !routeName || !version || !ttlRaw) {
1425
- res.statusCode = 400;
1426
- res.end("Missing pathname/routeName/version/ttl");
1427
- return;
1428
- }
1429
-
1430
- // Boot-race readiness signal (issue #719): the capture realm is stood
1431
- // up lazily on the first hit (temp server, registry import, and a Vite
1432
- // dep re-optimization the first shell-capture import can trigger). All
1433
- // are TRANSIENT — a retry seconds later succeeds. Tagging them 503 +
1434
- // x-rango-shell-dev: NOT-READY lets the read-through re-poll ONLY these
1435
- // (a bounded await of readiness) instead of mapping the boot window to
1436
- // a hard first-request MISS. Genuine negatives (404) stay untagged so a
1437
- // non-baked route never stalls the foreground.
1438
- const sendNotReady = (detail: string): void => {
1439
- res.statusCode = 503;
1440
- res.setHeader("x-rango-shell-dev", "NOT-READY");
1441
- res.end(detail);
1442
- };
1443
- // A Vite dependency re-optimization surfaces as ERR_OUTDATED_OPTIMIZED_DEP
1444
- // (import throws once, then re-imports clean) — retryable, unlike a real
1445
- // module fault (syntax error, missing export), which stays a hard 500.
1446
- // The message-regex fallback is NOT gratuitous: err.code is stripped when
1447
- // the error serializes across the module-runner/workerd RPC boundary (the
1448
- // message survives), so under the Cloudflare preset .code alone misses it.
1449
- const isReoptimizing = (err: any): boolean =>
1450
- err?.code === "ERR_OUTDATED_OPTIMIZED_DEP" ||
1451
- /Outdated Optimize Dep|optimized dependency|new dependencies optimized/i.test(
1452
- String(err?.message ?? ""),
1453
- );
1454
- // Fold the reoptimize-guard pasted at all three import/capture catch
1455
- // sites: emit NOT-READY + report handled for a transient re-optimization,
1456
- // else leave the caller to send its own terminal (500 for an entry
1457
- // import, 404 for a mid-capture failure that keeps runtime capture).
1458
- const handledAsReoptimizing = (err: any): boolean => {
1459
- if (!isReoptimizing(err)) return false;
1460
- sendNotReady(`Shell capture re-optimizing: ${err.message}`);
1461
- return true;
1462
- };
1463
- // Both entry-import sites (main-server rsc env, temp Node server) fail
1464
- // identically: reoptimize → NOT-READY, else → hard 500.
1465
- const handleShellImportError = (err: any): void => {
1466
- if (handledAsReoptimizing(err)) return;
1467
- res.statusCode = 500;
1468
- res.end(`Shell capture module refresh failed: ${err.message}`);
1469
- };
1470
- // Deterministic boot-race injection for e2e (issue #719 P3): fire ONE
1471
- // reopt-class NOT-READY per pathname through the REAL classifier so the
1472
- // read-through's re-poll path is exercised end-to-end (not just the
1473
- // unit-mocked fetch), then serve normally on the re-poll. Env-gated —
1474
- // inert in every non-test run.
1475
- if (
1476
- process.env.RANGO_E2E_INJECT_SHELL_NOTREADY === "1" &&
1477
- !injectedShellNotReadyPaths.has(pathname)
1478
- ) {
1479
- injectedShellNotReadyPaths.add(pathname);
1480
- const injected = Object.assign(
1481
- new Error("Outdated Optimize Dep (injected boot-race)"),
1482
- { code: "ERR_OUTDATED_OPTIMIZED_DEP" },
1483
- );
1484
- if (handledAsReoptimizing(injected)) return;
1485
- }
1486
- const ttl = Number(ttlRaw);
1487
- const swrRaw = url.searchParams.get("swr");
1488
- const swr = swrRaw === null ? undefined : Number(swrRaw);
1489
- const tagsRaw = url.searchParams.get("tags");
1490
- const tags = tagsRaw ? tagsRaw.split(",") : undefined;
1491
- const maxSnapshotBytesRaw = url.searchParams.get("maxSnapshotBytes");
1492
- const maxSnapshotBytes =
1493
- maxSnapshotBytesRaw === null
1494
- ? undefined
1495
- : Number(maxSnapshotBytesRaw);
1496
- // Boundary revalidation via the SHARED normalizer (shell-serve.ts):
1497
- // the param crossed an HTTP query string, and a garbage value must
1498
- // fall back to the capture default, never reach setTimeout as NaN
1499
- // (which Node clamps to ~1ms — an instant abort).
1500
- const captureTimeout = normalizeCaptureTimeout(
1501
- Number(url.searchParams.get("captureTimeout")),
1502
- );
1503
-
1504
- // Resolve the capture realms: main-server envs (Node preset) or the
1505
- // shared temp Node server (Cloudflare preset — no main RSC runner).
1506
- // Entry re-import per request picks up HMR edits, exactly like the
1507
- // prerender endpoint above.
1508
- const rscEnvMain = (server.environments as any)?.rsc;
1509
- let rscRealm: any = null;
1510
- let ssrRealm: any = null;
1511
- let ssrEntryId: string;
1512
- // Why the temp-server path returned no runner, if it did: a transient
1513
- // re-optimization is re-pollable (NOT-READY), a terminal create/import
1514
- // fault is not. Carried from getOrCreateTempServer to the readiness
1515
- // check below so only reopt re-polls (issue #719 P2).
1516
- let tempServerError: unknown = null;
1517
- if (rscEnvMain?.runner && s.resolvedEntryPath) {
1518
- try {
1519
- await rscEnvMain.runner.import(s.resolvedEntryPath);
1520
- } catch (err: any) {
1521
- handleShellImportError(err);
1522
- return;
1523
- }
1524
- rscRealm = rscEnvMain;
1525
- ssrRealm = (server.environments as any)?.ssr;
1526
- ssrEntryId =
1527
- (server.environments as any)?.ssr?.config?.build?.rollupOptions
1528
- ?.input?.index ?? VIRTUAL_IDS.ssr;
1529
- } else {
1530
- const tempResult = await getOrCreateTempServer();
1531
- if (tempResult.env) {
1532
- try {
1533
- await importEntryAndRegistry(tempResult.env);
1534
- } catch (err: any) {
1535
- handleShellImportError(err);
1536
- return;
1537
- }
1538
- } else {
1539
- tempServerError = tempResult.error;
1540
- }
1541
- rscRealm = tempResult.env;
1542
- ssrRealm = (prerenderTempServer?.environments as any)?.ssr;
1543
- ssrEntryId = "virtual:entry-ssr";
1544
- }
1545
- if (!rscRealm?.runner || !ssrRealm?.runner) {
1546
- // Reoptimization is the ONLY transient class: re-poll it (NOT-READY).
1547
- // A terminal temp-server create/import fault, or an SSR runner absent
1548
- // after a clean createServer, fails fast with a plain 503 (no
1549
- // NOT-READY header) so the read-through MISSes on its first attempt
1550
- // instead of re-polling a permanently-broken realm for the full
1551
- // readiness deadline (issue #719 P2).
1552
- if (tempServerError && handledAsReoptimizing(tempServerError)) return;
1553
- res.statusCode = 503;
1554
- res.end("Shell capture runners not available");
1555
- return;
1556
- }
1557
- let registry: Map<string, any> | null = null;
1558
- let registryError: unknown = null;
1559
- try {
1560
- const serverMod = await rscRealm.runner.import(
1561
- "@rangojs/router/server",
1562
- );
1563
- registry = serverMod.RouterRegistry ?? null;
1564
- } catch (err: any) {
1565
- registryError = err;
1566
- registry = null;
1567
- }
1568
- if (!registry || registry.size === 0) {
1569
- // Same rule as the runner check: a re-optimization mid-import is
1570
- // re-pollable (NOT-READY); a terminal import fault or a genuinely
1571
- // empty registry (no routers registered) fails fast (issue #719 P2).
1572
- if (registryError && handledAsReoptimizing(registryError)) return;
1573
- res.statusCode = 503;
1574
- res.end("Shell capture registry not available");
1575
- return;
1576
- }
1577
-
1578
- // Memo sweep FIRST: after request one the common case is a memo HIT
1579
- // (this fetch blocks a foreground document request), and the memoized
1580
- // body needs neither the pre-flight round-trip nor a capture. Keyed
1581
- // per router instance (= HMR generation) like the prerender memo.
1582
- const cacheKey = `shell|${pathname}|r=${routeName}|t=${ttl}|s=${swr ?? ""}|g=${(tags ?? []).join("+")}|c=${captureTimeout ?? ""}|v=${version}`;
1583
- for (const [, routerInstance] of registry) {
1584
- if (typeof routerInstance.match !== "function") continue;
1585
- const cached = devPrerenderCache.get(routerInstance, cacheKey);
1586
- if (cached !== undefined) {
1587
- res.setHeader("content-type", "application/json");
1588
- res.setHeader("x-rango-shell-dev", "HIT");
1589
- res.end(cached);
1590
- return;
1591
- }
1592
- }
1593
-
1594
- // Pre-flight: the route must be prerender-backed. Warms the payload
1595
- // memo the capture's dev prerender store will fetch, and closes the
1596
- // live-handler-bake hole (a non-pr route 404s here).
1597
- if (s.devServerOrigin) {
1598
- try {
1599
- const probe = await fetch(
1600
- `${s.devServerOrigin}/__rsc_prerender?pathname=${encodeURIComponent(pathname)}&routeName=${encodeURIComponent(routeName)}`,
1601
- { signal: AbortSignal.timeout(DEV_SHELL_PROBE_TIMEOUT_MS) },
1602
- );
1603
- if (!probe.ok) {
1604
- res.statusCode = 404;
1605
- res.end("Route is not prerenderable");
1606
- return;
1607
- }
1608
- } catch {
1609
- res.statusCode = 404;
1610
- res.end("Prerender pre-flight failed");
1611
- return;
1612
- }
1613
- }
1614
-
1615
- for (const [, routerInstance] of registry) {
1616
- if (typeof routerInstance.match !== "function") continue;
1617
- try {
1618
- const ssrModule = await ssrRealm.runner.import(ssrEntryId);
1619
- if (typeof ssrModule?.captureShellHTML !== "function") {
1620
- res.statusCode = 404;
1621
- res.end("SSR entry has no captureShellHTML");
1622
- return;
1623
- }
1624
- const captureMod = await rscRealm.runner.import(
1625
- "@rangojs/router/build/shell-capture",
1626
- );
1627
- const result = await captureMod.captureShellForBuild({
1628
- router: routerInstance,
1629
- urlPath: pathname,
1630
- routeName,
1631
- key: `${pathname}:shell`,
1632
- ttl,
1633
- swr,
1634
- tags,
1635
- maxSnapshotBytes,
1636
- captureTimeout,
1637
- buildEnv: s.resolvedBuildEnv,
1638
- versions: uniformVersions(version),
1639
- captureShellHTML: ssrModule.captureShellHTML,
1640
- debug: !!debugDiscovery,
1641
- });
1642
- if (result.outcome === "route-mismatch") continue;
1643
- if (result.outcome !== "stored" || !result.entry) {
1644
- res.statusCode = 404;
1645
- res.end(`Shell capture ${result.outcome}`);
1646
- return;
1647
- }
1648
- const body = JSON.stringify({
1649
- entry: result.entry,
1650
- ttl,
1651
- tags: result.tags,
1652
- });
1653
- devPrerenderCache.set(routerInstance, cacheKey, body);
1654
- res.setHeader("content-type", "application/json");
1655
- res.setHeader("x-rango-shell-dev", "MISS");
1656
- res.end(body);
1657
- return;
1658
- } catch (err: any) {
1659
- // A dep re-optimization mid-capture is a boot-race, not a capture
1660
- // failure: signal NOT-READY so the read-through re-polls instead of
1661
- // conceding a first-request MISS (issue #719).
1662
- if (handledAsReoptimizing(err)) return;
1663
- console.warn(
1664
- `[rango] Dev shell capture error for ${pathname} (route keeps runtime capture): ${err.message}`,
1665
- );
1666
- res.statusCode = 404;
1667
- res.end(`Shell capture error: ${err.message}`);
1668
- return;
1669
- }
1670
- }
1671
- res.statusCode = 404;
1672
- res.end("No router matched");
1673
- });
1674
-
1675
- // Watch url module and router files for changes and regenerate named-routes.gen.ts.
1676
- // Process files containing urls( or createRouter( to update the combined route map.
1677
- if (opts?.staticRouteTypesGeneration !== false) {
1678
- const isGeneratedRouteFile = (filePath: string): boolean =>
1679
- filePath.endsWith(".gen.ts") &&
1680
- (filePath.includes("named-routes.gen.ts") ||
1681
- filePath.includes("urls.gen.ts"));
1682
-
1683
- // Files no discovery scan reaches are ignored unless the RSC graph
1684
- // loaded them (docs/manifests.md, "A reload no file of the app asked for").
1685
- const isOutsideApp = (filePath: string): boolean =>
1686
- isFileOutsideApp(
1687
- s.projectRoot,
1688
- filePath,
1689
- (server.environments as any)?.rsc?.moduleGraph,
1690
- );
1691
-
1692
- const regenerateGeneratedRouteFiles = () => {
1693
- if (s.perRouterManifests.length > 0) {
1694
- writeRouteTypesFiles(s);
1695
- } else {
1696
- writeCombinedRouteTypesWithTracking(s);
1697
- }
1698
- };
1699
- const routeShapeSignature = () =>
1700
- JSON.stringify(
1701
- s.perRouterManifests.map(
1702
- ({
1703
- id,
1704
- routeManifest,
1705
- routeTrailingSlash,
1706
- routeSearchSchemas,
1707
- }) => ({
1708
- id,
1709
- routeManifest,
1710
- routeTrailingSlash,
1711
- routeSearchSchemas,
1712
- }),
1713
- ),
1714
- );
1715
-
1716
- const maybeHandleGeneratedRouteFileMutation = (
1717
- filePath: string,
1718
- ): boolean => {
1719
- if (!isGeneratedRouteFile(filePath)) return false;
1720
- if (consumeSelfGenWrite(s, filePath)) return true;
1721
- // In Cloudflare dev (no module runner), perRouterManifests is never
1722
- // refreshed after HMR so regenerateGeneratedRouteFiles() would use
1723
- // stale data and revert user edits. Source files own route state;
1724
- // gen files are derived output. Skip regeneration and let the next
1725
- // source-file change rebuild them from the static parser.
1726
- const hasRunner = !!(server.environments as any)?.rsc?.runner;
1727
- if (!hasRunner) return true;
1728
- regenerateGeneratedRouteFiles();
1729
- return true;
1730
- };
1731
-
1732
- // Debounce timer for batching rapid route-file changes (e.g. afterEach
1733
- // restoring two files in quick succession). The cheap checks (extension,
1734
- // scanFilter, content sniff) run synchronously to gate non-route files;
1735
- // only the expensive regeneration is debounced.
1736
- let routeChangeTimer: ReturnType<typeof setTimeout> | undefined;
1737
-
1738
- // Drop the routes-manifest virtual module's cached transform so the
1739
- // next runner evaluation regenerates it from current state.
1740
- const invalidateRoutesManifestModule = (rscEnv: any) => {
1741
- for (const virtualId of [
1742
- VIRTUAL_ROUTES_MANIFEST_ID,
1743
- `\0${VIRTUAL_ROUTES_MANIFEST_ID}`,
1744
- ]) {
1745
- const virtualMod = rscEnv.moduleGraph?.getModuleById?.(virtualId);
1746
- if (virtualMod) rscEnv.moduleGraph.invalidateModule(virtualMod);
1747
- }
1748
- };
1749
-
1750
- // Re-run runtime discovery so factory-generated routes that the
1751
- // static parser cannot see are refreshed after source changes.
1752
- // The state-machine concerns (queued/pending/gatePending) are
1753
- // owned by the gate created above (./discovery/gate-state.ts).
1754
- // Here we provide just the env-specific work.
1755
- const refreshRuntimeDiscovery = async () => {
1756
- const rscEnv = (server.environments as any)?.rsc;
1757
- const hasMainRunner = !!rscEnv?.runner;
1758
- let previousRouteShape = routeShapeSignature();
1759
- await gate.runRefreshCycle(async () => {
1760
- const hmrStart = performance.now();
1761
- try {
1762
- if (hasMainRunner) {
1763
- const recoveringColdStart = s.perRouterManifests.length === 0;
1764
- await timed(debugDiscovery, "hmr discoverRouters", () =>
1765
- discoverRouters(s, rscEnv, (server.environments as any)?.ssr),
1766
- );
1767
- timedSync(debugDiscovery, "hmr writeRouteTypesFiles", () =>
1768
- writeRouteTypesFiles(s),
1769
- );
1770
- await timed(debugDiscovery, "hmr propagateDiscoveryState", () =>
1771
- propagateDiscoveryState(rscEnv),
1772
- );
1773
- // The manifest module emitted after a failed cold discovery has
1774
- // no gen-file import, so no gen write ever invalidates it.
1775
- if (recoveringColdStart) invalidateRoutesManifestModule(rscEnv);
1776
- } else {
1777
- // Cloudflare HMR has no main RSC runner (workerd is a separate
1778
- // runtime): re-discover on the temp Node runner. Invalidate its
1779
- // RSC module graph (or close+recreate as a fallback) so the
1780
- // runner re-reads the freshly edited source. Keeping the same
1781
- // Vite instance alive avoids disrupting workerd's transport
1782
- // during the first post-cold-start module-fetch window. After
1783
- // a cold start whose temp runner failed there is none, and
1784
- // getOrCreateTempServer creates one. A recovering cycle needs
1785
- // no routes-manifest invalidation here: the worker reload below
1786
- // invalidates its whole module graph.
1787
- const tempRscEnv = await timed(
1788
- debugDiscovery,
1789
- "hmr refreshTempRscEnv (cloudflare)",
1790
- () => refreshTempRscEnv(),
1791
- );
1792
- if (!tempRscEnv) {
1793
- throw new Error(
1794
- "temp runner unavailable for cloudflare HMR rediscovery",
1795
- );
1796
- }
1797
- await timed(
1798
- debugDiscovery,
1799
- "hmr discoverRouters (cloudflare)",
1800
- () =>
1801
- discoverRouters(
1802
- s,
1803
- tempRscEnv,
1804
- (prerenderTempServer?.environments as any)?.ssr,
1805
- ),
1806
- );
1807
- timedSync(debugDiscovery, "hmr writeRouteTypesFiles", () =>
1808
- writeRouteTypesFiles(s),
1809
- );
1810
- }
1811
- if (s.lastDiscoveryError) {
1812
- debugDiscovery?.(
1813
- "hmr: cleared lastDiscoveryError (%s) after successful rediscovery",
1814
- s.lastDiscoveryError.message,
1815
- );
1816
- s.lastDiscoveryError = null;
1817
- }
1818
- transitionWhenOverlay = null;
1819
- // Cloudflare dev: on a successful cycle drop the workerd runner's
1820
- // cached worker-entry chain so the next request re-evaluates
1821
- // createRouter() with the new routes. Fired here in the work path
1822
- // (not the caller's .then()) so a queued follow-up cycle that
1823
- // succeeds after an earlier failed cycle still reloads:
1824
- // runRefreshCycle recurses queued work without awaiting it, so the
1825
- // original call already resolved on the failed cycle. A failed
1826
- // cycle throws above and never reaches here, so a broken edit
1827
- // never reloads the worker onto bad source.
1828
- if (rscEnv && !rscEnv.runner) {
1829
- const nextRouteShape = routeShapeSignature();
1830
- let expectedEpoch: number | undefined;
1831
- if (nextRouteShape !== previousRouteShape) {
1832
- expectedEpoch = Math.max(
1833
- Date.now(),
1834
- (s.devDiscoveryEpoch ?? 0) + 1,
1835
- );
1836
- s.devDiscoveryEpoch = expectedEpoch;
1837
- }
1838
- previousRouteShape = nextRouteShape;
1839
- forceCloudflareWorkerReload(rscEnv, expectedEpoch);
1840
- }
1841
- } catch (err: any) {
1842
- recordDiscoveryFailure(s, err);
1843
- // An invalid transition({ when }) is a definition error the
1844
- // user must see: terminal error plus the browser's Vite overlay,
1845
- // not a warning while the last-good route tree keeps serving.
1846
- const whenError = showTransitionWhenError(err);
1847
- if (whenError) {
1848
- console.error(whenError.message);
1849
- } else {
1850
- console.warn(
1851
- `[rango] Runtime re-discovery failed: ${err.message}`,
1852
- );
1853
- }
1854
- debugDiscovery?.(
1855
- "hmr: lastDiscoveryError set (%s) — manifest preserved at last-good; recovery mode active (any in-scan source change will trigger rediscovery)",
1856
- err?.message,
1857
- );
1858
- } finally {
1859
- debugDiscovery?.(
1860
- "hmr re-discovery done (%sms)",
1861
- (performance.now() - hmrStart).toFixed(1),
1862
- );
1863
- }
1864
- });
1865
- };
1866
-
1867
- // Cloudflare dev only. workerd serves every request through the
1868
- // runner-worker singleton, which re-resolves the worker entry per
1869
- // request via runner.import("virtual:cloudflare/worker-entry"). The
1870
- // route table lives in the user's createRouter() instance, captured
1871
- // when that entry chain (entry -> router -> urls) was last evaluated
1872
- // and then cached in the runner's evaluatedModules. The route-file
1873
- // watcher refreshes discovery + types on the Node side, but the worker
1874
- // keeps serving the cached (stale) router: route-definition modules
1875
- // have no import.meta.hot boundary, so Vite never sends the worker an
1876
- // HMR update for them and the entry chain is never evicted.
1877
- //
1878
- // Fix: after discovery completes, (1) invalidate the worker env's
1879
- // Node-side module graph, (2) send a full-reload to the worker, then
1880
- // (3) probe until the active router reports the new discovery epoch.
1881
- // Step (2) alone is insufficient: the full-reload handler clears the
1882
- // runner's evaluatedModules and re-imports entrypoints, but each
1883
- // re-import fetches the module back through this Node-side graph, which
1884
- // still holds the pre-edit transform of urls.tsx — so createRouter()
1885
- // rebuilds the stale route table and the new route 404s/hits the
1886
- // catch-all. Invalidating the graph forces a fresh transform on
1887
- // re-fetch (the same mechanism refreshTempRscEnv uses for discovery),
1888
- // so the re-import re-runs createRouter() with the new routes. The probe
1889
- // is detached because the refresh callback still holds the discovery
1890
- // gate; awaiting it here would deadlock the request that proves the new
1891
- // router is active. Once it succeeds, the client hot channel broadcasts
1892
- // readiness to open and newly-booted stale documents.
1893
- const forceCloudflareWorkerReload = (
1894
- rscEnv: any,
1895
- expectedEpoch: number | undefined,
1896
- ) => {
1897
- if (!rscEnv?.hot) return;
1898
-
1899
- const graph = rscEnv.moduleGraph;
1900
- const reloadWorkerd = () => {
1901
- if (graph?.invalidateAll) {
1902
- graph.invalidateAll();
1903
- debugDiscovery?.("hmr: invalidated workerd rsc module graph");
1904
- }
1905
- rscEnv.hot.send({ type: "full-reload" });
1906
- debugDiscovery?.(
1907
- "hmr: forced workerd rsc env reload (full-reload)",
1908
- );
1909
- };
1910
- reloadWorkerd();
1911
-
1912
- if (expectedEpoch === undefined) return;
1913
- void (async () => {
1914
- const deadline = Date.now() + 15_000;
1915
- let reloadBackoffMs = 100;
1916
- let nextReloadAt = Date.now() + reloadBackoffMs;
1917
- do {
1918
- if (devServerClosed || expectedEpoch !== s.devDiscoveryEpoch) {
1919
- return;
1920
- }
1921
- await new Promise<void>((resolve) => setTimeout(resolve, 25));
1922
- if (devServerClosed || expectedEpoch !== s.devDiscoveryEpoch) {
1923
- return;
1924
- }
1925
- try {
1926
- const response = await fetch(getDevServerOrigin() + "/", {
1927
- cache: "no-store",
1928
- headers: {
1929
- [DEV_DISCOVERY_PROBE_HEADER]: String(expectedEpoch),
1930
- },
1931
- signal: AbortSignal.timeout(1_000),
1932
- });
1933
- if (
1934
- !devServerClosed &&
1935
- expectedEpoch === s.devDiscoveryEpoch &&
1936
- response.headers.get(DEV_DISCOVERY_EPOCH_HEADER) ===
1937
- String(expectedEpoch)
1938
- ) {
1939
- publishDevDiscoveryReady(expectedEpoch);
1940
- return;
1941
- }
1942
- await response.body?.cancel().catch(() => {});
1943
- // A response without the expected epoch proves an older worker
1944
- // evaluation won the race after the initial invalidation. Clear
1945
- // that completed evaluation and retry the reload. Back off the
1946
- // retries: reloading faster than workerd can evaluate prevents
1947
- // convergence and retains overlapping module generations.
1948
- const now = Date.now();
1949
- if (now >= nextReloadAt) {
1950
- reloadWorkerd();
1951
- reloadBackoffMs = Math.min(reloadBackoffMs * 2, 1_000);
1952
- nextReloadAt = now + reloadBackoffMs;
1953
- }
1954
- } catch {}
1955
- } while (Date.now() < deadline);
1956
-
1957
- debugDiscovery?.(
1958
- "hmr: workerd readiness probe timed out at epoch %d",
1959
- expectedEpoch,
1960
- );
1961
- })();
1962
- };
1963
-
1964
- const scheduleRouteRegeneration = () => {
1965
- clearTimeout(routeChangeTimer);
1966
- routeChangeTimer = setTimeout(async () => {
1967
- routeChangeTimer = undefined;
1968
- const regenStart = debugDiscovery ? performance.now() : 0;
1969
- const rscEnv = (server.environments as any)?.rsc;
1970
- const skipStaticWrite =
1971
- !rscEnv?.runner && s.perRouterManifests.length > 0;
1972
- // Refresh clientUrls projections + state BEFORE any gen-file
1973
- // write below: the write invalidates the routes-manifest virtual
1974
- // module, whose regenerated code replays state.clientUrlProjectionMap
1975
- // (clear + set). A stale map at that moment bakes stale literals
1976
- // that clobber the runtime registry before the re-discovery entry
1977
- // import materializes the client mount (node HMR served old
1978
- // client-urls patterns until restart). Lenient: import failures
1979
- // keep last-known state; discoverRouters' strict pass reports.
1980
- if (rscEnv?.runner && s.clientUrlSourceByReferenceId?.size) {
1981
- try {
1982
- const serverMod = await rscEnv.runner.import(
1983
- "@rangojs/router/server",
1984
- );
1985
- await refreshRecordedClientUrlProjections(
1986
- s,
1987
- (server.environments as any)?.ssr,
1988
- serverMod,
1989
- rscEnv,
1990
- );
1991
- // Force the routes-manifest virtual module to re-transform:
1992
- // its generated code REPLAYS projections (clear + set) on
1993
- // every rsc program reload, and self-gen-write suppression
1994
- // keeps its cached transform alive through this cycle — a
1995
- // stale replay after propagateDiscoveryState would clobber
1996
- // the refreshed registry as the realm's last write and the
1997
- // next router evaluation would materialize the old mount.
1998
- invalidateRoutesManifestModule(rscEnv);
1999
- } catch (err: any) {
2000
- debugDiscovery?.(
2001
- "watcher: clientUrls projection pre-refresh failed: %s",
2002
- err?.message,
2003
- );
2004
- }
2005
- }
2006
- try {
2007
- // In cloudflare dev with a populated runtime manifest, the
2008
- // static parser produces a strictly smaller (and actively
2009
- // wrong) gen file — supplementGenFilesWithRuntimeRoutes can
2010
- // only restore factory-only prefixes, and apps with mixed
2011
- // static+factory routes under shared prefixes (cf-stress)
2012
- // collapse to the 19-route static view. Skip the static
2013
- // write entirely; runtime rediscovery below will overwrite
2014
- // the gen file with the authoritative manifest.
2015
- if (skipStaticWrite) {
2016
- debugDiscovery?.(
2017
- "watcher: skipping static write (cloudflare HMR — runtime rediscovery owns gen file)",
2018
- );
2019
- } else {
2020
- writeCombinedRouteTypesWithTracking(s);
2021
- if (s.perRouterManifests.length > 0) {
2022
- supplementGenFilesWithRuntimeRoutes(s);
2023
- }
2024
- }
2025
- } catch (err: any) {
2026
- console.error(`[rango] Route regeneration error: ${err.message}`);
2027
- }
2028
- debugDiscovery?.(
2029
- "watcher: regenerated gen files (%sms)",
2030
- (performance.now() - regenStart).toFixed(1),
2031
- );
2032
- // Async: re-run runtime discovery to refresh factory-generated
2033
- // routes that the static parser cannot resolve. Resolves the
2034
- // discovery gate when complete.
2035
- if (canRefreshRuntimeDiscovery(s)) {
2036
- // The cloudflare workerd reload fires inside refreshRuntimeDiscovery
2037
- // on the successful cycle (see forceCloudflareWorkerReload call
2038
- // there) so queued follow-up cycles also trigger it.
2039
- refreshRuntimeDiscovery().catch((err: any) => {
2040
- console.warn(
2041
- `[rango] Runtime re-discovery error: ${err.message}`,
2042
- );
2043
- // Even on error, unblock the gate so workerd's reload doesn't
2044
- // hang indefinitely against the previous manifest.
2045
- resolveDiscoveryGate();
2046
- });
2047
- }
2048
- }, 100);
2049
- };
2050
-
2051
- // The `.use` signature of each component-use module at its last
2052
- // save: a save that keeps it (markup, hooks) re-projects nothing.
2053
- const componentUseSignatures = new Map<string, string>();
2054
- const componentUseChanged = (file: string, source: string) => {
2055
- const next = componentUseSignature(source, file);
2056
- const changed = next !== componentUseSignatures.get(file);
2057
- if (next === undefined) componentUseSignatures.delete(file);
2058
- else componentUseSignatures.set(file, next);
2059
- return changed;
2060
- };
2061
-
2062
- const handleRouteFileChange = (filePath: string) => {
2063
- if (isOutsideApp(filePath)) {
2064
- debugDiscovery?.(
2065
- "watcher: skip %s (outside the discovery scan)",
2066
- filePath,
2067
- );
2068
- return;
2069
- }
2070
- if (maybeHandleGeneratedRouteFileMutation(filePath)) return;
2071
- if (
2072
- !filePath.endsWith(".ts") &&
2073
- !filePath.endsWith(".tsx") &&
2074
- !filePath.endsWith(".js") &&
2075
- !filePath.endsWith(".jsx")
2076
- ) {
2077
- if (s.lastDiscoveryError) {
2078
- debugDiscovery?.(
2079
- "watcher: skip non-source %s [LASTERR %s]",
2080
- filePath,
2081
- s.lastDiscoveryError.message,
2082
- );
2083
- }
2084
- return;
2085
- }
2086
- // Apply scan filter as early-exit before reading file
2087
- if (s.scanFilter && !s.scanFilter(filePath)) {
2088
- if (s.lastDiscoveryError) {
2089
- debugDiscovery?.(
2090
- "watcher: skip scan-filter %s [LASTERR %s]",
2091
- filePath,
2092
- s.lastDiscoveryError.message,
2093
- );
2094
- }
2095
- return;
2096
- }
2097
- // Recovery mode: when the previous HMR re-discovery failed, the
2098
- // import graph is incomplete and the manifest is stuck at the
2099
- // last-good state. The fix may land in a non-route file (e.g. a
2100
- // helper imported by the router, a missing module being created,
2101
- // or a "use client" component) that the narrow content sniff
2102
- // would otherwise filter out. While in recovery, treat any
2103
- // in-scan source change as a candidate for rediscovery; the
2104
- // tighter filter resumes once discovery succeeds again.
2105
- const inRecoveryMode = !!s.lastDiscoveryError;
2106
- try {
2107
- const source = readFileSync(filePath, "utf-8");
2108
- const trimmed = source.trimStart();
2109
- const isUseClient =
2110
- trimmed.startsWith('"use client"') ||
2111
- trimmed.startsWith("'use client'");
2112
- // clientUrls() modules are "use client" by contract yet define
2113
- // routes: their edits must re-run discovery (server projection +
2114
- // generated types), so only bail on use-client files WITHOUT a
2115
- // clientUrls() definition. Scar: before this carve-out, editing a
2116
- // clientUrls module's route shape in dev left the serving router on
2117
- // the stale projection — the old pattern kept matching and the new
2118
- // one 404ed until a full restart.
2119
- let hasClientUrls = source.includes("clientUrls(");
2120
- if (hasClientUrls) {
2121
- hasClientUrls =
2122
- firstCodeMatchIndex(source, /\bclientUrls\(/g) >= 0;
2123
- }
2124
- // A component whose `.use` a group reads changes the group's
2125
- // projection too (its loaders, loading(), revalidate() keys):
2126
- // on its first save, and when a `.use` changed, came or went.
2127
- const hasComponentUse = componentUseChanged(filePath, source);
2128
- if (
2129
- !inRecoveryMode &&
2130
- isUseClient &&
2131
- !hasClientUrls &&
2132
- !hasComponentUse
2133
- ) {
2134
- return;
2135
- }
2136
- // Cheap raw pre-check first; only when a candidate token is present
2137
- // do we confirm it occurs in real code (not a comment/string) via a
2138
- // single allocation-free code-region scan. Most saved files contain
2139
- // neither token and skip the scan entirely. This avoids a comment or
2140
- // string mention spuriously marking a file relevant and triggering an
2141
- // unnecessary re-discovery on save.
2142
- let hasUrls = source.includes("urls(");
2143
- let hasCreateRouter = /\bcreateRouter\s*[<(]/.test(source);
2144
- if (hasUrls) hasUrls = firstCodeMatchIndex(source, /urls\(/g) >= 0;
2145
- if (hasCreateRouter) {
2146
- hasCreateRouter =
2147
- firstCodeMatchIndex(source, /\bcreateRouter\s*[<(]/g) >= 0;
2148
- }
2149
- // hasClientUrls counts here too: in a clientUrls() module the only
2150
- // `urls(` token sits INSIDE the `clientUrls(` identifier, which the
2151
- // code scan correctly rejects as a sub-identifier match — so
2152
- // without this the file would silently fail the sniff.
2153
- if (
2154
- !inRecoveryMode &&
2155
- !hasUrls &&
2156
- !hasCreateRouter &&
2157
- !hasClientUrls &&
2158
- !hasComponentUse
2159
- ) {
2160
- return;
2161
- }
2162
- if (inRecoveryMode) {
2163
- debugDiscovery?.(
2164
- "watcher: recovery rediscovery for %s (urls=%s, router=%s, clientUrls=%s, useClient=%s) [LASTERR %s]",
2165
- filePath,
2166
- hasUrls,
2167
- hasCreateRouter,
2168
- hasClientUrls,
2169
- isUseClient,
2170
- s.lastDiscoveryError!.message,
2171
- );
2172
- } else {
2173
- debugDiscovery?.(
2174
- "watcher: %s matches (urls=%s, router=%s, clientUrls=%s, componentUse=%s)",
2175
- filePath,
2176
- hasUrls,
2177
- hasCreateRouter,
2178
- hasClientUrls,
2179
- hasComponentUse,
2180
- );
2181
- }
2182
- // A "use client" clientUrls module is an HMR-accepted client
2183
- // boundary in the rsc graph: its edit never invalidates the router
2184
- // module that materialized its projection, so the re-discovery
2185
- // entry import would cache-hit and keep serving the stale mount
2186
- // (old patterns 200, new patterns 404 until restart). Invalidate
2187
- // the entry + router sources so the import re-creates the routers
2188
- // against the refreshed projection (installed by the pre-entry
2189
- // refresh in discover-routers.ts).
2190
- const mainRscEnv = (server.environments as any)?.rsc;
2191
- if (
2192
- ((isUseClient && hasClientUrls) || hasComponentUse) &&
2193
- mainRscEnv?.runner
2194
- ) {
2195
- const rscGraph = mainRscEnv.moduleGraph;
2196
- if (rscGraph?.getModulesByFile) {
2197
- // Importers must be invalidated too: the virtual RSC entry
2198
- // holds a live `import { router }` binding, and re-evaluating
2199
- // router.tsx alone leaves that binding on the OLD instance —
2200
- // the request pipeline would keep serving the stale mount.
2201
- // Vite's invalidateModule already walks importers. Share its
2202
- // seen set across roots instead of recursively starting a new
2203
- // traversal at every importer (quadratic on a large graph).
2204
- // Vite's walk skips HMR-accepting importers and soft-invalidates
2205
- // static ones (vs the old unconditional hard walk) — safe here:
2206
- // the entry and every router source are invalidated directly as
2207
- // roots, and ancestors get the exact treatment Vite's own
2208
- // file-change propagation applies on a server urls edit.
2209
- // Cloudflare has no local runner and skips this block: its temp
2210
- // discovery graph and workerd graph are invalidated wholesale
2211
- // by refreshRuntimeDiscovery() after this watcher event.
2212
- const routerSourceFiles = new Set<string>();
2213
- if (s.resolvedEntryPath) {
2214
- routerSourceFiles.add(resolve(s.resolvedEntryPath));
2215
- }
2216
- for (const entry of s.perRouterManifests) {
2217
- if (entry.sourceFile) {
2218
- routerSourceFiles.add(resolve(entry.sourceFile));
2219
- }
2220
- }
2221
- const seen = new Set<any>();
2222
- for (const file of routerSourceFiles) {
2223
- const mods = modulesForFile(rscGraph, file);
2224
- if (!mods) {
2225
- debugDiscovery?.(
2226
- "watcher: clientUrls invalidation found no rsc modules for %s",
2227
- file,
2228
- );
2229
- continue;
2230
- }
2231
- for (const mod of mods) {
2232
- rscGraph.invalidateModule(mod, seen);
2233
- }
2234
- }
2235
- debugDiscovery?.(
2236
- "watcher: clientUrls edit invalidated %d rsc module(s) for %d router file(s)",
2237
- seen.size,
2238
- routerSourceFiles.size,
2239
- );
2240
- }
2241
- }
2242
- // Invalidate cache when a router file changes (new router added/removed)
2243
- if (hasCreateRouter) {
2244
- const nestedRouterConflict = findNestedRouterConflict([
2245
- ...(s.cachedRouterFiles ?? []),
2246
- resolve(filePath),
2247
- ]);
2248
- if (nestedRouterConflict) {
2249
- server.config.logger.error(
2250
- formatNestedRouterConflictError(nestedRouterConflict),
2251
- );
2252
- return;
2253
- }
2254
- s.cachedRouterFiles = undefined;
2255
- }
2256
- // Note the event in the gate machine IMMEDIATELY (before the
2257
- // 100ms debounce and any downstream HMR fanout). This sets
2258
- // both `pendingEvents` (so refresh's finally holds the gate
2259
- // through the tail window even if no rediscovery is queued)
2260
- // and resets `discoveryDone` to a fresh pending promise (so
2261
- // workerd reloads triggered by the same source change can't
2262
- // observe a stale resolved gate from cold-start). Resolved
2263
- // by the trailing refreshRuntimeDiscovery() cycle, which runs
2264
- // under the same predicate.
2265
- if (canRefreshRuntimeDiscovery(s)) {
2266
- gate.noteRouteEvent();
2267
- }
2268
- scheduleRouteRegeneration();
2269
- } catch (readErr: any) {
2270
- if (s.lastDiscoveryError) {
2271
- debugDiscovery?.(
2272
- "watcher: read error %s: %s [LASTERR %s]",
2273
- filePath,
2274
- readErr?.message,
2275
- s.lastDiscoveryError.message,
2276
- );
2277
- }
2278
- // Ignore read errors for deleted/moved files
2279
- }
2280
- };
2281
-
2282
- // Handle both "add" and "change" events: editors with atomic saves
2283
- // (unlink + rename) emit "add" instead of "change", and chokidar's
2284
- // polling mode on CI Linux can also emit "add" for overwrites.
2285
- server.watcher.on("add", handleRouteFileChange);
2286
- server.watcher.on("change", handleRouteFileChange);
2287
-
2288
- // Regenerate gen files when they are deleted (e.g. manual cleanup).
2289
- // Same no-runner guard as change/add: stale perRouterManifests would
2290
- // reintroduce reverted content.
2291
- server.watcher.on("unlink", (filePath) => {
2292
- if (!isGeneratedRouteFile(filePath) || isOutsideApp(filePath)) return;
2293
- const hasRunner = !!(server.environments as any)?.rsc?.runner;
2294
- if (!hasRunner) return;
2295
- regenerateGeneratedRouteFiles();
2296
- });
2297
- }
146
+ devServerPhase(s, server);
2298
147
  },
2299
148
 
2300
- // Build mode: create a temporary Vite dev server to access the RSC
2301
- // environment's module runner, then discover routers and generate manifests.
2302
- // The manifest data is stored for the virtual module's load hook.
2303
- async buildStart() {
2304
- if (!s.isBuildMode) return;
2305
- // Only run once across environment builds
2306
- if (s.mergedRouteManifest !== null) {
2307
- debugDiscovery?.(
2308
- "build: skip (already discovered, env=%s)",
2309
- this.environment?.name ?? "?",
2310
- );
2311
- return;
2312
- }
2313
- const buildStartTime = performance.now();
2314
- debugDiscovery?.("build: start (env=%s)", this.environment?.name ?? "?");
2315
- resetStagedBuildAssets(s.projectRoot);
2316
- resetPrerenderCollection(s);
2317
-
2318
- // Acquire build-time env bindings if configured
2319
- await timed(debugDiscovery, "build acquireBuildEnv", () =>
2320
- acquireBuildEnv(s, viteCommand, viteMode),
2321
- );
2322
-
2323
- let tempServer: any = null;
2324
- // Signal to user-space code (e.g. reverse.ts) that build-time discovery
2325
- // is active. Uses globalThis because the temp server's module runner
2326
- // creates a separate module context — there is no shared import path
2327
- // between the vite plugin and user code loaded via runner.import().
2328
- (globalThis as any).__rscRouterDiscoveryActive = true;
2329
- try {
2330
- tempServer = await timed(
2331
- debugDiscovery,
2332
- "build createTempRscServer",
2333
- () => createTempRscServer(s, { forceBuild: true }),
2334
- );
2335
-
2336
- const rscEnv = (tempServer.environments as any)?.rsc;
2337
- if (!rscEnv?.runner) {
2338
- console.warn(
2339
- "[rango] RSC environment runner not available during build, skipping manifest generation",
2340
- );
2341
- return;
2342
- }
2343
-
2344
- // Point resolvedStaticModules at the temp server's expose-internal-ids
2345
- // plugin so that discoverRouters() can access the static handler module
2346
- // map after the temp server's transforms populate it.
2347
- const tempIdsPlugin = (tempServer as any).config?.plugins?.find(
2348
- (p: any) => p.name === "@rangojs/router:expose-internal-ids",
2349
- );
2350
- if (tempIdsPlugin?.api?.staticHandlerModules) {
2351
- s.resolvedStaticModules = tempIdsPlugin.api.staticHandlerModules;
2352
- }
2353
-
2354
- await timed(debugDiscovery, "build discoverRouters", () =>
2355
- discoverRouters(s, rscEnv, (tempServer.environments as any)?.ssr),
2356
- );
2357
- // Update named-routes.gen.ts from runtime discovery.
2358
- // The runtime manifest includes dynamically generated routes
2359
- // that the static parser cannot extract from source code.
2360
- timedSync(debugDiscovery, "build writeRouteTypesFiles", () =>
2361
- writeRouteTypesFiles(s),
2362
- );
2363
- } catch (err: any) {
2364
- // Extract the user source file from the stack trace (skip internal frames)
2365
- const sourceFile = err.stack
2366
- ?.split("\n")
2367
- .find(
2368
- (line: string) =>
2369
- line.includes(s.projectRoot) && !line.includes("node_modules"),
2370
- )
2371
- ?.match(/\(([^)]+)\)/)?.[1];
2372
- // Extract the route name from "Unknown route: <name>" errors
2373
- const routeName = err.message?.match(/Unknown route: (.+)/)?.[1];
2374
- const details = [
2375
- routeName ? ` Route name: ${routeName}` : null,
2376
- sourceFile ? ` File: ${sourceFile}` : null,
2377
- err.stack ? ` Stack:\n${err.stack}` : null,
2378
- ]
2379
- .filter(Boolean)
2380
- .join("\n");
2381
- throw new Error(
2382
- `[rango] Build-time router discovery failed:\n${details}`,
2383
- { cause: err },
2384
- );
2385
- } finally {
2386
- delete (globalThis as any).__rscRouterDiscoveryActive;
2387
- if (tempServer && s.shellCandidates?.length) {
2388
- // Prerender+ppr candidates exist: keep the temp server (and its
2389
- // realm — tries installed, registry populated) alive for the
2390
- // post-build shell capture phase (buildApp post, producer B #699).
2391
- // The prelude embeds built client asset URLs, so the capture can
2392
- // only run after the client build; that phase closes the server.
2393
- // buildEnv release is deferred with it — a bake-lane loader
2394
- // executing during the capture may read ctx.env.
2395
- s.shellPhaseTempServer = tempServer;
2396
- } else {
2397
- if (tempServer) {
2398
- await timed(debugDiscovery, "build tempServer.close", () =>
2399
- tempServer.close(),
2400
- );
2401
- }
2402
- await releaseBuildEnv(s);
2403
- }
2404
- debugDiscovery?.(
2405
- "build discovery done (%sms)",
2406
- (performance.now() - buildStartTime).toFixed(1),
2407
- );
2408
- }
149
+ buildStart() {
150
+ return buildDiscoveryPhase(s, this);
2409
151
  },
2410
152
 
2411
- // Post-build phases, after EVERY environment bundle is written.
2412
- //
2413
- // 1. Cache versions (discovery/router-versions-phase.ts): hashes the
2414
- // server output as postprocessBundle left it and writes the per-router
2415
- // table into the built version module. First, because phase 2 stamps
2416
- // its entries with these versions and reads the same RSC out dir.
2417
- // 2. PPR shell capture (producer B, #699): the shell prelude embeds built
2418
- // client asset URLs (bootstrap entry), which do not exist at
2419
- // buildStart. The kept temp server and the buildEnv were deferred AS A
2420
- // PAIR in buildStart's finally; this finally is the pair's success-path
2421
- // owner (buildEnd below owns the aborted-build path) — the phase itself
2422
- // is a pure producer and tears down only the globals it installs.
2423
153
  buildApp: {
2424
154
  order: "post",
2425
- async handler(builder) {
2426
- try {
2427
- await runShellPrerenderPhase(
2428
- s,
2429
- builder as any,
2430
- runRouterVersionsPhase(s, builder as any),
2431
- );
2432
- } finally {
2433
- if (s.isBuildMode) {
2434
- const tempServer = s.shellPhaseTempServer;
2435
- s.shellPhaseTempServer = null;
2436
- if (tempServer) await tempServer.close();
2437
- await releaseBuildEnv(s);
2438
- }
2439
- }
155
+ handler(builder) {
156
+ return postBuildPhase(s, builder);
2440
157
  },
2441
158
  },
2442
159
 
2443
- // An environment build failure aborts the builder before the buildApp
2444
- // post hook — never leak the kept temp server (open handles hang the CLI)
2445
- // or the deferred buildEnv (a live miniflare proxy).
2446
- async buildEnd(error) {
2447
- if (!error || !s.shellPhaseTempServer) return;
2448
- const tempServer = s.shellPhaseTempServer;
2449
- s.shellPhaseTempServer = null;
2450
- try {
2451
- await tempServer.close();
2452
- } finally {
2453
- await releaseBuildEnv(s);
2454
- }
160
+ buildEnd(error) {
161
+ return buildEndPhase(s, error);
2455
162
  },
2456
163
 
2457
164
  // Suppress vite's HMR cascade for our own gen-file writes.
@@ -2460,7 +167,7 @@ export function createRouterDiscoveryPlugin(
2460
167
  // writes the configured gen files (default `router.named-routes.gen.ts`,
2461
168
  // but the source filenames and gen suffix are user-configurable). The
2462
169
  // chokidar watcher then fires twice independently: our
2463
- // `handleRouteFileChange` (already short-circuited by
170
+ // `handleRouteFileChange` (dev-server-phase.ts, already short-circuited by
2464
171
  // `consumeSelfGenWrite` inside `maybeHandleGeneratedRouteFileMutation`),
2465
172
  // AND vite's own HMR pipeline (which invalidates the gen file's
2466
173
  // importers and triggers a second workerd full reload — visible to the
@@ -2542,114 +249,8 @@ export function createRouterDiscoveryPlugin(
2542
249
  return null;
2543
250
  },
2544
251
 
2545
- // Record handler chunk metadata and RSC entry filename during RSC build.
2546
- // Used by closeBundle for handler code eviction and prerender data injection.
2547
252
  generateBundle(_options: any, bundle: any) {
2548
- // Inputs of the per-router cache versions (discovery/build-versions.ts).
2549
- // Each pass overwrites its slot: plugin-rsc runs the RSC and SSR builds
2550
- // twice, and the real pass comes last.
2551
- if (s.isBuildMode) {
2552
- const envName = this.environment?.name;
2553
- if (envName) {
2554
- s.versionModuleFiles.set(envName, recordVersionModuleFiles(bundle));
2555
- }
2556
- if (envName === "rsc") {
2557
- s.serverBuildGraph = recordServerGraph(this, bundle);
2558
- } else if (envName === "client") {
2559
- s.clientBuildGraph = recordClientGraph(this, bundle);
2560
- } else if (envName === "ssr") {
2561
- s.ssrBundle = recordBundleFiles(bundle);
2562
- }
2563
- }
2564
- if (this.environment?.name !== "rsc") return;
2565
- const genStart = debugBuild ? performance.now() : 0;
2566
-
2567
- // Record RSC entry chunk filename for closeBundle injection
2568
- for (const [fileName, chunk] of Object.entries(bundle) as [
2569
- string,
2570
- any,
2571
- ][]) {
2572
- if (chunk.type === "chunk" && chunk.isEntry) {
2573
- s.rscEntryFileName = fileName;
2574
- break;
2575
- }
2576
- }
2577
-
2578
- if (!s.resolvedPrerenderModules?.size && !s.resolvedStaticModules?.size) {
2579
- debugBuild?.(
2580
- "generateBundle (rsc): no handlers to scan (%sms)",
2581
- (performance.now() - genStart).toFixed(1),
2582
- );
2583
- return;
2584
- }
2585
-
2586
- // Clear maps at the start of each RSC generateBundle pass.
2587
- // Vite 6 multi-environment builds run RSC twice (analysis + production);
2588
- // clearing prevents stale/duplicate records from the analysis pass.
2589
- s.handlerChunkInfoMap.clear();
2590
- s.staticHandlerChunkInfoMap.clear();
2591
-
2592
- for (const [fileName, chunk] of Object.entries(bundle) as [
2593
- string,
2594
- any,
2595
- ][]) {
2596
- if (chunk.type !== "chunk") continue;
2597
-
2598
- // Scan all chunks for handler exports (handlers may land in any chunk)
2599
- if (s.resolvedPrerenderModules?.size) {
2600
- const handlers = extractHandlerExportsFromChunk(
2601
- chunk.code,
2602
- s.resolvedPrerenderModules,
2603
- "Prerender",
2604
- false,
2605
- // Mark onDemand exports (retained, not evicted) from the
2606
- // discovery-derived $$id set — the same evaluated source that sets
2607
- // the runtime od trie flag, so retention and serve can never
2608
- // disagree. Populated by discoverRouters (buildStart), which runs
2609
- // before this generateBundle pass.
2610
- s.onDemandHandlerIds,
2611
- );
2612
- if (handlers.length > 0) {
2613
- const existing = s.handlerChunkInfoMap.get(fileName);
2614
- if (existing) {
2615
- existing.exports.push(...handlers);
2616
- } else {
2617
- s.handlerChunkInfoMap.set(fileName, {
2618
- fileName,
2619
- exports: handlers,
2620
- });
2621
- }
2622
- }
2623
- }
2624
-
2625
- if (s.resolvedStaticModules?.size) {
2626
- const handlers = extractHandlerExportsFromChunk(
2627
- chunk.code,
2628
- s.resolvedStaticModules,
2629
- "Static",
2630
- false,
2631
- );
2632
- if (handlers.length > 0) {
2633
- const existing = s.staticHandlerChunkInfoMap.get(fileName);
2634
- if (existing) {
2635
- existing.exports.push(...handlers);
2636
- } else {
2637
- s.staticHandlerChunkInfoMap.set(fileName, {
2638
- fileName,
2639
- exports: handlers,
2640
- });
2641
- }
2642
- }
2643
- }
2644
- }
2645
-
2646
- debugBuild?.(
2647
- "generateBundle (rsc): scanned %d chunks, %d prerender chunk(s), %d static chunk(s) (%sms)",
2648
- Object.keys(bundle).length,
2649
- s.handlerChunkInfoMap.size,
2650
- s.staticHandlerChunkInfoMap.size,
2651
- (performance.now() - genStart).toFixed(1),
2652
- );
253
+ bundleScanPhase(s, this, bundle);
2653
254
  },
2654
255
 
2655
256
  // Build-time pre-rendering: evict handler code and inject collected prerender data.