@wular/pnext 0.0.24 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/bin/pnext +1 -1
  2. package/package.json +2 -2
  3. package/reference/data/bench.json +398 -223
  4. package/reference/getting-started.md +1 -1
  5. package/src/api/client-navigation.ts +7 -5
  6. package/src/cli/adapters/vercel-warm.ts +120 -28
  7. package/src/cli/build.ts +56 -15
  8. package/src/cli/create.ts +1 -1
  9. package/src/cli/dev.ts +2 -1
  10. package/src/cli/index.ts +146 -10
  11. package/src/cli/migrate/report.ts +1 -1
  12. package/src/cli/serve/pipeline.ts +23 -6
  13. package/src/cli/serve/ui.ts +1 -1
  14. package/src/cli/start.ts +2 -1
  15. package/src/cli/typegen.ts +5 -2
  16. package/src/client/build.ts +217 -58
  17. package/src/client/chunk-fold.ts +7 -1
  18. package/src/client/entry.ts +44 -7
  19. package/src/client/router/runtime.ts +1016 -230
  20. package/src/client/router/types.ts +15 -0
  21. package/src/compat/actions/client-plugin.ts +10 -0
  22. package/src/compat/actions/rewrite.ts +1 -0
  23. package/src/compat/bundler/optimize-package-imports.ts +375 -28
  24. package/src/compat/client/navigation-scroll.ts +29 -3
  25. package/src/compat/client/optimistic-routing.ts +2 -2
  26. package/src/compat/client/segment-cache-policy.ts +4 -18
  27. package/src/compat/client/segment-cache.ts +104 -0
  28. package/src/compat/client/segment-prefetch.ts +6 -6
  29. package/src/compat/next/config-loader.ts +6 -4
  30. package/src/compat/next/navigation.ts +16 -2
  31. package/src/compat/pages/router.ts +102 -9
  32. package/src/compat/protocol.ts +10 -1
  33. package/src/compat/register/actions.ts +2 -0
  34. package/src/compat/register/bundler.ts +4 -6
  35. package/src/compat/register/routing.ts +3 -4
  36. package/src/compat/tsconfig-defaults.ts +3 -2
  37. package/src/config.ts +3 -3
  38. package/src/css/postcss.ts +450 -17
  39. package/src/dev/client-actions.ts +2 -0
  40. package/src/dev/server.ts +87 -14
  41. package/src/render/island-context.ts +3 -0
  42. package/src/render/ppr.ts +14 -0
  43. package/src/render/renderer.ts +268 -42
  44. package/src/request/context.ts +32 -11
  45. package/src/resolve/scan-facts.ts +23 -4
  46. package/src/routing/proxy.ts +29 -25
  47. package/src/routing/routes.ts +1 -1
  48. package/src/runtime/loader.ts +47 -1
  49. package/src/runtime/modules.ts +546 -65
  50. package/src/runtime/vendor-build.ts +109 -22
  51. package/src/runtime/vendor.ts +7 -3
  52. package/src/utils/ansi.ts +3 -1
  53. package/src/utils/fs.ts +17 -3
@@ -3,7 +3,7 @@ import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { existsSync, readFileSync, type Dirent } from 'node:fs'
5
5
  import { fileURLToPath } from 'node:url'
6
- import { copyFile, mkdir, readdir, symlink, writeFile } from 'node:fs/promises'
6
+ import { copyFile, mkdir, readdir, readlink, rm, symlink, writeFile } from 'node:fs/promises'
7
7
  import type { OnResolveResult, Plugin } from 'esbuild'
8
8
  import { build } from '../utils/esbuild'
9
9
  import { drainPreplanBuilds, vendorTraceEnabled, vendorTraceRow } from '../runtime/vendor'
@@ -12,7 +12,7 @@ import {
12
12
  clientReferenceModuleSource,
13
13
  hasUseClientDirective,
14
14
  } from '../client/reference-stub'
15
- import { noteCompiledClientReference } from '../client/reference'
15
+ import { noteCompiledClientReference, ssrClientReference } from '../client/reference'
16
16
  import { getClientActionBundler } from '../dev/client-actions'
17
17
  import { deferredDynamicImportSpecifiers, devDynamicSplitEnabled } from '../resolve/dynamic'
18
18
  import { noteModuleGeneration, noteModuleImported } from './module-generations'
@@ -54,7 +54,11 @@ import {
54
54
  resolveExternalLoadTarget,
55
55
  resolvePackageSpecifier,
56
56
  } from '../resolve/imports'
57
- import { importSpecifiers, rewriteSpecifierLiterals } from '../resolve/scan-facts'
57
+ import {
58
+ importSpecifiers,
59
+ moduleSpecifierEdges,
60
+ rewriteSpecifierLiterals,
61
+ } from '../resolve/scan-facts'
58
62
  import {
59
63
  cacheRoot,
60
64
  devArtifactUsable,
@@ -102,6 +106,44 @@ let buildActive = 0
102
106
  const buildQueue: (() => void)[] = []
103
107
  const moduleBuilds = new Map<string, Promise<string>>()
104
108
  const routeBundleBuilds = new Map<string, Promise<string>>()
109
+ // A cache hit from THIS process is safe after its entry drain, but a production
110
+ // build also has a warm child filling the same content-addressed directory.
111
+ // That other process can atomically publish an importer before its slower
112
+ // dependency. Cache each artifact's parsed static edges, but re-check those
113
+ // members on every acceptance: a later phase may remove a dependency that was
114
+ // present during an earlier check. This avoids repeated reads/parses without
115
+ // turning one observation into a permanent claim that the closure still exists.
116
+ const completeArtifactClosures = new Map<string, readonly string[]>()
117
+ let moduleGraphFailure: Error | undefined
118
+
119
+ /** Reset build-only graph state before starting another build in this process. */
120
+ export function resetModuleGraphFailure(): void {
121
+ moduleGraphFailure = undefined
122
+ completeArtifactClosures.clear()
123
+ }
124
+
125
+ /**
126
+ * Fail a build even when its renderer converted a module-resolution exception
127
+ * into a 500 response. Rendering may recover from user-code errors, but a
128
+ * missing content-addressed artifact means the build output itself is broken.
129
+ */
130
+ export function throwIfModuleGraphFailed(): void {
131
+ if (moduleGraphFailure !== undefined) throw moduleGraphFailure
132
+ }
133
+
134
+ function noteModuleGraphFailure(error: unknown): void {
135
+ moduleGraphFailure ??= error instanceof Error ? error : new Error(String(error))
136
+ }
137
+
138
+ function isCompiledModuleResolutionError(error: unknown, href: string): boolean {
139
+ const message = error instanceof Error ? error.message : String(error)
140
+ if (!message.includes('Cannot find module')) return false
141
+ return (
142
+ href.includes('/cache/server/') ||
143
+ /[/\\]cache[/\\]server[/\\]/.test(message) ||
144
+ message.includes('/cache/server/')
145
+ )
146
+ }
105
147
 
106
148
  // PNEXT_TRACE=server: attribute every per-module compile to its profile,
107
149
  // so cross-profile duplicate work is countable rather than inferred. Counts are
@@ -186,6 +228,9 @@ const routeModuleLoaders = new Map<string, Promise<DevRouteModuleLoaders>>()
186
228
  // request. Compiled hrefs are content-addressed, so a save that fixes the module yields a new href
187
229
  // and the entry is never consulted again.
188
230
  const moduleEvalErrors = new Map<string, unknown>()
231
+ const warmRetryHrefs = new Map<string, Promise<string>>()
232
+ const warmImports = new Map<string, Promise<void>>()
233
+ let warmRetryGeneration = 0
189
234
 
190
235
  /**
191
236
  * Import a compiled module, re-throwing its first evaluation error on later imports.
@@ -200,20 +245,81 @@ export async function importModuleOnce<T>(href: string): Promise<T> {
200
245
  try {
201
246
  return (await import(href)) as T
202
247
  } catch (error) {
248
+ if (isCompiledModuleResolutionError(error, href)) noteModuleGraphFailure(error)
203
249
  moduleEvalErrors.set(href, error)
204
250
  throw error
205
251
  }
206
252
  }
207
253
 
254
+ interface ImportDevModuleOptions {
255
+ /** A speculative import whose evaluation failure must not become render state. */
256
+ warm?: boolean
257
+ }
258
+
259
+ async function nextWarmRetryHref(href: string) {
260
+ const url = new URL(href)
261
+ const generation = ++warmRetryGeneration
262
+ if (url.protocol !== 'file:') {
263
+ url.searchParams.set('pnext-warm-retry', String(generation))
264
+ return url.href
265
+ }
266
+ // Bun keys failed file imports by their underlying path even when the URL query changes. Copy the
267
+ // immutable content-addressed artifact beside itself: the distinct path retries its top-level
268
+ // evaluation, while the shared directory keeps all relative imports resolving identically.
269
+ const source = fileURLToPath(url)
270
+ const extension = path.extname(source)
271
+ const stem = extension ? source.slice(0, -extension.length) : source
272
+ const retry = `${stem}.pnext-warm-retry-${generation}${extension}`
273
+ await copyFile(source, retry)
274
+ return pathToFileHref(retry)
275
+ }
276
+
208
277
  /** `importModuleOnce` plus dev's registry-generation accounting. */
209
- export async function importDevModule<T>(href: string): Promise<T> {
210
- if (moduleEvalErrors.has(href)) throw moduleEvalErrors.get(href)
211
- // Pre-planned vendor builds are enqueued without an awaiter; nothing
212
- // evaluates until they all settle (no-op when the pipeline is empty).
213
- await drainPreplanBuilds()
214
- const module = await importModuleOnce<T>(href)
215
- noteModuleImported(href)
216
- return module
278
+ export async function importDevModule<T>(
279
+ href: string,
280
+ options: ImportDevModuleOptions = {},
281
+ ): Promise<T> {
282
+ const warming = warmImports.get(href)
283
+ if (warming) {
284
+ await warming
285
+ return importDevModule<T>(href)
286
+ }
287
+
288
+ let finishWarm: (() => void) | undefined
289
+ let warmSettled: Promise<void> | undefined
290
+ if (options.warm) {
291
+ warmSettled = new Promise(resolve => {
292
+ finishWarm = resolve
293
+ })
294
+ warmImports.set(href, warmSettled)
295
+ }
296
+
297
+ let importHref = href
298
+ let attemptedEvaluation = false
299
+ try {
300
+ importHref = (await warmRetryHrefs.get(href)) ?? href
301
+ if (moduleEvalErrors.has(importHref)) throw moduleEvalErrors.get(importHref)
302
+ // Pre-planned vendor builds are enqueued without an awaiter; nothing
303
+ // evaluates until they all settle (no-op when the pipeline is empty).
304
+ await drainPreplanBuilds()
305
+ attemptedEvaluation = true
306
+ const module = await importModuleOnce<T>(importHref)
307
+ noteModuleImported(href)
308
+ return module
309
+ } catch (error) {
310
+ if (options.warm && attemptedEvaluation) {
311
+ // Bun retains a failed ESM evaluation in its registry. Drop our first-error record and give
312
+ // the real render a fresh module identity, so speculative work can never poison the request.
313
+ const retryHref = nextWarmRetryHref(href)
314
+ warmRetryHrefs.set(href, retryHref)
315
+ if (moduleEvalErrors.get(importHref) === error) moduleEvalErrors.delete(importHref)
316
+ await retryHref
317
+ }
318
+ throw error
319
+ } finally {
320
+ finishWarm?.()
321
+ if (warmSettled && warmImports.get(href) === warmSettled) warmImports.delete(href)
322
+ }
217
323
  }
218
324
  const extensionlessDynamicImportExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
219
325
  const externalLoadNamespaces = {
@@ -466,6 +572,7 @@ export async function devServerModuleHref(
466
572
  bundleExternalPackages: reactCompatEnabled(config),
467
573
  })
468
574
  await drainModuleBuilds(visited)
575
+ assertCompiledArtifactClosure(compiled)
469
576
  return pathToFileHref(compiled)
470
577
  } finally {
471
578
  release()
@@ -491,6 +598,7 @@ export async function devClientModuleHref(
491
598
  clientLayerOptions(config, conditionTarget),
492
599
  )
493
600
  await drainModuleBuilds(visited)
601
+ assertCompiledArtifactClosure(compiled)
494
602
  return pathToFileHref(compiled)
495
603
  } finally {
496
604
  release()
@@ -617,46 +725,12 @@ function startRouteVendorStage(config: ResolvedConfig, route: RouteManifestEntry
617
725
  if (process.env.PNEXT_DISABLE_VENDOR_STAGE) return
618
726
  if (!reactCompatEnabled(config)) return
619
727
  const conditionTarget = serverBundleTargetForRuntime(route.segmentConfig?.runtime)
620
- const options: DevModuleOptions = {
621
- aliases: coreAliases(config, 'server'),
622
- profile: 'compat',
623
- conditionTarget,
624
- externalLoadTarget: externalLoadTargetForConditionTarget(conditionTarget),
625
- stubClientImports: true,
626
- rewriteExternalServerImports: true,
627
- bundleExternalPackages: true,
628
- }
629
- // Only the SERVER vendor layer is seeded here: the client-reference walk already fans the
630
- // client-SSR demand out, so seeding that layer too only competes for the JS thread.
631
- //
632
- // Layered seeding: a separate walk pruned at 'use client' boundaries, so client-only packages get
633
- // no server-target builds. On-demand fallback covers anything needed sooner.
634
- // eslint-disable-next-line turbo/no-undeclared-env-vars
635
- const layered = process.env.PNEXT_VENDOR_SEED_LAYERED !== '0'
636
728
  const seedT0 = performance.now()
637
729
  void (async () => {
638
- const graph = devModuleGraph(config)
639
- const demand = new Set<string>()
640
- const dropped = new Set<string>()
641
- // The layer of a source is part of the record the naming walk just scanned,
642
- // so the seed reads it instead of re-reading the whole route graph.
643
- const prune = (file: string) =>
644
- (devHeadTrimEnabled() ? graph.isClientSource(file) : fileHasUseClientDirective(file)).catch(
645
- () => false,
646
- )
647
- for (const file of files) {
648
- const full = await graph.packageDemand(file)
649
- const serverReachable = layered ? new Set(await graph.packageDemand(file, prune)) : undefined
650
- for (const specifier of full) {
651
- if (!shouldBundleExternalPackage(specifier, config, file, options)) continue
652
- if (serverReachable && !serverReachable.has(specifier)) dropped.add(specifier)
653
- else demand.add(specifier)
654
- }
655
- }
656
- for (const specifier of demand) dropped.delete(specifier)
730
+ const { demand, dropped, layered } = await devVendorSeedSplit(config, route, files)
657
731
  if (traceEnabled('server')) {
658
732
  console.log(
659
- `dev-import vendor seed walk ${route.route} in ${formatDuration(performance.now() - seedT0)} (kept ${demand.size}, dropped ${dropped.size})`,
733
+ `dev-import vendor seed walk ${route.route} in ${formatDuration(performance.now() - seedT0)} (kept ${demand.length}, dropped ${dropped.length})`,
660
734
  )
661
735
  }
662
736
  if (vendorTraceEnabled()) {
@@ -685,6 +759,64 @@ function startRouteVendorStage(config: ResolvedConfig, route: RouteManifestEntry
685
759
  })().catch(() => undefined)
686
760
  }
687
761
 
762
+ /**
763
+ * The seed's split of a route's package demand: what the SERVER layer will ask for, and what only a
764
+ * client subtree reaches (which the client-SSR pass demands at its own target, so a server build of
765
+ * it is work nothing consumes).
766
+ *
767
+ * Only the SERVER vendor layer is seeded: the client-reference pass fans the client-SSR demand out
768
+ * itself, so seeding that layer too only competes for the JS thread.
769
+ *
770
+ * @internal Exported as a test seam - the split is otherwise only visible as timing.
771
+ */
772
+ export async function devVendorSeedSplit(
773
+ config: ResolvedConfig,
774
+ route: RouteManifestEntry,
775
+ files: string[],
776
+ ) {
777
+ const conditionTarget = serverBundleTargetForRuntime(route.segmentConfig?.runtime)
778
+ const options: DevModuleOptions = {
779
+ aliases: coreAliases(config, 'server'),
780
+ profile: 'compat',
781
+ conditionTarget,
782
+ externalLoadTarget: externalLoadTargetForConditionTarget(conditionTarget),
783
+ stubClientImports: true,
784
+ rewriteExternalServerImports: true,
785
+ bundleExternalPackages: true,
786
+ }
787
+ // Layered seeding: a separate walk pruned at 'use client' boundaries, so client-only packages get
788
+ // no server-target builds. On-demand fallback covers anything needed sooner.
789
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
790
+ const layered = process.env.PNEXT_VENDOR_SEED_LAYERED !== '0'
791
+ const graph = devModuleGraph(config)
792
+ const demand = new Set<string>()
793
+ const dropped = new Set<string>()
794
+ // The layer of a source is part of the record the naming walk just scanned,
795
+ // so the seed reads it instead of re-reading the whole route graph.
796
+ const clientSource = (file: string) =>
797
+ (devHeadTrimEnabled() ? graph.isClientSource(file) : fileHasUseClientDirective(file)).catch(
798
+ () => false,
799
+ )
800
+ // A file the route bundle names as a SERVER entry is never a boundary: `writeDevRouteBundle` loads
801
+ // every layout (and a server page) through the server namespace whatever its directive, so pruning
802
+ // one would drop exactly the packages the bundle then demands - serially, mid-request, in front of
803
+ // the response.
804
+ const serverEntries = new Set(files.map(file => path.resolve(file)))
805
+ if (route.client) serverEntries.delete(path.resolve(route.file))
806
+ const prune = (file: string) => (serverEntries.has(file) ? false : clientSource(file))
807
+ for (const file of files) {
808
+ const full = await graph.packageDemand(file)
809
+ const serverReachable = layered ? new Set(await graph.packageDemand(file, prune)) : undefined
810
+ for (const specifier of full) {
811
+ if (!shouldBundleExternalPackage(specifier, config, file, options)) continue
812
+ if (serverReachable && !serverReachable.has(specifier)) dropped.add(specifier)
813
+ else demand.add(specifier)
814
+ }
815
+ }
816
+ for (const specifier of demand) dropped.delete(specifier)
817
+ return { demand: [...demand], dropped: [...dropped], layered }
818
+ }
819
+
688
820
  export async function devRouteModuleLoaders(
689
821
  config: ResolvedConfig,
690
822
  route: RouteManifestEntry,
@@ -744,9 +876,17 @@ async function createDevRouteModuleLoaders(
744
876
  }),
745
877
  )
746
878
  }
879
+ const batched = new Set(devClientReferenceGroups(config, route).batched)
747
880
  const clientModuleLoader = async (file: string) => {
748
881
  const module = bundle.clientModules[file]
749
882
  if (module) return module
883
+ // The batched client-reference pass owns this file: join its one compile
884
+ // instead of starting a per-module walk over the same graph.
885
+ if (batched.has(path.resolve(file))) {
886
+ const modules = await devClientReferenceModules(config, route).catch(() => undefined)
887
+ const batchedModule = modules?.[path.resolve(file)]
888
+ if (batchedModule) return batchedModule
889
+ }
750
890
  return importDevModule<Record<string, unknown>>(
751
891
  await devClientModuleHref(
752
892
  config,
@@ -759,6 +899,193 @@ async function createDevRouteModuleLoaders(
759
899
  return { moduleLoader, clientModuleLoader }
760
900
  }
761
901
 
902
+ /**
903
+ * The route's SSR-able client references, split by who compiles them. `batched` is one esbuild pass
904
+ * over the whole set (the client-SSR layer's own bundle, below); `perModule` is what the pass cannot
905
+ * own and the per-file walk still names:
906
+ *
907
+ * - a reference inside node_modules SSRs through its package's client vendor bundle
908
+ * (`packageClientModuleHref`), so bundling a second copy here would split the package's singletons;
909
+ * - a pages-router source SSRs under a different condition target than the bundle's client layer.
910
+ */
911
+ export function devClientReferenceGroups(config: ResolvedConfig, route: RouteManifestEntry) {
912
+ const batched: string[] = []
913
+ const perModule: string[] = []
914
+ const seen = new Set<string>()
915
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
916
+ const bundling = reactCompatEnabled(config) && process.env.PNEXT_DEV_CLIENT_REF_BUNDLE !== '0'
917
+ const target = serverBundleTargetForRuntime(route.segmentConfig?.runtime)
918
+ for (const reference of route.clientReferences) {
919
+ if (!ssrClientReference(reference) || seen.has(reference.file)) continue
920
+ seen.add(reference.file)
921
+ const file = path.resolve(reference.file)
922
+ const batchable =
923
+ bundling &&
924
+ existsSync(file) &&
925
+ !isCssFile(file) &&
926
+ !nodeModulesPackageDir(file) &&
927
+ clientLayerConditionTarget(config, file, target) === 'client'
928
+ // The pass names its entries by resolved path (that is the key a module out
929
+ // of it carries); everything else keeps the reference's own name, which is
930
+ // what the per-file walk has always been handed.
931
+ if (batchable) batched.push(file)
932
+ else perModule.push(reference.file)
933
+ }
934
+ return { batched, perModule }
935
+ }
936
+
937
+ // Keyed on the bundle's own content-addressed path, so a save that renames it
938
+ // never hands back the previous graph's modules.
939
+ const clientReferenceModules = new Map<string, Promise<Record<string, Record<string, unknown>>>>()
940
+ const clientReferenceBundleBuilds = new Map<string, Promise<string>>()
941
+ // Bundle names memoized per reference set; a save may rename any of them, so
942
+ // `clearDevRouteBundleKeys` drops these with the route bundle's own names.
943
+ const clientReferenceBundleKeys = new Map<string, Promise<string>>()
944
+
945
+ /**
946
+ * Every batchable client reference of the route, compiled in ONE esbuild pass and evaluated once.
947
+ * The per-module walk it replaces re-entered the compile pipeline for each reference and each of its
948
+ * imports, which is the dev first page's long pole; the pass names the same client-SSR layer
949
+ * (`clientSsrAliases`, client conditions, packages through their vendor bundles), so a module out of
950
+ * it is the same artifact the walk would have produced - and the render joins this promise through
951
+ * `clientModuleLoader` rather than compiling its own.
952
+ */
953
+ export async function devClientReferenceModules(
954
+ config: ResolvedConfig,
955
+ route: RouteManifestEntry,
956
+ options: { evaluate?: boolean } = {},
957
+ ) {
958
+ const { batched } = devClientReferenceGroups(config, route)
959
+ if (batched.length === 0) return undefined
960
+ const release = devModuleGraph(config).hold()
961
+ const outFile = await devClientReferenceBundlePath(config, route, batched).catch(error => {
962
+ release()
963
+ throw error
964
+ })
965
+ if (options.evaluate === false) {
966
+ await writeDevClientReferenceBundle(config, route, batched, outFile).finally(release)
967
+ return undefined
968
+ }
969
+ const existing = clientReferenceModules.get(outFile)
970
+ if (existing) {
971
+ release()
972
+ return existing
973
+ }
974
+ const next = loadDevClientReferenceModules(config, route, batched, outFile)
975
+ .catch(error => {
976
+ clientReferenceModules.delete(outFile)
977
+ throw error
978
+ })
979
+ .finally(release)
980
+ clientReferenceModules.set(outFile, next)
981
+ return next
982
+ }
983
+
984
+ async function loadDevClientReferenceModules(
985
+ config: ResolvedConfig,
986
+ route: RouteManifestEntry,
987
+ files: string[],
988
+ outFile: string,
989
+ ) {
990
+ const bundleFile = await writeDevClientReferenceBundle(config, route, files, outFile)
991
+ const bundle = await profileDevImport(`import client references ${route.route}`, () =>
992
+ // Warm import: a reference that throws while evaluating speculatively must not leave Bun's
993
+ // registry holding the failure for the render that asks for it next.
994
+ importDevModule<DevRouteBundle>(pathToFileHref(bundleFile), { warm: true }),
995
+ )
996
+ return bundle.clientModules
997
+ }
998
+
999
+ /**
1000
+ * Named by the whole source graph of every reference in it, exactly like the route bundle. Memoized
1001
+ * on the reference set so concurrent demands (boot warm, request preload, the render itself) await
1002
+ * ONE name and therefore meet each other in the load memo below instead of each starting a pass.
1003
+ */
1004
+ function devClientReferenceBundlePath(
1005
+ config: ResolvedConfig,
1006
+ route: RouteManifestEntry,
1007
+ files: string[],
1008
+ ) {
1009
+ const memoKey = `${route.id}\0${files.join('\0')}`
1010
+ const memoized = clientReferenceBundleKeys.get(memoKey)
1011
+ if (memoized) return memoized
1012
+ const next = computeDevClientReferenceBundlePath(config, route, files).catch(error => {
1013
+ clientReferenceBundleKeys.delete(memoKey)
1014
+ throw error
1015
+ })
1016
+ clientReferenceBundleKeys.set(memoKey, next)
1017
+ return next
1018
+ }
1019
+
1020
+ async function computeDevClientReferenceBundlePath(
1021
+ config: ResolvedConfig,
1022
+ route: RouteManifestEntry,
1023
+ files: string[],
1024
+ ) {
1025
+ const graph = devModuleGraph(config)
1026
+ const hashes = await Promise.all(files.map(file => graph.graphHash(file)))
1027
+ const hash = createHash('sha256').update(hashes.join('\0')).digest('hex').slice(0, 16)
1028
+ const bundle = path.join(
1029
+ cacheRoot(config.outPath),
1030
+ 'client-refs',
1031
+ `${route.id}.${hash}.client-refs.mjs`,
1032
+ )
1033
+ noteModuleGeneration(`client-refs:${route.route}`, bundle)
1034
+ return bundle
1035
+ }
1036
+
1037
+ async function writeDevClientReferenceBundle(
1038
+ config: ResolvedConfig,
1039
+ route: RouteManifestEntry,
1040
+ files: string[],
1041
+ outFile: string,
1042
+ ) {
1043
+ if (devArtifactUsable(outFile)) return outFile
1044
+
1045
+ const existing = clientReferenceBundleBuilds.get(outFile)
1046
+ if (existing) return existing
1047
+
1048
+ const next = profileDevImport(`build client references ${route.route}`, async () => {
1049
+ await mkdir(path.dirname(outFile), { recursive: true })
1050
+ const result = await withBuildSlot(() =>
1051
+ build({
1052
+ stdin: {
1053
+ contents: routeBundleEntrySource([], files),
1054
+ loader: 'ts',
1055
+ resolveDir: config.root,
1056
+ sourcefile: `${route.id}.client-references.ts`,
1057
+ },
1058
+ bundle: true,
1059
+ write: false,
1060
+ format: 'esm',
1061
+ platform: 'neutral',
1062
+ target: 'es2022',
1063
+ loader: { '.js': 'jsx', '.mjs': 'jsx' },
1064
+ jsx: 'automatic',
1065
+ jsxImportSource: 'preact',
1066
+ packages: 'external',
1067
+ logLevel: 'silent',
1068
+ ...serverDefineOptions(),
1069
+ plugins: [
1070
+ serverAssetPlugin(config),
1071
+ devRouteBundlePlugin(config, route),
1072
+ ...getBundlerExtensions().serverEsbuildPlugins(config),
1073
+ ],
1074
+ }),
1075
+ )
1076
+ const output = result.outputFiles[0]
1077
+ if (!output) throw new Error(`Failed to build dev client references for ${route.route}`)
1078
+ await writeCompiledFile(outFile, output.text)
1079
+ return outFile
1080
+ }).catch(error => {
1081
+ clientReferenceBundleBuilds.delete(outFile)
1082
+ throw error
1083
+ })
1084
+
1085
+ clientReferenceBundleBuilds.set(outFile, next)
1086
+ return next
1087
+ }
1088
+
762
1089
  interface DevRouteBundle {
763
1090
  modules: Record<string, Record<string, unknown>>
764
1091
  clientModules: Record<string, Record<string, unknown>>
@@ -907,12 +1234,17 @@ function devRouteBundlePlugin(config: ResolvedConfig, route: RouteManifestEntry)
907
1234
  registerExternalLoadHandlers(build, config, serverOptions, externalLoadNamespaces.server)
908
1235
  registerExternalLoadHandlers(build, config, clientOptions, externalLoadNamespaces.client)
909
1236
 
1237
+ // `resolveDir` is what an unclaimed specifier resolves against: the asset and loader-rule
1238
+ // plugins fall back to esbuild's own resolution, and a module in a plugin namespace has no
1239
+ // directory of its own - without this a `./x.module.css` next to its importer is looked for at
1240
+ // the process cwd, exactly as if the per-module path had compiled it from the wrong folder.
910
1241
  build.onLoad({ filter: /.*/, namespace: 'pnext-route-server' }, async args => ({
911
1242
  contents: rewriteServerSource(await readText(args.path), args.path, {
912
1243
  nextFonts: nextCompatEnabled(config),
913
1244
  root: config.root,
914
1245
  }),
915
1246
  loader: esbuildLoader(args.path),
1247
+ resolveDir: path.dirname(args.path),
916
1248
  }))
917
1249
  build.onLoad({ filter: /.*/, namespace: 'pnext-route-client' }, async args => {
918
1250
  // A client-side import of a 'use server' module becomes the RPC stub.
@@ -921,6 +1253,7 @@ function devRouteBundlePlugin(config: ResolvedConfig, route: RouteManifestEntry)
921
1253
  return {
922
1254
  contents: await clientActions.stubSource(args.path),
923
1255
  loader: 'js',
1256
+ resolveDir: path.dirname(args.path),
924
1257
  }
925
1258
  }
926
1259
  return {
@@ -929,6 +1262,7 @@ function devRouteBundlePlugin(config: ResolvedConfig, route: RouteManifestEntry)
929
1262
  root: config.root,
930
1263
  }),
931
1264
  loader: esbuildLoader(args.path),
1265
+ resolveDir: path.dirname(args.path),
932
1266
  }
933
1267
  })
934
1268
  build.onLoad({ filter: /.*/, namespace: 'pnext-client-reference' }, async args => ({
@@ -937,6 +1271,7 @@ function devRouteBundlePlugin(config: ResolvedConfig, route: RouteManifestEntry)
937
1271
  await clientReferenceExportNames(config, args.path),
938
1272
  ),
939
1273
  loader: 'js',
1274
+ resolveDir: path.dirname(args.path),
940
1275
  }))
941
1276
  },
942
1277
  }
@@ -1095,6 +1430,108 @@ async function writeCompiledFile(file: string, contents: string) {
1095
1430
  }
1096
1431
  }
1097
1432
 
1433
+ /**
1434
+ * Whether every file URL reachable from a compiled artifact is already on
1435
+ * disk. Individual files publish atomically, but the graph is a set of files:
1436
+ * the build's warm child can expose an importer to the parent before it has
1437
+ * exposed that importer's slower sibling. A mere existsSync(importer) is
1438
+ * therefore not a complete-cache invariant across processes.
1439
+ */
1440
+ function missingCompiledArtifact(entry: string): string | undefined {
1441
+ const root = compiledArtifactProfileRoot(entry)
1442
+ if (!root) return existsSync(entry) ? undefined : entry
1443
+
1444
+ const visiting = new Set<string>()
1445
+ const visit = (file: string): string | undefined => {
1446
+ if (visiting.has(file)) return undefined
1447
+ if (!existsSync(file)) return file
1448
+ // Raw framework/package file URLs are ordinary immutable dependencies, not
1449
+ // members of the materialized graph whose publication order we own.
1450
+ if (!isInside(root, file) || !compiledScriptFilePattern.test(file)) return undefined
1451
+
1452
+ visiting.add(file)
1453
+ const cachedTargets = completeArtifactClosures.get(file)
1454
+ if (cachedTargets) {
1455
+ for (const target of cachedTargets) {
1456
+ const missing = visit(target)
1457
+ if (missing) return missing
1458
+ }
1459
+ visiting.delete(file)
1460
+ return undefined
1461
+ }
1462
+
1463
+ let source: string
1464
+ try {
1465
+ source = readFileSync(file, 'utf8')
1466
+ } catch {
1467
+ return file
1468
+ }
1469
+ const targets: string[] = []
1470
+ for (const { specifier, kind } of moduleSpecifierEdges(source, file)) {
1471
+ // A dynamic import is an on-demand publication boundary: its target may
1472
+ // belong to a later client-chunk phase even when both artifacts share a
1473
+ // profile directory. Static imports must be complete before evaluation.
1474
+ if (kind === 'dynamic') continue
1475
+ const { sourcePath } = splitHash(specifier)
1476
+ if (!sourcePath.startsWith('file://')) continue
1477
+ let target: string
1478
+ try {
1479
+ target = fileURLToPath(sourcePath)
1480
+ } catch {
1481
+ return sourcePath
1482
+ }
1483
+ if (!isInside(root, target)) {
1484
+ if (existsSync(target)) continue
1485
+ const emittedRoot = compiledArtifactProfileRoot(target)
1486
+ // A different profile is produced by a separate build phase. Only a
1487
+ // dead reference to this same profile can be a relocated cache member.
1488
+ if (!emittedRoot || path.basename(emittedRoot) !== path.basename(root)) continue
1489
+ target = path.resolve(root, path.relative(emittedRoot, target))
1490
+ }
1491
+ const missing = visit(target)
1492
+ if (missing) return missing
1493
+ targets.push(target)
1494
+ }
1495
+ completeArtifactClosures.set(file, targets)
1496
+ visiting.delete(file)
1497
+ return undefined
1498
+ }
1499
+
1500
+ return visit(entry)
1501
+ }
1502
+
1503
+ /** Accept a complete hit, or join the exact missing member already being published locally. */
1504
+ function compiledArtifactReadyOrCompleting(entry: string, visited: Set<string>): boolean {
1505
+ if (!devArtifactUsable(entry)) return false
1506
+ const missing = missingCompiledArtifact(entry)
1507
+ if (!missing) return true
1508
+ const completing = moduleBuilds.get(missing)
1509
+ if (!completing) return false
1510
+ ownedBuilds(visited).add(completing)
1511
+ return true
1512
+ }
1513
+
1514
+ function assertCompiledArtifactClosure(entry: string): void {
1515
+ const missing = missingCompiledArtifact(entry)
1516
+ if (!missing) return
1517
+ const error = new Error(`incomplete server module graph ${entry}: ${missing}`)
1518
+ noteModuleGraphFailure(error)
1519
+ throw error
1520
+ }
1521
+
1522
+ function compiledArtifactProfileRoot(file: string): string | undefined {
1523
+ const marker = `${path.sep}cache${path.sep}server${path.sep}`
1524
+ const index = file.lastIndexOf(marker)
1525
+ if (index < 0) return undefined
1526
+ const profileStart = index + marker.length
1527
+ const profileEnd = file.indexOf(path.sep, profileStart)
1528
+ if (profileEnd < 0) return undefined
1529
+ const profile = file.slice(profileStart, profileEnd)
1530
+ return profile === 'modules' || profile.startsWith('modules-')
1531
+ ? file.slice(0, profileEnd)
1532
+ : undefined
1533
+ }
1534
+
1098
1535
  /** Where `file` compiles to under `profile`, named by its current source graph. */
1099
1536
  async function devModulePathFor(config: ResolvedConfig, file: string, profile: string) {
1100
1537
  return devServerPath(config, file, profile, await devModuleGraph(config).graphHash(file))
@@ -1116,11 +1553,11 @@ async function writeDevModule(
1116
1553
  noteModuleGeneration(visitedKey, outFile)
1117
1554
  if (visited.has(visitedKey)) return outFile
1118
1555
 
1119
- // The name carries the hash of this module's whole source graph, so an existing artifact is by
1120
- // construction current. An unusable one (the cache folder was wiped from outside) falls through to
1121
- // recompile, which overwrites it in place - never delete first, since Bun caches a failed resolution for
1122
- // the life of the process.
1123
- if (devArtifactUsable(outFile)) return outFile
1556
+ // The name carries the hash of this module's whole source graph, so an existing artifact is current.
1557
+ // It is reusable only when its emitted closure is present too: another process may have published the
1558
+ // importer first. An unusable artifact falls through to recompile and is overwritten in place - never
1559
+ // delete first, since Bun caches a failed resolution for the life of the process.
1560
+ if (compiledArtifactReadyOrCompleting(outFile, visited)) return outFile
1124
1561
 
1125
1562
  // Already being built: its artifact path is all this caller needs, and the entry's drain is what
1126
1563
  // waits for the bytes. Awaiting it here is what used to deadlock two walks meeting a cycle. The
@@ -1170,6 +1607,7 @@ async function writeDevModuleUncached(
1170
1607
  return outFile
1171
1608
  }
1172
1609
 
1610
+ let source: string | undefined
1173
1611
  // A client-profile compile of a 'use server' module emits the RPC stub, so
1174
1612
  // the browser bundle POSTs to the endpoint instead of shipping server code.
1175
1613
  if (options.profile === 'client' && nextCompatEnabled(config)) {
@@ -1180,10 +1618,16 @@ async function writeDevModuleUncached(
1180
1618
  await writeCompiledFile(outFile, await clientActions.stubSource(file))
1181
1619
  return outFile
1182
1620
  }
1621
+ const loaded = await clientActions.loadClientSource(file, config.root)
1622
+ source = loaded.source
1623
+ if (loaded.stubSource !== undefined) {
1624
+ await writeCompiledFile(outFile, loaded.stubSource)
1625
+ return outFile
1626
+ }
1183
1627
  }
1184
1628
  }
1185
1629
 
1186
- let source = await readText(file)
1630
+ source ??= await readText(file)
1187
1631
  if (/\.mdx?$/.test(file) && nextCompatEnabled(config)) {
1188
1632
  // Markdown modules must be compiled to JS before import analysis and the
1189
1633
  // stdin-based bundle pass (which bypasses file-resolved onLoad plugins).
@@ -1263,10 +1707,31 @@ async function writeDevModuleUncached(
1263
1707
  .map(({ target }) => writeDevModule(config, target, visited, effectiveOptions)),
1264
1708
  ])
1265
1709
 
1266
- await writeCompiledFile(outFile, await compiled)
1710
+ const compiledSource = await compiled
1711
+ if (skipModuleWriteForTest(file)) return outFile
1712
+ await delayWarmModuleWriteForTest(file)
1713
+ await writeCompiledFile(outFile, compiledSource)
1267
1714
  return outFile
1268
1715
  }
1269
1716
 
1717
+ /** Test-only seam for proving an irreparable incomplete graph fails the build. */
1718
+ function skipModuleWriteForTest(file: string) {
1719
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
1720
+ const suffix = process.env.PNEXT_TEST_SKIP_MODULE_WRITE
1721
+ return Boolean(suffix && file.endsWith(suffix))
1722
+ }
1723
+
1724
+ /** Test-only seam for forcing the build/warm-child partial-graph publication window. */
1725
+ async function delayWarmModuleWriteForTest(file: string) {
1726
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
1727
+ const setting = process.env.PNEXT_TEST_DELAY_WARM_MODULE_WRITE
1728
+ if (!setting || !process.argv.some(argument => argument.endsWith('vercel-warm.ts'))) return
1729
+ const separator = setting.lastIndexOf(':')
1730
+ if (separator < 1 || !file.endsWith(setting.slice(0, separator))) return
1731
+ const delay = Number(setting.slice(separator + 1))
1732
+ if (Number.isFinite(delay) && delay > 0) await Bun.sleep(delay)
1733
+ }
1734
+
1270
1735
  const shakeEntrySpecifier = 'pnext-shake-entry'
1271
1736
  const shakeEntryNamespace = 'pnext-shake-entry'
1272
1737
 
@@ -1361,7 +1826,7 @@ async function writeShakenDynamicModule(
1361
1826
  ): Promise<string> {
1362
1827
  const profile = devModuleProfile(options)
1363
1828
  const outFile = await shakenModulePath(config, targetFile, profile, shakeExportsKey(usedExports))
1364
- if (devArtifactUsable(outFile)) return outFile
1829
+ if (compiledArtifactReadyOrCompleting(outFile, visited)) return outFile
1365
1830
 
1366
1831
  const key = `shake\0${outFile}`
1367
1832
  if (moduleBuilds.has(key)) return outFile
@@ -2543,6 +3008,7 @@ const routeBundleKeys = new Map<string, Promise<string>>()
2543
3008
  /** A save may rename any route bundle: drop every memoized name. */
2544
3009
  export function clearDevRouteBundleKeys() {
2545
3010
  routeBundleKeys.clear()
3011
+ clientReferenceBundleKeys.clear()
2546
3012
  }
2547
3013
 
2548
3014
  // The route bundle inlines the route file and its layouts, so its name carries
@@ -2698,11 +3164,7 @@ async function linkDirectoryAssetContext(
2698
3164
  await writeText(target, await stagedManifestSource(source))
2699
3165
  return
2700
3166
  }
2701
- try {
2702
- await symlink(source, target, 'file')
2703
- } catch {
2704
- if (!existsSync(target)) await copyFile(source, target)
2705
- }
3167
+ await linkOrCopyAsset(source, target)
2706
3168
  }),
2707
3169
  )
2708
3170
  }
@@ -2720,6 +3182,29 @@ async function stagedManifestSource(file: string) {
2720
3182
  // into subdirectories, but never link code files — those compile through the
2721
3183
  // module graph into their own staged output and must not be shadowed by a raw
2722
3184
  // symlink.
3185
+ /** Stage an asset, repairing a dangling link left by cache relocation. */
3186
+ async function linkOrCopyAsset(source: string, target: string) {
3187
+ const resolvedSource = path.resolve(source)
3188
+ // Concurrent walkers keep a repaired link and remove only the stale target.
3189
+ for (let attempt = 0; attempt < 4; attempt += 1) {
3190
+ try {
3191
+ await symlink(source, target, 'file')
3192
+ return
3193
+ } catch (error) {
3194
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') break
3195
+ }
3196
+ try {
3197
+ const linked = await readlink(target)
3198
+ if (path.resolve(path.dirname(target), linked) === resolvedSource) return
3199
+ await rm(target, { force: true })
3200
+ } catch {
3201
+ // It disappeared between EEXIST and inspection; retry the link.
3202
+ if (existsSync(target)) return
3203
+ }
3204
+ }
3205
+ if (!existsSync(target)) await copyFile(source, target)
3206
+ }
3207
+
2723
3208
  async function mirrorAssetDirectory(sourceDir: string, targetDir: string) {
2724
3209
  let entries: Dirent<string>[]
2725
3210
  try {
@@ -2739,11 +3224,7 @@ async function mirrorAssetDirectory(sourceDir: string, targetDir: string) {
2739
3224
  if (isCodeFile(entry.name)) return
2740
3225
  if (existsSync(target)) return
2741
3226
  await mkdir(path.dirname(target), { recursive: true })
2742
- try {
2743
- await symlink(source, target, 'file')
2744
- } catch {
2745
- if (!existsSync(target)) await copyFile(source, target)
2746
- }
3227
+ await linkOrCopyAsset(source, target)
2747
3228
  }),
2748
3229
  )
2749
3230
  }