@wular/pnext 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (371) hide show
  1. package/README.md +153 -0
  2. package/bin/pnext +67 -0
  3. package/config/lint/base.js +48 -0
  4. package/config/ts/base.json +26 -0
  5. package/config/ts/react.json +13 -0
  6. package/package.json +70 -0
  7. package/reference/compat.md +63 -0
  8. package/reference/config.md +120 -0
  9. package/reference/css.md +58 -0
  10. package/reference/dev.md +69 -0
  11. package/reference/env.md +40 -0
  12. package/reference/metadata.md +86 -0
  13. package/reference/navigation.md +149 -0
  14. package/reference/overview.md +35 -0
  15. package/reference/performance.md +97 -0
  16. package/reference/rendering.md +127 -0
  17. package/reference/routing.md +167 -0
  18. package/reference/typegen.md +64 -0
  19. package/src/api/cache.ts +80 -0
  20. package/src/api/client-cache.ts +9 -0
  21. package/src/api/client-navigation.ts +279 -0
  22. package/src/api/dynamic.tsx +102 -0
  23. package/src/api/link.tsx +119 -0
  24. package/src/api/navigation.ts +198 -0
  25. package/src/api/router/events.ts +53 -0
  26. package/src/api/router/history.ts +70 -0
  27. package/src/api/router/hub.ts +194 -0
  28. package/src/api/router/policies.ts +107 -0
  29. package/src/api/router/runtime.ts +5238 -0
  30. package/src/api/router/types.ts +299 -0
  31. package/src/api/router.ts +167 -0
  32. package/src/api/server.ts +323 -0
  33. package/src/api/suspense.ts +16 -0
  34. package/src/cache/context.ts +61 -0
  35. package/src/cli/adapters/vercel-warm.ts +375 -0
  36. package/src/cli/adapters/vercel.ts +1310 -0
  37. package/src/cli/analyze-print.ts +181 -0
  38. package/src/cli/analyze.ts +328 -0
  39. package/src/cli/boot-trace.ts +29 -0
  40. package/src/cli/build.ts +3114 -0
  41. package/src/cli/dev.ts +276 -0
  42. package/src/cli/index.ts +196 -0
  43. package/src/cli/named-bin.ts +119 -0
  44. package/src/cli/serve-ui.ts +160 -0
  45. package/src/cli/start.ts +1425 -0
  46. package/src/client/build.ts +2136 -0
  47. package/src/client/chunk-fold.ts +526 -0
  48. package/src/client/entry.ts +1525 -0
  49. package/src/client/paths.ts +22 -0
  50. package/src/client/prebuilt.ts +621 -0
  51. package/src/client/profile.ts +75 -0
  52. package/src/client/react-compiler.ts +94 -0
  53. package/src/client/reference-stub.ts +145 -0
  54. package/src/client/reference.ts +47 -0
  55. package/src/compat/actions/action-client.ts +531 -0
  56. package/src/compat/actions/action-dispatch.ts +676 -0
  57. package/src/compat/actions/action-router.ts +40 -0
  58. package/src/compat/actions/action-shared.ts +95 -0
  59. package/src/compat/actions/client-plugin.ts +135 -0
  60. package/src/compat/actions/client-stub.ts +65 -0
  61. package/src/compat/actions/config.ts +164 -0
  62. package/src/compat/actions/detect.ts +208 -0
  63. package/src/compat/actions/discovery.ts +244 -0
  64. package/src/compat/actions/early-submit.ts +40 -0
  65. package/src/compat/actions/endpoint.ts +602 -0
  66. package/src/compat/actions/flight.ts +52 -0
  67. package/src/compat/actions/form-state.ts +73 -0
  68. package/src/compat/actions/hoist.ts +485 -0
  69. package/src/compat/actions/ids.ts +39 -0
  70. package/src/compat/actions/index.ts +41 -0
  71. package/src/compat/actions/instances.ts +144 -0
  72. package/src/compat/actions/origin.ts +109 -0
  73. package/src/compat/actions/protocol.ts +125 -0
  74. package/src/compat/actions/registry.ts +74 -0
  75. package/src/compat/actions/rewrite.ts +282 -0
  76. package/src/compat/actions/serve.ts +414 -0
  77. package/src/compat/actions/server-tag.ts +21 -0
  78. package/src/compat/actions/unrecognized-error.ts +30 -0
  79. package/src/compat/adapter/build-complete.ts +257 -0
  80. package/src/compat/bundler/bun-externals.ts +53 -0
  81. package/src/compat/bundler/cjs-exports.ts +542 -0
  82. package/src/compat/bundler/config.ts +363 -0
  83. package/src/compat/bundler/externals.ts +34 -0
  84. package/src/compat/bundler/import-meta-url.ts +60 -0
  85. package/src/compat/bundler/modularize-imports.ts +119 -0
  86. package/src/compat/bundler/new-url-asset.ts +87 -0
  87. package/src/compat/bundler/optimize-package-imports.ts +273 -0
  88. package/src/compat/bundler/polyfill.ts +88 -0
  89. package/src/compat/bundler/react-compiler.ts +61 -0
  90. package/src/compat/bundler/react-profiler.tsx +25 -0
  91. package/src/compat/bundler/relay-transform.ts +116 -0
  92. package/src/compat/bundler/require-context.ts +281 -0
  93. package/src/compat/bundler/resolve-extensions.ts +75 -0
  94. package/src/compat/bundler/source-cache.ts +61 -0
  95. package/src/compat/bundler/static-imports.ts +25 -0
  96. package/src/compat/bundler/symlink-imports.ts +119 -0
  97. package/src/compat/bundler/tsconfig-paths.ts +50 -0
  98. package/src/compat/bundler/wasm.ts +153 -0
  99. package/src/compat/bundler/webpack-loaders.ts +685 -0
  100. package/src/compat/bundler/worker.ts +278 -0
  101. package/src/compat/cache/build-flags.ts +81 -0
  102. package/src/compat/cache/build-prerender-errors.ts +163 -0
  103. package/src/compat/cache/custom-handler.ts +159 -0
  104. package/src/compat/cache/fetch-patch.ts +745 -0
  105. package/src/compat/cache/handler.ts +100 -0
  106. package/src/compat/cache/modern-handler.ts +275 -0
  107. package/src/compat/cache/resume-data-cache.ts +143 -0
  108. package/src/compat/cache/revalidate.ts +759 -0
  109. package/src/compat/cache/runtime-error.ts +124 -0
  110. package/src/compat/cache/use-cache-transform.ts +961 -0
  111. package/src/compat/cache/use-cache.ts +1695 -0
  112. package/src/compat/cache-control.ts +269 -0
  113. package/src/compat/client/base-path.ts +64 -0
  114. package/src/compat/client/css-order.ts +36 -0
  115. package/src/compat/client/errors/control-flow.ts +92 -0
  116. package/src/compat/client/errors/error-boundary.ts +222 -0
  117. package/src/compat/client/errors/global-error.ts +238 -0
  118. package/src/compat/client/errors/install.ts +217 -0
  119. package/src/compat/client/errors/lazy.ts +53 -0
  120. package/src/compat/client/errors/primitive-throw.ts +126 -0
  121. package/src/compat/client/errors/soft-refresh.ts +14 -0
  122. package/src/compat/client/link-status.ts +86 -0
  123. package/src/compat/client/nav-compat-runtime.ts +57 -0
  124. package/src/compat/client/nav-compat.ts +42 -0
  125. package/src/compat/client/navigation-scroll.ts +154 -0
  126. package/src/compat/client/optimistic-routing.ts +206 -0
  127. package/src/compat/client/prefetch-cache.ts +111 -0
  128. package/src/compat/client/route-announcer.ts +72 -0
  129. package/src/compat/client/segment-cache-policy.ts +159 -0
  130. package/src/compat/client/segment-cache.ts +1077 -0
  131. package/src/compat/client/segment-prefetch.ts +375 -0
  132. package/src/compat/client/trailing-slash.ts +24 -0
  133. package/src/compat/css/chunking.ts +254 -0
  134. package/src/compat/css/inline-css.ts +73 -0
  135. package/src/compat/css/lightningcss.ts +90 -0
  136. package/src/compat/css/modules.ts +373 -0
  137. package/src/compat/css/nonce.ts +30 -0
  138. package/src/compat/css/sass-plugin.ts +65 -0
  139. package/src/compat/css/sass.ts +392 -0
  140. package/src/compat/css/styled-jsx-runtime.ts +80 -0
  141. package/src/compat/css/styled-jsx.ts +49 -0
  142. package/src/compat/edge-runtime.ts +71 -0
  143. package/src/compat/export/client.ts +112 -0
  144. package/src/compat/export/index.ts +272 -0
  145. package/src/compat/export/standalone.ts +207 -0
  146. package/src/compat/image-optimizer/cache.ts +119 -0
  147. package/src/compat/image-optimizer/detect.ts +143 -0
  148. package/src/compat/image-optimizer/index.ts +601 -0
  149. package/src/compat/image-optimizer/source.ts +243 -0
  150. package/src/compat/index.ts +458 -0
  151. package/src/compat/lifecycle/after-scope.ts +86 -0
  152. package/src/compat/lifecycle/after.ts +173 -0
  153. package/src/compat/lifecycle/error-funnel.ts +306 -0
  154. package/src/compat/lifecycle/error-serialize.ts +87 -0
  155. package/src/compat/lifecycle/error-ui.ts +167 -0
  156. package/src/compat/lifecycle/instrumentation-client.ts +138 -0
  157. package/src/compat/lifecycle/instrumentation.ts +277 -0
  158. package/src/compat/lifecycle/node-console.ts +19 -0
  159. package/src/compat/lifecycle/testmode.ts +263 -0
  160. package/src/compat/mdx/compile.ts +219 -0
  161. package/src/compat/mdx/next-mdx-stub.ts +46 -0
  162. package/src/compat/mdx/plugin.ts +37 -0
  163. package/src/compat/metadata-route-artifacts.ts +458 -0
  164. package/src/compat/metadata.ts +295 -0
  165. package/src/compat/middleware/manifest.ts +210 -0
  166. package/src/compat/misc/action-return.ts +173 -0
  167. package/src/compat/next/cache.ts +211 -0
  168. package/src/compat/next/canonical-url.ts +35 -0
  169. package/src/compat/next/client-cache.ts +57 -0
  170. package/src/compat/next/client-navigation.ts +313 -0
  171. package/src/compat/next/client-only.ts +3 -0
  172. package/src/compat/next/client-script.tsx +215 -0
  173. package/src/compat/next/client-server.ts +39 -0
  174. package/src/compat/next/config-loader.ts +569 -0
  175. package/src/compat/next/config.ts +29 -0
  176. package/src/compat/next/constants.cjs +6 -0
  177. package/src/compat/next/constants.ts +6 -0
  178. package/src/compat/next/custom-server.ts +236 -0
  179. package/src/compat/next/dist/client/components/app-router-headers.ts +32 -0
  180. package/src/compat/next/dist/server/app-render/work-unit-async-storage.external.cjs +38 -0
  181. package/src/compat/next/dist/server/web/spec-extension/revalidate.ts +1 -0
  182. package/src/compat/next/dist/server/web/spec-extension/unstable-cache.ts +1 -0
  183. package/src/compat/next/dist/server/web/spec-extension/unstable-no-store.ts +1 -0
  184. package/src/compat/next/dynamic.tsx +46 -0
  185. package/src/compat/next/error.tsx +148 -0
  186. package/src/compat/next/font/cache.ts +171 -0
  187. package/src/compat/next/font/google.ts +2 -0
  188. package/src/compat/next/font/index.ts +8 -0
  189. package/src/compat/next/font/local.ts +5 -0
  190. package/src/compat/next/font/runtime-client.ts +71 -0
  191. package/src/compat/next/font/runtime.ts +974 -0
  192. package/src/compat/next/font/shared.ts +281 -0
  193. package/src/compat/next/form.tsx +156 -0
  194. package/src/compat/next/head.tsx +10 -0
  195. package/src/compat/next/headers.ts +247 -0
  196. package/src/compat/next/image/config.ts +196 -0
  197. package/src/compat/next/image/optimizer.ts +96 -0
  198. package/src/compat/next/image/patterns.ts +103 -0
  199. package/src/compat/next/image/shared.ts +141 -0
  200. package/src/compat/next/image/static-metadata.ts +283 -0
  201. package/src/compat/next/image/validate.ts +269 -0
  202. package/src/compat/next/image-client.tsx +215 -0
  203. package/src/compat/next/image-props.ts +575 -0
  204. package/src/compat/next/image-usage.ts +102 -0
  205. package/src/compat/next/image.tsx +56 -0
  206. package/src/compat/next/index.ts +1 -0
  207. package/src/compat/next/legacy-image.tsx +97 -0
  208. package/src/compat/next/link-usage.ts +29 -0
  209. package/src/compat/next/link-validation-transform.ts +200 -0
  210. package/src/compat/next/link.tsx +466 -0
  211. package/src/compat/next/navigation.cjs +21 -0
  212. package/src/compat/next/navigation.ts +188 -0
  213. package/src/compat/next/offline.ts +51 -0
  214. package/src/compat/next/og.ts +324 -0
  215. package/src/compat/next/optimistic-route-state.ts +188 -0
  216. package/src/compat/next/preferred-region.ts +39 -0
  217. package/src/compat/next/redirects.ts +131 -0
  218. package/src/compat/next/resource-hints.ts +136 -0
  219. package/src/compat/next/rewrites.ts +350 -0
  220. package/src/compat/next/root-params.ts +142 -0
  221. package/src/compat/next/router.cjs +49 -0
  222. package/src/compat/next/router.ts +143 -0
  223. package/src/compat/next/script.tsx +355 -0
  224. package/src/compat/next/server-only.ts +3 -0
  225. package/src/compat/next/server.ts +28 -0
  226. package/src/compat/next/svgr.ts +58 -0
  227. package/src/compat/next/telemetry.ts +77 -0
  228. package/src/compat/next/user-agent.ts +100 -0
  229. package/src/compat/next/web-vitals.ts +56 -0
  230. package/src/compat/otel/api.ts +95 -0
  231. package/src/compat/otel/client-trace-metadata.ts +71 -0
  232. package/src/compat/otel/fetch-span.ts +77 -0
  233. package/src/compat/otel/tracer.ts +944 -0
  234. package/src/compat/pages/client-plugin.ts +108 -0
  235. package/src/compat/pages/index.ts +527 -0
  236. package/src/compat/pages/router-state.ts +94 -0
  237. package/src/compat/ppr/io.ts +38 -0
  238. package/src/compat/ppr/missing-root-params.ts +105 -0
  239. package/src/compat/ppr/root-params-scan.ts +164 -0
  240. package/src/compat/ppr/root-params-transform.ts +75 -0
  241. package/src/compat/ppr/root-params.ts +129 -0
  242. package/src/compat/ppr/segment-config-incompat.ts +34 -0
  243. package/src/compat/protocol.ts +202 -0
  244. package/src/compat/react/client.ts +59 -0
  245. package/src/compat/react/compiler-runtime.ts +60 -0
  246. package/src/compat/react/dom-client.ts +115 -0
  247. package/src/compat/react/dom-react-server.ts +20 -0
  248. package/src/compat/react/dom-server.ts +40 -0
  249. package/src/compat/react/dom.ts +154 -0
  250. package/src/compat/react/preact.ts +522 -0
  251. package/src/compat/react/react-server.ts +84 -0
  252. package/src/compat/react/router-shim.ts +26 -0
  253. package/src/compat/react/server-component-use.ts +48 -0
  254. package/src/compat/react/server-inserted-html.ts +87 -0
  255. package/src/compat/react/server.ts +156 -0
  256. package/src/compat/react/view-transition.ts +60 -0
  257. package/src/compat/register/actions.ts +875 -0
  258. package/src/compat/register/boot.ts +141 -0
  259. package/src/compat/register/build-tier.ts +11 -0
  260. package/src/compat/register/build.ts +182 -0
  261. package/src/compat/register/bundler.ts +587 -0
  262. package/src/compat/register/cache.ts +85 -0
  263. package/src/compat/register/client-errors.ts +18 -0
  264. package/src/compat/register/config.ts +17 -0
  265. package/src/compat/register/css-extras.ts +120 -0
  266. package/src/compat/register/edge-runtime.ts +6 -0
  267. package/src/compat/register/errors.ts +46 -0
  268. package/src/compat/register/export.ts +23 -0
  269. package/src/compat/register/font.ts +36 -0
  270. package/src/compat/register/hooks.ts +34 -0
  271. package/src/compat/register/image.ts +133 -0
  272. package/src/compat/register/index.ts +111 -0
  273. package/src/compat/register/instrumentation-client.ts +35 -0
  274. package/src/compat/register/lifecycle.ts +86 -0
  275. package/src/compat/register/mdx.ts +48 -0
  276. package/src/compat/register/middleware.ts +36 -0
  277. package/src/compat/register/misc.ts +44 -0
  278. package/src/compat/register/otel.ts +288 -0
  279. package/src/compat/register/pages-api.ts +473 -0
  280. package/src/compat/register/ppr.ts +56 -0
  281. package/src/compat/register/protocol.ts +57 -0
  282. package/src/compat/register/proxy.ts +127 -0
  283. package/src/compat/register/render.ts +268 -0
  284. package/src/compat/register/routing.ts +410 -0
  285. package/src/compat/register/segment.ts +1903 -0
  286. package/src/compat/register/static-image.ts +21 -0
  287. package/src/compat/register/typed-routes.ts +35 -0
  288. package/src/compat/register/usecache.ts +131 -0
  289. package/src/compat/register/validation.ts +56 -0
  290. package/src/compat/segment/loading-boundary.ts +113 -0
  291. package/src/compat/segment/page-slot.ts +200 -0
  292. package/src/compat/segment/tree.ts +481 -0
  293. package/src/compat/segment/vary-key.ts +102 -0
  294. package/src/compat/segment/vary-params.ts +551 -0
  295. package/src/compat/static-params.ts +33 -0
  296. package/src/compat/tsconfig-defaults.ts +301 -0
  297. package/src/compat/typecheck/index.ts +1481 -0
  298. package/src/compat/typecheck/worker.ts +26 -0
  299. package/src/compat/typed-routes/index.ts +92 -0
  300. package/src/compat/typed-routes/manifest.ts +356 -0
  301. package/src/compat/typed-routes/typegen.ts +566 -0
  302. package/src/compat/validation/errors.ts +159 -0
  303. package/src/compat/validation/index.ts +1770 -0
  304. package/src/compat/validation/prerender-diagnostics.ts +1508 -0
  305. package/src/compat-bootstrap.ts +67 -0
  306. package/src/config.ts +218 -0
  307. package/src/css/build.ts +697 -0
  308. package/src/css/index.ts +2 -0
  309. package/src/css/postcss.ts +236 -0
  310. package/src/css/worker.ts +34 -0
  311. package/src/dev/client-actions.ts +35 -0
  312. package/src/dev/client-chunk-store.ts +92 -0
  313. package/src/dev/client-key-cache.ts +178 -0
  314. package/src/dev/global-css-cache.ts +212 -0
  315. package/src/dev/imports.ts +2430 -0
  316. package/src/dev/module-cache.ts +721 -0
  317. package/src/dev/module-generations.ts +38 -0
  318. package/src/dev/module-transform.ts +188 -0
  319. package/src/dev/node-module-bundle-cache.ts +63 -0
  320. package/src/dev/restart-cache.ts +10 -0
  321. package/src/dev/route-bundle-key-cache.ts +154 -0
  322. package/src/dev/route-facts-cache.ts +223 -0
  323. package/src/dev/server.ts +1710 -0
  324. package/src/dynamic/source.ts +307 -0
  325. package/src/dynamic/tree-shake.ts +262 -0
  326. package/src/env.ts +92 -0
  327. package/src/extensions.ts +1898 -0
  328. package/src/index.ts +34 -0
  329. package/src/internal.ts +43 -0
  330. package/src/islands/boundary-error.ts +8 -0
  331. package/src/islands/static-children.ts +37 -0
  332. package/src/islands/static-slots.ts +106 -0
  333. package/src/ppr-postpone.ts +24 -0
  334. package/src/ppr.ts +784 -0
  335. package/src/proxy.ts +752 -0
  336. package/src/render/hooks.ts +384 -0
  337. package/src/render/index.ts +1 -0
  338. package/src/render/island-context.ts +47 -0
  339. package/src/render/metadata.ts +857 -0
  340. package/src/render/renderer.ts +7391 -0
  341. package/src/render/resource-hints.ts +44 -0
  342. package/src/render/slots.tsx +679 -0
  343. package/src/request/context.ts +396 -0
  344. package/src/resolve/engine.ts +219 -0
  345. package/src/resolve/imports.ts +1104 -0
  346. package/src/resolve/scan-facts.ts +474 -0
  347. package/src/resolve/source-text.ts +86 -0
  348. package/src/routing/forwarded.ts +41 -0
  349. package/src/routing/handler.ts +271 -0
  350. package/src/routing/href.ts +203 -0
  351. package/src/routing/metadata.ts +1018 -0
  352. package/src/routing/request-runtime.ts +43 -0
  353. package/src/routing/routes.ts +2560 -0
  354. package/src/routing/slots.ts +432 -0
  355. package/src/runtime/server.ts +3453 -0
  356. package/src/runtime/vendor.ts +1160 -0
  357. package/src/style-modules.d.ts +9 -0
  358. package/src/typegen.ts +151 -0
  359. package/src/types.ts +725 -0
  360. package/src/utils/ansi.ts +9 -0
  361. package/src/utils/content-type.ts +31 -0
  362. package/src/utils/decode.ts +7 -0
  363. package/src/utils/dev-profile.ts +31 -0
  364. package/src/utils/error-log.ts +29 -0
  365. package/src/utils/fs-cache.ts +31 -0
  366. package/src/utils/fs.ts +119 -0
  367. package/src/utils/html.ts +46 -0
  368. package/src/utils/serialize.ts +378 -0
  369. package/src/utils/source.ts +35 -0
  370. package/src/utils/verbose.ts +39 -0
  371. package/tsconfig.json +10 -0
@@ -0,0 +1,38 @@
1
+ // Stale ESM registry generations - the accounting behind dev's recycle trigger. Compiled server
2
+ // artifacts are content-graph-named, so every save that changes a module gives it a NEW href and
3
+ // the server imports that; Bun's registry has no eviction API, so the previous href stays linked
4
+ // for the process lifetime.
5
+ //
6
+ // Counting them needs no registry walk: an artifact goes stale exactly when its (profile, source)
7
+ // pair is renamed to a different artifact, and it only costs memory if it was ever imported. Both
8
+ // facts are known at the two call sites below, so the count is an O(1) side effect of compiling.
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ /** Current artifact per `${profile}:${file}`. */
12
+ const liveNames = new Map<string, string>();
13
+ /** Artifacts that actually entered the ES module registry. */
14
+ const imported = new Set<string>();
15
+ let stale = 0;
16
+
17
+ /** Record that `outFile` is now the artifact for `key` (`${profile}:${file}`). */
18
+ export function noteModuleGeneration(key: string, outFile: string) {
19
+ const previous = liveNames.get(key);
20
+ if (previous !== outFile) {
21
+ liveNames.set(key, outFile);
22
+ // Only a generation the registry actually holds costs memory; one that was
23
+ // compiled and superseded before any import cost disk alone.
24
+ if (previous !== undefined && imported.delete(previous)) stale += 1;
25
+ }
26
+ }
27
+
28
+ /** Record that `href` was imported, so superseding it will count as stale. */
29
+ export function noteModuleImported(href: string) {
30
+ // Named as a path on the compile side, imported as an href: normalise through
31
+ // the same URL codec both sides use, or every entry would look never-imported.
32
+ imported.add(href.startsWith('file:') ? fileURLToPath(href) : href);
33
+ }
34
+
35
+ /** Live and superseded registry generations of compiled server modules. */
36
+ export function moduleGenerationStats() {
37
+ return { live: imported.size, stale };
38
+ }
@@ -0,0 +1,188 @@
1
+ // Batched transform pass for server modules: plain ESM sources are transformed with oxc and get their
2
+ // import specifiers rewritten by us, instead of paying a per-module esbuild bundle whose only real job -
3
+ // with `packages:'external'` and every specifier resolved by our own plugin - is specifier rewriting.
4
+ //
5
+ // esbuild's bundler is still load-bearing for a handful of shapes (below); `transformBailReason` sniffs
6
+ // those out per file and the caller falls back to the esbuild build for them. Same pipeline in dev and prod.
7
+ import { transformSync, type TransformOptions } from 'oxc-transform';
8
+ import { isCommonJsModuleSource } from '../resolve/imports';
9
+
10
+ /** Why a source cannot take the transform path — one per esbuild-only shape. */
11
+ export type TransformBailReason =
12
+ | 'commonjs' // require()/module.exports: CJS→ESM conversion + the require-alias map
13
+ | 'glob-import' // non-literal `import()`: esbuild's `__glob` expansion
14
+ | 'enum' // oxc's enum lowering omits `E || {}`, breaking declaration merging
15
+ | 'helpers' // the transform needed @oxc-project/runtime helper imports
16
+ | 'parse-error'
17
+ | 'specifier'; // a specifier the bundler resolves by inlining (assets with a
18
+ // loader rule, `#subpath` facades, require-aliases)
19
+
20
+ // `import(` whose argument is not a string literal — esbuild turns those into a
21
+ // `__glob({...})` map of the matching directory, which needs the bundler.
22
+ const NON_LITERAL_DYNAMIC_IMPORT = /\bimport\s*\(\s*(?!['"])[^)]/;
23
+ const REQUIRE_CALL = /\brequire\s*\(/;
24
+ const COMMONJS_EXPORTS = /\bmodule\.exports\b|\bexports\s*\.\s*[A-Za-z_$]/;
25
+ const ENUM_DECLARATION = /(?:^|[^.\w$])enum\s+[A-Za-z_$]/;
26
+
27
+ export function transformBailReason(source: string, file: string): TransformBailReason | undefined {
28
+ if (REQUIRE_CALL.test(source) || COMMONJS_EXPORTS.test(source) || isCommonJsModuleSource(source, file)) {
29
+ return 'commonjs';
30
+ }
31
+ if (NON_LITERAL_DYNAMIC_IMPORT.test(source)) return 'glob-import';
32
+ if (ENUM_DECLARATION.test(source)) return 'enum';
33
+ return undefined;
34
+ }
35
+
36
+ /**
37
+ * TS/JSX → ESM for one server module. `define` mirrors the esbuild build's own
38
+ * `define` so constant folding stays identical across the two paths.
39
+ */
40
+ export function transformServerModule(
41
+ source: string,
42
+ file: string,
43
+ define: Record<string, string> | undefined,
44
+ ): { code: string } | { bail: TransformBailReason } {
45
+ const options: TransformOptions = {
46
+ lang: langForFile(file),
47
+ jsx: { runtime: 'automatic', importSource: 'preact' },
48
+ // No `target`: these modules are loaded by Bun, so syntax lowering buys nothing and oxc's
49
+ // lowering plugins are expensive on the transform line.
50
+ sourcemap: false,
51
+ ...(define ? { define } : {}),
52
+ };
53
+ const result = transformSync(file, source, options);
54
+ if (result.errors.length > 0) return { bail: 'parse-error' };
55
+ // Runtime helpers would import `@oxc-project/runtime`, which the app does not
56
+ // have; esbuild inlines its equivalents, so hand those sources back to it.
57
+ if (Object.keys(result.helpersUsed).length > 0) return { bail: 'helpers' };
58
+ return { code: dropUnreferencedImports(result.code) };
59
+ }
60
+
61
+ // `.ts` forbids JSX (`<T>x` is a type assertion); `.js`/`.jsx` app modules may
62
+ // contain JSX, matching the esbuild build's `loader: { '.js': 'jsx' }`.
63
+ function langForFile(file: string): 'ts' | 'tsx' | 'jsx' {
64
+ if (/\.[mc]?ts$/.test(file)) return 'ts';
65
+ if (file.endsWith('.tsx')) return 'tsx';
66
+ return 'jsx';
67
+ }
68
+
69
+ // --------------------------------------------------------------------------
70
+ // emitted-module specifier scan
71
+ // --------------------------------------------------------------------------
72
+
73
+ export type SpecifierKind = 'import-statement' | 'dynamic-import';
74
+
75
+ export interface FoundSpecifier {
76
+ start: number;
77
+ end: number;
78
+ value: string;
79
+ kind: SpecifierKind;
80
+ }
81
+
82
+ const SPECIFIER_RE =
83
+ /(?:^|[\n;{}])\s*(?:import|export)\b[^;'"]*?\bfrom\s*("[^"]*"|'[^']*')|(?:^|[\n;{}])\s*import\s*("[^"]*"|'[^']*')|\bimport\s*\(\s*("[^"]*"|'[^']*')\s*\)/g;
84
+
85
+ /** Every module specifier in emitted ESM, with the span of its string literal. */
86
+ export function outputSpecifiers(code: string): FoundSpecifier[] {
87
+ const found: FoundSpecifier[] = [];
88
+ SPECIFIER_RE.lastIndex = 0;
89
+ let match: RegExpExecArray | null;
90
+ while ((match = SPECIFIER_RE.exec(code))) {
91
+ for (let group = 1; group <= 3; group += 1) {
92
+ const literal = match[group];
93
+ if (literal === undefined) continue;
94
+ const start = match.index + match[0].lastIndexOf(literal);
95
+ found.push({
96
+ start,
97
+ end: start + literal.length,
98
+ value: literal.slice(1, -1),
99
+ kind: group === 3 ? 'dynamic-import' : 'import-statement',
100
+ });
101
+ }
102
+ }
103
+ return found;
104
+ }
105
+
106
+ /** Apply `{start, end, value}` replacements to `code` (spans must not overlap). */
107
+ export function spliceSource(code: string, edits: { start: number; end: number; value: string }[]) {
108
+ let next = code;
109
+ for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
110
+ next = `${next.slice(0, edit.start)}${edit.value}${next.slice(edit.end)}`;
111
+ }
112
+ return next;
113
+ }
114
+
115
+ // --------------------------------------------------------------------------
116
+ // drop-unreferenced-imports post-pass
117
+ // --------------------------------------------------------------------------
118
+
119
+ const IMPORT_STATEMENT_RE =
120
+ /(?:^|\n)[ \t]*import\s+([A-Za-z_$][\w$]*\s*,\s*)?(?:\*\s*as\s+([A-Za-z_$][\w$]*)|\{([^}]*)\}|([A-Za-z_$][\w$]*))\s+from\s*(?:"[^"]*"|'[^']*')\s*;?/g;
121
+
122
+ /**
123
+ * oxc keeps an import whose only uses were type positions it could not see through; the emitted module then
124
+ * imports a name the compiled target does not export and fails to load. esbuild's TS pipeline drops those,
125
+ * so drop any import statement whose every binding is unreferenced in the rest of the module - textual and
126
+ * therefore conservative: a name mentioned anywhere else, even in a string, keeps its import.
127
+ * Side-effect-only `import 'x'` never matches this pattern and is always kept.
128
+ */
129
+ export function dropUnreferencedImports(code: string) {
130
+ const statements: { start: number; end: number; names: string[] }[] = [];
131
+ IMPORT_STATEMENT_RE.lastIndex = 0;
132
+ let match: RegExpExecArray | null;
133
+ while ((match = IMPORT_STATEMENT_RE.exec(code))) {
134
+ const [text, defaultWithNamed, namespace, named, defaultOnly] = match;
135
+ const names: string[] = [];
136
+ if (defaultWithNamed) names.push(defaultWithNamed.replace(/\s*,\s*$/, '').trim());
137
+ if (namespace) names.push(namespace);
138
+ if (defaultOnly) names.push(defaultOnly);
139
+ if (named !== undefined) {
140
+ for (const part of named.split(',')) {
141
+ const local = part.split(/\bas\b/).pop()?.trim();
142
+ // An unparsed clause (comments, odd spacing) yields no local name: keep.
143
+ if (!local) continue;
144
+ if (!/^[A-Za-z_$][\w$]*$/.test(local)) return code;
145
+ names.push(local);
146
+ }
147
+ }
148
+ if (names.length === 0) continue;
149
+ const leading = text.startsWith('\n') ? 1 : 0;
150
+ statements.push({ start: match.index + leading, end: match.index + text.length, names });
151
+ }
152
+ if (statements.length === 0) return code;
153
+
154
+ // Reference test runs against the module with every import statement cut out,
155
+ // so one import's bindings never count as a use of another's.
156
+ const gaps: string[] = [];
157
+ let cursor = 0;
158
+ for (const statement of statements) {
159
+ gaps.push(code.slice(cursor, statement.start));
160
+ cursor = statement.end;
161
+ }
162
+ gaps.push(code.slice(cursor));
163
+ const used = referencedNames(gaps.join('\n'));
164
+ const unused = statements.filter(statement => statement.names.every(name => !used.has(name)));
165
+ if (unused.length === 0) return code;
166
+ return spliceSource(
167
+ code,
168
+ unused.map(({ start, end }) => ({ start, end, value: '' })),
169
+ );
170
+ }
171
+
172
+ const IDENTIFIER_RE = /[A-Za-z_$][\w$]*/g;
173
+
174
+ /**
175
+ * Every identifier token that is not a member-access property name. A single
176
+ * leading `.` is member access (`obj.name`, `obj?.name`); a doubled one is the
177
+ * spread/rest operator (`...name`), which IS a reference.
178
+ */
179
+ function referencedNames(body: string) {
180
+ const names = new Set<string>();
181
+ IDENTIFIER_RE.lastIndex = 0;
182
+ let match: RegExpExecArray | null;
183
+ while ((match = IDENTIFIER_RE.exec(body))) {
184
+ if (body[match.index - 1] === '.' && body[match.index - 2] !== '.') continue;
185
+ names.add(match[0]);
186
+ }
187
+ return names;
188
+ }
@@ -0,0 +1,63 @@
1
+ // Persisted node_modules bundles - the restart half of runtime/server.ts' `transformNodeModuleSource`.
2
+ // The node_modules load plugin esbuild-bundles each ESM package file it matches and remembers the
3
+ // result in memory only, so every restart re-runs those builds on the request path while the route
4
+ // bundle it is evaluating waits.
5
+ //
6
+ // The entry's own stat names the record, but the build inlines the package's relative graph too, so
7
+ // the record carries EVERY esbuild input and is reused only while all of them still stat the same.
8
+ // Anything else - a missing input, an unparseable record, a vanished file - is a miss, never a
9
+ // stale hit: the bundle it names is imported directly, and Bun caches a bad import for the life of
10
+ // the process.
11
+ import { mkdir, readFile, stat } from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import { writeFileAtomic } from '../utils/fs';
14
+
15
+ /** `[file, mtimeMs, size]` for one esbuild input of the bundle. */
16
+ type InputEntry = [string, number, number];
17
+
18
+ interface BundleRecord {
19
+ inputs: InputEntry[];
20
+ code: string;
21
+ }
22
+
23
+ /** The recorded bundle when every input it was built from is unchanged. */
24
+ export async function readNodeModuleBundle(recordFile: string): Promise<string | undefined> {
25
+ const contents = await readFile(recordFile, 'utf8').catch(() => undefined);
26
+ if (contents === undefined) return undefined;
27
+ let record: BundleRecord | undefined;
28
+ try {
29
+ record = JSON.parse(contents) as BundleRecord;
30
+ } catch {
31
+ return undefined;
32
+ }
33
+ if (typeof record?.code !== 'string' || !Array.isArray(record.inputs)) return undefined;
34
+ if (record.inputs.length === 0) return undefined;
35
+ const held = await Promise.all(
36
+ record.inputs.map(async ([input, mtimeMs, size]) => {
37
+ const stats = await stat(input).catch(() => undefined);
38
+ return stats?.mtimeMs === mtimeMs && stats.size === size;
39
+ }),
40
+ );
41
+ return held.every(Boolean) ? record.code : undefined;
42
+ }
43
+
44
+ export async function writeNodeModuleBundle(
45
+ recordFile: string,
46
+ inputs: string[],
47
+ code: string,
48
+ ): Promise<void> {
49
+ const stated = await Promise.all(
50
+ inputs.map(async (input): Promise<InputEntry | undefined> => {
51
+ const stats = await stat(input).catch(() => undefined);
52
+ return stats ? [input, stats.mtimeMs, stats.size] : undefined;
53
+ }),
54
+ );
55
+ // An input that vanished between the build and here would make the record
56
+ // unvalidatable forever; leaving it unwritten costs the next boot one bundle.
57
+ if (stated.length === 0 || stated.some(entry => entry === undefined)) return;
58
+ await mkdir(path.dirname(recordFile), { recursive: true }).catch(() => undefined);
59
+ await writeFileAtomic(
60
+ recordFile,
61
+ JSON.stringify({ inputs: stated as InputEntry[], code } satisfies BundleRecord),
62
+ ).catch(() => undefined);
63
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `PNEXT_DEV_RESTART_CACHE=0` turns off everything that makes a dev restart reuse work from the previous
3
+ * process: the persisted client cache keys, the persisted next/font resolutions, and the boot-time proxy
4
+ * and client-reference warmups. Nothing else changes, so a restart can be measured with and without the
5
+ * whole group on one tree - the same bisect role `PNEXT_DISABLE_VENDOR_STAGE` plays for the vendor stage.
6
+ */
7
+ export function restartCacheEnabled() {
8
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
9
+ return process.env.PNEXT_DEV_RESTART_CACHE !== '0';
10
+ }
@@ -0,0 +1,154 @@
1
+ // Persisted route-bundle names - the restart half of dev/imports.ts' devRouteBundlePath. A route's
2
+ // bundle already lives at a content-addressed path, so a restart never has to rebuild it; deriving
3
+ // that path did, by walking the whole source graph of the route and its layouts breadth-first, with
4
+ // a stat round trip per level, on the first page of every restart. The sources cannot have moved
5
+ // while nothing was watching them *unless* the file did, so this persists the walk's answer and
6
+ // re-validates it with one flat stat per source.
7
+ //
8
+ // The same three-step ladder dev/client-key-cache.ts and dev/route-facts-cache.ts use:
9
+ // stat matches -> reuse (the common case, and the only O(1) one)
10
+ // stat moved -> read + hash; equal content still reuses the path
11
+ // content differs -> discard, walk again, record the new answer
12
+ // A missing source is a miss too, which is what keeps a deleted file from naming an artifact whose
13
+ // edges are gone.
14
+ //
15
+ // One file PER ROUTE, not one index, for the reason route-facts-cache records: the source lists are
16
+ // large across the whole table and the first page only ever asks about the route it is serving.
17
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { readFile, stat } from 'node:fs/promises';
19
+ import path from 'node:path';
20
+ import type { ResolvedConfig } from '../config';
21
+ import {
22
+ DEV_SOURCE_MISSING,
23
+ devTextSourceHash,
24
+ isBinaryDevSource,
25
+ type DevGraphSource,
26
+ } from './module-cache';
27
+ import { restartCacheEnabled } from './restart-cache';
28
+
29
+ interface RecordFile {
30
+ version: number;
31
+ /** Hash of everything in the name that is not a walked source (see recordKey). */
32
+ key: string;
33
+ /** The named bundle path itself — the whole point of the record. */
34
+ bundle: string;
35
+ files: DevGraphSource[];
36
+ }
37
+
38
+ /** Bumped whenever a record's shape changes; an older record is simply ignored. */
39
+ const RECORD_VERSION = 1;
40
+
41
+ const hash = (value: string) => Bun.hash(value).toString(36);
42
+
43
+ /** Records written this process (by record file), so a repeat naming does not re-serialize. */
44
+ const written = new Map<string, string>();
45
+
46
+ function enabled() {
47
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
48
+ return restartCacheEnabled() && process.env.PNEXT_DEV_BUNDLE_KEY_CACHE !== '0';
49
+ }
50
+
51
+ function recordFile(config: ResolvedConfig, routeId: string) {
52
+ return path.join(config.outPath, 'cache', 'route-bundles', `${hash(routeId)}.json`);
53
+ }
54
+
55
+ /**
56
+ * The entry files and the graph's own key (compat, aliases, the pnext build
57
+ * itself) decide the name as much as the sources do, so either one moving makes
58
+ * the record read as a miss instead of half-validating it.
59
+ */
60
+ function recordKey(graphKey: string, files: string[]) {
61
+ return hash(`${RECORD_VERSION}\n${graphKey}\n${files.join('\0')}`);
62
+ }
63
+
64
+ /** The persisted bundle path when every source it was named for is unchanged. */
65
+ export async function cachedRouteBundlePath(
66
+ config: ResolvedConfig,
67
+ routeId: string,
68
+ graphKey: string,
69
+ files: string[],
70
+ ): Promise<string | undefined> {
71
+ if (!enabled()) return undefined;
72
+ const key = recordKey(graphKey, files);
73
+ let record: RecordFile | undefined;
74
+ try {
75
+ record = JSON.parse(readFileSync(recordFile(config, routeId), 'utf8')) as RecordFile;
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ if (record?.version !== RECORD_VERSION || record.key !== key) return undefined;
80
+ if (typeof record.bundle !== 'string' || !Array.isArray(record.files)) return undefined;
81
+ const revalidated = await revalidate(record.files);
82
+ if (!revalidated) return undefined;
83
+ // Only the stats moved (a touch, a checkout): keep the name, record the new
84
+ // stats so the next boot takes the O(1) path again.
85
+ if (revalidated !== record.files) {
86
+ written.delete(recordFile(config, routeId));
87
+ write(config, routeId, key, record.bundle, revalidated);
88
+ }
89
+ return record.bundle;
90
+ }
91
+
92
+ export function saveRouteBundlePath(
93
+ config: ResolvedConfig,
94
+ routeId: string,
95
+ graphKey: string,
96
+ files: string[],
97
+ bundle: string,
98
+ sources: DevGraphSource[],
99
+ ): void {
100
+ if (!enabled()) return;
101
+ // A source the walk could not read describes a tree that no longer exists;
102
+ // the next boot walking again is the safe answer.
103
+ if (sources.some(([, , , srcHash]) => srcHash === DEV_SOURCE_MISSING)) return;
104
+ write(config, routeId, recordKey(graphKey, files), bundle, sources);
105
+ }
106
+
107
+ function write(
108
+ config: ResolvedConfig,
109
+ routeId: string,
110
+ key: string,
111
+ bundle: string,
112
+ files: DevGraphSource[],
113
+ ) {
114
+ const file = recordFile(config, routeId);
115
+ if (written.get(file) === bundle) return;
116
+ written.set(file, bundle);
117
+ const contents = JSON.stringify({
118
+ version: RECORD_VERSION,
119
+ key,
120
+ bundle,
121
+ files,
122
+ } satisfies RecordFile);
123
+ try {
124
+ mkdirSync(path.dirname(file), { recursive: true });
125
+ writeFileSync(file, contents);
126
+ } catch {
127
+ // Best effort: a missing record only costs the next boot a walk.
128
+ }
129
+ }
130
+
131
+ /**
132
+ * The recorded sources, re-stated: `files` itself when nothing moved, a fresh list when only stats moved,
133
+ * `undefined` when any content actually differs - or a source is gone, which is the same thing, since the
134
+ * walk has to run again.
135
+ */
136
+ async function revalidate(files: DevGraphSource[]): Promise<DevGraphSource[] | undefined> {
137
+ let moved = false;
138
+ const checked = await Promise.all(
139
+ files.map(async (entry): Promise<DevGraphSource | undefined> => {
140
+ const [file, mtimeMs, size, srcHash] = entry;
141
+ const stats = await stat(file).catch(() => undefined);
142
+ if (!stats) return undefined;
143
+ if (stats.mtimeMs === mtimeMs && stats.size === size) return entry;
144
+ // A binary source is hashed from its stat, so a moved stat IS a new hash.
145
+ if (isBinaryDevSource(file)) return undefined;
146
+ const source = await readFile(file, 'utf8').catch(() => undefined);
147
+ if (source === undefined || devTextSourceHash(source) !== srcHash) return undefined;
148
+ moved = true;
149
+ return [file, stats.mtimeMs, stats.size, srcHash];
150
+ }),
151
+ );
152
+ if (checked.some(entry => entry === undefined)) return undefined;
153
+ return moved ? (checked as DevGraphSource[]) : files;
154
+ }
@@ -0,0 +1,223 @@
1
+ // Persisted route facts - the restart half of routing/routes.ts' deferred scan. A route's facts
2
+ // (mode, segment config, client references, css imports, the source list) are derived by reading
3
+ // and sniffing every source the route's graph reaches, and that lands on the first page of every
4
+ // restart - the otel root interceptor alone forces it to decide one span attribute. The sources
5
+ // cannot have moved while nothing was watching them *unless* the file did, so this persists the
6
+ // walk's result next to the compiled cache and re-validates it with one stat per source.
7
+ //
8
+ // The same three-step ladder dev/client-key-cache.ts uses:
9
+ // stat matches -> reuse (the common case, and the only O(1) one)
10
+ // stat moved -> read + hash; equal content still reuses the facts
11
+ // content differs -> discard, walk again, record the new facts
12
+ //
13
+ // One file PER ROUTE, not one index: the whole table is large and the first page only ever asks
14
+ // about the route it is serving, so as a single index its JSON.parse landed, in full, on that
15
+ // request and gave most of the walk back.
16
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
17
+ import path from 'node:path';
18
+ import type { ResolvedConfig } from '../config';
19
+ import { setRouteFactsStore, type RouteFacts, type RouteFactsStore } from '../routing/routes';
20
+ import { restartCacheEnabled } from './restart-cache';
21
+
22
+ /** `[file, mtimeMs, size, contentHash]` — the same record shape the key cache uses. */
23
+ type SourceEntry = [string, number, number, string];
24
+
25
+ interface RecordFile {
26
+ version: number;
27
+ /** Everything the facts depend on that is not a walked source (see cacheKey). */
28
+ key: string;
29
+ files: SourceEntry[];
30
+ facts: RouteFacts;
31
+ }
32
+
33
+ /** Bumped whenever a record's shape changes; an older record is simply ignored. */
34
+ const RECORD_VERSION = 1;
35
+
36
+ const hash = (value: string) => Bun.hash(value).toString(36);
37
+
38
+ /**
39
+ * The facts of a route whose own sources did not move can still change when the
40
+ * thing that *interprets* them does: the resolved config (compat on/off, route
41
+ * extensions, aliases) or pnext itself. Both go in the record's key, so either
42
+ * one moving makes every record read as a miss instead of half-validating it.
43
+ */
44
+ function cacheKey(config: ResolvedConfig): string | undefined {
45
+ try {
46
+ const shape = JSON.stringify(config, (_key, value: unknown) =>
47
+ typeof value === 'function' ? '[fn]' : value,
48
+ );
49
+ return hash(`${RECORD_VERSION}\n${pnextBuildStamp()}\n${shape}`);
50
+ } catch {
51
+ // A config that will not serialize (cycles, exotic values) simply opts out.
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * mtime+size of the route scanner this process is running. The facts are the framework's own reading of
58
+ * the app's sources, so a pnext that changed since the record was written must re-derive them rather than
59
+ * inherit the old answers - which is what keeps this safe while pnext itself is being edited.
60
+ */
61
+ function pnextBuildStamp() {
62
+ try {
63
+ const self = Bun.fileURLToPath(import.meta.url);
64
+ const scanner = path.join(path.dirname(path.dirname(self)), 'routing', 'routes.ts');
65
+ const info = statSync(scanner, { throwIfNoEntry: false });
66
+ return info ? `${info.mtimeMs}:${info.size}` : self;
67
+ } catch {
68
+ return 'unknown';
69
+ }
70
+ }
71
+
72
+ interface FactsCache {
73
+ dir: string;
74
+ key: string;
75
+ /** Records written this process, so a repeat save does not re-serialize. */
76
+ written: Set<string>;
77
+ }
78
+
79
+ export function installRouteFactsCache(config: ResolvedConfig) {
80
+ if (!restartCacheEnabled()) return;
81
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
82
+ if (process.env.PNEXT_DEV_FACTS_CACHE === '0') return;
83
+ const key = cacheKey(config);
84
+ if (!key) return;
85
+ const cache: FactsCache = {
86
+ dir: path.join(config.outPath, 'cache', 'route-facts'),
87
+ key,
88
+ written: new Set(),
89
+ };
90
+ setRouteFactsStore(createStore(cache));
91
+ }
92
+
93
+ function recordFile(cache: FactsCache, routeId: string) {
94
+ return path.join(cache.dir, `${hash(routeId)}.json`);
95
+ }
96
+
97
+ function createStore(cache: FactsCache): RouteFactsStore {
98
+ return {
99
+ load(routeId) {
100
+ const record = readRecord(cache, routeId);
101
+ if (!record) {
102
+ stats.misses += 1;
103
+ return undefined;
104
+ }
105
+ const revalidated = revalidate(record.files);
106
+ if (!revalidated) {
107
+ stats.misses += 1;
108
+ return undefined;
109
+ }
110
+ stats.hits += 1;
111
+ // Only the stats moved (a touch, a checkout): keep the facts, record the
112
+ // new stats so the next boot takes the O(1) path again.
113
+ if (revalidated !== record.files) {
114
+ cache.written.delete(routeId);
115
+ write(cache, routeId, revalidated, record.facts);
116
+ }
117
+ return record.facts;
118
+ },
119
+ save(routeId, facts) {
120
+ const files = factsSources(facts);
121
+ if (!files) return;
122
+ write(cache, routeId, files, facts);
123
+ },
124
+ };
125
+ }
126
+
127
+ function readRecord(cache: FactsCache, routeId: string): RecordFile | undefined {
128
+ let parsed: RecordFile | undefined;
129
+ try {
130
+ parsed = JSON.parse(readFileSync(recordFile(cache, routeId), 'utf8')) as RecordFile;
131
+ } catch {
132
+ return undefined;
133
+ }
134
+ if (parsed?.version !== RECORD_VERSION || parsed.key !== cache.key) return undefined;
135
+ if (!parsed.facts || !Array.isArray(parsed.files)) return undefined;
136
+ return parsed;
137
+ }
138
+
139
+ /**
140
+ * Records are written from a synchronous getter, so the write is synchronous too - a debounce would
141
+ * have to survive an exit that the walk's caller never awaits. Each is a single small file, written
142
+ * once per process per route.
143
+ */
144
+ function write(cache: FactsCache, routeId: string, files: SourceEntry[], facts: RouteFacts) {
145
+ if (cache.written.has(routeId)) return;
146
+ cache.written.add(routeId);
147
+ const contents = JSON.stringify({
148
+ version: RECORD_VERSION,
149
+ key: cache.key,
150
+ files,
151
+ facts,
152
+ } satisfies RecordFile);
153
+ try {
154
+ mkdirSync(cache.dir, { recursive: true });
155
+ writeFileSync(recordFile(cache, routeId), contents);
156
+ } catch {
157
+ // Best effort: a missing record only costs the next boot a walk.
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Stat and hash every source the walk read. A file that cannot be read at all means the record would
163
+ * describe a tree that no longer exists, so nothing is written - the next boot walks, which is the safe
164
+ * answer.
165
+ */
166
+ function factsSources(facts: RouteFacts): SourceEntry[] | undefined {
167
+ const entries: SourceEntry[] = [];
168
+ for (const file of facts.sourceFiles) {
169
+ const entry = sourceEntry(file);
170
+ if (!entry) return undefined;
171
+ entries.push(entry);
172
+ }
173
+ return entries;
174
+ }
175
+
176
+ function sourceEntry(file: string): SourceEntry | undefined {
177
+ try {
178
+ const source = readFileSync(file, 'utf8');
179
+ // Stat AFTER the read: a file that changes between the two records the
180
+ // older stat, so the next boot re-hashes rather than trusting a stale one.
181
+ const info = statSync(file, { throwIfNoEntry: false });
182
+ if (!info) return undefined;
183
+ return [file, info.mtimeMs, info.size, hash(source)];
184
+ } catch {
185
+ return undefined;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * The recorded sources, re-stated: `files` itself when nothing moved, a fresh list when only stats moved,
191
+ * `undefined` when any content actually differs - or a source is gone, which is the same thing, since the
192
+ * walk has to run again.
193
+ */
194
+ function revalidate(files: SourceEntry[]): SourceEntry[] | undefined {
195
+ let moved = false;
196
+ const checked: SourceEntry[] = [];
197
+ for (const entry of files) {
198
+ const [file, mtimeMs, size, contentHash] = entry;
199
+ const info = statSync(file, { throwIfNoEntry: false });
200
+ if (!info) return undefined;
201
+ if (info.mtimeMs === mtimeMs && info.size === size) {
202
+ checked.push(entry);
203
+ continue;
204
+ }
205
+ let source: string;
206
+ try {
207
+ source = readFileSync(file, 'utf8');
208
+ } catch {
209
+ return undefined;
210
+ }
211
+ if (hash(source) !== contentHash) return undefined;
212
+ moved = true;
213
+ checked.push([file, info.mtimeMs, info.size, contentHash]);
214
+ }
215
+ return moved ? checked : files;
216
+ }
217
+
218
+ /** Bisect seam: `PNEXT_DEV_FACTS_STATS=1` prints the hit/miss split at exit. */
219
+ const stats = { hits: 0, misses: 0 };
220
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
221
+ if (process.env.PNEXT_DEV_FACTS_STATS === '1') {
222
+ process.on('exit', () => console.log(`route-facts hits=${stats.hits} misses=${stats.misses}`));
223
+ }