@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.
- package/bin/pnext +1 -1
- package/package.json +2 -2
- package/reference/data/bench.json +398 -223
- package/reference/getting-started.md +1 -1
- package/src/api/client-navigation.ts +7 -5
- package/src/cli/adapters/vercel-warm.ts +120 -28
- package/src/cli/build.ts +56 -15
- package/src/cli/create.ts +1 -1
- package/src/cli/dev.ts +2 -1
- package/src/cli/index.ts +146 -10
- package/src/cli/migrate/report.ts +1 -1
- package/src/cli/serve/pipeline.ts +23 -6
- package/src/cli/serve/ui.ts +1 -1
- package/src/cli/start.ts +2 -1
- package/src/cli/typegen.ts +5 -2
- package/src/client/build.ts +217 -58
- package/src/client/chunk-fold.ts +7 -1
- package/src/client/entry.ts +44 -7
- package/src/client/router/runtime.ts +1016 -230
- package/src/client/router/types.ts +15 -0
- package/src/compat/actions/client-plugin.ts +10 -0
- package/src/compat/actions/rewrite.ts +1 -0
- package/src/compat/bundler/optimize-package-imports.ts +375 -28
- package/src/compat/client/navigation-scroll.ts +29 -3
- package/src/compat/client/optimistic-routing.ts +2 -2
- package/src/compat/client/segment-cache-policy.ts +4 -18
- package/src/compat/client/segment-cache.ts +104 -0
- package/src/compat/client/segment-prefetch.ts +6 -6
- package/src/compat/next/config-loader.ts +6 -4
- package/src/compat/next/navigation.ts +16 -2
- package/src/compat/pages/router.ts +102 -9
- package/src/compat/protocol.ts +10 -1
- package/src/compat/register/actions.ts +2 -0
- package/src/compat/register/bundler.ts +4 -6
- package/src/compat/register/routing.ts +3 -4
- package/src/compat/tsconfig-defaults.ts +3 -2
- package/src/config.ts +3 -3
- package/src/css/postcss.ts +450 -17
- package/src/dev/client-actions.ts +2 -0
- package/src/dev/server.ts +87 -14
- package/src/render/island-context.ts +3 -0
- package/src/render/ppr.ts +14 -0
- package/src/render/renderer.ts +268 -42
- package/src/request/context.ts +32 -11
- package/src/resolve/scan-facts.ts +23 -4
- package/src/routing/proxy.ts +29 -25
- package/src/routing/routes.ts +1 -1
- package/src/runtime/loader.ts +47 -1
- package/src/runtime/modules.ts +546 -65
- package/src/runtime/vendor-build.ts +109 -22
- package/src/runtime/vendor.ts +7 -3
- package/src/utils/ansi.ts +3 -1
- package/src/utils/fs.ts +17 -3
package/src/runtime/modules.ts
CHANGED
|
@@ -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 {
|
|
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>(
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
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.
|
|
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
|
|
1120
|
-
//
|
|
1121
|
-
//
|
|
1122
|
-
// the life of the process.
|
|
1123
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|