@wular/pnext 0.0.13 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wular/pnext",
3
- "version": "0.0.13",
3
+ "version": "0.0.14",
4
4
  "description": "A fast little framework for server-first React apps, fully compatible with Next.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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').then(href => report(file, fileURLToPath(href)))
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: string[] = []
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
- pageRoutes.push(route.file)
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
- ...[...pageServerFiles(config, pageRoutes)]
304
- .filter(file => !skip.has(file))
305
- .map(file => compileServer(config, file)),
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(file => !skip.has(file))
308
- .map(file =>
309
- devClientModuleHref(config, file, 'build').then(href =>
310
- report(file, fileURLToPath(href)),
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
- const config = await loadConfig(root)
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 { DEFAULT_OUT_DIR, 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
- const RUNTIME_ROOT = '/var/task'
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
- architecture: 'x86_64',
34
- os: 'linux',
35
- cpu: 'x64',
36
- libc: 'glibc',
37
- } as const
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 ?? [])
@@ -113,13 +139,40 @@ function packRules(config: ResolvedConfig): PackRules {
113
139
  // default out root goes with it: a `distDir` app moved its output away, but
114
140
  // the next.config bundle the function imports at boot still lives there.
115
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),
116
151
  }
117
152
  }
118
153
 
119
- function shipsDir(pack: PackRules, name: string, source: string) {
120
- return !pack.prunedDirs.has(name) || pack.keepPaths.has(path.resolve(source))
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)
121
172
  }
122
173
 
174
+ const isOutRoot = (pack: PackRules, dir: string) => path.resolve(dir) === pack.outRootPath
175
+
123
176
  function shipsFile(pack: PackRules, name: string) {
124
177
  return !pack.droppedSuffixes.some(suffix => name.endsWith(suffix))
125
178
  }
@@ -139,6 +192,12 @@ export async function writeVercelOutput(
139
192
  options: { verbose?: boolean; warm?: WarmChild } = {},
140
193
  ) {
141
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
+ }
142
201
  const outputPath = path.join(config.root, '.vercel', 'output')
143
202
  const staticFiles = manifest.staticFiles ?? {}
144
203
  // Warming runs in its own process (see ./vercel-warm) and normally started
@@ -335,14 +394,26 @@ async function writeServerFunction(
335
394
  )
336
395
 
337
396
  // Baked absolute paths (compiled cache imports, manifest file paths) point
338
- // 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
+ )
339
406
  await log.step('rewrite baked paths', () =>
340
- rewriteBakedPaths(path.join(functionPath, appRel, path.relative(config.root, config.outPath)), [
341
- ...(pnextInWorkspace
342
- ? []
343
- : [[pnextRoot, toPosixPath(path.join(RUNTIME_ROOT, pnextTargetRel))] as const]),
344
- [workspaceRoot, RUNTIME_ROOT] as const,
345
- ]),
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
+ ),
346
417
  )
347
418
 
348
419
  // Bun's runtime onResolve plugins never see bare specifiers, so the compat aliases the dev pipeline
@@ -457,13 +528,16 @@ async function copyTree(
457
528
  pack: PackRules,
458
529
  excludeNames: string[] = [],
459
530
  visited = new Set<string>([realDir(from) ?? path.resolve(from)]),
531
+ inOutRoot = pack.keepPaths.has(path.resolve(from)),
460
532
  ) {
461
533
  const exclude = new Set(excludeNames)
462
534
  const clone = directoryCloneEnabled ? await directoryCloner() : null
463
535
  // Nothing to skip at this level: the whole tree clones in one syscall.
464
536
  if (exclude.size === 0 && !existsSync(to)) {
465
537
  await mkdir(path.dirname(to), { recursive: true })
466
- if (clone?.(from, to)) return normalizeClone(to, pack, visited)
538
+ if (clone?.(from, to)) {
539
+ return normalizeClone(to, pack, visited, inOutRoot, isOutRoot(pack, from))
540
+ }
467
541
  }
468
542
  // The build output is still live while the walk runs — typegen rewrites
469
543
  // `.pnext/types` under it — so a directory can vanish between the parent's
@@ -477,23 +551,29 @@ async function copyTree(
477
551
  const target = path.join(to, entry.name)
478
552
  // Matched by name before type: a symlinked `node_modules` must be pruned
479
553
  // as the directory it points at, not followed.
480
- if (exclude.has(entry.name) || !shipsDir(pack, entry.name, source)) return
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))
481
561
  if (entry.isSymbolicLink()) {
482
562
  const link = resolveLinkTarget(source)
483
563
  if (!link) return
484
564
  if (link.isDirectory) {
485
565
  if (visited.has(link.path)) return
486
566
  visited.add(link.path)
487
- return copyTree(link.path, target, pack, [], visited)
567
+ return copyTree(link.path, target, pack, [], visited, childInOutRoot)
488
568
  }
489
569
  return shipsFile(pack, entry.name) ? linkOrCopyFile(link.path, target) : undefined
490
570
  }
491
571
  // One syscall for the whole subtree beats one per file — the excluded
492
572
  // names are stripped from the copy afterwards.
493
573
  if (entry.isDirectory() && clone?.(source, target)) {
494
- return normalizeClone(target, pack, visited)
574
+ return normalizeClone(target, pack, visited, childInOutRoot, isOutRoot(pack, source))
495
575
  }
496
- if (entry.isDirectory()) return copyTree(source, target, pack, [], visited)
576
+ if (entry.isDirectory()) return copyTree(source, target, pack, [], visited, childInOutRoot)
497
577
  if (entry.isFile() && shipsFile(pack, entry.name)) return linkOrCopyFile(source, target)
498
578
  }),
499
579
  )
@@ -552,18 +632,30 @@ async function directoryCloner() {
552
632
  * both paths emit the same tree. Reading directory entries costs nothing next
553
633
  * to the per-file copies this replaces.
554
634
  */
555
- async function normalizeClone(dir: string, pack: PackRules, visited: Set<string>) {
556
- const stack = [dir]
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]]
557
646
  const pending: Promise<unknown>[] = []
558
647
  while (stack.length > 0) {
559
- const current = stack.pop()!
648
+ const [current, inside, atRoot] = stack.pop()!
560
649
  for (const entry of readdirSync(current, { withFileTypes: true })) {
561
650
  const full = path.join(current, entry.name)
562
- // Matched by name before type, as the walk does: the pages-compat shim
563
- // links `node_modules` at the app root, and following it would pull the
564
- // whole store into the function. The clone is already inside the
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
565
654
  // function, so the walk's keep-path exemption cannot apply here.
566
- if (pack.prunedDirs.has(entry.name)) {
655
+ if (
656
+ (atRoot && pack.dropNames.has(entry.name)) ||
657
+ ((!inside || entry.isSymbolicLink()) && pack.prunedDirs.has(entry.name))
658
+ ) {
567
659
  rmSync(full, { recursive: true, force: true })
568
660
  } else if (entry.isSymbolicLink()) {
569
661
  const link = resolveLinkTarget(full)
@@ -572,12 +664,12 @@ async function normalizeClone(dir: string, pack: PackRules, visited: Set<string>
572
664
  if (link.isDirectory) {
573
665
  if (visited.has(link.path)) continue
574
666
  visited.add(link.path)
575
- pending.push(copyTree(link.path, full, pack, [], visited))
667
+ pending.push(copyTree(link.path, full, pack, [], visited, inside))
576
668
  } else if (shipsFile(pack, entry.name)) {
577
669
  pending.push(linkOrCopyFile(link.path, full))
578
670
  }
579
671
  } else if (entry.isDirectory()) {
580
- stack.push(full)
672
+ stack.push([full, inside, false])
581
673
  } else if (!shipsFile(pack, entry.name)) {
582
674
  rmSync(full, { force: true })
583
675
  }
@@ -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 declaredFonts = new Map<string, FontDefinition>()
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
- if (font.files.every(file => existsSync(file))) return font
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
- * Dev reuses the persisted resolution when it still describes disk: a restart otherwise re-fetches every
371
- * Google stylesheet and font file to re-derive URLs whose bytes are already emitted. A build always
372
- * resolves for real - it owns the output directory it just emptied.
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) return definition.resolve(context)
380
- const persisted = await persistedFont(context.config, key)
381
- if (persisted) return persisted
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
- if (context.dev) {
392
- void resolved.then(font => persistFont(context.config, key, font)).catch(() => undefined)
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
- await mkdir(outDir, { recursive: true })
755
- if (!existsSync(file)) {
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
- return { ext, file, type: fontMimeType(ext), url: `/${mediaSegments.join('/')}/${filename}` }
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