@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,2136 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
3
+ import { copyFile, mkdir, readFile, rename, stat } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import {
6
+ build,
7
+ type BuildOptions,
8
+ type Metafile,
9
+ type OnResolveArgs,
10
+ type OnResolveOptions,
11
+ type OnResolveResult,
12
+ type Plugin,
13
+ type PluginBuild,
14
+ type ResolveResult,
15
+ } from 'esbuild';
16
+ import { foldInitialChunks } from './chunk-fold';
17
+ import { clientEntryName } from './paths';
18
+ import { clientProfile } from './profile';
19
+ import { publicEnvDefines, type ResolvedConfig } from '../config';
20
+ import { cssModuleClientPlugin } from '../css';
21
+ import { withAssetPrefix } from '../css/build';
22
+ import { devDynamicSplitEnabled, rewriteDeferredDynamicImports, rewriteLiteralDynamicCalls } from '../dynamic/source';
23
+ import { scanFacts } from '../resolve/scan-facts';
24
+ import { readSourceText } from '../resolve/source-text';
25
+ import { ensureDir, listFiles, readText, writeText } from '../utils/fs';
26
+ import { escapeRegex } from '../utils/source';
27
+ import {
28
+ getExternalPackagePolicy,
29
+ resolveImport,
30
+ resolveLinkedPackageSpecifier,
31
+ } from '../resolve/imports';
32
+ import {
33
+ CLIENT_RUNTIME_MODULE,
34
+ DYN_SHARED_GLOBAL,
35
+ dynSharedSpecifiers,
36
+ clientEntrySource,
37
+ clientRuntimeFacts,
38
+ clientRuntimeSource,
39
+ type ClientRuntimeFacts,
40
+ } from './entry';
41
+ import {
42
+ ensurePrebuiltRuntime,
43
+ prebuiltAssets,
44
+ prebuiltExternalPlugin,
45
+ prebuiltModuleUrl,
46
+ prebuiltRuntimeKey,
47
+ prebuiltSpecifierFilter,
48
+ settlePrebuiltRuntime,
49
+ type PrebuiltRuntime,
50
+ } from './prebuilt';
51
+ import { findLayouts } from '../routing/routes';
52
+ import { clientReferenceId, ssrClientReference, type ClientReference } from './reference';
53
+ import { shouldReactCompile, transformReactCompiler } from './react-compiler';
54
+ import { createVerboseLogger } from '../utils/verbose';
55
+ import { dim as dimText } from '../utils/ansi';
56
+ import type { RouteManifestEntry } from '../types';
57
+ import {
58
+ applyClientSourceAsyncPreTransforms,
59
+ applyClientSourceTransforms,
60
+ getAssetExtensions,
61
+ getBundlerExtensions,
62
+ getCompatModeExtensions,
63
+ getImportAliasExtensions,
64
+ hasClientSourceAsyncPreTransforms,
65
+ type CompatModeExtensions,
66
+ } from '../extensions';
67
+
68
+ /**
69
+ * Write the standalone static chunks a bundler extension supplies (compat: the
70
+ * no-module polyfills chunk) verbatim into the client `chunks/` dir. These sit
71
+ * outside esbuild's entry graph, so they are emitted here rather than through a
72
+ * build. No-op for pure-core apps (the seam returns none).
73
+ */
74
+ export async function emitStaticClientChunks(config: ResolvedConfig, outDir: string) {
75
+ const chunks = getBundlerExtensions().staticClientChunks(config);
76
+ if (chunks.length === 0) return;
77
+ const chunksDir = path.join(outDir, 'chunks');
78
+ await ensureDir(chunksDir);
79
+ for (const chunk of chunks) {
80
+ await writeText(path.join(chunksDir, chunk.name), chunk.contents);
81
+ }
82
+ }
83
+
84
+ interface ClientBuildOptions {
85
+ config: ResolvedConfig;
86
+ route: RouteManifestEntry;
87
+ outDir: string;
88
+ dev?: boolean;
89
+ }
90
+
91
+ interface ClientBatchBuildOptions {
92
+ config: ResolvedConfig;
93
+ routes: RouteManifestEntry[];
94
+ outDir: string;
95
+ verbose?: boolean;
96
+ /**
97
+ * The app defines at least one server action (the build's action manifest). Unset means "unknown" -
98
+ * dev, where the manifest is discovered per request - and the action runtime is emitted, since only
99
+ * prod bundles are budgeted.
100
+ */
101
+ hasServerActions?: boolean;
102
+ /**
103
+ * A pipeline already warmed by the caller (the build starts one under the
104
+ * route-fact scan). Omitted — dev, tests — the stage opens its own.
105
+ */
106
+ pipeline?: ClientSourcePipeline;
107
+ }
108
+
109
+ const virtualEntryNamespace = 'pnext-client-entry';
110
+
111
+ function nextCompatEnabled(config: ResolvedConfig) {
112
+ return getCompatModeExtensions().nextEnabled(config);
113
+ }
114
+
115
+ /**
116
+ * Convention-file lookups (error / not-found / global-error / layout) are directory walks with four
117
+ * `existsSync` probes per level, and the entry builder asks the same questions of the same
118
+ * directories once per route. Memoized for the duration of one build and cleared at each entry
119
+ * point, so a dev rebuild re-stats after a file is added or removed.
120
+ */
121
+ const conventionCache = new Map<string, unknown>();
122
+
123
+ function memoConvention<T>(key: string, compute: () => T): T {
124
+ if (conventionCache.has(key)) return conventionCache.get(key) as T;
125
+ const value = compute();
126
+ conventionCache.set(key, value);
127
+ return value;
128
+ }
129
+
130
+ /**
131
+ * Whether the action client runtime is emitted at all. Two app facts feed it:
132
+ * the build's own action manifest, and the scan's `actions`/`form` client-entry
133
+ * reasons (a `<form action={fn}>` or next/form the manifest cannot see). Either
134
+ * one unset (dev) keeps the runtime, so only a provably action-free prod app
135
+ * drops it.
136
+ */
137
+ function appUsesActions(routes: RouteManifestEntry[], hasServerActions?: boolean) {
138
+ if (hasServerActions !== false) return true;
139
+ return routes.some(route =>
140
+ route.clientEntryReasons?.some(reason => reason === 'actions' || reason === 'form'),
141
+ );
142
+ }
143
+
144
+ /**
145
+ * Whether this route has an `error.*` or `not-found.*` for the in-tree ClientErrorBoundary to
146
+ * render. Without one the boundary would only re-throw, and the deferred window last resort already
147
+ * owns that path - so the whole boundary cluster stays off first paint.
148
+ */
149
+ function hasRouteErrorBoundary(config: ResolvedConfig, route: RouteManifestEntry): boolean {
150
+ return (
151
+ Boolean(routeErrorFile(config, route) ?? routeNotFoundFile(config, route)) ||
152
+ routeThrowsClientControlFlow(route)
153
+ );
154
+ }
155
+
156
+ /**
157
+ * A `'use client'` module in the route graph calls notFound()/forbidden()/unauthorized(). The throw
158
+ * happens during hydration, so the boundary - which renders its built-in 404 with no not-found.*
159
+ * file - has to be in the tree to catch it.
160
+ */
161
+ function routeThrowsClientControlFlow(route: RouteManifestEntry): boolean {
162
+ return route.clientEntryReasons?.includes('control-flow') === true;
163
+ }
164
+
165
+ function routeErrorFile(config: ResolvedConfig, route: RouteManifestEntry): string | undefined {
166
+ return routeConventionFile(config, route, 'error');
167
+ }
168
+
169
+ function routeNotFoundFile(config: ResolvedConfig, route: RouteManifestEntry): string | undefined {
170
+ return routeConventionFile(config, route, 'not-found');
171
+ }
172
+
173
+ // Nearest segment convention file for a route: walked from the page's own
174
+ // segment up to the app root.
175
+ function routeConventionFile(
176
+ config: ResolvedConfig,
177
+ route: RouteManifestEntry,
178
+ name: string,
179
+ ): string | undefined {
180
+ if (!nextCompatEnabled(config)) return undefined;
181
+ const appPath = path.resolve(config.appPath);
182
+ const start = path.dirname(path.resolve(route.file));
183
+ const relative = path.relative(appPath, start);
184
+ if (relative.startsWith('..') || path.isAbsolute(relative)) return undefined;
185
+ // Memoized per directory, not per route: sibling routes walk the same chain,
186
+ // and a parent's answer is the tail of every child's walk.
187
+ return memoConvention(`${name}\0${start}`, () => {
188
+ let dir = start;
189
+ for (;;) {
190
+ for (const ext of ['tsx', 'ts', 'jsx', 'js']) {
191
+ const candidate = path.join(dir, `${name}.${ext}`);
192
+ if (existsSync(candidate)) return candidate;
193
+ }
194
+ if (dir === appPath) return undefined;
195
+ const parent = path.dirname(dir);
196
+ if (parent === dir) return undefined;
197
+ dir = parent;
198
+ }
199
+ });
200
+ }
201
+
202
+ // The app-root global-error.* convention file (single per app, unlike error.*
203
+ // which is per-segment): threaded into the client entry so installClientErrors
204
+ // can mount a user global-error component for client throws that escape every
205
+ // route error boundary. Absent → the built-in fallback document is used.
206
+ function globalErrorFile(config: ResolvedConfig): string | undefined {
207
+ if (!nextCompatEnabled(config)) return undefined;
208
+ const appPath = path.resolve(config.appPath);
209
+ return memoConvention(`global-error\0${appPath}`, () => {
210
+ for (const ext of ['tsx', 'ts', 'jsx', 'js']) {
211
+ const candidate = path.join(appPath, `global-error.${ext}`);
212
+ if (existsSync(candidate)) return candidate;
213
+ }
214
+ return undefined;
215
+ });
216
+ }
217
+
218
+ /**
219
+ * Whether the route's ROOT layout is itself a `'use client'` component. Only
220
+ * then does the client entry re-render the whole document body through that
221
+ * layout at hydration (the client-shell path). Mirrors the renderer's
222
+ * `rootIsClient` check (render/renderer.ts) so the two stay in lock-step: the
223
+ * renderer marks the root layout's slots with `data-pnext-slot` exactly when
224
+ * this is true, and the shell path depends on those markers.
225
+ */
226
+ function hasClientRootLayout(config: ResolvedConfig, route: RouteManifestEntry): boolean {
227
+ if (route.client) return false;
228
+ const rootLayout = shellLayoutOrder(config, route)[0];
229
+ if (!rootLayout) return false;
230
+ return route.clientReferences.some(reference => reference.file === rootLayout);
231
+ }
232
+
233
+ /**
234
+ * Layout files outermost-first, from the layout hierarchy - NOT from `route.clientReferences`, whose
235
+ * path-hash sort can put a non-layout reference ahead of the root layout and make the shell drop its
236
+ * subtree.
237
+ */
238
+ function shellLayoutOrder(config: ResolvedConfig, route: RouteManifestEntry): string[] {
239
+ // Asked three times per route (shell order, client-root check, runtime facts).
240
+ return memoConvention(`layouts\0${config.appPath}\0${route.file}`, () =>
241
+ findLayouts(config.appPath, route.file),
242
+ );
243
+ }
244
+
245
+ /**
246
+ * Where the artifact lives. In dev it must sit OUTSIDE `config.outPath`: a cold
247
+ * page is defined by that directory being wiped, and an artifact wiped with it
248
+ * would be rebuilt on exactly the request it exists to make cheap. In prod it
249
+ * ships beside the entries, and entries reach it by a RELATIVE url so any
250
+ * basePath/assetPrefix applies to it for free.
251
+ */
252
+ function prebuiltLocation(config: ResolvedConfig, outDir: string, key: string, dev: boolean) {
253
+ if (dev) {
254
+ return {
255
+ dir: prebuiltRuntimeDir(config, key),
256
+ publicPath: `/__pnext/runtime/${key}`,
257
+ assetPath: `__pnext/runtime/${key}`,
258
+ };
259
+ }
260
+ // The SERVED url, not a relative one: chunks sit a directory below the
261
+ // entries, so no single relative path is correct from both — and esbuild
262
+ // writes an external path through verbatim, the same string everywhere.
263
+ const served = nextCompatEnabled(config)
264
+ ? `/_next/static/rt/${key}`
265
+ : `/assets/rt/${key}`;
266
+ return {
267
+ dir: path.join(outDir, 'rt', key),
268
+ publicPath: withAssetPrefix(config, served),
269
+ assetPath: `assets/rt/${key}`,
270
+ };
271
+ }
272
+
273
+ /** Where the dev server reads the artifact back from. */
274
+ export function prebuiltRuntimeDir(config: ResolvedConfig, key: string) {
275
+ return path.join(config.root, 'node_modules', '.cache', 'pnext', 'client-runtime', key);
276
+ }
277
+
278
+ /**
279
+ * Opt-in, not default: the prebuild halves a cold page's client preload stage but costs bytes, and
280
+ * per-surface keying (`surfaceGroups`) does not recover them - the artifact's re-export boundary
281
+ * adds gz per page and pushes the core "all features used" ceiling over its cell. Keying only pays
282
+ * when routes demand genuinely different surfaces.
283
+ * `=1` to enable, `PNEXT_PREBUILT_GROUPS=0` for the shared-artifact arm.
284
+ */
285
+ function prebuiltEnabled() {
286
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
287
+ return process.env.PNEXT_PREBUILT_RUNTIME === '1';
288
+ }
289
+
290
+ /**
291
+ * The framework surface one route's generated entry demands, as a string. A pure function of the
292
+ * compat facts `routerImportSource` emits its imports from and of the route's own
293
+ * `ClientRuntimeFacts` - the two records that decide every framework import an entry makes. Error /
294
+ * not-found files enter as booleans, not paths: two routes with different `error.tsx` files import
295
+ * the same framework names.
296
+ */
297
+ function surfaceSignature(config: ResolvedConfig, route: RouteManifestEntry, actions: boolean) {
298
+ const nextCompat = nextCompatEnabled(config);
299
+ const facts = clientRuntimeFacts(
300
+ [
301
+ {
302
+ route,
303
+ shell: hasClientRootLayout(config, route),
304
+ boundary: hasRouteErrorBoundary(config, route),
305
+ notFound: Boolean(routeNotFoundFile(config, route)),
306
+ },
307
+ ],
308
+ nextCompat,
309
+ );
310
+ return JSON.stringify([
311
+ nextCompat,
312
+ actions,
313
+ Boolean(routeErrorFile(config, route)),
314
+ Boolean(globalErrorFile(config)),
315
+ Boolean(routeNotFoundFile(config, route)),
316
+ routeThrowsClientControlFlow(route) === true,
317
+ Boolean(route.needsRouterEntry),
318
+ facts,
319
+ ]);
320
+ }
321
+
322
+ interface PreparedPrebuilt {
323
+ runtime: PrebuiltRuntime;
324
+ plugin: Plugin;
325
+ /** Fold what the build demanded into the artifact. */
326
+ settle(): Promise<void>;
327
+ }
328
+
329
+ /**
330
+ * Open the prebuilt artifact for a client build, or return undefined when the
331
+ * prebuild is off. The returned plugin routes every framework specifier at the
332
+ * artifact; `settle()` afterwards covers whatever the build turned out to need.
333
+ */
334
+ async function preparePrebuilt(
335
+ config: ResolvedConfig,
336
+ outDir: string,
337
+ dev: boolean,
338
+ sourceOf: (importer: string) => string | undefined,
339
+ signature = '',
340
+ ): Promise<PreparedPrebuilt | undefined> {
341
+ if (!prebuiltEnabled()) return undefined;
342
+ const buildOptions = baseClientBuildOptions(config, dev);
343
+ const key = prebuiltRuntimeKey(config, buildOptions, signature);
344
+ const { dir, publicPath, assetPath } = prebuiltLocation(config, outDir, key, dev);
345
+ // The artifact build gets the same plugin chain — and therefore the same
346
+ // aliases, transforms and asset seam — as the route build it stands in for.
347
+ // Not the prebuilt plugin itself: inside the artifact, framework imports are
348
+ // exactly what is being bundled.
349
+ const options = {
350
+ aliases: Object.keys(getImportAliasExtensions().aliases(config, 'client')),
351
+ key,
352
+ dir,
353
+ publicPath,
354
+ assetPath,
355
+ buildOptions,
356
+ plugins: () => clientBuildPlugins(config, createClientSourcePipeline(config)),
357
+ };
358
+ const runtime = await ensurePrebuiltRuntime(options);
359
+ const unprobed = new Set<string>();
360
+ return {
361
+ runtime,
362
+ plugin: prebuiltExternalPlugin(runtime, prebuiltSpecifierFilter(config), unprobed, sourceOf),
363
+ async settle() {
364
+ await settlePrebuiltRuntime(runtime, options, outDir, unprobed);
365
+ },
366
+ };
367
+ }
368
+
369
+ /** The served URL of a deferred dynamic reference's on-demand chunk (dev split). */
370
+ export function deferredDynamicChunkHref(reference: Pick<ClientReference, 'id'>) {
371
+ return `/__pnext/client-dyn/${reference.id}.js`;
372
+ }
373
+
374
+ export interface DeferredDynamicRef {
375
+ file: string;
376
+ exportName: string;
377
+ }
378
+
379
+ // Process-level registry the dev chunk endpoint resolves ids through. Entries
380
+ // come from the pipeline rewrite below and from each out-dir's sidecar (a
381
+ // restart serving a cached entry never re-ran the rewrite).
382
+ const deferredDynamicRefs = new Map<string, DeferredDynamicRef>();
383
+
384
+ export function registerDeferredDynamicRef(id: string, ref: DeferredDynamicRef) {
385
+ deferredDynamicRefs.set(id, ref);
386
+ }
387
+
388
+ export function deferredDynamicRefById(id: string) {
389
+ return deferredDynamicRefs.get(id);
390
+ }
391
+
392
+ /** Sidecar name persisted next to a dev entry naming its deferred dynamic refs. */
393
+ export const DEFERRED_DYNAMIC_SIDECAR = 'dyn-refs.json';
394
+
395
+ /** Dev-only: deferred dynamic references load from the on-demand chunk endpoint. */
396
+ function devDeferredDynamicHref(dev: boolean | undefined) {
397
+ if (!dev || !devDynamicSplitEnabled()) return undefined;
398
+ return (reference: ClientReference) =>
399
+ reference.dynamic && !ssrClientReference(reference)
400
+ ? deferredDynamicChunkHref(reference)
401
+ : undefined;
402
+ }
403
+
404
+ /** Deferred chunk URLs resolve at runtime, never inside the entry build. */
405
+ function deferredDynamicExternalPlugin(): Plugin {
406
+ return {
407
+ name: 'pnext-dyn-split-external',
408
+ setup(build) {
409
+ build.onResolve({ filter: /^\/__pnext\/client-dyn\// }, args => ({
410
+ path: args.path,
411
+ external: true,
412
+ }));
413
+ },
414
+ };
415
+ }
416
+
417
+ export async function buildClientEntry({ config, route, outDir, dev }: ClientBuildOptions) {
418
+ conventionCache.clear();
419
+ await ensureDir(outDir);
420
+ const source = clientEntrySource({
421
+ deferredDynamicHref: devDeferredDynamicHref(dev),
422
+ pageFile: route.client ? route.file : undefined,
423
+ clientReferences: route.clientReferences,
424
+ nextCompat: nextCompatEnabled(config),
425
+ // Single-route (dev / batch fallback) build: no app-wide action manifest here.
426
+ actions: true,
427
+ errorFile: routeErrorFile(config, route),
428
+ globalErrorFile: globalErrorFile(config),
429
+ notFoundFile: routeNotFoundFile(config, route),
430
+ controlFlow: routeThrowsClientControlFlow(route),
431
+ router: Boolean(route.needsRouterEntry),
432
+ clientRootLayout: hasClientRootLayout(config, route),
433
+ shellLayoutOrder: shellLayoutOrder(config, route),
434
+ });
435
+ const entryName = clientEntryName(route);
436
+ const outfile = path.join(outDir, `${entryName}.js`);
437
+ const pipeline = createClientSourcePipeline(config, dev === true);
438
+ pipeline.warmRoutes([route]);
439
+ const runtimeFacts = clientRuntimeFacts(
440
+ [
441
+ {
442
+ route,
443
+ shell: hasClientRootLayout(config, route),
444
+ boundary: hasRouteErrorBoundary(config, route),
445
+ notFound: Boolean(routeNotFoundFile(config, route)),
446
+ },
447
+ ],
448
+ nextCompatEnabled(config),
449
+ );
450
+ const prebuilt = await clientProfile.timeAsync('prebuilt', () => preparePrebuilt(
451
+ config,
452
+ outDir,
453
+ dev === true,
454
+ importer =>
455
+ importer === CLIENT_RUNTIME_MODULE
456
+ ? clientRuntimeSource(runtimeFacts)
457
+ : (pipeline.sourceOf(importer) ?? readTextSyncSafe(importer)),
458
+ // One route is its own surface group, so the key is the same one the batch
459
+ // build would give this route's group — dev and prod derive it identically.
460
+ surfaceSignature(config, route, true),
461
+ ));
462
+
463
+ let metafile: Metafile | undefined;
464
+ try {
465
+ const result = await profileClientBuild(route, dev, () =>
466
+ clientProfile.timeAsync('esbuild', () => build({
467
+ ...baseClientBuildOptions(config, dev),
468
+ stdin: {
469
+ contents: source,
470
+ loader: 'ts',
471
+ resolveDir: process.cwd(),
472
+ sourcefile: `${route.id}.ts`,
473
+ },
474
+ outdir: outDir,
475
+ entryNames: entryName,
476
+ metafile: true,
477
+ plugins: clientBuildPlugins(config, pipeline, [
478
+ ...(prebuilt ? [prebuilt.plugin] : []),
479
+ ...(devDeferredDynamicHref(dev) ? [deferredDynamicExternalPlugin()] : []),
480
+ clientRuntimePlugin(runtimeFacts),
481
+ ]),
482
+ })),
483
+ );
484
+ metafile = result.metafile;
485
+ } catch (error) {
486
+ throw withClientImportTrace(error, config, route, source);
487
+ }
488
+ await clientProfile.timeAsync('prebuiltSettle', () => prebuilt?.settle() ?? Promise.resolve());
489
+
490
+ if (metafile) {
491
+ metafile = await clientProfile.timeAsync('fold', () => foldInitialChunks({
492
+ outDir,
493
+ metafile: metafile!,
494
+ buildOptions: baseClientBuildOptions(config, dev),
495
+ }));
496
+ }
497
+ await clientProfile.timeAsync('write', () => nameSharedClientChunks(outDir, metafile));
498
+ if (dev && devDynamicSplitEnabled() && pipeline.deferredRefs().size > 0) {
499
+ await writeText(
500
+ path.join(outDir, DEFERRED_DYNAMIC_SIDECAR),
501
+ JSON.stringify(Object.fromEntries(pipeline.deferredRefs())),
502
+ );
503
+ }
504
+ clientProfile.report(`client entry ${route.id}`);
505
+
506
+ return outfile;
507
+ }
508
+
509
+ /**
510
+ * Dev split: bundle ONE deferred dynamic reference on browser demand. The
511
+ * chunk is its own esbuild build, so single-instance vendors (preact and its
512
+ * facades) resolve to shared shims reading the entry's published namespaces
513
+ * (see dynSharedTableSource) instead of bundling a second copy whose hooks
514
+ * would never see the entry renderer's dispatch.
515
+ */
516
+ export async function buildClientDynamicChunk({
517
+ config,
518
+ route,
519
+ reference,
520
+ outDir,
521
+ }: {
522
+ config: ResolvedConfig;
523
+ route?: RouteManifestEntry;
524
+ reference: DeferredDynamicRef & { id: string };
525
+ outDir: string;
526
+ }) {
527
+ await ensureDir(outDir);
528
+ const pipeline = createClientSourcePipeline(config, true);
529
+ const prebuilt = await preparePrebuilt(
530
+ config,
531
+ outDir,
532
+ true,
533
+ importer => pipeline.sourceOf(importer) ?? readTextSyncSafe(importer),
534
+ route ? surfaceSignature(config, route, true) : '',
535
+ );
536
+ const source = [
537
+ reference.exportName === 'default'
538
+ ? `export { default } from ${JSON.stringify(reference.file)};`
539
+ : '',
540
+ `export * from ${JSON.stringify(reference.file)};`,
541
+ ].join('\n');
542
+ const outfile = path.join(outDir, `${reference.id}.js`);
543
+ await clientProfile.timeAsync('dynChunk', () => build({
544
+ ...baseClientBuildOptions(config, true),
545
+ stdin: {
546
+ contents: source,
547
+ loader: 'ts',
548
+ resolveDir: process.cwd(),
549
+ sourcefile: `${reference.id}.dyn.ts`,
550
+ },
551
+ outdir: outDir,
552
+ entryNames: reference.id,
553
+ plugins: clientBuildPlugins(config, pipeline, [
554
+ ...(prebuilt ? [prebuilt.plugin] : []),
555
+ dynSharedVendorPlugin(nextCompatEnabled(config)),
556
+ deferredDynamicExternalPlugin(),
557
+ ]),
558
+ }));
559
+ await prebuilt?.settle();
560
+ clientProfile.report(`client dynamic chunk ${reference.id}`);
561
+ return outfile;
562
+ }
563
+
564
+ /** Shared-vendor shims: preact resolves to the entry's published namespace. */
565
+ function dynSharedVendorPlugin(nextCompat: boolean): Plugin {
566
+ const namespace = 'pnext-dyn-shared';
567
+ const filter = new RegExp(`^(?:${dynSharedSpecifiers(nextCompat).map(escapeRegex).join('|')})$`);
568
+ return {
569
+ name: 'pnext-dyn-shared-vendor',
570
+ setup(build) {
571
+ build.onResolve({ filter }, args =>
572
+ args.namespace === namespace ? undefined : { path: args.path, namespace },
573
+ );
574
+ build.onLoad({ filter: /.*/, namespace }, async args => {
575
+ const names = Object.keys((await import(args.path)) as Record<string, unknown>).filter(
576
+ name => /^[A-Za-z_$][\w$]*$/.test(name) && name !== 'default',
577
+ );
578
+ const lines = [
579
+ `const m = window.${DYN_SHARED_GLOBAL}[${JSON.stringify(args.path)}];`,
580
+ 'export default (m && m.default);',
581
+ ...names.map(name => `export const ${name} = m.${name};`),
582
+ ];
583
+ return { contents: lines.join('\n'), loader: 'js' };
584
+ });
585
+ },
586
+ };
587
+ }
588
+
589
+ /**
590
+ * Bundle every route's client entry in a single esbuild build. With `splitting`
591
+ * enabled, esbuild emits the shared dependency graph (preact runtime, ui kit,
592
+ * trpc/auth clients) into shared chunks once, instead of re-bundling it from
593
+ * scratch for each route. This turns N independent multi-second builds into one.
594
+ */
595
+ export async function buildClientEntries({
596
+ config,
597
+ routes,
598
+ outDir,
599
+ verbose,
600
+ hasServerActions,
601
+ pipeline: warmed,
602
+ }: ClientBatchBuildOptions) {
603
+ if (!warmed) conventionCache.clear();
604
+ const actions = appUsesActions(routes, hasServerActions);
605
+ const entries = clientProfile.time('entrySources', () => routes
606
+ .filter(route => route.client || route.clientReferences.length > 0 || route.needsRouterEntry)
607
+ .map(route => ({
608
+ route,
609
+ entryName: clientEntryName(route),
610
+ source: clientEntrySource({
611
+ pageFile: route.client ? route.file : undefined,
612
+ clientReferences: route.clientReferences,
613
+ nextCompat: nextCompatEnabled(config),
614
+ actions,
615
+ errorFile: routeErrorFile(config, route),
616
+ globalErrorFile: globalErrorFile(config),
617
+ notFoundFile: routeNotFoundFile(config, route),
618
+ controlFlow: routeThrowsClientControlFlow(route),
619
+ router: Boolean(route.needsRouterEntry),
620
+ clientRootLayout: hasClientRootLayout(config, route),
621
+ shellLayoutOrder: shellLayoutOrder(config, route),
622
+ }),
623
+ })));
624
+ if (entries.length === 0) return;
625
+ // Every route's client sources start their transform and React Compiler pass here, in one batch on
626
+ // oxc's threadpool, while esbuild is still booting and walking the graph. A caller-warmed pipeline
627
+ // has them in flight already; warming twice is a Map hit, so the same call covers both.
628
+ const pipeline = warmed ?? createClientSourcePipeline(config);
629
+ pipeline.warmRoutes(entries.map(entry => entry.route));
630
+ await ensureDir(outDir);
631
+ const groups = surfaceGroups(config, entries, actions);
632
+ clientProfile.count('group#', groups.length);
633
+ const built: BuiltEntryGroup[] = [];
634
+ for (const group of groups) {
635
+ built.push(await buildEntryGroup({ config, outDir, entries, group, pipeline, verbose }));
636
+ }
637
+
638
+ // Fold and rename once over the finished output, never per group. Chunk names
639
+ // are content hashes, so two groups emit the same file for the same chunk —
640
+ // and the fold DELETES the members it merges, so a per-group fold would
641
+ // unlink a chunk another group's manifest still points at.
642
+ let merged: Metafile = { inputs: {}, outputs: {} };
643
+ for (const item of built) {
644
+ if (!item.metafile) continue;
645
+ Object.assign(merged.inputs, item.metafile.inputs);
646
+ Object.assign(merged.outputs, item.metafile.outputs);
647
+ }
648
+ merged = await clientProfile.timeAsync('fold', () =>
649
+ foldInitialChunks({ outDir, metafile: merged, buildOptions: baseClientBuildOptions(config) }),
650
+ );
651
+ const renames = await clientProfile.timeAsync('write', () =>
652
+ nameSharedClientChunks(outDir, merged),
653
+ );
654
+ clientProfile.time('entryImports', () => {
655
+ for (const item of built) {
656
+ if (item.metafile) {
657
+ assignClientEntryImports(item.group.entries, merged, outDir, renames, item.prebuilt);
658
+ }
659
+ }
660
+ });
661
+ clientProfile.report('client stage');
662
+ }
663
+
664
+ interface ClientEntry {
665
+ route: RouteManifestEntry;
666
+ entryName: string;
667
+ source: string;
668
+ }
669
+
670
+ interface SurfaceGroup {
671
+ signature: string;
672
+ facts: ClientRuntimeFacts;
673
+ entries: ClientEntry[];
674
+ }
675
+
676
+ interface BuiltEntryGroup {
677
+ group: SurfaceGroup;
678
+ metafile: Metafile | undefined;
679
+ prebuilt: PreparedPrebuilt | undefined;
680
+ }
681
+
682
+ /**
683
+ * Split the app's entries into the sets that share a framework surface - one esbuild build each,
684
+ * because a shared module graph cannot resolve one specifier to two artifacts. With the prebuild
685
+ * off there is exactly one group and this is the single batched build it has always been. The
686
+ * split is what makes the artifact pay off on a multi-route app: shared, its initial tier is the
687
+ * UNION of every route's demand, so a light route downloads a heavy one's closure. The cost is
688
+ * that user code shared across two groups is bundled into both.
689
+ */
690
+ function surfaceGroups(
691
+ config: ResolvedConfig,
692
+ entries: ClientEntry[],
693
+ actions: boolean,
694
+ ): SurfaceGroup[] {
695
+ const facts = (members: ClientEntry[]) =>
696
+ clientRuntimeFacts(
697
+ members.map(entry => ({
698
+ route: entry.route,
699
+ shell: hasClientRootLayout(config, entry.route),
700
+ boundary: hasRouteErrorBoundary(config, entry.route),
701
+ notFound: Boolean(routeNotFoundFile(config, entry.route)),
702
+ })),
703
+ nextCompatEnabled(config),
704
+ );
705
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
706
+ if (!prebuiltEnabled() || process.env.PNEXT_PREBUILT_GROUPS === '0')
707
+ return [{ signature: '', facts: facts(entries), entries }];
708
+ const bySignature = new Map<string, ClientEntry[]>();
709
+ for (const entry of entries) {
710
+ const signature = surfaceSignature(config, entry.route, actions);
711
+ const members = bySignature.get(signature) ?? [];
712
+ bySignature.set(signature, members);
713
+ members.push(entry);
714
+ }
715
+ return [...bySignature].map(([signature, members]) => ({
716
+ signature,
717
+ // Every member shares the signature, so the group's runtime facts ARE each
718
+ // member's own — the shared runtime module stops carrying glue for shapes
719
+ // no route in this group has.
720
+ facts: facts(members),
721
+ entries: members,
722
+ }));
723
+ }
724
+
725
+ async function buildEntryGroup({
726
+ config,
727
+ outDir,
728
+ entries,
729
+ group,
730
+ pipeline,
731
+ verbose,
732
+ }: {
733
+ config: ResolvedConfig;
734
+ outDir: string;
735
+ /** Every entry in the app: the batch fallback and `sourceOf` span groups. */
736
+ entries: ClientEntry[];
737
+ group: SurfaceGroup;
738
+ pipeline: ClientSourcePipeline;
739
+ verbose?: boolean;
740
+ }): Promise<BuiltEntryGroup> {
741
+ const prebuilt = await clientProfile.timeAsync('prebuilt', () =>
742
+ preparePrebuilt(
743
+ config,
744
+ outDir,
745
+ false,
746
+ importer => {
747
+ if (importer === CLIENT_RUNTIME_MODULE) return clientRuntimeSource(group.facts);
748
+ const entry = entries.find(item => item.route.id === importer);
749
+ return entry?.source ?? pipeline.sourceOf(importer) ?? readTextSyncSafe(importer);
750
+ },
751
+ group.signature,
752
+ ),
753
+ );
754
+
755
+ let metafile: Metafile | undefined;
756
+ try {
757
+ const result = await clientProfile.timeAsync('esbuild', () => build({
758
+ ...baseClientBuildOptions(config),
759
+ entryPoints: group.entries.map(entry => ({
760
+ in: virtualEntryPath(entry.route.id),
761
+ out: entry.entryName,
762
+ })),
763
+ outdir: outDir,
764
+ metafile: true,
765
+ plugins: clientBuildPlugins(config, pipeline, [
766
+ ...(prebuilt ? [prebuilt.plugin] : []),
767
+ virtualEntryPlugin(group.entries),
768
+ clientRuntimePlugin(group.facts),
769
+ ]),
770
+ }));
771
+ metafile = result.metafile;
772
+ } catch (error) {
773
+ // A batched build cannot attribute a "Could not resolve" error to a single route, so fall back to
774
+ // per-route builds - the offending route then throws with its precise client import trace. Other
775
+ // diagnostics already carry a clear message from the batch build; re-throw rather than let a
776
+ // per-route build surface a more confusing downstream error.
777
+ if (!hasUnresolvedImportError(error)) throw error;
778
+ for (const { route } of group.entries) {
779
+ await buildClientEntry({ config, route, outDir });
780
+ }
781
+ throw error;
782
+ }
783
+ await clientProfile.timeAsync('prebuiltSettle', () => prebuilt?.settle() ?? Promise.resolve());
784
+
785
+ if (verbose && metafile) reportClientBundleSizes(group.entries, metafile, outDir);
786
+ return { group, metafile, prebuilt };
787
+ }
788
+
789
+ /**
790
+ * The client sources the route table already names - every `'use client'` reference plus a client
791
+ * page's own file. Deliberately NOT a module walk: this set is free (the scan produced it), it is
792
+ * the bulk of any app's first-party client graph, and whatever it misses esbuild still discovers.
793
+ */
794
+ function routeClientSources(routes: RouteManifestEntry[]) {
795
+ const files = new Set<string>();
796
+ for (const route of routes) {
797
+ if (route.client) files.add(route.file);
798
+ for (const reference of route.clientReferences) files.add(reference.file);
799
+ }
800
+ return files;
801
+ }
802
+
803
+ // Record each entry's static chunk closure on the route so rendered pages can
804
+ // modulepreload it — without this the browser discovers the chunk list only
805
+ // after the entry itself downloads, serializing the two round trips.
806
+ function assignClientEntryImports(
807
+ entries: { route: RouteManifestEntry; entryName: string }[],
808
+ metafile: Metafile,
809
+ outDir: string,
810
+ renames: Map<string, string>,
811
+ prebuilt: PreparedPrebuilt | undefined,
812
+ ) {
813
+ const outputs = metafile.outputs;
814
+ // One basename index for the whole entry set: the linear scan this replaces
815
+ // was O(entries × outputs), which on a route-per-page app is the entire
816
+ // output list walked once per route.
817
+ const byBasename = new Map<string, string>();
818
+ for (const item of Object.keys(outputs)) {
819
+ // First match wins, as the linear scan did.
820
+ const basename = path.basename(item);
821
+ if (!byBasename.has(basename)) byBasename.set(basename, item);
822
+ }
823
+ for (const { route, entryName } of entries) {
824
+ const entryOutput = byBasename.get(`${entryName}.js`);
825
+ if (!entryOutput) continue;
826
+ const staticImports = new Set<string>();
827
+ const dynamicImports = new Set<string>();
828
+ const visited = new Set([`${entryOutput}:static`]);
829
+ const queue = [{ output: entryOutput, dynamic: false }];
830
+ // eslint-disable-next-line @typescript-eslint/prefer-for-of
831
+ for (let head = 0; head < queue.length; head += 1) {
832
+ const current = queue[head];
833
+ if (!current) continue;
834
+ for (const imported of outputs[current.output]?.imports ?? []) {
835
+ if (imported.kind !== 'import-statement' && imported.kind !== 'dynamic-import') continue;
836
+ const dynamic = current.dynamic || imported.kind === 'dynamic-import';
837
+ const visitKey = `${imported.path}:${dynamic ? 'dynamic' : 'static'}`;
838
+ if (visited.has(visitKey)) continue;
839
+ visited.add(visitKey);
840
+ // Prebuilt-runtime shims are external: already a url relative to the
841
+ // assets root, and with no output in THIS metafile to walk into. Their
842
+ // own chunk closure comes from the artifact's manifest instead, so the
843
+ // page preloads the whole set rather than discovering chunks one
844
+ // round trip after the shim lands.
845
+ if (imported.external) {
846
+ if (!prebuilt || imported.path !== prebuiltModuleUrl(prebuilt.runtime)) continue;
847
+ const assets = prebuiltAssets(prebuilt.runtime);
848
+ for (const asset of assets.static) (dynamic ? dynamicImports : staticImports).add(asset);
849
+ for (const asset of assets.dynamic) dynamicImports.add(asset);
850
+ continue;
851
+ }
852
+ queue.push({ output: imported.path, dynamic });
853
+ const basename = path.basename(imported.path);
854
+ const relative = path.relative(outDir, path.resolve(imported.path));
855
+ const publicRelative = path
856
+ .join('assets', path.dirname(relative), renames.get(basename) ?? basename)
857
+ .split(path.sep)
858
+ .join('/');
859
+ (dynamic ? dynamicImports : staticImports).add(publicRelative);
860
+ }
861
+ }
862
+ if (staticImports.size > 0) route.clientEntryImports = [...staticImports];
863
+ for (const asset of staticImports) dynamicImports.delete(asset);
864
+ if (dynamicImports.size > 0) route.clientDynamicImports = [...dynamicImports];
865
+ }
866
+ }
867
+
868
+ /**
869
+ * A single batched build can't report per-route *time* (esbuild shares and
870
+ * parallelizes the work), but the metafile gives per-route *size*: the entry
871
+ * plus every chunk reachable from it. Shared chunks are counted for each route
872
+ * that pulls them in, so this reflects real download weight, not disk footprint.
873
+ */
874
+ function reportClientBundleSizes(
875
+ entries: { route: RouteManifestEntry; entryName: string }[],
876
+ metafile: Metafile,
877
+ outDir: string,
878
+ ) {
879
+ const log = createVerboseLogger(true, 'client');
880
+ const outputs = metafile.outputs;
881
+ const rows = entries
882
+ .map(({ route, entryName }) => {
883
+ const key = `${path.relative(process.cwd(), path.join(outDir, `${entryName}.js`))}`;
884
+ const output = outputs[key]
885
+ ? key
886
+ : Object.keys(outputs).find(item => path.basename(item) === `${entryName}.js`);
887
+ const bytes = output ? reachableOutputBytes(output, outputs) : 0;
888
+ return { route: route.route, bytes };
889
+ })
890
+ .sort((a, b) => b.bytes - a.bytes);
891
+
892
+ for (const row of rows) {
893
+ log.log(`${row.route} ${dimText(`→ ${formatBytes(row.bytes)}`)}`);
894
+ }
895
+ }
896
+
897
+ function reachableOutputBytes(entry: string, outputs: Metafile['outputs']) {
898
+ const visited = new Set<string>();
899
+ const queue = [entry];
900
+ let bytes = 0;
901
+
902
+ while (queue.length > 0) {
903
+ const current = queue.shift();
904
+ if (!current || visited.has(current)) continue;
905
+ visited.add(current);
906
+ const output = outputs[current];
907
+ if (!output) continue;
908
+ bytes += output.bytes;
909
+ for (const imported of output.imports) {
910
+ if (imported.kind === 'import-statement' || imported.kind === 'dynamic-import') {
911
+ queue.push(imported.path);
912
+ }
913
+ }
914
+ }
915
+
916
+ return bytes;
917
+ }
918
+
919
+ function formatBytes(bytes: number) {
920
+ if (bytes < 1024) return `${bytes} B`;
921
+ const units = ['KB', 'MB'] as const;
922
+ let value = bytes / 1024;
923
+ let unitIndex = 0;
924
+ while (value >= 1024 && unitIndex < units.length - 1) {
925
+ value /= 1024;
926
+ unitIndex += 1;
927
+ }
928
+ return `${value >= 10 ? value.toFixed(1) : value.toFixed(2)} ${units[unitIndex]}`;
929
+ }
930
+
931
+ function baseClientBuildOptions(config: ResolvedConfig, dev = false): BuildOptions {
932
+ const extensions = getBundlerExtensions();
933
+ const pure = extensions.clientPureFunctions(config);
934
+ const inject = extensions.clientInjects();
935
+ return {
936
+ ...(inject.length > 0 ? { inject } : {}),
937
+ chunkNames: 'chunks/[name]-[hash]',
938
+ bundle: true,
939
+ format: 'esm',
940
+ splitting: true,
941
+ target: 'es2022',
942
+ jsx: 'automatic',
943
+ jsxImportSource: 'preact',
944
+ // Minification is dead weight in dev: nobody reads the bundle and it roughly
945
+ // doubles esbuild time. Ship minified only for production builds.
946
+ minify: !dev,
947
+ ...(pure.length > 0 ? { pure } : {}),
948
+ // Browser sourcemaps are opt-in (`productionBrowserSourceMaps`, matching Next's default of
949
+ // off): shipping them publishes first-party source to every visitor. Opted in, prod emits
950
+ // external `.js.map` linked by a `//# sourceMappingURL=` comment - never inline, which would
951
+ // bloat the served JS with source text. Dev never emits: the un-minified output is readable.
952
+ sourcemap: !dev && config.productionBrowserSourceMaps === true ? 'linked' : false,
953
+ conditions: ['browser', 'style'],
954
+ define: {
955
+ ...publicEnvDefines(),
956
+ // compiler.define / defineServer (compat); defineServer keys fold to
957
+ // `undefined` client-side so fallback branches stay reachable.
958
+ ...extensions.clientDefines(),
959
+ 'process.browser': 'true',
960
+ // Next inlines the deployment id into every graph (main thread included),
961
+ // so a client component reading `process.env.NEXT_DEPLOYMENT_ID` during
962
+ // render doesn't hit an undefined `process` in the browser.
963
+ ...deploymentIdDefine(),
964
+ },
965
+ logOverride: { 'suspicious-logical-operator': 'silent' },
966
+ // Static image imports (.png/.jpg/.svg/…) are handled by
967
+ // clientStaticAssetPlugin, which emits the Next static-image metadata object
968
+ // — NOT a bare `file` loader URL string. Only the font formats keep the file
969
+ // loader here.
970
+ loader: {
971
+ '.woff': 'file',
972
+ '.woff2': 'file',
973
+ '.ttf': 'file',
974
+ '.otf': 'file',
975
+ '.eot': 'file',
976
+ },
977
+ };
978
+ }
979
+
980
+ function deploymentIdDefine(): Record<string, string> {
981
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
982
+ const deploymentId = process.env.NEXT_DEPLOYMENT_ID;
983
+ return deploymentId === undefined
984
+ ? {}
985
+ : { 'process.env.NEXT_DEPLOYMENT_ID': JSON.stringify(deploymentId) };
986
+ }
987
+
988
+ /**
989
+ * Wrap every plugin's onResolve/onLoad so the profile reports, per plugin, how
990
+ * many callbacks esbuild made and how long they held the JS thread. Each call
991
+ * is an IPC round trip, so the counts are what distinguishes "our transforms are
992
+ * slow" from "we are answering 40k questions". Off unless PNEXT_CLIENT_PROFILE.
993
+ */
994
+ function instrumentedPlugins(plugins: Plugin[]): Plugin[] {
995
+ if (!clientProfile.enabled) return plugins;
996
+ return plugins.map(plugin => ({
997
+ name: plugin.name,
998
+ setup(build) {
999
+ const wrap = (kind: 'resolve' | 'load', register: (options: any, cb: any) => void) =>
1000
+ (options: any, callback: any) =>
1001
+ register(options, async (args: any) => {
1002
+ clientProfile.count(`${plugin.name}:${kind}#`);
1003
+ const start = performance.now();
1004
+ try {
1005
+ return await (callback as (a: unknown) => unknown)(args);
1006
+ } finally {
1007
+ clientProfile.count(`${plugin.name}:${kind}ms`, performance.now() - start);
1008
+ }
1009
+ });
1010
+ return plugin.setup({
1011
+ ...build,
1012
+ onResolve: wrap('resolve', build.onResolve.bind(build)),
1013
+ onLoad: wrap('load', build.onLoad.bind(build)),
1014
+ });
1015
+ },
1016
+ }));
1017
+ }
1018
+
1019
+ /**
1020
+ * One esbuild onResolve hook for the whole chain instead of one per plugin. esbuild offers an
1021
+ * import path to every plugin whose filter matches, in order, until one answers - and each offer is
1022
+ * an IPC round trip against the single JS thread that serves every concurrent build. The client
1023
+ * chain's filters overlap almost completely, so dispatching in-process collapses that to one round
1024
+ * trip per import path: same order, same first-answer-wins rule, same plugin credited for each
1025
+ * diagnostic (`pluginName` is stamped back on).
1026
+ *
1027
+ * `PNEXT_CLIENT_COALESCE=0` restores a hook per plugin, to bisect it.
1028
+ */
1029
+ export function coalesceResolveHooks(plugins: Plugin[], name: string): Plugin[] {
1030
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
1031
+ if (process.env.PNEXT_CLIENT_COALESCE === '0') return plugins;
1032
+ interface Registration {
1033
+ plugin: string;
1034
+ filter: RegExp;
1035
+ namespace?: string;
1036
+ callback: (args: OnResolveArgs) => unknown;
1037
+ }
1038
+ return [
1039
+ {
1040
+ name,
1041
+ async setup(build) {
1042
+ const registered: Registration[] = [];
1043
+ // Registrations made after setup (rare, and esbuild allows it) keep
1044
+ // their own hook: the chain's order is already fixed by then.
1045
+ let sealed = false;
1046
+ /** The chain, minus one plugin's own hooks — see `resolve` below. */
1047
+ const dispatch = async (args: OnResolveArgs, skip?: string) => {
1048
+ for (const entry of registered) {
1049
+ if (entry.plugin === skip) continue;
1050
+ if (entry.namespace !== undefined && entry.namespace !== args.namespace) continue;
1051
+ if (!entry.filter.test(args.path)) continue;
1052
+ const result = (await entry.callback(args)) as OnResolveResult | null | undefined;
1053
+ if (result == null) continue;
1054
+ for (const message of [...(result.errors ?? []), ...(result.warnings ?? [])]) {
1055
+ message.pluginName ??= entry.plugin;
1056
+ }
1057
+ return result;
1058
+ }
1059
+ return undefined;
1060
+ };
1061
+ for (const plugin of plugins) {
1062
+ const proxy: PluginBuild = {
1063
+ ...build,
1064
+ onResolve(options: OnResolveOptions, callback: (args: OnResolveArgs) => unknown) {
1065
+ if (sealed) return build.onResolve(options, callback as never);
1066
+ registered.push({
1067
+ plugin: plugin.name,
1068
+ // Drop /g and /y: a sticky or global regex carries lastIndex
1069
+ // between tests, which would make dispatch order-dependent.
1070
+ filter: new RegExp(options.filter.source, options.filter.flags.replace(/[gy]/g, '')),
1071
+ namespace: options.namespace,
1072
+ callback,
1073
+ });
1074
+ },
1075
+ // esbuild's own `resolve` re-runs every plugin EXCEPT the caller's (its recursion guard,
1076
+ // keyed on plugin name). Sharing one name would make it skip the whole chain and fall
1077
+ // straight to native resolution, so run the chain here minus this plugin and only hand
1078
+ // esbuild what nobody claimed.
1079
+ resolve: (specifier, options = {}) =>
1080
+ dispatch(
1081
+ {
1082
+ path: specifier,
1083
+ importer: options.importer ?? '',
1084
+ namespace: options.namespace ?? '',
1085
+ resolveDir: options.resolveDir ?? '',
1086
+ kind: options.kind ?? 'import-statement',
1087
+ pluginData: options.pluginData as unknown,
1088
+ with: options.with ?? {},
1089
+ },
1090
+ plugin.name,
1091
+ ).then(result =>
1092
+ result && resolveHookSettled(result)
1093
+ ? resolvedFromHook(specifier, result)
1094
+ : build.resolve(specifier, { ...options, pluginName: name }),
1095
+ ),
1096
+ };
1097
+ await plugin.setup(proxy);
1098
+ }
1099
+ sealed = true;
1100
+ if (registered.length === 0) return;
1101
+ const filter = new RegExp(registered.map(entry => `(?:${entry.filter.source})`).join('|'));
1102
+ build.onResolve({ filter }, args => dispatch(args));
1103
+ },
1104
+ },
1105
+ ];
1106
+ }
1107
+
1108
+ /**
1109
+ * A chain answer in the shape `resolve()` promises its callers. `external` without a path means "keep
1110
+ * this specifier, do not bundle it" - esbuild's own rule, verified against the binary.
1111
+ */
1112
+ function resolvedFromHook(specifier: string, result: OnResolveResult): ResolveResult {
1113
+ const resolved = result.path ?? (result.external ? specifier : '');
1114
+ return {
1115
+ errors: (result.errors ?? []) as ResolveResult['errors'],
1116
+ warnings: (result.warnings ?? []) as ResolveResult['warnings'],
1117
+ path: resolved,
1118
+ external: result.external ?? false,
1119
+ sideEffects: result.sideEffects ?? true,
1120
+ namespace: result.namespace ?? (result.path ? 'file' : ''),
1121
+ suffix: result.suffix ?? '',
1122
+ pluginData: result.pluginData as unknown,
1123
+ };
1124
+ }
1125
+
1126
+ /**
1127
+ * Whether a chain answer settles the resolve on its own. Verified against the esbuild binary:
1128
+ * `null`/`undefined` defers to the next callback, and ANY other object ends the chain - an empty one
1129
+ * included, which then falls through to esbuild's native resolution rather than to the next plugin.
1130
+ */
1131
+ function resolveHookSettled(result: OnResolveResult) {
1132
+ return (
1133
+ result.path !== undefined ||
1134
+ result.external !== undefined ||
1135
+ (result.errors?.length ?? 0) > 0 ||
1136
+ (result.warnings?.length ?? 0) > 0
1137
+ );
1138
+ }
1139
+
1140
+ function clientBuildPlugins(
1141
+ config: ResolvedConfig,
1142
+ pipeline: ClientSourcePipeline,
1143
+ extra: Plugin[] = [],
1144
+ ): Plugin[] {
1145
+ return coalesceResolveHooks(instrumentedPlugins([
1146
+ serverOnlyClientImportPlugin(),
1147
+ // The virtual-module plugins (`extra`: generated entries + the shared route
1148
+ // runtime) go first so they claim their own synthetic specifiers before the
1149
+ // resolve-chain plugins are asked about them — one entry point per route is
1150
+ // otherwise offered to every plugin in the chain that only ever declines it.
1151
+ ...extra,
1152
+ ...getBundlerExtensions().clientEsbuildPlugins(config),
1153
+ linkedPackageClientResolvePlugin(config),
1154
+ clientStaticAssetPlugin(config),
1155
+ pipeline.plugin(),
1156
+ importAliasPlugin(config),
1157
+ cssModuleClientPlugin(),
1158
+ ]), 'pnext-client-resolve-chain');
1159
+ }
1160
+
1161
+ function importAliasPlugin(config: ResolvedConfig): Plugin {
1162
+ const aliases = getImportAliasExtensions().aliases(config, 'client');
1163
+ const specifiers = Object.keys(aliases);
1164
+ return {
1165
+ name: 'pnext-import-alias',
1166
+ setup(build) {
1167
+ build.onResolve({ filter: /^(?:react(?:-dom)?|next)(?:\/|$)/ }, args => {
1168
+ if (aliases[args.path]) return undefined;
1169
+ const message = getImportAliasExtensions().missingImportError(config, args.path);
1170
+ return message ? { errors: [{ text: message }] } : undefined;
1171
+ });
1172
+ if (specifiers.length === 0) return;
1173
+ build.onResolve(
1174
+ { filter: new RegExp(`^(${specifiers.map(escapeRegex).join('|')})$`) },
1175
+ async args => {
1176
+ const target = aliases[args.path];
1177
+ if (!target) return undefined;
1178
+ if (path.isAbsolute(target)) return { path: target };
1179
+ return build.resolve(target, {
1180
+ kind: args.kind,
1181
+ importer: args.importer,
1182
+ namespace: args.namespace,
1183
+ resolveDir: args.resolveDir,
1184
+ });
1185
+ },
1186
+ );
1187
+ },
1188
+ };
1189
+ }
1190
+
1191
+ // preact core + preact/compat + react/react-dom/next are single-instance: they must ALWAYS resolve
1192
+ // through the framework/compat alias, which pins one canonical absolute file per specifier. The
1193
+ // linked-package resolver runs BEFORE that alias and matches bare specifiers, so without this guard
1194
+ // a workspace declaring `preact` (or react/react-dom) as a `file:`/`link:` dependency resolves bare
1195
+ // `preact` to the linked copy while pnext-internal compat code keeps resolving to the alias -
1196
+ // shipping TWO physical preact cores. That breaks single-instance option hooks, context identity and
1197
+ // error interception. Never let the linked-package resolver shadow these; the alias owns them.
1198
+ const frameworkAliasedSpecifier = /^(?:preact|react|react-dom|next)(?:\/|$)/;
1199
+
1200
+ function linkedPackageClientResolvePlugin(config: ResolvedConfig): Plugin {
1201
+ const compatPreact = path.resolve(import.meta.dirname, '..', 'compat', 'react', 'preact.ts');
1202
+ const compatReactClient = path.resolve(import.meta.dirname, '..', 'compat', 'react', 'client.ts');
1203
+ return {
1204
+ name: 'pnext-linked-package-client-resolve',
1205
+ setup(build) {
1206
+ build.onResolve({ filter: /^\.\/preact$/ }, args => {
1207
+ // Also matches MIRRORED copies of the compat react shim (build-cache
1208
+ // outputs under node_modules paths, e.g. @next/third-parties client
1209
+ // components) where no sibling ./preact exists; only a real sibling
1210
+ // file (an app's own ./preact.*) opts out.
1211
+ if (path.resolve(args.importer) === compatReactClient) return { path: compatPreact };
1212
+ const hasRealSibling = ['.ts', '.tsx', '.js', '.mjs', '.jsx'].some(ext =>
1213
+ existsSync(path.join(args.resolveDir, `preact${ext}`)),
1214
+ );
1215
+ if (hasRealSibling) return undefined;
1216
+ return { path: compatPreact };
1217
+ });
1218
+ // One build's worth of memo, keyed on exactly what esbuild hands us: the resolver walks
1219
+ // node_modules and probes package targets per call, and a directory of components importing
1220
+ // the same few packages asks the same question repeatedly. Per-BUILD, so a linked package's
1221
+ // layout changing between builds is still seen - esbuild already assumes it is fixed within one.
1222
+ const linked = new Map<string, string | undefined>();
1223
+ build.onResolve({ filter: /^[^./#][^:]*$/ }, args => {
1224
+ if (frameworkAliasedSpecifier.test(args.path)) return undefined;
1225
+ const dir = args.resolveDir || config.root;
1226
+ const key = `${dir}\0${args.path}`;
1227
+ let resolved = linked.get(key);
1228
+ if (resolved === undefined && !linked.has(key)) {
1229
+ resolved = resolveLinkedPackageSpecifier(
1230
+ config.root,
1231
+ path.join(dir, 'pnext-resolve.ts'),
1232
+ args.path,
1233
+ ['browser', 'style', 'import', 'default'],
1234
+ );
1235
+ linked.set(key, resolved);
1236
+ }
1237
+ return resolved ? { path: resolved } : undefined;
1238
+ });
1239
+ },
1240
+ };
1241
+ }
1242
+
1243
+ // Mirror of the server build's static-asset seam for the CLIENT bundle. A static image import from a
1244
+ // 'use client' component resolves to a generated ESM module exporting the Next static-image metadata
1245
+ // object - with the asset emitted under `/_next/static/media/...` - rather than a bare `file` loader
1246
+ // URL string. Uses core's `staticAssetModule` extension seam so compat (next/image) supplies the
1247
+ // descriptor and pure-core apps fall back to the generic URL module.
1248
+ const staticImageAssetFilter = /\.(?:png|jpe?g|gif|webp|avif|svg|ico|bmp)(?:$|[?#])/;
1249
+
1250
+ function clientStaticAssetPlugin(config: ResolvedConfig): Plugin {
1251
+ return {
1252
+ name: 'pnext-client-static-assets',
1253
+ setup(build) {
1254
+ build.onResolve({ filter: staticImageAssetFilter }, args => {
1255
+ // A configured `turbopack.rules` loader chain for this extension (e.g.
1256
+ // `*.svg`) preempts the generic static-image resolver: defer so the
1257
+ // compat loader-rule plugin runs the chain instead.
1258
+ if (getAssetExtensions().hasLoaderRuleFor(args.path)) {
1259
+ return undefined;
1260
+ }
1261
+ return {
1262
+ path: resolveAssetPath(args.path, args.resolveDir),
1263
+ namespace: 'pnext-client-static-asset',
1264
+ };
1265
+ });
1266
+ build.onLoad({ filter: /.*/, namespace: 'pnext-client-static-asset' }, async args => ({
1267
+ contents: await staticImageModuleSource(config, args.path),
1268
+ loader: 'js',
1269
+ }));
1270
+ },
1271
+ };
1272
+ }
1273
+
1274
+ function resolveAssetPath(specifier: string, resolveDir: string) {
1275
+ const { sourcePath, hash } = splitAssetHash(specifier);
1276
+ const resolved = path.isAbsolute(sourcePath) ? sourcePath : path.resolve(resolveDir, sourcePath);
1277
+ return `${resolved}${hash}`;
1278
+ }
1279
+
1280
+ function splitAssetHash(specifier: string) {
1281
+ const index = specifier.search(/[?#]/);
1282
+ return index === -1
1283
+ ? { sourcePath: specifier, hash: '' }
1284
+ : { sourcePath: specifier.slice(0, index), hash: specifier.slice(index) };
1285
+ }
1286
+
1287
+ async function staticImageModuleSource(config: ResolvedConfig, file: string) {
1288
+ const { sourcePath } = splitAssetHash(file);
1289
+ const bytes = new Uint8Array(await readFile(sourcePath));
1290
+ const emitted: string[] = [];
1291
+ const emit = (relative: string) => {
1292
+ emitted.push(relative);
1293
+ return `/${relative}`;
1294
+ };
1295
+ const compat = await getAssetExtensions().staticAssetModule({ sourcePath, bytes, emit });
1296
+ const source = compat ?? coreStaticAssetModule(sourcePath, bytes, emit);
1297
+ for (const relative of emitted) {
1298
+ const target = path.join(config.outPath, 'public', ...relative.split('/'));
1299
+ await mkdir(path.dirname(target), { recursive: true });
1300
+ if (!existsSync(target)) await copyFile(sourcePath, target);
1301
+ }
1302
+ return source;
1303
+ }
1304
+
1305
+ // Core's generic static-asset module (no compat override): emit under a hashed
1306
+ // `/_next/static/media` URL and export the URL string as default. Mirrors the
1307
+ // identically named helper in runtime/server.ts + dev/imports.ts.
1308
+ function coreStaticAssetModule(
1309
+ sourcePath: string,
1310
+ bytes: Uint8Array,
1311
+ emit: (relative: string) => string,
1312
+ ): string {
1313
+ const ext = path.extname(sourcePath).toLowerCase() || '.bin';
1314
+ const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 8);
1315
+ const base = path.basename(sourcePath, path.extname(sourcePath)).replace(/[^A-Za-z0-9_-]+/g, '-');
1316
+ const relative = path.posix.join('_next', 'static', 'media', `${base}.${hash}${ext}`);
1317
+ const src = emit(relative);
1318
+ return `const src = ${JSON.stringify(src)};\nexport default src;\nexport { src };\n`;
1319
+ }
1320
+
1321
+ /**
1322
+ * The single onLoad owner of first-party client source: it reads each file ONCE and runs every
1323
+ * rewrite that file needs - compat's registered transform chain, core's literal `dynamic()` rewrite
1324
+ * and the React Compiler pass - then hands esbuild the result with the right loader.
1325
+ *
1326
+ * Merging matters for correctness as much as for cost: esbuild gives a file to the FIRST plugin
1327
+ * whose onLoad filter matches, so a chain of independent loaders means whichever registers first
1328
+ * silently shadows the rest. That is what switched the React Compiler off for next-compat apps.
1329
+ *
1330
+ * App-convention .js/.mjs may contain JSX (jsxImportSource is preact), so those parse with the jsx
1331
+ * loader; scoped to the source roots, so third-party node_modules .js keeps esbuild's default loader.
1332
+ */
1333
+ function createClientSourcePipeline(config: ResolvedConfig, dev = false) {
1334
+ const sourceRewrite = nextCompatEnabled(config);
1335
+ const compiler = getCompatModeExtensions().reactCompilerOptions(config);
1336
+ const asyncPre = hasClientSourceAsyncPreTransforms();
1337
+ // Under a source root, and under a root only first-party source plus transpiled packages - said in
1338
+ // the FILTER, not the callback: a dependency's files are the bulk of a real graph, so a callback
1339
+ // would decline most of them one at a time.
1340
+ const roots = clientSourceRoots(config).map(root => escapeRegex(root)).join('|');
1341
+ const sep = escapeRegex(path.sep);
1342
+ const filter = new RegExp(
1343
+ `^(?:${roots})(?:${sep}${nonSegmentPattern('node_modules', sep)})*${sep}[^${sep}]*\\.(?:[cm]?[jt]sx?)$`,
1344
+ );
1345
+ const transpiled = sourceRewrite ? transpiledPackageFilter(roots) : undefined;
1346
+ // One in-flight pipeline per file for the whole build: `warm()` and the
1347
+ // onLoad hook share it, so a warmed file is already done (or in flight on
1348
+ // oxc's threadpool) by the time esbuild asks for it, and a component shared
1349
+ // by twenty routes is prepared once.
1350
+ const prepared = new Map<string, Promise<OnLoadOutput>>();
1351
+ // Deferred dynamic refs THIS pipeline rewrote — persisted as the out-dir sidecar.
1352
+ const deferredRefs = new Map<string, DeferredDynamicRef>();
1353
+ // The text esbuild actually parsed, kept so the prebuilt seam can read a
1354
+ // file's framework imports off the POST-transform source — a compat rewrite
1355
+ // can add or rename one, and the seam must offer exactly what is there.
1356
+ const loaded = new Map<string, string>();
1357
+
1358
+ function prepare(resolved: string): Promise<OnLoadOutput> {
1359
+ const existing = prepared.get(resolved);
1360
+ if (existing) return existing;
1361
+ const pending = runPipeline(resolved);
1362
+ prepared.set(resolved, pending);
1363
+ return pending;
1364
+ }
1365
+
1366
+ async function runPipeline(resolved: string): Promise<OnLoadOutput> {
1367
+ const inNodeModules = resolved.split(path.sep).includes('node_modules');
1368
+ // A nested node_modules under a source root is a dependency, not app
1369
+ // source: only an explicitly transpiled package opts into the rewrites,
1370
+ // and none of them are ever React-Compiled.
1371
+ if (inNodeModules && (!sourceRewrite || !isTranspiledPackageFile(resolved))) return undefined;
1372
+ clientProfile.count('loadFiles');
1373
+ // Build-scoped cache: the route-fact walk has already read most of these,
1374
+ // so this is a map lookup rather than a second read of the same file.
1375
+ const original = await clientProfile.timeAsync('read', () => readSourceText(resolved));
1376
+ let contents = original;
1377
+ // Worker bundling runs first and awaits: it consumes `import.meta.url`,
1378
+ // which the sync chain inlines.
1379
+ if (asyncPre) {
1380
+ contents = await applyClientSourceAsyncPreTransforms(contents, resolved, config.root);
1381
+ }
1382
+ contents = clientProfile.time('chain', () =>
1383
+ applyClientSourceTransforms(contents, resolved, config.root),
1384
+ );
1385
+ contents = clientProfile.time('dynamic', () => rewriteLiteralDynamicCalls(contents, resolved));
1386
+ if (dev && devDynamicSplitEnabled()) {
1387
+ contents = rewriteDeferredDynamicImports(
1388
+ contents,
1389
+ resolved,
1390
+ specifier => resolveImport(rootFromFile(resolved), resolved, specifier),
1391
+ target => {
1392
+ const id = clientReferenceId(target.file, target.exportName);
1393
+ registerDeferredDynamicRef(id, target);
1394
+ deferredRefs.set(id, target);
1395
+ return deferredDynamicChunkHref({ id });
1396
+ },
1397
+ );
1398
+ }
1399
+ if (compiler && !inNodeModules && shouldReactCompile(contents, resolved)) {
1400
+ contents = await clientProfile.timeAsync('reactCompiler', () =>
1401
+ reactCompiled(resolved, contents, compiler),
1402
+ );
1403
+ }
1404
+ // Unchanged sources go back to esbuild's native loader; only .js/.mjs
1405
+ // must be claimed to force the jsx parse.
1406
+ loaded.set(resolved, contents);
1407
+ if (contents === original && !/\.m?js$/.test(resolved)) return undefined;
1408
+ return { contents, loader: clientSourceLoader(resolved) };
1409
+ }
1410
+
1411
+ return {
1412
+ /** The parsed text of a file this build has loaded, if it has. */
1413
+ sourceOf(file: string) {
1414
+ return loaded.get(file);
1415
+ },
1416
+ /** Deferred dynamic refs rewritten during this build (dev split). */
1417
+ deferredRefs() {
1418
+ return deferredRefs;
1419
+ },
1420
+ /**
1421
+ * Start the pipeline for sources the route table already names, before esbuild runs. Without it
1422
+ * every file waits to be discovered by the bundler's graph walk and its React Compiler pass
1423
+ * queues behind that walk.
1424
+ */
1425
+ /** `warm` for whole routes - every client source the route table names. */
1426
+ warmRoutes(routes: RouteManifestEntry[]) {
1427
+ this.warm(routeClientSources(routes));
1428
+ },
1429
+ warm(files: Iterable<string>) {
1430
+ for (const file of files) {
1431
+ const resolved = path.resolve(file);
1432
+ if (!filter.test(resolved) && !transpiled?.test(resolved)) continue;
1433
+ // Errors surface at the onLoad await, where esbuild attributes them to
1434
+ // the importing module; nothing must reject on the warm path.
1435
+ prepare(resolved).catch(() => undefined);
1436
+ }
1437
+ },
1438
+ /**
1439
+ * The single onLoad owner of first-party client source: it reads each file ONCE and runs every
1440
+ * rewrite that file needs, then hands esbuild the result with the right loader. Merging matters
1441
+ * for correctness as much as for cost: esbuild gives a file to the FIRST plugin whose onLoad
1442
+ * filter matches, so independent loaders mean whichever registers first silently shadows the rest.
1443
+ */
1444
+ plugin(): Plugin {
1445
+ return {
1446
+ name: 'pnext-client-source',
1447
+ setup(build) {
1448
+ // namespace: 'file' — an onLoad without a namespace matches EVERY
1449
+ // namespace, which would hijack virtual modules (e.g. the server-action
1450
+ // client stub) whose paths end in .js and feed back the original source.
1451
+ build.onLoad({ filter, namespace: 'file' }, args => prepare(path.resolve(args.path)));
1452
+ // Second hook, not a wider first one: `transpilePackages` opts a
1453
+ // dependency's own files into the same pipeline, and they are the only
1454
+ // node_modules paths that ever produce contents.
1455
+ if (transpiled) {
1456
+ build.onLoad({ filter: transpiled, namespace: 'file' }, args =>
1457
+ prepare(path.resolve(args.path)),
1458
+ );
1459
+ }
1460
+ },
1461
+ };
1462
+ },
1463
+ };
1464
+ }
1465
+
1466
+ type OnLoadOutput = { contents: string; loader: 'js' | 'ts' | 'tsx' | 'jsx' } | undefined;
1467
+
1468
+ export type ClientSourcePipeline = ReturnType<typeof createClientSourcePipeline>;
1469
+
1470
+ /**
1471
+ * Open the client-source pipeline early so the build can feed it routes as the fact scan resolves
1472
+ * them - the transforms and the React Compiler then run on oxc's threadpool underneath the rest of
1473
+ * the build. Hand the result to `buildClientEntries`; it is the same object the stage would
1474
+ * otherwise create for itself.
1475
+ */
1476
+ export function startClientSources(config: ResolvedConfig): ClientSourcePipeline {
1477
+ conventionCache.clear();
1478
+ return createClientSourcePipeline(config);
1479
+ }
1480
+
1481
+ // The compiled-source cache is keyed on the file and a hash of its post-rewrite
1482
+ // input, so a rebuild of an unchanged file skips the oxc pass. It holds the
1483
+ // PROMISE, so two concurrent loads of the same source (two routes sharing a
1484
+ // component) share one compile instead of racing two.
1485
+ const reactCompiledCache = new Map<string, { hash: bigint; code: Promise<string> }>();
1486
+
1487
+ function reactCompiled(
1488
+ file: string,
1489
+ source: string,
1490
+ options: NonNullable<ReturnType<CompatModeExtensions['reactCompilerOptions']>>,
1491
+ ) {
1492
+ const hash = Bun.hash.wyhash(`${options.target}\0${source}`);
1493
+ const cached = reactCompiledCache.get(file);
1494
+ if (cached?.hash === hash) return cached.code;
1495
+ const code = transformReactCompiler(source, file, options);
1496
+ reactCompiledCache.set(file, { hash, code });
1497
+ return code;
1498
+ }
1499
+
1500
+ // config.root can sit outside the workspace root (and vice versa), so both
1501
+ // anchor the filter — as does each one's realpath, since esbuild reports
1502
+ // resolved paths through the OS realpath.
1503
+ function clientSourceRoots(config: ResolvedConfig) {
1504
+ const roots = new Set<string>();
1505
+ for (const root of [config.root, config.workspaceRoot]) {
1506
+ const resolved = path.resolve(root);
1507
+ roots.add(resolved);
1508
+ try {
1509
+ roots.add(realpathSync.native(resolved));
1510
+ } catch {
1511
+ // A root that cannot be realpath'd is already covered by its literal form.
1512
+ }
1513
+ }
1514
+ return [...roots];
1515
+ }
1516
+
1517
+ // `.cjs` keeps the plain js loader: CommonJS never carries JSX, and parsing a
1518
+ // `a < b` comparison as JSX would fail the build.
1519
+ function clientSourceLoader(file: string): 'js' | 'ts' | 'tsx' | 'jsx' {
1520
+ if (file.endsWith('.tsx')) return 'tsx';
1521
+ if (/\.[cm]?ts$/.test(file)) return 'ts';
1522
+ return file.endsWith('.cjs') ? 'js' : 'jsx';
1523
+ }
1524
+
1525
+ // A path segment that is NOT `word`, spelled positively: esbuild's Go regexp has
1526
+ // no lookahead, so the negation enumerates "differs at some position / ends
1527
+ // early / runs longer".
1528
+ function nonSegmentPattern(word: string, sep: string) {
1529
+ const parts = [`${word}[^${sep}]+`];
1530
+ for (let index = 0; index < word.length; index += 1) {
1531
+ const prefix = word.slice(0, index);
1532
+ parts.push(prefix, `${prefix}[^${sep}${word[index]}][^${sep}]*`);
1533
+ }
1534
+ return `(?:${parts.join('|')})`;
1535
+ }
1536
+
1537
+ /**
1538
+ * Filter for the node_modules files the pipeline still owns - the packages `transpilePackages` names.
1539
+ * A policy that publishes no list cannot be narrowed safely, so that case keeps every node_modules
1540
+ * path on the hook.
1541
+ */
1542
+ function transpiledPackageFilter(roots: string): RegExp | undefined {
1543
+ const sep = escapeRegex(path.sep);
1544
+ const names = getExternalPackagePolicy().transpiled?.();
1545
+ if (names?.length === 0) return undefined;
1546
+ const packages = names
1547
+ ? `(?:${names.map(name => escapeRegex(name).split('/').join(sep)).join('|')})${sep}`
1548
+ : '';
1549
+ return new RegExp(`^(?:${roots}).*${sep}node_modules${sep}${packages}.*\\.(?:[cm]?[jt]sx?)$`);
1550
+ }
1551
+
1552
+ function isTranspiledPackageFile(file: string) {
1553
+ const parts = file.split(path.sep);
1554
+ const nodeModules = parts.lastIndexOf('node_modules');
1555
+ if (nodeModules === -1) return false;
1556
+ const first = parts[nodeModules + 1];
1557
+ if (!first) return false;
1558
+ const name = first.startsWith('@') ? `${first}/${parts[nodeModules + 2] ?? ''}` : first;
1559
+ return getExternalPackagePolicy().transpile(name);
1560
+ }
1561
+
1562
+ function virtualEntryPath(routeId: string) {
1563
+ return `${virtualEntryNamespace}:${routeId}`;
1564
+ }
1565
+
1566
+ function virtualEntryPlugin(entries: { route: RouteManifestEntry; source: string }[]): Plugin {
1567
+ const sources = new Map(entries.map(entry => [entry.route.id, entry.source]));
1568
+ return {
1569
+ name: 'pnext-virtual-client-entry',
1570
+ setup(build) {
1571
+ build.onResolve({ filter: new RegExp(`^${escapeRegex(virtualEntryNamespace)}:`) }, args => ({
1572
+ path: args.path.slice(virtualEntryNamespace.length + 1),
1573
+ namespace: virtualEntryNamespace,
1574
+ }));
1575
+ build.onLoad({ filter: /.*/, namespace: virtualEntryNamespace }, args => {
1576
+ const contents = sources.get(args.path);
1577
+ if (contents === undefined) return undefined;
1578
+ return { contents, loader: 'ts', resolveDir: process.cwd() };
1579
+ });
1580
+ },
1581
+ };
1582
+ }
1583
+
1584
+ // The shared route runtime: one virtual module per build, imported by every
1585
+ // generated entry stub. With `splitting` on, esbuild hoists it into a single
1586
+ // chunk instead of inlining ~19 KB of identical glue in each route entry.
1587
+ function clientRuntimePlugin(facts: ClientRuntimeFacts): Plugin {
1588
+ return {
1589
+ name: 'pnext-client-runtime',
1590
+ setup(build) {
1591
+ build.onResolve({ filter: new RegExp(`^${escapeRegex(CLIENT_RUNTIME_MODULE)}$`) }, () => ({
1592
+ path: CLIENT_RUNTIME_MODULE,
1593
+ namespace: CLIENT_RUNTIME_MODULE,
1594
+ }));
1595
+ build.onLoad({ filter: /.*/, namespace: CLIENT_RUNTIME_MODULE }, () => ({
1596
+ contents: clientRuntimeSource(facts),
1597
+ loader: 'ts',
1598
+ resolveDir: process.cwd(),
1599
+ }));
1600
+ },
1601
+ };
1602
+ }
1603
+
1604
+ async function profileClientBuild<T>(
1605
+ route: RouteManifestEntry,
1606
+ dev: boolean | undefined,
1607
+ task: () => Promise<T>,
1608
+ ) {
1609
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
1610
+ if (!process.env.PNEXT_DEV_PROFILE) return task();
1611
+ const start = performance.now();
1612
+ try {
1613
+ return await task();
1614
+ } finally {
1615
+ const mode = dev ? 'dev' : 'prod';
1616
+ console.log(
1617
+ `dev-profile client build ${mode} ${route.route} in ${formatDuration(performance.now() - start)}`,
1618
+ );
1619
+ }
1620
+ }
1621
+
1622
+ function formatDuration(durationMs: number) {
1623
+ const ms = Math.max(0, durationMs);
1624
+ if (ms < 10) return `${ms.toFixed(1)}ms`;
1625
+ if (ms < 1000) return `${Math.round(ms)}ms`;
1626
+ return `${(ms / 1000).toFixed(ms < 10000 ? 2 : 1)}s`;
1627
+ }
1628
+
1629
+ function serverOnlyClientImportPlugin(): Plugin {
1630
+ return {
1631
+ name: 'pnext-server-only-client-import',
1632
+ setup(build) {
1633
+ build.onResolve(
1634
+ { filter: /^(next|next\/headers|next\/font|next\/font\/google|next\/font\/local)$/ },
1635
+ args => ({
1636
+ errors: [
1637
+ {
1638
+ text: `${args.path} is server-only and cannot be imported from Client Components.`,
1639
+ },
1640
+ ],
1641
+ }),
1642
+ );
1643
+ },
1644
+ };
1645
+ }
1646
+
1647
+ interface EsbuildLikeError extends Error {
1648
+ errors?: {
1649
+ text?: string;
1650
+ location?: {
1651
+ file?: string;
1652
+ };
1653
+ }[];
1654
+ }
1655
+
1656
+ interface TraceParent {
1657
+ file: string;
1658
+ specifier: string;
1659
+ }
1660
+
1661
+ const traceSourceExtensions = ['.tsx', '.ts', '.jsx', '.js', '.mts', '.cts', '.module.css', '.css'];
1662
+
1663
+ function withClientImportTrace(
1664
+ error: unknown,
1665
+ config: ResolvedConfig,
1666
+ route: RouteManifestEntry,
1667
+ entrySource: string,
1668
+ ) {
1669
+ const message = error instanceof Error ? error.message : String(error);
1670
+ const trace = clientImportTrace(config, route, entrySource, error);
1671
+ if (!trace) return error;
1672
+ const next = new Error(`${message}\n\n${trace}`);
1673
+ if (error instanceof Error) {
1674
+ next.name = error.name;
1675
+ next.stack = error.stack ? `${error.stack}\n\n${trace}` : next.stack;
1676
+ Object.assign(next, error);
1677
+ }
1678
+ return next;
1679
+ }
1680
+
1681
+ function clientImportTrace(
1682
+ config: ResolvedConfig,
1683
+ route: RouteManifestEntry,
1684
+ entrySource: string,
1685
+ error: unknown,
1686
+ ) {
1687
+ const unresolved = unresolvedImportFromError(error);
1688
+ if (!unresolved) return undefined;
1689
+
1690
+ const entryFile = `${route.id}.ts`;
1691
+ const failedFile = unresolved.file ? resolveTraceFile(unresolved.file) : undefined;
1692
+ const parents = new Map<string, TraceParent>();
1693
+ const visited = new Set<string>();
1694
+ const queue: { file: string; source: string }[] = [{ file: entryFile, source: entrySource }];
1695
+ let unresolvedImporter: string | undefined;
1696
+
1697
+ while (queue.length > 0) {
1698
+ const current = queue.shift();
1699
+ if (!current || visited.has(current.file)) continue;
1700
+ visited.add(current.file);
1701
+ if (visited.size > 2000) break;
1702
+
1703
+ for (const specifier of localImports(current.file, current.source)) {
1704
+ const resolved = resolveTraceImport(config.root, current.file, specifier);
1705
+ if (!resolved) {
1706
+ if (
1707
+ specifier === unresolved.specifier &&
1708
+ (!failedFile || sameFile(current.file, failedFile))
1709
+ ) {
1710
+ unresolvedImporter = current.file;
1711
+ queue.length = 0;
1712
+ break;
1713
+ }
1714
+ continue;
1715
+ }
1716
+
1717
+ if (!parents.has(resolved)) parents.set(resolved, { file: current.file, specifier });
1718
+ if (failedFile && sameFile(resolved, failedFile)) {
1719
+ unresolvedImporter = resolved;
1720
+ queue.length = 0;
1721
+ break;
1722
+ }
1723
+ if (!visited.has(resolved) && !isAssetLike(resolved)) {
1724
+ queue.push({ file: resolved, source: readTextSyncSafe(resolved) });
1725
+ }
1726
+ }
1727
+ }
1728
+
1729
+ if (!unresolvedImporter) return undefined;
1730
+ const trace = tracePath(entryFile, unresolvedImporter, parents);
1731
+ if (trace.length === 0) return undefined;
1732
+
1733
+ return [
1734
+ `pnext client import trace for route ${route.route}:`,
1735
+ ...trace.map(
1736
+ (file, index) => `${index === 0 ? ' ' : ' -> '}${displayTraceFile(config, file)}`,
1737
+ ),
1738
+ ` -> ${unresolved.specifier} (unresolved in browser client bundle)`,
1739
+ ].join('\n');
1740
+ }
1741
+
1742
+ /** True when the build failed because an import could not be resolved. */
1743
+ function hasUnresolvedImportError(error: unknown): boolean {
1744
+ const errors = (error as EsbuildLikeError).errors;
1745
+ if (Array.isArray(errors)) {
1746
+ return errors.some(item => item.text?.startsWith('Could not resolve '));
1747
+ }
1748
+ const message = error instanceof Error ? error.message : String(error);
1749
+ return message.includes('Could not resolve ');
1750
+ }
1751
+
1752
+ function unresolvedImportFromError(error: unknown) {
1753
+ const buildError = error as EsbuildLikeError;
1754
+ const diagnostic = buildError.errors?.find(item => item.text?.startsWith('Could not resolve '));
1755
+ const message = error instanceof Error ? error.message : String(error);
1756
+ const text = diagnostic?.text ?? message;
1757
+ const specifier = /Could not resolve "([^"]+)"/.exec(text)?.[1];
1758
+ if (!specifier) return undefined;
1759
+ return {
1760
+ specifier,
1761
+ file: diagnostic?.location?.file ?? errorFileFromMessage(message),
1762
+ };
1763
+ }
1764
+
1765
+ function errorFileFromMessage(message: string) {
1766
+ const match = /\n\s+([^:\n]+):\d+:\d+:\s+ERROR:\s+Could not resolve /.exec(message);
1767
+ return match?.[1];
1768
+ }
1769
+
1770
+ function resolveTraceImport(root: string, fromFile: string, specifier: string) {
1771
+ if (path.isAbsolute(specifier)) return resolveTraceCandidate(specifier);
1772
+ return resolveImport(root, fromFile, specifier);
1773
+ }
1774
+
1775
+ function resolveTraceFile(file: string) {
1776
+ return canonicalTraceFile(
1777
+ path.isAbsolute(file) ? path.resolve(file) : path.resolve(process.cwd(), file),
1778
+ );
1779
+ }
1780
+
1781
+ function resolveTraceCandidate(base: string) {
1782
+ const ext = path.extname(base);
1783
+ const candidates = ext
1784
+ ? [base]
1785
+ : [
1786
+ ...traceSourceExtensions.map(extension => `${base}${extension}`),
1787
+ ...traceSourceExtensions.map(extension => path.join(base, `index${extension}`)),
1788
+ base,
1789
+ ];
1790
+ const resolved = candidates.find(isTraceFile);
1791
+ return resolved ? canonicalTraceFile(resolved) : undefined;
1792
+ }
1793
+
1794
+ function isTraceFile(file: string) {
1795
+ try {
1796
+ return statSync(file).isFile();
1797
+ } catch {
1798
+ return false;
1799
+ }
1800
+ }
1801
+
1802
+ function readTextSyncSafe(file: string) {
1803
+ try {
1804
+ return existsSync(file) ? readFileSync(file, 'utf8') : '';
1805
+ } catch {
1806
+ return '';
1807
+ }
1808
+ }
1809
+
1810
+ function tracePath(entryFile: string, targetFile: string, parents: Map<string, TraceParent>) {
1811
+ const pathItems = [targetFile];
1812
+ let current = targetFile;
1813
+
1814
+ while (current !== entryFile) {
1815
+ const parent = parents.get(current);
1816
+ if (!parent) return [];
1817
+ current = parent.file;
1818
+ pathItems.push(current);
1819
+ }
1820
+
1821
+ return pathItems.reverse();
1822
+ }
1823
+
1824
+ function sameFile(a: string, b: string) {
1825
+ return canonicalTraceFile(a) === canonicalTraceFile(b);
1826
+ }
1827
+
1828
+ function displayTraceFile(config: ResolvedConfig, file: string) {
1829
+ if (!path.isAbsolute(file)) return `${file} (generated client entry)`;
1830
+ const root = canonicalTraceFile(config.root);
1831
+ const workspaceRoot = canonicalTraceFile(config.workspaceRoot);
1832
+ const resolvedFile = canonicalTraceFile(file);
1833
+ const rootRelative = path.relative(root, resolvedFile);
1834
+ if (!rootRelative.startsWith('..') && !path.isAbsolute(rootRelative)) return rootRelative;
1835
+ const workspaceRelative = path.relative(workspaceRoot, resolvedFile);
1836
+ if (!workspaceRelative.startsWith('..') && !path.isAbsolute(workspaceRelative))
1837
+ return workspaceRelative;
1838
+ return resolvedFile;
1839
+ }
1840
+
1841
+ function canonicalTraceFile(file: string) {
1842
+ try {
1843
+ return realpathSync.native(file);
1844
+ } catch {
1845
+ return path.resolve(file);
1846
+ }
1847
+ }
1848
+
1849
+ /** One walked client source, with the stat that lets a later boot skip reading it. */
1850
+ export interface ClientCacheSource {
1851
+ file: string;
1852
+ mtimeMs: number;
1853
+ size: number;
1854
+ hash: string;
1855
+ }
1856
+
1857
+ export interface ClientCacheKeyParts {
1858
+ key: string;
1859
+ staticHash: string;
1860
+ sources: ClientCacheSource[];
1861
+ }
1862
+
1863
+ /**
1864
+ * The half of the key that is not a walked source: the client build pipeline's own sources, the
1865
+ * generated entry, and the route's reference list. A handful of small reads, so it is cheap to
1866
+ * recompute on every lookup - which is what lets the expensive source walk be validated from a
1867
+ * persisted index instead (see dev/client-key-cache.ts).
1868
+ */
1869
+ export async function clientCacheStaticHash(
1870
+ route: RouteManifestEntry,
1871
+ nextCompat?: boolean,
1872
+ config?: ResolvedConfig,
1873
+ ) {
1874
+ // The key must observe convention files added since the last build, so it
1875
+ // never reads a memo an earlier call left behind.
1876
+ conventionCache.clear();
1877
+ const hash = createHash('sha256');
1878
+ for (const file of clientBuildPipelineFiles()) {
1879
+ hash.update(file);
1880
+ hash.update('\0');
1881
+ hash.update(await readText(file));
1882
+ hash.update('\0');
1883
+ }
1884
+
1885
+ hash.update(
1886
+ clientEntrySource({
1887
+ // Dev-only callers (client-key-cache): the key must move with the split
1888
+ // flag or a toggled PNEXT_DYNAMIC_SPLIT would serve the other arm's entry.
1889
+ deferredDynamicHref: devDeferredDynamicHref(true),
1890
+ pageFile: route.client ? route.file : undefined,
1891
+ clientReferences: route.clientReferences,
1892
+ nextCompat,
1893
+ errorFile: nextCompat && config ? routeErrorFile(config, route) : undefined,
1894
+ globalErrorFile: nextCompat && config ? globalErrorFile(config) : undefined,
1895
+ notFoundFile: nextCompat && config ? routeNotFoundFile(config, route) : undefined,
1896
+ controlFlow: nextCompat ? routeThrowsClientControlFlow(route) : false,
1897
+ router: Boolean(route.needsRouterEntry),
1898
+ clientRootLayout: config ? hasClientRootLayout(config, route) : false,
1899
+ shellLayoutOrder: config ? shellLayoutOrder(config, route) : [],
1900
+ }),
1901
+ );
1902
+ hash.update('\0');
1903
+ hash.update(route.client ? route.file : '');
1904
+ hash.update(JSON.stringify(route.clientReferences));
1905
+ return hash.digest('hex');
1906
+ }
1907
+
1908
+ /** The out-dir name: the static half plus every walked source's content hash. */
1909
+ export function clientCacheKeyFrom(
1910
+ staticHash: string,
1911
+ sources: readonly { file: string; hash: string }[],
1912
+ ) {
1913
+ const hash = createHash('sha256').update(staticHash).update('\0');
1914
+ for (const source of sources) {
1915
+ hash.update(source.file);
1916
+ hash.update('\0');
1917
+ hash.update(source.hash);
1918
+ hash.update('\0');
1919
+ }
1920
+ return hash.digest('hex').slice(0, 16);
1921
+ }
1922
+
1923
+ export function clientSourceHash(source: string) {
1924
+ return Bun.hash(source).toString(36);
1925
+ }
1926
+
1927
+ /**
1928
+ * Read one walked source. Stat first: a file that changes between the two
1929
+ * records the older stat, so the next boot re-hashes it rather than trusting a
1930
+ * hash that stat never described.
1931
+ */
1932
+ export async function readClientCacheSource(file: string): Promise<ClientCacheSource> {
1933
+ const stats = await stat(file).catch(() => undefined);
1934
+ return {
1935
+ file,
1936
+ mtimeMs: stats?.mtimeMs ?? 0,
1937
+ size: stats?.size ?? 0,
1938
+ hash: clientSourceHash(await readText(file)),
1939
+ };
1940
+ }
1941
+
1942
+ export async function clientCacheKeyParts(
1943
+ route: RouteManifestEntry,
1944
+ nextCompat?: boolean,
1945
+ config?: ResolvedConfig,
1946
+ staticHash?: string,
1947
+ ): Promise<ClientCacheKeyParts> {
1948
+ const staticDigest = staticHash ?? (await clientCacheStaticHash(route, nextCompat, config));
1949
+ const sources = await Promise.all(
1950
+ (await clientSourceFiles(route)).map(file => readClientCacheSource(file)),
1951
+ );
1952
+ return { key: clientCacheKeyFrom(staticDigest, sources), staticHash: staticDigest, sources };
1953
+ }
1954
+
1955
+ export async function clientCacheKey(
1956
+ route: RouteManifestEntry,
1957
+ nextCompat?: boolean,
1958
+ config?: ResolvedConfig,
1959
+ ) {
1960
+ return (await clientCacheKeyParts(route, nextCompat, config)).key;
1961
+ }
1962
+
1963
+ function clientBuildPipelineFiles() {
1964
+ return [
1965
+ path.join(import.meta.dirname, 'entry.ts'),
1966
+ path.resolve(import.meta.dirname, '..', 'api', 'router.ts'),
1967
+ path.resolve(import.meta.dirname, '..', 'api', 'client-navigation.ts'),
1968
+ path.join(import.meta.dirname, 'react-compiler.ts'),
1969
+ path.join(import.meta.dirname, 'build.ts'),
1970
+ path.resolve(import.meta.dirname, '..', 'compat', 'react', 'client.ts'),
1971
+ path.resolve(import.meta.dirname, '..', 'compat', 'react', 'preact.ts'),
1972
+ ].filter(file => existsSync(file));
1973
+ }
1974
+
1975
+ async function clientSourceFiles(route: RouteManifestEntry) {
1976
+ const entryFiles = [
1977
+ ...(route.client ? [route.file] : []),
1978
+ ...route.clientReferences.map(reference => reference.file),
1979
+ ];
1980
+ const files = new Set<string>();
1981
+
1982
+ await Promise.all(entryFiles.map(file => collectClientSourceFile(file, files)));
1983
+
1984
+ return [...files].sort();
1985
+ }
1986
+
1987
+ // The walk fans out: every file joins `files` before its own read starts, so
1988
+ // concurrent branches still visit each file once. Serially this was the whole
1989
+ // cost of a route's client cache key (~1.8 s on a 300-module route).
1990
+ async function collectClientSourceFile(file: string, files: Set<string>) {
1991
+ if (files.has(file) || !existsSync(file)) return;
1992
+ files.add(file);
1993
+ if (isAssetLike(file)) return;
1994
+
1995
+ const source = rewriteLiteralDynamicCalls(await readText(file), file);
1996
+ await Promise.all(
1997
+ [...localImports(file, source)].map(specifier => {
1998
+ const resolved = resolveImport(rootFromFile(file), file, specifier);
1999
+ return resolved ? collectClientSourceFile(resolved, files) : Promise.resolve();
2000
+ }),
2001
+ );
2002
+ }
2003
+
2004
+ // Value imports, re-exports and literal `import()` of one module, off the
2005
+ // memoized parse (PERF-REWRITES #21) — type-only imports bind nothing and are
2006
+ // already excluded there.
2007
+ export function localImports(file: string, source: string) {
2008
+ const facts = scanFacts(file, source);
2009
+ const imports = new Set<string>();
2010
+ for (const edge of facts.imports) imports.add(edge.specifier);
2011
+ for (const edge of facts.dynamicImports) imports.add(edge.specifier);
2012
+ return imports;
2013
+ }
2014
+
2015
+ function isAssetLike(file: string) {
2016
+ return /\.(css|svg|png|jpe?g|gif|webp|ico|woff2?)$/.test(file);
2017
+ }
2018
+
2019
+ function rootFromFile(file: string) {
2020
+ const parts = file.split(path.sep);
2021
+ const srcApp = parts.lastIndexOf('src');
2022
+ if (srcApp !== -1 && parts[srcApp + 1] === 'app')
2023
+ return parts.slice(0, srcApp).join(path.sep) || path.sep;
2024
+ const app = parts.lastIndexOf('app');
2025
+ if (app !== -1) return parts.slice(0, app).join(path.sep) || path.sep;
2026
+ return path.dirname(file);
2027
+ }
2028
+
2029
+ // esbuild names every shared chunk "chunk-<hash>", which makes bundle reports
2030
+ // unreadable. Rename each one after the module that dominates it by size in
2031
+ // the metafile: the package name for node_modules code, the file stem for app
2032
+ // code. Falls back to "shared" when a build ran without a metafile.
2033
+ async function nameSharedClientChunks(outDir: string, metafile?: Metafile) {
2034
+ const chunksDir = path.join(outDir, 'chunks');
2035
+ const files = (await listFiles(chunksDir)).filter(file => file.endsWith('.js'));
2036
+ const labels = metafile ? chunkLabelsFromMetafile(metafile) : new Map<string, string>();
2037
+ const renames = new Map<string, string>();
2038
+
2039
+ for (const file of files) {
2040
+ const basename = path.basename(file);
2041
+ if (!basename.startsWith('chunk-')) continue;
2042
+ renames.set(basename, `${labels.get(basename) ?? 'shared'}-${hashSuffix(basename)}.js`);
2043
+ }
2044
+
2045
+ if (renames.size === 0) return renames;
2046
+
2047
+ await Promise.all(
2048
+ [...renames].map(async ([from, to]) => {
2049
+ const fromPath = path.join(chunksDir, from);
2050
+ const toPath = path.join(chunksDir, to);
2051
+ if (from === to || !existsSync(fromPath)) return;
2052
+ try {
2053
+ await rename(fromPath, toPath);
2054
+ } catch (error) {
2055
+ // Tolerate a concurrent build having already moved this chunk (ENOENT);
2056
+ // the reference rewrite below still points at the renamed file.
2057
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
2058
+ }
2059
+ // Move the external sourcemap alongside its chunk. The `sourceMappingURL`
2060
+ // comment in the renamed chunk is rewritten below (replaceChunkNames matches
2061
+ // the `chunk-<hash>.js` prefix inside `chunk-<hash>.js.map`), so the map file
2062
+ // must adopt the new name to keep the link resolvable.
2063
+ const fromMap = `${fromPath}.map`;
2064
+ if (!existsSync(fromMap)) return;
2065
+ try {
2066
+ await rename(fromMap, `${toPath}.map`);
2067
+ } catch (error) {
2068
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
2069
+ }
2070
+ }),
2071
+ );
2072
+
2073
+ // One read+rewrite per output file. An app with a route per page has hundreds
2074
+ // of them, so they go out concurrently rather than one await at a time.
2075
+ const outputs = (await listFiles(outDir)).filter(item => item.endsWith('.js'));
2076
+ await Promise.all(
2077
+ outputs.map(async file => {
2078
+ const source = await readText(file);
2079
+ const next = replaceChunkNames(source, renames);
2080
+ if (next !== source) await writeText(file, next);
2081
+ }),
2082
+ );
2083
+
2084
+ return renames;
2085
+ }
2086
+
2087
+ function hashSuffix(file: string) {
2088
+ return path.basename(file, '.js').split('-').at(-1) ?? 'chunk';
2089
+ }
2090
+
2091
+ function chunkLabelsFromMetafile(metafile: Metafile) {
2092
+ const labels = new Map<string, string>();
2093
+ for (const [outputPath, output] of Object.entries(metafile.outputs)) {
2094
+ const basename = path.basename(outputPath);
2095
+ if (!basename.startsWith('chunk-') || !basename.endsWith('.js')) continue;
2096
+ const weights = new Map<string, number>();
2097
+ for (const [input, { bytesInOutput }] of Object.entries(output.inputs)) {
2098
+ const label = chunkInputLabel(input);
2099
+ if (!label) continue;
2100
+ weights.set(label, (weights.get(label) ?? 0) + bytesInOutput);
2101
+ }
2102
+ const top = [...weights.entries()].sort((a, b) => b[1] - a[1])[0];
2103
+ if (top) labels.set(basename, top[0]);
2104
+ }
2105
+ return labels;
2106
+ }
2107
+
2108
+ function chunkInputLabel(input: string) {
2109
+ const posix = input.split(path.sep).join('/');
2110
+ const packageIndex = posix.lastIndexOf('node_modules/');
2111
+ if (packageIndex !== -1) {
2112
+ const parts = posix.slice(packageIndex + 'node_modules/'.length).split('/');
2113
+ const name = parts[0]?.startsWith('@') ? `${parts[0]}-${parts[1] ?? ''}` : parts[0];
2114
+ return sanitizeChunkLabel(name ?? '');
2115
+ }
2116
+ return sanitizeChunkLabel(
2117
+ posix
2118
+ .split('/')
2119
+ .at(-1)
2120
+ ?.replace(/\.[^.]+$/, '') ?? '',
2121
+ );
2122
+ }
2123
+
2124
+ function sanitizeChunkLabel(label: string) {
2125
+ const clean = label
2126
+ .replace(/^@/, '')
2127
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
2128
+ .replace(/^[-.]+|[-.]+$/g, '')
2129
+ .slice(0, 40);
2130
+ return clean || undefined;
2131
+ }
2132
+
2133
+ function replaceChunkNames(source: string, renames: Map<string, string>) {
2134
+ const pattern = new RegExp([...renames.keys()].map(escapeRegex).join('|'), 'g');
2135
+ return source.replace(pattern, match => renames.get(match) ?? match);
2136
+ }