@wular/pnext 0.0.12 → 0.0.14
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/package.json +1 -1
- package/src/cli/adapters/vercel-warm.ts +102 -19
- package/src/cli/adapters/vercel.ts +127 -33
- package/src/cli/serve/pipeline.ts +25 -7
- package/src/cli/start.ts +1 -1
- package/src/compat/next/config-loader.ts +111 -56
- package/src/compat/next/font/cache.ts +5 -3
- package/src/compat/next/font/runtime.ts +60 -15
- package/src/config.ts +13 -4
package/package.json
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* routes' server modules as soon as the route scan hands them over, and blocks on stdin for the
|
|
21
21
|
* go-ahead to finish the rest once the manifest is written.
|
|
22
22
|
*/
|
|
23
|
-
import { existsSync } from 'node:fs'
|
|
23
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
24
24
|
import { readFile } from 'node:fs/promises'
|
|
25
25
|
import path from 'node:path'
|
|
26
26
|
import { fileURLToPath } from 'node:url'
|
|
@@ -33,8 +33,9 @@ import {
|
|
|
33
33
|
setEmitCompiledSpecifiersManifest,
|
|
34
34
|
} from '../../runtime/modules'
|
|
35
35
|
import { getCompatModeExtensions } from '../../extensions'
|
|
36
|
+
import { getFontExtensions } from '../../render/hooks'
|
|
36
37
|
import { findConventionFiles, findLayouts } from '../../routing/routes'
|
|
37
|
-
import { registerServerRuntime } from '../../runtime/loader'
|
|
38
|
+
import { registerServerRuntime, serverBundleTargetForRuntime } from '../../runtime/loader'
|
|
38
39
|
import { namedBunBinary } from '../boot/named-bin'
|
|
39
40
|
import type { VerboseLogger } from '../../utils/verbose'
|
|
40
41
|
import type { BuildManifest } from '../../types'
|
|
@@ -252,8 +253,17 @@ function pageServerFiles(config: ResolvedConfig, routeFiles: Iterable<string>) {
|
|
|
252
253
|
return files
|
|
253
254
|
}
|
|
254
255
|
|
|
255
|
-
const compileServer = (config: ResolvedConfig, file: string) =>
|
|
256
|
-
devServerModuleHref(config, file, 'build'
|
|
256
|
+
const compileServer = (config: ResolvedConfig, file: string, runtime = '') =>
|
|
257
|
+
devServerModuleHref(config, file, 'build', {
|
|
258
|
+
conditionTarget: serverBundleTargetForRuntime(runtime || undefined),
|
|
259
|
+
}).then(href => report(compileKey(file, runtime), fileURLToPath(href)))
|
|
260
|
+
|
|
261
|
+
// A finished compile and a finished import are different facts about the same source, and a restart
|
|
262
|
+
// skips by key: reporting a handler's compile under its plain path would mark it imported too, so a
|
|
263
|
+
// crash in ONE handler's import would skip every other handler's import on the next attempt.
|
|
264
|
+
const compileKey = (file: string, runtime: string) => `${runtime}\u0000${file}`
|
|
265
|
+
const splitCompileKey = (key: string) => key.split('\u0000') as [string, string]
|
|
266
|
+
const importKey = (file: string) => `import\u0000${file}`
|
|
257
267
|
|
|
258
268
|
/**
|
|
259
269
|
* Compile the page routes' server modules from the build's route scan, long before the manifest
|
|
@@ -279,19 +289,24 @@ export async function warmRouteModules(
|
|
|
279
289
|
manifest: BuildManifest,
|
|
280
290
|
skip: ReadonlySet<string> = new Set(),
|
|
281
291
|
) {
|
|
292
|
+
// Every compile below is keyed by runtime as well as file. A module reachable from routes with
|
|
293
|
+
// different `runtime` segments resolves to a different artifact per route, and the renderer asks
|
|
294
|
+
// for the one its route selects — warming only the default target writes artifacts no request
|
|
295
|
+
// ever reads, and leaves the real ones to be compiled against a read-only filesystem.
|
|
282
296
|
const clientFiles = new Set<string>()
|
|
283
297
|
const handlers: BuildManifest['routes'] = []
|
|
284
|
-
const pageRoutes
|
|
298
|
+
const pageRoutes = new Map<string, string[]>()
|
|
285
299
|
|
|
286
300
|
for (const route of manifest.routes) {
|
|
287
301
|
if (route.kind === 'handler') {
|
|
288
|
-
if (!skip.has(route.file)) handlers.push(route)
|
|
302
|
+
if (!skip.has(importKey(route.file))) handlers.push(route)
|
|
289
303
|
continue
|
|
290
304
|
}
|
|
291
|
-
|
|
305
|
+
const runtime = route.segmentConfig?.runtime ?? ''
|
|
306
|
+
pageRoutes.set(runtime, [...(pageRoutes.get(runtime) ?? []), route.file])
|
|
292
307
|
for (const reference of route.clientReferences) {
|
|
293
308
|
if (ssrClientReference(reference) && existsSync(reference.file)) {
|
|
294
|
-
clientFiles.add(reference.file)
|
|
309
|
+
clientFiles.add(compileKey(reference.file, runtime))
|
|
295
310
|
}
|
|
296
311
|
}
|
|
297
312
|
}
|
|
@@ -300,16 +315,29 @@ export async function warmRouteModules(
|
|
|
300
315
|
// transforms in memory); the compiled build cache is only used with compat.
|
|
301
316
|
if (getCompatModeExtensions().reactEnabled(config)) {
|
|
302
317
|
await Promise.all([
|
|
303
|
-
...[...
|
|
304
|
-
|
|
305
|
-
|
|
318
|
+
...[...pageRoutes].flatMap(([runtime, routeFiles]) =>
|
|
319
|
+
[...pageServerFiles(config, routeFiles)]
|
|
320
|
+
.filter(file => !skip.has(compileKey(file, runtime)))
|
|
321
|
+
.map(file => compileServer(config, file, runtime)),
|
|
322
|
+
),
|
|
323
|
+
// Handlers are compiled here, not left to the import below: the import runs the source through
|
|
324
|
+
// the registered runtime, while a request resolves the route as a compiled module under the
|
|
325
|
+
// condition target its `runtime` segment selects. Only this pass writes THAT artifact, and
|
|
326
|
+
// without it the first request compiles the handler against a read-only filesystem.
|
|
327
|
+
...handlers.map(route =>
|
|
328
|
+
compileServer(config, route.file, route.segmentConfig?.runtime ?? ''),
|
|
329
|
+
),
|
|
306
330
|
...[...clientFiles]
|
|
307
|
-
.filter(
|
|
308
|
-
.map(
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
331
|
+
.filter(key => !skip.has(key))
|
|
332
|
+
.map(key => {
|
|
333
|
+
const [runtime, file] = splitCompileKey(key)
|
|
334
|
+
return devClientModuleHref(
|
|
335
|
+
config,
|
|
336
|
+
file,
|
|
337
|
+
'build',
|
|
338
|
+
serverBundleTargetForRuntime(runtime || undefined),
|
|
339
|
+
).then(href => report(key, fileURLToPath(href)))
|
|
340
|
+
}),
|
|
313
341
|
])
|
|
314
342
|
}
|
|
315
343
|
console.log(COMPILED_MARKER)
|
|
@@ -325,15 +353,68 @@ export async function warmRouteModules(
|
|
|
325
353
|
// time, so a handler that takes the process down is unambiguous and a
|
|
326
354
|
// restart must not run it again (Bun caches a failed import for the
|
|
327
355
|
// process' lifetime anyway, so a retry could not succeed either).
|
|
328
|
-
report(route.file)
|
|
356
|
+
report(importKey(route.file))
|
|
329
357
|
try {
|
|
330
358
|
await import(pathToFileHref(route.file))
|
|
331
359
|
} catch (error) {
|
|
332
360
|
console.warn(`vercel adapter: importing ${route.file} failed during warmup:`, error)
|
|
333
361
|
}
|
|
334
362
|
}
|
|
363
|
+
|
|
364
|
+
await materializeFonts(config, [...pageRoutes.values()].flat())
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* `next/font` emits its assets, and records the resolution a render reads, the first time something
|
|
369
|
+
* asks for a font - which on a deployed server is a request, against a read-only filesystem. Fonts are
|
|
370
|
+
* declared at module scope, so evaluating the modules that declare them is enough to register them;
|
|
371
|
+
* the render layer's own prewarm hook then resolves every one while writing still works, leaving the
|
|
372
|
+
* files under the out dir and the resolution in the build's font index. Only layouts are evaluated,
|
|
373
|
+
* not the whole page graph: a font belongs to the document shell, and the rest of the graph running
|
|
374
|
+
* at build time is exactly what this pass avoids.
|
|
375
|
+
*/
|
|
376
|
+
async function materializeFonts(config: ResolvedConfig, pageRoutes: string[]) {
|
|
377
|
+
// Nothing to register, and evaluating an app's layouts for no reason is a compile it never asked
|
|
378
|
+
// for: the font runtime is only in the compiled cache when something in the graph imports
|
|
379
|
+
// `next/font`, which the compile pass above has just settled.
|
|
380
|
+
if (!compiledFontRuntime(config)) return
|
|
381
|
+
const layouts = new Set<string>()
|
|
382
|
+
for (const routeFile of pageRoutes) {
|
|
383
|
+
for (const layout of findLayouts(config.appPath, routeFile)) {
|
|
384
|
+
if (existsSync(layout)) layouts.add(layout)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
for (const file of layouts) {
|
|
388
|
+
try {
|
|
389
|
+
await import(await devServerModuleHref(config, file, 'build'))
|
|
390
|
+
} catch (error) {
|
|
391
|
+
console.warn(`vercel adapter: evaluating ${file} for fonts failed during warmup:`, error)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
await getFontExtensions().prewarmFontAssets(config, { dev: false })
|
|
335
395
|
}
|
|
336
396
|
|
|
397
|
+
/** Whether the compile pass wrote an artifact for the next/font runtime. */
|
|
398
|
+
function compiledFontRuntime(config: ResolvedConfig): boolean {
|
|
399
|
+
const stack = [path.join(config.outPath, 'cache', 'server')]
|
|
400
|
+
while (stack.length > 0) {
|
|
401
|
+
const current = stack.pop()!
|
|
402
|
+
let entries
|
|
403
|
+
try {
|
|
404
|
+
entries = readdirSync(current, { withFileTypes: true })
|
|
405
|
+
} catch {
|
|
406
|
+
continue
|
|
407
|
+
}
|
|
408
|
+
for (const entry of entries) {
|
|
409
|
+
if (entry.isDirectory()) stack.push(path.join(current, entry.name))
|
|
410
|
+
else if (entry.name.startsWith('runtime.') && current.endsWith(FONT_RUNTIME_DIR)) return true
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return false
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const FONT_RUNTIME_DIR = path.join('compat', 'next', 'font')
|
|
417
|
+
|
|
337
418
|
/**
|
|
338
419
|
* Read the parent's line-delimited requests until the `skip` message, running
|
|
339
420
|
* each prewarm batch as it arrives. Returns the final request.
|
|
@@ -362,7 +443,9 @@ async function readWarmRequests(config: ResolvedConfig): Promise<Partial<WarmReq
|
|
|
362
443
|
|
|
363
444
|
if (import.meta.main) {
|
|
364
445
|
const root = process.argv[2]
|
|
365
|
-
|
|
446
|
+
// serve: the parent already wrote the config bundle this child would otherwise re-emit over the
|
|
447
|
+
// top of - same content-addressed path, opened with O_TRUNC, while the parent may be importing it.
|
|
448
|
+
const config = await loadConfig(root, { serve: true })
|
|
366
449
|
await bootstrapCompat(config)
|
|
367
450
|
// This process only ever runs for a vercel-adapter build; record each
|
|
368
451
|
// artifact's specifiers so the parent's trace step can read them back.
|
|
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, readdirSync, realpathSync, rmSync, statSync }
|
|
|
3
3
|
import { builtinModules } from 'node:module'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { fileURLToPath } from 'node:url'
|
|
6
|
-
import { pathToFileHref } from '../../config'
|
|
6
|
+
import { DEFAULT_OUT_DIR, devOutSegment, pathToFileHref } from '../../config'
|
|
7
7
|
import type { ResolvedConfig } from '../../config'
|
|
8
8
|
import { globalCssSources } from '../../css/build'
|
|
9
9
|
import { getImportAliasExtensions } from '../../extensions'
|
|
@@ -19,7 +19,13 @@ import type { BuildManifest, StaticFileMetadata } from '../../types'
|
|
|
19
19
|
|
|
20
20
|
// Vercel unpacks a function's `.func` directory here at runtime. Baked
|
|
21
21
|
// absolute paths in the build output are rewritten to this root.
|
|
22
|
-
|
|
22
|
+
//
|
|
23
|
+
// PNEXT_VERCEL_EMULATE points both this and FUNCTION_PLATFORM at a local path and the host's own
|
|
24
|
+
// platform, so the built function can be run on this machine exactly as the deployment does - the
|
|
25
|
+
// only way to reproduce a request-time failure that needs the function's read-only root.
|
|
26
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
27
|
+
const emulateRoot = process.env.PNEXT_VERCEL_EMULATE
|
|
28
|
+
const RUNTIME_ROOT = emulateRoot ? path.resolve(emulateRoot) : '/var/task'
|
|
23
29
|
|
|
24
30
|
// The single catch-all server function: the same `pnext start` pipeline,
|
|
25
31
|
// running on Vercel's Bun runtime (`"bunVersion": "1.x"` in vercel.json).
|
|
@@ -29,12 +35,20 @@ const SERVER_FUNCTION = '_pnext'
|
|
|
29
35
|
// goes into .vc-config.json and the same target selects the native packages
|
|
30
36
|
// shipped inside it, so the two can never drift. Vercel's default instruction
|
|
31
37
|
// set is x86_64 and its function runtime is glibc-based.
|
|
32
|
-
const FUNCTION_PLATFORM =
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
const FUNCTION_PLATFORM = emulateRoot
|
|
39
|
+
? {
|
|
40
|
+
architecture: process.arch === 'arm64' ? 'arm64' : 'x86_64',
|
|
41
|
+
os: process.platform === 'darwin' ? 'darwin' : 'linux',
|
|
42
|
+
cpu: process.arch,
|
|
43
|
+
// No libc constraint off linux: darwin packages declare none.
|
|
44
|
+
libc: process.platform === 'darwin' ? '' : 'glibc',
|
|
45
|
+
}
|
|
46
|
+
: ({
|
|
47
|
+
architecture: 'x86_64',
|
|
48
|
+
os: 'linux',
|
|
49
|
+
cpu: 'x64',
|
|
50
|
+
libc: 'glibc',
|
|
51
|
+
} as const)
|
|
38
52
|
|
|
39
53
|
// Directories that only ever hold build-tool output, pruned at *any* depth. Depth matters: pruning
|
|
40
54
|
// only a package's top level ships every nested one. node_modules and .git are here for the same
|
|
@@ -71,6 +85,12 @@ interface PackRules {
|
|
|
71
85
|
droppedSuffixes: string[]
|
|
72
86
|
/** Absolute sources exempt from the prune list — the app's own build output. */
|
|
73
87
|
keepPaths: Set<string>
|
|
88
|
+
/** Absolute sources dropped even though they sit inside a kept path. */
|
|
89
|
+
dropPaths: Set<string>
|
|
90
|
+
/** Same set as `dropPaths`, by basename — the clone pass walks copies, not sources. */
|
|
91
|
+
dropNames: Set<string>
|
|
92
|
+
/** Resolved out root, so a walk can tell when it is standing exactly on it. */
|
|
93
|
+
outRootPath: string
|
|
74
94
|
}
|
|
75
95
|
|
|
76
96
|
// Test seam: the packing suite builds one app both ways and diffs the trees,
|
|
@@ -86,6 +106,9 @@ export function setPackPruningEnabled(enabled: boolean) {
|
|
|
86
106
|
}
|
|
87
107
|
}
|
|
88
108
|
|
|
109
|
+
// Build-time scratch directories at the top level of the out root.
|
|
110
|
+
const outRootScratch = [devOutSegment, 'typecheck', 'types']
|
|
111
|
+
|
|
89
112
|
function packRules(config: ResolvedConfig): PackRules {
|
|
90
113
|
if (!packPruningEnabled) {
|
|
91
114
|
return {
|
|
@@ -94,6 +117,9 @@ function packRules(config: ResolvedConfig): PackRules {
|
|
|
94
117
|
prunedDirs: new Set(['node_modules', '.git']),
|
|
95
118
|
droppedSuffixes: [],
|
|
96
119
|
keepPaths: new Set([path.resolve(config.outPath)]),
|
|
120
|
+
dropPaths: new Set(),
|
|
121
|
+
dropNames: new Set(),
|
|
122
|
+
outRootPath: path.resolve(config.outRootPath),
|
|
97
123
|
}
|
|
98
124
|
}
|
|
99
125
|
const keep = new Set(config.adapter?.keep ?? [])
|
|
@@ -109,15 +135,44 @@ function packRules(config: ResolvedConfig): PackRules {
|
|
|
109
135
|
suffix => !keep.has(suffix),
|
|
110
136
|
),
|
|
111
137
|
// The app's outDir shares its name with the prune list; it holds the
|
|
112
|
-
// compiled module cache the function serves from and must survive.
|
|
113
|
-
|
|
138
|
+
// compiled module cache the function serves from and must survive. The
|
|
139
|
+
// default out root goes with it: a `distDir` app moved its output away, but
|
|
140
|
+
// the next.config bundle the function imports at boot still lives there.
|
|
141
|
+
keepPaths: new Set([path.resolve(config.outPath), path.resolve(config.root, DEFAULT_OUT_DIR)]),
|
|
142
|
+
// Kept because they sit inside the out dir, shipped because nothing prunes
|
|
143
|
+
// within a kept path — but a dev server's scratch tree (tens of MB on a
|
|
144
|
+
// machine that ran `pnext dev`), the typecheck scratch and the generated
|
|
145
|
+
// types are all build-time only, and the bun1.x function has a 150 MiB cap.
|
|
146
|
+
// Absolute, not by name: `types` and `dist` are ordinary directory names in
|
|
147
|
+
// a dependency, and only these exact ones are build scratch.
|
|
148
|
+
dropPaths: new Set(outRootScratch.map(name => path.resolve(config.outRootPath, name))),
|
|
149
|
+
dropNames: new Set(outRootScratch),
|
|
150
|
+
outRootPath: path.resolve(config.outRootPath),
|
|
114
151
|
}
|
|
115
152
|
}
|
|
116
153
|
|
|
117
|
-
|
|
118
|
-
|
|
154
|
+
// `inOutRoot` marks a walk already inside the app's own build output. Nothing in there is a package
|
|
155
|
+
// store or a build-tool scratch dir the prune list is aimed at: the compiled module cache mirrors
|
|
156
|
+
// SOURCE paths, so every module compiled out of a dependency sits under a literal `node_modules`
|
|
157
|
+
// segment, and pruning by name deletes artifacts the function resolves by exact path at request time.
|
|
158
|
+
function shipsDir(
|
|
159
|
+
pack: PackRules,
|
|
160
|
+
name: string,
|
|
161
|
+
source: string,
|
|
162
|
+
inOutRoot = false,
|
|
163
|
+
symlink = false,
|
|
164
|
+
) {
|
|
165
|
+
const absolute = path.resolve(source)
|
|
166
|
+
if (pack.dropPaths.has(absolute)) return false
|
|
167
|
+
// A *link* named node_modules inside the out root points at the real store, and following it
|
|
168
|
+
// copies the whole thing; a real directory there is the compiled module cache, whose artifacts
|
|
169
|
+
// mirror source paths — so one of them is literally `<out>/cache/.../node_modules/<pkg>/...`.
|
|
170
|
+
if (inOutRoot) return !(symlink && pack.prunedDirs.has(name))
|
|
171
|
+
return !pack.prunedDirs.has(name) || pack.keepPaths.has(absolute)
|
|
119
172
|
}
|
|
120
173
|
|
|
174
|
+
const isOutRoot = (pack: PackRules, dir: string) => path.resolve(dir) === pack.outRootPath
|
|
175
|
+
|
|
121
176
|
function shipsFile(pack: PackRules, name: string) {
|
|
122
177
|
return !pack.droppedSuffixes.some(suffix => name.endsWith(suffix))
|
|
123
178
|
}
|
|
@@ -137,6 +192,12 @@ export async function writeVercelOutput(
|
|
|
137
192
|
options: { verbose?: boolean; warm?: WarmChild } = {},
|
|
138
193
|
) {
|
|
139
194
|
const log = createVerboseLogger(options.verbose ?? false, 'vercel')
|
|
195
|
+
if (emulateRoot) {
|
|
196
|
+
console.warn(
|
|
197
|
+
`pnext: PNEXT_VERCEL_EMULATE is set — this output is built for ${FUNCTION_PLATFORM.os}-` +
|
|
198
|
+
`${FUNCTION_PLATFORM.cpu} and rooted at ${RUNTIME_ROOT}. It runs locally; do not deploy it.`,
|
|
199
|
+
)
|
|
200
|
+
}
|
|
140
201
|
const outputPath = path.join(config.root, '.vercel', 'output')
|
|
141
202
|
const staticFiles = manifest.staticFiles ?? {}
|
|
142
203
|
// Warming runs in its own process (see ./vercel-warm) and normally started
|
|
@@ -333,14 +394,26 @@ async function writeServerFunction(
|
|
|
333
394
|
)
|
|
334
395
|
|
|
335
396
|
// Baked absolute paths (compiled cache imports, manifest file paths) point
|
|
336
|
-
// at the build machine's workspace; rewrite them to the runtime root.
|
|
397
|
+
// at the build machine's workspace; rewrite them to the runtime root. A
|
|
398
|
+
// `distDir` app moved its output away from the default out root, but the
|
|
399
|
+
// next.config bundle stays there and bakes paths of its own, so both trees go
|
|
400
|
+
// through the pass.
|
|
401
|
+
const bakedRoots = new Set(
|
|
402
|
+
[config.outPath, path.resolve(config.root, DEFAULT_OUT_DIR)].map(dir =>
|
|
403
|
+
path.join(functionPath, appRel, path.relative(config.root, dir)),
|
|
404
|
+
),
|
|
405
|
+
)
|
|
337
406
|
await log.step('rewrite baked paths', () =>
|
|
338
|
-
|
|
339
|
-
...(
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
407
|
+
Promise.all(
|
|
408
|
+
[...bakedRoots].map(dir =>
|
|
409
|
+
rewriteBakedPaths(dir, [
|
|
410
|
+
...(pnextInWorkspace
|
|
411
|
+
? []
|
|
412
|
+
: [[pnextRoot, toPosixPath(path.join(RUNTIME_ROOT, pnextTargetRel))] as const]),
|
|
413
|
+
[workspaceRoot, RUNTIME_ROOT] as const,
|
|
414
|
+
]),
|
|
415
|
+
),
|
|
416
|
+
),
|
|
344
417
|
)
|
|
345
418
|
|
|
346
419
|
// Bun's runtime onResolve plugins never see bare specifiers, so the compat aliases the dev pipeline
|
|
@@ -455,13 +528,16 @@ async function copyTree(
|
|
|
455
528
|
pack: PackRules,
|
|
456
529
|
excludeNames: string[] = [],
|
|
457
530
|
visited = new Set<string>([realDir(from) ?? path.resolve(from)]),
|
|
531
|
+
inOutRoot = pack.keepPaths.has(path.resolve(from)),
|
|
458
532
|
) {
|
|
459
533
|
const exclude = new Set(excludeNames)
|
|
460
534
|
const clone = directoryCloneEnabled ? await directoryCloner() : null
|
|
461
535
|
// Nothing to skip at this level: the whole tree clones in one syscall.
|
|
462
536
|
if (exclude.size === 0 && !existsSync(to)) {
|
|
463
537
|
await mkdir(path.dirname(to), { recursive: true })
|
|
464
|
-
if (clone?.(from, to))
|
|
538
|
+
if (clone?.(from, to)) {
|
|
539
|
+
return normalizeClone(to, pack, visited, inOutRoot, isOutRoot(pack, from))
|
|
540
|
+
}
|
|
465
541
|
}
|
|
466
542
|
// The build output is still live while the walk runs — typegen rewrites
|
|
467
543
|
// `.pnext/types` under it — so a directory can vanish between the parent's
|
|
@@ -475,23 +551,29 @@ async function copyTree(
|
|
|
475
551
|
const target = path.join(to, entry.name)
|
|
476
552
|
// Matched by name before type: a symlinked `node_modules` must be pruned
|
|
477
553
|
// as the directory it points at, not followed.
|
|
478
|
-
if (
|
|
554
|
+
if (
|
|
555
|
+
exclude.has(entry.name) ||
|
|
556
|
+
!shipsDir(pack, entry.name, source, inOutRoot, entry.isSymbolicLink())
|
|
557
|
+
) {
|
|
558
|
+
return
|
|
559
|
+
}
|
|
560
|
+
const childInOutRoot = inOutRoot || pack.keepPaths.has(path.resolve(source))
|
|
479
561
|
if (entry.isSymbolicLink()) {
|
|
480
562
|
const link = resolveLinkTarget(source)
|
|
481
563
|
if (!link) return
|
|
482
564
|
if (link.isDirectory) {
|
|
483
565
|
if (visited.has(link.path)) return
|
|
484
566
|
visited.add(link.path)
|
|
485
|
-
return copyTree(link.path, target, pack, [], visited)
|
|
567
|
+
return copyTree(link.path, target, pack, [], visited, childInOutRoot)
|
|
486
568
|
}
|
|
487
569
|
return shipsFile(pack, entry.name) ? linkOrCopyFile(link.path, target) : undefined
|
|
488
570
|
}
|
|
489
571
|
// One syscall for the whole subtree beats one per file — the excluded
|
|
490
572
|
// names are stripped from the copy afterwards.
|
|
491
573
|
if (entry.isDirectory() && clone?.(source, target)) {
|
|
492
|
-
return normalizeClone(target, pack, visited)
|
|
574
|
+
return normalizeClone(target, pack, visited, childInOutRoot, isOutRoot(pack, source))
|
|
493
575
|
}
|
|
494
|
-
if (entry.isDirectory()) return copyTree(source, target, pack, [], visited)
|
|
576
|
+
if (entry.isDirectory()) return copyTree(source, target, pack, [], visited, childInOutRoot)
|
|
495
577
|
if (entry.isFile() && shipsFile(pack, entry.name)) return linkOrCopyFile(source, target)
|
|
496
578
|
}),
|
|
497
579
|
)
|
|
@@ -550,18 +632,30 @@ async function directoryCloner() {
|
|
|
550
632
|
* both paths emit the same tree. Reading directory entries costs nothing next
|
|
551
633
|
* to the per-file copies this replaces.
|
|
552
634
|
*/
|
|
553
|
-
async function normalizeClone(
|
|
554
|
-
|
|
635
|
+
async function normalizeClone(
|
|
636
|
+
dir: string,
|
|
637
|
+
pack: PackRules,
|
|
638
|
+
visited: Set<string>,
|
|
639
|
+
inOutRoot = false,
|
|
640
|
+
atOutRoot = false,
|
|
641
|
+
) {
|
|
642
|
+
// `atOutRoot` only at the level the walk handed over, mirroring the absolute
|
|
643
|
+
// paths shipsDir drops: the scratch dirs exist at the out root's top level and
|
|
644
|
+
// their names (`types`, `dev`) are ordinary ones anywhere else.
|
|
645
|
+
const stack: [string, boolean, boolean][] = [[dir, inOutRoot, atOutRoot]]
|
|
555
646
|
const pending: Promise<unknown>[] = []
|
|
556
647
|
while (stack.length > 0) {
|
|
557
|
-
const current = stack.pop()!
|
|
648
|
+
const [current, inside, atRoot] = stack.pop()!
|
|
558
649
|
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
559
650
|
const full = path.join(current, entry.name)
|
|
560
|
-
// Matched by name before type, as the walk does:
|
|
561
|
-
//
|
|
562
|
-
// whole
|
|
651
|
+
// Matched by name before type, as the walk does: a `node_modules` symlink
|
|
652
|
+
// in the app root points at the real store, and following it would pull
|
|
653
|
+
// the whole thing into the function. The clone is already inside the
|
|
563
654
|
// function, so the walk's keep-path exemption cannot apply here.
|
|
564
|
-
if (
|
|
655
|
+
if (
|
|
656
|
+
(atRoot && pack.dropNames.has(entry.name)) ||
|
|
657
|
+
((!inside || entry.isSymbolicLink()) && pack.prunedDirs.has(entry.name))
|
|
658
|
+
) {
|
|
565
659
|
rmSync(full, { recursive: true, force: true })
|
|
566
660
|
} else if (entry.isSymbolicLink()) {
|
|
567
661
|
const link = resolveLinkTarget(full)
|
|
@@ -570,12 +664,12 @@ async function normalizeClone(dir: string, pack: PackRules, visited: Set<string>
|
|
|
570
664
|
if (link.isDirectory) {
|
|
571
665
|
if (visited.has(link.path)) continue
|
|
572
666
|
visited.add(link.path)
|
|
573
|
-
pending.push(copyTree(link.path, full, pack, [], visited))
|
|
667
|
+
pending.push(copyTree(link.path, full, pack, [], visited, inside))
|
|
574
668
|
} else if (shipsFile(pack, entry.name)) {
|
|
575
669
|
pending.push(linkOrCopyFile(link.path, full))
|
|
576
670
|
}
|
|
577
671
|
} else if (entry.isDirectory()) {
|
|
578
|
-
stack.push(full)
|
|
672
|
+
stack.push([full, inside, false])
|
|
579
673
|
} else if (!shipsFile(pack, entry.name)) {
|
|
580
674
|
rmSync(full, { force: true })
|
|
581
675
|
}
|
|
@@ -88,7 +88,7 @@ export async function createRequestHandler(
|
|
|
88
88
|
// `start` already resolved (and bootstrapped) the config for its output-mode
|
|
89
89
|
// guard; reusing it skips a second loadConfig — env load, pnext.config +
|
|
90
90
|
// next.config imports, app-path resolution — before the ready banner.
|
|
91
|
-
const config = options.config ?? (await loadConfig(options.root))
|
|
91
|
+
const config = options.config ?? (await loadConfig(options.root, { serve: true }))
|
|
92
92
|
// Compat plugin loader: the single gated seam that populates the core
|
|
93
93
|
// extension registries when compat is enabled (no-op for pure-core apps).
|
|
94
94
|
// A prod server answers the very next request, so it takes both tiers here.
|
|
@@ -578,8 +578,13 @@ export async function createRequestHandler(
|
|
|
578
578
|
method === 'GET' &&
|
|
579
579
|
handlerResponse.status === 200
|
|
580
580
|
) {
|
|
581
|
-
|
|
582
|
-
|
|
581
|
+
try {
|
|
582
|
+
await writeFile(built.file, new Uint8Array(await handlerResponse.clone().arrayBuffer()))
|
|
583
|
+
handlerResponse.headers.set('x-nextjs-cache', 'MISS')
|
|
584
|
+
} catch {
|
|
585
|
+
// Best-effort like the page path below: a read-only serving filesystem
|
|
586
|
+
// (a serverless host) costs the cache write, never the response.
|
|
587
|
+
}
|
|
583
588
|
}
|
|
584
589
|
if (
|
|
585
590
|
!built &&
|
|
@@ -593,7 +598,9 @@ export async function createRequestHandler(
|
|
|
593
598
|
handlerResponse.status < 500
|
|
594
599
|
) {
|
|
595
600
|
const file = lazyStaticHandlerPath(config.outPath, url.pathname)
|
|
596
|
-
|
|
601
|
+
// Same best-effort contract as the page path below.
|
|
602
|
+
try {
|
|
603
|
+
if (!file) throw new Error('unsafe path')
|
|
597
604
|
await mkdir(path.dirname(file), { recursive: true })
|
|
598
605
|
await writeFile(file, new Uint8Array(await handlerResponse.clone().arrayBuffer()))
|
|
599
606
|
const relative = path
|
|
@@ -617,6 +624,8 @@ export async function createRequestHandler(
|
|
|
617
624
|
}
|
|
618
625
|
await persistManifest()
|
|
619
626
|
handlerResponse.headers.set('x-nextjs-cache', 'MISS')
|
|
627
|
+
} catch {
|
|
628
|
+
// Lazy caching is best-effort; the response is still served.
|
|
620
629
|
}
|
|
621
630
|
}
|
|
622
631
|
return applyProxyResponse(handlerResponse, proxyResponse)
|
|
@@ -831,8 +840,12 @@ export async function createRequestHandler(
|
|
|
831
840
|
pageResponse.status === 200 &&
|
|
832
841
|
(pageResponse.headers.get('content-type') ?? '').includes('text/html')
|
|
833
842
|
) {
|
|
834
|
-
|
|
835
|
-
|
|
843
|
+
try {
|
|
844
|
+
await writeFile(stalePage, await pageResponse.clone().text())
|
|
845
|
+
pageResponse.headers.set('x-nextjs-cache', 'MISS')
|
|
846
|
+
} catch {
|
|
847
|
+
// Best-effort write-back; the stale copy stays and the fresh render still serves.
|
|
848
|
+
}
|
|
836
849
|
}
|
|
837
850
|
// On-demand revalidation (valid x-prerender-revalidate): the fresh render
|
|
838
851
|
// replaces any prebuilt copy and is reported as REVALIDATED (Next's
|
|
@@ -844,7 +857,12 @@ export async function createRequestHandler(
|
|
|
844
857
|
pageResponse.status === 200 &&
|
|
845
858
|
(pageResponse.headers.get('content-type') ?? '').includes('text/html')
|
|
846
859
|
) {
|
|
847
|
-
|
|
860
|
+
try {
|
|
861
|
+
await writeFile(built.file, await pageResponse.clone().text())
|
|
862
|
+
} catch {
|
|
863
|
+
// Best-effort: an on-demand revalidation still returns the fresh
|
|
864
|
+
// render when the prebuilt copy cannot be replaced.
|
|
865
|
+
}
|
|
848
866
|
}
|
|
849
867
|
pageResponse.headers.set('x-nextjs-cache', 'REVALIDATED')
|
|
850
868
|
}
|
package/src/cli/start.ts
CHANGED
|
@@ -113,7 +113,7 @@ function installProcessErrorHandlers(): void {
|
|
|
113
113
|
// `__NEXT_PRIVATE_STANDALONE_CONFIG` set. pnext mirrors that: load the compat config, read the output
|
|
114
114
|
// mode from the build-compat registry, and reproduce Next's exact messages.
|
|
115
115
|
async function guardUnservableOutputMode(root?: string): Promise<ResolvedConfig> {
|
|
116
|
-
const config = await loadConfig(root)
|
|
116
|
+
const config = await loadConfig(root, { serve: true })
|
|
117
117
|
await bootstrapCompat(config)
|
|
118
118
|
const mode = getBuildExtensions().compat.nextOutputMode()
|
|
119
119
|
if (mode === 'export') {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// export / top-level await / dynamic import() and CJS+ESM authoring styles.
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
|
|
16
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
17
17
|
import { createRequire } from 'node:module'
|
|
18
18
|
import path from 'node:path'
|
|
19
19
|
import { pathToFileURL } from 'node:url'
|
|
@@ -130,11 +130,17 @@ function findTsconfig(root: string): string | undefined {
|
|
|
130
130
|
* Bundle the config with esbuild (external node_modules) and import the result.
|
|
131
131
|
* The emitted file is written next to the config so Node resolves the app's
|
|
132
132
|
* node_modules and relative assets from the original location.
|
|
133
|
+
*
|
|
134
|
+
* The bundle is also a BUILD artifact: the build leaves it on disk, deployment adapters ship it with
|
|
135
|
+
* the app's out dir, and a serve imports that file rather than re-running the pass. Request time is
|
|
136
|
+
* read-only on a serverless host (Vercel's `/var/task`), where the pass cannot write at all - and
|
|
137
|
+
* where it could, it would still put an esbuild spawn on every cold start. A serve whose build left
|
|
138
|
+
* no bundle (the config changed since, matching `next start` re-reading next.config) still bundles.
|
|
133
139
|
*/
|
|
134
140
|
async function importConfigModule(
|
|
135
141
|
configPath: string,
|
|
136
142
|
root: string,
|
|
137
|
-
dev: boolean,
|
|
143
|
+
options: { dev: boolean; serve: boolean },
|
|
138
144
|
): Promise<NextConfigExport> {
|
|
139
145
|
const cjs = cjsConfigFormat(configPath, root)
|
|
140
146
|
if (process.env.PNEXT_CONFIG_FAST !== '0' && selfContainedConfig(configPath)) {
|
|
@@ -147,66 +153,112 @@ async function importConfigModule(
|
|
|
147
153
|
}
|
|
148
154
|
// Dev keeps its scratch bundle in its own subtree; a concurrent build wipes
|
|
149
155
|
// only the build-owned `config/`.
|
|
150
|
-
const outDir = path.join(root, '.pnext', ...(dev ? ['dev'] : []), 'config')
|
|
151
|
-
mkdirSync(outDir, { recursive: true })
|
|
156
|
+
const outDir = path.join(root, '.pnext', ...(options.dev ? ['dev'] : []), 'config')
|
|
152
157
|
// Content-addressed, not timestamped: a per-boot name leaked one bundle per
|
|
153
158
|
// dev start and put a path that changes every boot into the module graph,
|
|
154
|
-
// which renamed every artifact that could reach it.
|
|
159
|
+
// which renamed every artifact that could reach it. It also makes the name a
|
|
160
|
+
// pure function of the config source, so a serve recomputes what its build wrote.
|
|
155
161
|
const outfile = path.join(
|
|
156
162
|
outDir,
|
|
157
163
|
`next.config.${configSourceKey(configPath)}.${cjs ? 'cjs' : 'mjs'}`,
|
|
158
164
|
)
|
|
165
|
+
if (options.serve && existsSync(outfile)) return importConfigBundle(outfile, cjs)
|
|
159
166
|
try {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
167
|
+
mkdirSync(outDir, { recursive: true })
|
|
168
|
+
await bundleConfig({ configPath, root, outDir, outfile, cjs })
|
|
169
|
+
} catch (error) {
|
|
170
|
+
// The only way here on a serve is a build that shipped no bundle for this config, and the most
|
|
171
|
+
// likely reason the rebuild then failed is the read-only request-time filesystem it was meant to
|
|
172
|
+
// avoid. Name the artifact rather than surfacing an esbuild write error from a boot path.
|
|
173
|
+
if (!options.serve) throw error
|
|
174
|
+
throw new Error(
|
|
175
|
+
`pnext: ${path.relative(root, outfile)} is missing and could not be rebuilt here — run ` +
|
|
176
|
+
'`pnext build` and deploy its output dir, which carries the config bundle. Cause: ' +
|
|
177
|
+
(error instanceof Error ? error.message : String(error)),
|
|
178
|
+
{ cause: error },
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
pruneStaleConfigBundles(outDir, outfile)
|
|
182
|
+
return importConfigBundle(outfile, cjs)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function bundleConfig({
|
|
186
|
+
configPath,
|
|
187
|
+
root,
|
|
188
|
+
outDir,
|
|
189
|
+
outfile,
|
|
190
|
+
cjs,
|
|
191
|
+
}: {
|
|
192
|
+
configPath: string
|
|
193
|
+
root: string
|
|
194
|
+
outDir: string
|
|
195
|
+
outfile: string
|
|
196
|
+
cjs: boolean
|
|
197
|
+
}) {
|
|
198
|
+
return build({
|
|
199
|
+
entryPoints: [configPath],
|
|
200
|
+
outfile,
|
|
201
|
+
bundle: true,
|
|
202
|
+
platform: 'node',
|
|
203
|
+
format: cjs ? 'cjs' : 'esm',
|
|
204
|
+
target: 'esnext',
|
|
205
|
+
// Keep bare package imports external so Node resolves them from the app's
|
|
206
|
+
// node_modules at import time (preserves CJS/ESM interop and identity).
|
|
207
|
+
packages: 'external',
|
|
208
|
+
// Fixture/app configs may require() or require.resolve() Next internals
|
|
209
|
+
// (next/dist/*) that don't exist without a real `next` install. Stub them
|
|
210
|
+
// so the config still loads; features pointing at the stubs degrade
|
|
211
|
+
// gracefully instead of crashing config loading.
|
|
212
|
+
plugins: [nextDistStubPlugin(outDir), nextMdxConfigStubPlugin()],
|
|
213
|
+
banner: cjs
|
|
214
|
+
? {
|
|
215
|
+
js:
|
|
216
|
+
'var __pnextNodeRequire = module.require.bind(module);\n' +
|
|
217
|
+
`var require = __pnextNodeRequire("node:module").createRequire(${JSON.stringify(pathToFileURL(configPath).href)});`,
|
|
218
|
+
}
|
|
219
|
+
: {
|
|
220
|
+
js:
|
|
221
|
+
'import { createRequire as __pnextCreateRequire } from "node:module";\n' +
|
|
222
|
+
`const require = __pnextCreateRequire(${JSON.stringify(pathToFileURL(configPath).href)});`,
|
|
223
|
+
},
|
|
224
|
+
// Relative/absolute imports (incl. .ts/.cts/.mts/.cjs/.mjs and tsconfig
|
|
225
|
+
// `paths` aliases) are bundled in. esbuild reads `paths`/`extends` and
|
|
226
|
+
// `baseUrl` from the app tsconfig.
|
|
227
|
+
tsconfig: findTsconfig(root),
|
|
228
|
+
// __dirname / __filename in CJS-authored .ts configs (node-api-cjs) and
|
|
229
|
+
// import.meta.url in ESM configs (node-api-esm) must point at the config's
|
|
230
|
+
// original location, not the emitted file under .pnext/config.
|
|
231
|
+
define: {
|
|
232
|
+
__dirname: JSON.stringify(path.dirname(configPath)),
|
|
233
|
+
__filename: JSON.stringify(configPath),
|
|
234
|
+
'import.meta.url': JSON.stringify(pathToFileURL(configPath).href),
|
|
235
|
+
},
|
|
236
|
+
logLevel: 'silent',
|
|
237
|
+
sourcemap: false,
|
|
238
|
+
metafile: false,
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function importConfigBundle(outfile: string, cjs: boolean): Promise<NextConfigExport> {
|
|
243
|
+
if (cjs) return createRequire(pathToFileURL(outfile))(outfile) as NextConfigExport
|
|
244
|
+
const module = (await import(pathToFileURL(outfile).href)) as { default?: NextConfigExport }
|
|
245
|
+
return module.default ?? {}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Drop bundles left by an earlier config source. The emitted file used to be deleted right after it
|
|
250
|
+
* was imported, which is what made a serve re-bundle; keeping only the current key holds the line
|
|
251
|
+
* that motivated the delete - one bundle per app, not one per edit.
|
|
252
|
+
*/
|
|
253
|
+
function pruneStaleConfigBundles(outDir: string, outfile: string): void {
|
|
254
|
+
try {
|
|
255
|
+
for (const entry of readdirSync(outDir)) {
|
|
256
|
+
if (!/^next\.config\..+\.[cm]js$/.test(entry)) continue
|
|
257
|
+
if (path.join(outDir, entry) === outfile) continue
|
|
258
|
+
rmSync(path.join(outDir, entry), { force: true })
|
|
205
259
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
} finally {
|
|
209
|
-
rmSync(outfile, { force: true })
|
|
260
|
+
} catch {
|
|
261
|
+
// Housekeeping only: a missing or unreadable out dir costs nothing here.
|
|
210
262
|
}
|
|
211
263
|
}
|
|
212
264
|
|
|
@@ -589,7 +641,7 @@ function validateDevIndicatorPosition(config: NextConfigObject): void {
|
|
|
589
641
|
*/
|
|
590
642
|
export async function loadNextConfig(
|
|
591
643
|
root: string,
|
|
592
|
-
options: { dev?: boolean } = {},
|
|
644
|
+
options: { dev?: boolean; serve?: boolean } = {},
|
|
593
645
|
): Promise<LoadNextConfigResult> {
|
|
594
646
|
const configPath = findConfigPath(root)
|
|
595
647
|
if (!configPath) {
|
|
@@ -599,7 +651,10 @@ export async function loadNextConfig(
|
|
|
599
651
|
}
|
|
600
652
|
let exported: NextConfigExport
|
|
601
653
|
try {
|
|
602
|
-
exported = await importConfigModule(configPath, root,
|
|
654
|
+
exported = await importConfigModule(configPath, root, {
|
|
655
|
+
dev: Boolean(options.dev),
|
|
656
|
+
serve: Boolean(options.serve),
|
|
657
|
+
})
|
|
603
658
|
} catch (error) {
|
|
604
659
|
// A next.config that pulls in an optional wrapper dependency not installed
|
|
605
660
|
// in this app (e.g. @next/mdx when deps are declared but not installed)
|
|
@@ -7,12 +7,11 @@
|
|
|
7
7
|
// - every local source file must still match the stat it was read at, so a font swapped while the
|
|
8
8
|
// server was not running re-resolves and re-emits.
|
|
9
9
|
// Nothing here is used by `pnext build`, which owns the directory it just wiped.
|
|
10
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
11
11
|
import { mkdir, stat } from 'node:fs/promises'
|
|
12
12
|
import { createHash } from 'node:crypto'
|
|
13
13
|
import path from 'node:path'
|
|
14
14
|
import type { ResolvedConfig } from '../../../config'
|
|
15
|
-
import { restartCacheEnabled } from '../../../dev/restart/enabled'
|
|
16
15
|
import { writeFileAtomic } from '../../../utils/fs'
|
|
17
16
|
import type { MetadataLink } from '../../../types'
|
|
18
17
|
|
|
@@ -61,6 +60,10 @@ function fontIndex(config: ResolvedConfig): FontIndex {
|
|
|
61
60
|
if (!created.dirty) return
|
|
62
61
|
created.dirty = false
|
|
63
62
|
try {
|
|
63
|
+
// The debounced write mkdirs; this path can be the FIRST write of the run
|
|
64
|
+
// (a build's warm child resolves fonts and exits before the timer fires),
|
|
65
|
+
// and a missing directory would silently cost the deployment its index.
|
|
66
|
+
mkdirSync(path.dirname(created.file), { recursive: true })
|
|
64
67
|
writeFileSync(created.file, serializeIndex(created.fonts))
|
|
65
68
|
} catch {
|
|
66
69
|
// Best effort: a missing index only costs the next boot a re-resolve.
|
|
@@ -74,7 +77,6 @@ export async function persistedFont(
|
|
|
74
77
|
config: ResolvedConfig,
|
|
75
78
|
key: string,
|
|
76
79
|
): Promise<PersistableFont | undefined> {
|
|
77
|
-
if (!restartCacheEnabled()) return undefined
|
|
78
80
|
const entry = fontIndex(config).fonts.get(key)
|
|
79
81
|
if (!entry) return undefined
|
|
80
82
|
if (!entry.files.every(file => existsSync(file))) return undefined
|
|
@@ -10,6 +10,7 @@ import { onExtensionHostReset, registerResponseFinalizers } from '../../../exten
|
|
|
10
10
|
import { getWorkUnit } from '../../../request/context'
|
|
11
11
|
import type { MetadataLink } from '../../../types'
|
|
12
12
|
import { persistFont, persistedFont } from './cache'
|
|
13
|
+
import { restartCacheEnabled } from '../../../dev/restart/enabled'
|
|
13
14
|
import { escapeRegex } from '../../../utils/code'
|
|
14
15
|
import {
|
|
15
16
|
familyStack,
|
|
@@ -113,8 +114,16 @@ let googleMetadataCache: Record<string, GoogleFontMetadata> | undefined
|
|
|
113
114
|
* Every font definition constructed so far, whether or not a render has read its `.className` yet.
|
|
114
115
|
* Definitions are created when the app's font module evaluates - well before the render that registers
|
|
115
116
|
* them - which is what makes the resolve prewarmable.
|
|
117
|
+
*
|
|
118
|
+
* Process-global rather than module-local: a compiled artifact imports this module through the build
|
|
119
|
+
* cache, so the app's font declarations land in a DIFFERENT instance than the one the orchestrator
|
|
120
|
+
* bootstrapped, and a prewarm reading a module-local map would find it empty.
|
|
116
121
|
*/
|
|
117
|
-
const
|
|
122
|
+
const DECLARED_FONTS = Symbol.for('pnext.compat.font.declared')
|
|
123
|
+
const declaredFonts = ((globalThis as Record<symbol, unknown>)[DECLARED_FONTS] ??= new Map<
|
|
124
|
+
string,
|
|
125
|
+
FontDefinition
|
|
126
|
+
>()) as Map<string, FontDefinition>
|
|
118
127
|
|
|
119
128
|
/**
|
|
120
129
|
* Resolve the declared fonts ahead of the render that will ask for them. Fire-and-forget: this only
|
|
@@ -360,25 +369,31 @@ async function resolveFont(definition: FontDefinition, context: FontResolveConte
|
|
|
360
369
|
// A `pnext build` wiping the output directory under a running server must
|
|
361
370
|
// not leave cached font URLs pointing at deleted files; re-emit them.
|
|
362
371
|
// Font bytes stay cached in memory, so this never refetches the network.
|
|
363
|
-
|
|
372
|
+
// An empty entry is a font served from its origin because this filesystem
|
|
373
|
+
// rejects the write; there is nothing on disk to go stale.
|
|
374
|
+
if (font.files.every(file => !file || existsSync(file))) return font
|
|
364
375
|
const fresh = resolveAndPersist(definition, context, key)
|
|
365
376
|
resolvedFontCache.set(key, fresh)
|
|
366
377
|
return fresh
|
|
367
378
|
}
|
|
368
379
|
|
|
369
380
|
/**
|
|
370
|
-
*
|
|
371
|
-
* Google stylesheet and font file to re-derive URLs whose bytes are already emitted.
|
|
372
|
-
*
|
|
381
|
+
* Reuse the persisted resolution when it still describes disk. In dev that saves a restart re-fetching
|
|
382
|
+
* every Google stylesheet and font file to re-derive URLs whose bytes are already emitted. In a
|
|
383
|
+
* production serve it is what makes fonts work at all: the build resolved and emitted them, and request
|
|
384
|
+
* time has neither a writable output directory nor any need to reach Google again. A build re-resolves
|
|
385
|
+
* for real without a special case - it empties the output directory first, so the recorded files are
|
|
386
|
+
* gone and the entry is rejected.
|
|
373
387
|
*/
|
|
374
388
|
async function resolveThroughCache(
|
|
375
389
|
definition: FontDefinition,
|
|
376
390
|
context: FontResolveContext,
|
|
377
391
|
key: string,
|
|
378
392
|
): Promise<ResolvedFont> {
|
|
379
|
-
if (!context.dev
|
|
380
|
-
|
|
381
|
-
|
|
393
|
+
if (!context.dev || restartCacheEnabled()) {
|
|
394
|
+
const persisted = await persistedFont(context.config, key)
|
|
395
|
+
if (persisted) return persisted
|
|
396
|
+
}
|
|
382
397
|
return resolveAndPersist(definition, context, key)
|
|
383
398
|
}
|
|
384
399
|
|
|
@@ -388,9 +403,8 @@ function resolveAndPersist(
|
|
|
388
403
|
key: string,
|
|
389
404
|
): Promise<ResolvedFont> {
|
|
390
405
|
const resolved = definition.resolve(context)
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
}
|
|
406
|
+
// Recorded in a build too: that record is what a deployed server reads instead of resolving.
|
|
407
|
+
void resolved.then(font => persistFont(context.config, key, font)).catch(() => undefined)
|
|
394
408
|
return resolved
|
|
395
409
|
}
|
|
396
410
|
|
|
@@ -717,7 +731,7 @@ async function emitRemoteFontFile(context: FontResolveContext, url: string, emit
|
|
|
717
731
|
googleFontFileCache.set(url, cached)
|
|
718
732
|
}
|
|
719
733
|
const bytes = await cached
|
|
720
|
-
return emitFontBytes(context, bytes, fontExt(url), emit)
|
|
734
|
+
return emitFontBytes(context, bytes, fontExt(url), emit, undefined, url)
|
|
721
735
|
}
|
|
722
736
|
|
|
723
737
|
async function fetchFontFile(url: string) {
|
|
@@ -741,6 +755,7 @@ async function emitFontBytes(
|
|
|
741
755
|
ext: string,
|
|
742
756
|
emit: EmitFontOptions,
|
|
743
757
|
sourceFile?: string,
|
|
758
|
+
originUrl?: string,
|
|
744
759
|
) {
|
|
745
760
|
const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 16)
|
|
746
761
|
// Next's loader names emitted files `[hash]-s.p.[ext]`: `-s` when a
|
|
@@ -751,12 +766,39 @@ async function emitFontBytes(
|
|
|
751
766
|
const mediaSegments = ['_next', 'static', 'media']
|
|
752
767
|
const outDir = path.join(context.config.outPath, 'public', ...mediaSegments)
|
|
753
768
|
const file = path.join(outDir, filename)
|
|
754
|
-
|
|
755
|
-
|
|
769
|
+
const emitted = {
|
|
770
|
+
ext,
|
|
771
|
+
file,
|
|
772
|
+
type: fontMimeType(ext),
|
|
773
|
+
url: `/${mediaSegments.join('/')}/${filename}`,
|
|
774
|
+
}
|
|
775
|
+
if (existsSync(file)) return emitted
|
|
776
|
+
try {
|
|
777
|
+
await mkdir(outDir, { recursive: true })
|
|
756
778
|
if (sourceFile) await copyFile(sourceFile, file)
|
|
757
779
|
else await writeFile(file, bytes)
|
|
780
|
+
return emitted
|
|
781
|
+
} catch (error) {
|
|
782
|
+
// A deployed server's filesystem is read-only, and a font first reached by a request that was
|
|
783
|
+
// never prerendered lands here. A Google font still renders from its origin; a local font has
|
|
784
|
+
// no such address, so that one is a real error.
|
|
785
|
+
if (!originUrl) throw error
|
|
786
|
+
warnFontSelfHostUnavailable()
|
|
787
|
+
// `file` empty, not the path that was just proven unwritable: the resolution memo re-runs
|
|
788
|
+
// whenever a recorded file is missing from disk, so naming one here would re-resolve (and
|
|
789
|
+
// re-attempt the write) on every render for the life of the process.
|
|
790
|
+
return { ...emitted, file: '', url: originUrl }
|
|
758
791
|
}
|
|
759
|
-
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
let warnedFontSelfHost = false
|
|
795
|
+
function warnFontSelfHostUnavailable() {
|
|
796
|
+
if (warnedFontSelfHost) return
|
|
797
|
+
warnedFontSelfHost = true
|
|
798
|
+
console.warn(
|
|
799
|
+
'pnext: next/font assets cannot be written on this filesystem; serving them from their origin ' +
|
|
800
|
+
'instead of self-hosting.',
|
|
801
|
+
)
|
|
760
802
|
}
|
|
761
803
|
|
|
762
804
|
function normalizeLocalSources(
|
|
@@ -833,6 +875,9 @@ function loadGoogleMetadata(): Record<string, GoogleFontMetadata> {
|
|
|
833
875
|
throw new Error(`Invalid Google font metadata file: ${override}`)
|
|
834
876
|
}
|
|
835
877
|
|
|
878
|
+
// Resolved by specifier, never bundled: the data is a megabyte of metadata and the package is
|
|
879
|
+
// optional. Deployment adapters ship it through `deploymentPackages` instead — the import scan
|
|
880
|
+
// cannot see a dynamic specifier, and request time has no way to install one.
|
|
836
881
|
const metadata =
|
|
837
882
|
tryLoadGoogleMetadata('next-font/google/font-data.json') ?? tryLoadGoogleMetadata('next-font')
|
|
838
883
|
if (metadata) return metadata
|
package/src/config.ts
CHANGED
|
@@ -23,8 +23,15 @@ export type ResolvedConfig = Required<Pick<PNextConfig, 'outDir' | 'basePath'>>
|
|
|
23
23
|
typesPath: string
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The out root before any `outDir`/`distDir` override. Also where a config source keeps the build
|
|
28
|
+
* artifacts it must emit BEFORE the override it carries is known — the next.config bundle sits here
|
|
29
|
+
* even for an app whose own output moved elsewhere, so deployment adapters ship it from here.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_OUT_DIR = '.pnext'
|
|
32
|
+
|
|
26
33
|
const defaultConfig = {
|
|
27
|
-
outDir:
|
|
34
|
+
outDir: DEFAULT_OUT_DIR,
|
|
28
35
|
basePath: '',
|
|
29
36
|
} satisfies Required<Pick<PNextConfig, 'outDir' | 'basePath'>>
|
|
30
37
|
|
|
@@ -48,7 +55,7 @@ export interface ConfigSourceOverrides {
|
|
|
48
55
|
}
|
|
49
56
|
export type ConfigSource = (
|
|
50
57
|
root: string,
|
|
51
|
-
options: { dev?: boolean },
|
|
58
|
+
options: { dev?: boolean; serve?: boolean },
|
|
52
59
|
) => Promise<ConfigSourceOverrides>
|
|
53
60
|
|
|
54
61
|
let configSource: ConfigSource | undefined
|
|
@@ -60,7 +67,7 @@ export function registerConfigSource(source: ConfigSource): void {
|
|
|
60
67
|
async function resolveConfigSource(
|
|
61
68
|
config: PNextConfig,
|
|
62
69
|
root: string,
|
|
63
|
-
options: { dev?: boolean },
|
|
70
|
+
options: { dev?: boolean; serve?: boolean },
|
|
64
71
|
): Promise<ConfigSourceOverrides> {
|
|
65
72
|
if (!config.compat?.next) return {}
|
|
66
73
|
if (!configSource) {
|
|
@@ -70,9 +77,11 @@ async function resolveConfigSource(
|
|
|
70
77
|
return configSource(root, options)
|
|
71
78
|
}
|
|
72
79
|
|
|
80
|
+
// `serve` marks a production start (including a deployment adapter's request handler): config sources
|
|
81
|
+
// then consume what the build emitted instead of regenerating it, since request time can be read-only.
|
|
73
82
|
export async function loadConfig(
|
|
74
83
|
rootInput = process.cwd(),
|
|
75
|
-
options: { dev?: boolean } = {},
|
|
84
|
+
options: { dev?: boolean; serve?: boolean } = {},
|
|
76
85
|
): Promise<ResolvedConfig> {
|
|
77
86
|
const root = canonicalRoot(path.resolve(rootInput))
|
|
78
87
|
await loadEnv(root, options)
|