@wular/pnext 0.0.16 → 0.0.18
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 +88 -3
- package/src/cli/adapters/vercel.ts +52 -21
- package/src/cli/migrate/package-json.ts +21 -1
- package/src/cli/migrate/report.ts +3 -1
- package/src/cli/migrate/run.ts +3 -2
- package/src/cli/migrate/scan.ts +3 -0
- package/src/client/reference.ts +18 -0
- package/src/compat/next/font/runtime.ts +34 -11
- package/src/runtime/modules.ts +2 -0
- package/src/runtime/vendor-build.ts +18 -3
- package/src/utils/fs.ts +13 -0
package/package.json
CHANGED
|
@@ -26,15 +26,16 @@ import path from 'node:path'
|
|
|
26
26
|
import { fileURLToPath } from 'node:url'
|
|
27
27
|
import { loadConfig, pathToFileHref, type ResolvedConfig } from '../../config'
|
|
28
28
|
import { bootstrapCompat } from '../../compat-bootstrap'
|
|
29
|
-
import { ssrClientReference } from '../../client/reference'
|
|
29
|
+
import { compiledClientReferenceFiles, ssrClientReference } from '../../client/reference'
|
|
30
30
|
import {
|
|
31
31
|
devClientModuleHref,
|
|
32
32
|
devServerModuleHref,
|
|
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'
|
|
@@ -281,6 +282,7 @@ export async function warmRouteModules(
|
|
|
281
282
|
) {
|
|
282
283
|
const clientFiles = new Set<string>()
|
|
283
284
|
const handlers: BuildManifest['routes'] = []
|
|
285
|
+
const pages: BuildManifest['routes'] = []
|
|
284
286
|
const pageRoutes: string[] = []
|
|
285
287
|
|
|
286
288
|
for (const route of manifest.routes) {
|
|
@@ -288,6 +290,7 @@ export async function warmRouteModules(
|
|
|
288
290
|
if (!skip.has(route.file)) handlers.push(route)
|
|
289
291
|
continue
|
|
290
292
|
}
|
|
293
|
+
pages.push(route)
|
|
291
294
|
pageRoutes.push(route.file)
|
|
292
295
|
for (const reference of route.clientReferences) {
|
|
293
296
|
if (ssrClientReference(reference) && existsSync(reference.file)) {
|
|
@@ -327,11 +330,93 @@ export async function warmRouteModules(
|
|
|
327
330
|
// process' lifetime anyway, so a retry could not succeed either).
|
|
328
331
|
report(route.file)
|
|
329
332
|
try {
|
|
330
|
-
await import(
|
|
333
|
+
await import(await handlerModuleHref(config, route))
|
|
331
334
|
} catch (error) {
|
|
332
335
|
console.warn(`vercel adapter: importing ${route.file} failed during warmup:`, error)
|
|
333
336
|
}
|
|
334
337
|
}
|
|
338
|
+
|
|
339
|
+
// Pages last, one at a time: a module's vendor bundles are written when it is IMPORTED, not when
|
|
340
|
+
// it compiles, and a bundle this pass misses is one the read-only function cannot create later.
|
|
341
|
+
await importPageModules(config, pages, skip)
|
|
342
|
+
|
|
343
|
+
// Client modules for the same reason, plus the references only the compile saw - a 'use client'
|
|
344
|
+
// module inside a dependency is reached from a server component, so no route owns it. Vendoring
|
|
345
|
+
// one late is worse than failing: it is a second copy, whose React context the render never holds.
|
|
346
|
+
await importClientModules(
|
|
347
|
+
config,
|
|
348
|
+
new Set([...clientFiles, ...compiledClientReferenceFiles()]),
|
|
349
|
+
skip,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
// Fonts resolve during a render, so a fully dynamic app emits none at build. The imports above ran
|
|
353
|
+
// the module-scope loader calls that declare them; flush to disk while it is still writable.
|
|
354
|
+
try {
|
|
355
|
+
await getFontExtensions().prewarmFontAssets(config, {})
|
|
356
|
+
} catch (error) {
|
|
357
|
+
console.warn('vercel adapter: emitting next/font assets failed during warmup:', error)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Import each SSR-eligible client module through its compiled href, the way the render does. */
|
|
362
|
+
async function importClientModules(
|
|
363
|
+
config: ResolvedConfig,
|
|
364
|
+
clientFiles: ReadonlySet<string>,
|
|
365
|
+
skip: ReadonlySet<string>,
|
|
366
|
+
) {
|
|
367
|
+
if (!getCompatModeExtensions().reactEnabled(config)) return
|
|
368
|
+
for (const file of clientFiles) {
|
|
369
|
+
if (skip.has(file)) continue
|
|
370
|
+
const href = await devClientModuleHref(config, file, 'build')
|
|
371
|
+
report(file, fileURLToPath(href))
|
|
372
|
+
try {
|
|
373
|
+
await import(href)
|
|
374
|
+
} catch (error) {
|
|
375
|
+
console.warn(`vercel adapter: importing ${file} failed during warmup:`, error)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Where the serve pipeline imports a route handler's module from — mirrors `moduleHrefForRoute` in
|
|
382
|
+
* cli/serve/pipeline.ts, guard included. Warming through the raw source instead leaves the compiled
|
|
383
|
+
* artifact unwritten, and the function then tries to compile it on the first request, against a
|
|
384
|
+
* read-only filesystem.
|
|
385
|
+
*/
|
|
386
|
+
function handlerModuleHref(config: ResolvedConfig, route: BuildManifest['routes'][number]) {
|
|
387
|
+
return config.compat?.next || config.compat?.react || config.compat?.reactCompiler
|
|
388
|
+
? devServerModuleHref(config, route.file, 'build', {
|
|
389
|
+
conditionTarget: serverBundleTargetForRuntime(route.segmentConfig?.runtime),
|
|
390
|
+
})
|
|
391
|
+
: Promise.resolve(pathToFileHref(route.file))
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Import every server module the page routes reach, deduped across routes so a shared layout is
|
|
396
|
+
* evaluated once. Reported with its artifact before the import, exactly like the handler loop: one
|
|
397
|
+
* that takes the process down is unambiguous and must not run again on the restart.
|
|
398
|
+
*/
|
|
399
|
+
async function importPageModules(
|
|
400
|
+
config: ResolvedConfig,
|
|
401
|
+
pages: BuildManifest['routes'],
|
|
402
|
+
skip: ReadonlySet<string>,
|
|
403
|
+
) {
|
|
404
|
+
if (!getCompatModeExtensions().reactEnabled(config)) return
|
|
405
|
+
const imported = new Set<string>()
|
|
406
|
+
for (const route of pages) {
|
|
407
|
+
registerServerRuntime(config, route.sourceFiles)
|
|
408
|
+
for (const file of pageServerFiles(config, [route.file])) {
|
|
409
|
+
if (skip.has(file) || imported.has(file)) continue
|
|
410
|
+
imported.add(file)
|
|
411
|
+
const href = await devServerModuleHref(config, file, 'build')
|
|
412
|
+
report(file, fileURLToPath(href))
|
|
413
|
+
try {
|
|
414
|
+
await import(href)
|
|
415
|
+
} catch (error) {
|
|
416
|
+
console.warn(`vercel adapter: importing ${file} failed during warmup:`, error)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
335
420
|
}
|
|
336
421
|
|
|
337
422
|
/**
|
|
@@ -116,8 +116,29 @@ function packRules(config: ResolvedConfig): PackRules {
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
/**
|
|
120
|
+
* A keep path and everything under it. Prefix, not equality: the compiled module cache mirrors the
|
|
121
|
+
* source layout, so a dependency's artifacts land in `<outDir>/cache/server/<profile>/node_modules/
|
|
122
|
+
* ...`. That segment names a source tree, not a package store, and pruning it by name ships the
|
|
123
|
+
* importer without the module it imports — the function then 500s on the first request that reaches
|
|
124
|
+
* it. Only the app's own build output is exempt; a real `node_modules` beside it still prunes.
|
|
125
|
+
*/
|
|
126
|
+
function withinKeepPath(pack: PackRules, source: string) {
|
|
127
|
+
const resolved = path.resolve(source)
|
|
128
|
+
for (const keep of pack.keepPaths) {
|
|
129
|
+
if (resolved === keep || resolved.startsWith(`${keep}${path.sep}`)) return true
|
|
130
|
+
}
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `isLink` is load-bearing, not a detail: the exemption covers real directories the build output
|
|
136
|
+
* owns, never a link out of it. The proxy shim links `node_modules` into `.pnext`, and following
|
|
137
|
+
* that would pull the whole store into the function.
|
|
138
|
+
*/
|
|
139
|
+
function shipsDir(pack: PackRules, name: string, source: string, isLink = false) {
|
|
140
|
+
if (!pack.prunedDirs.has(name)) return true
|
|
141
|
+
return !isLink && withinKeepPath(pack, source)
|
|
121
142
|
}
|
|
122
143
|
|
|
123
144
|
function shipsFile(pack: PackRules, name: string) {
|
|
@@ -146,15 +167,6 @@ export async function writeVercelOutput(
|
|
|
146
167
|
const warm = options.warm ?? startWarmChild(config)
|
|
147
168
|
await log.step('prepare output directory', () => emptyDir(outputPath))
|
|
148
169
|
|
|
149
|
-
await log.step('copy static files', () =>
|
|
150
|
-
copyStaticFiles(
|
|
151
|
-
path.join(config.outPath, 'public'),
|
|
152
|
-
path.join(outputPath, 'static'),
|
|
153
|
-
relative => !staticFiles[relative] || canServeStaticOnVercel(staticFiles[relative]),
|
|
154
|
-
),
|
|
155
|
-
)
|
|
156
|
-
const overrides = await staticOverrides(path.join(config.outPath, 'public'), staticFiles)
|
|
157
|
-
|
|
158
170
|
// The compiled set is already final once the child reaches its handler
|
|
159
171
|
// phase, so `onCompiled` resolves this ahead of full completion and
|
|
160
172
|
// writeServerFunction starts tracing while the child's handler imports
|
|
@@ -175,6 +187,17 @@ export async function writeVercelOutput(
|
|
|
175
187
|
const functionPath = path.join(outputPath, 'functions', `${SERVER_FUNCTION}.func`)
|
|
176
188
|
await writeServerFunction(config, manifest, functionPath, warmedModules, warmSettled, log)
|
|
177
189
|
|
|
190
|
+
// After the warm pass, never before: it is what emits next/font bytes under public/, and a fully
|
|
191
|
+
// dynamic app renders nothing at build, so copying earlier ships a CDN with no fonts.
|
|
192
|
+
await log.step('copy static files', () =>
|
|
193
|
+
copyStaticFiles(
|
|
194
|
+
path.join(config.outPath, 'public'),
|
|
195
|
+
path.join(outputPath, 'static'),
|
|
196
|
+
relative => !staticFiles[relative] || canServeStaticOnVercel(staticFiles[relative]),
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
const overrides = await staticOverrides(path.join(config.outPath, 'public'), staticFiles)
|
|
200
|
+
|
|
178
201
|
const routes: NonNullable<VercelConfig['routes']> = [
|
|
179
202
|
// Chunk and font filenames are content-hashed; serve them immutable.
|
|
180
203
|
{
|
|
@@ -466,7 +489,7 @@ async function copyTree(
|
|
|
466
489
|
// Nothing to skip at this level: the whole tree clones in one syscall.
|
|
467
490
|
if (exclude.size === 0 && !existsSync(to)) {
|
|
468
491
|
await mkdir(path.dirname(to), { recursive: true })
|
|
469
|
-
if (clone?.(from, to)) return normalizeClone(to, pack, visited)
|
|
492
|
+
if (clone?.(from, to)) return normalizeClone(to, from, pack, visited)
|
|
470
493
|
}
|
|
471
494
|
// The build output is still live while the walk runs — typegen rewrites
|
|
472
495
|
// `.pnext/types` under it — so a directory can vanish between the parent's
|
|
@@ -480,7 +503,8 @@ async function copyTree(
|
|
|
480
503
|
const target = path.join(to, entry.name)
|
|
481
504
|
// Matched by name before type: a symlinked `node_modules` must be pruned
|
|
482
505
|
// as the directory it points at, not followed.
|
|
483
|
-
if (exclude.has(entry.name) || !shipsDir(pack, entry.name, source))
|
|
506
|
+
if (exclude.has(entry.name) || !shipsDir(pack, entry.name, source, entry.isSymbolicLink()))
|
|
507
|
+
return
|
|
484
508
|
if (entry.isSymbolicLink()) {
|
|
485
509
|
const link = resolveLinkTarget(source)
|
|
486
510
|
if (!link) return
|
|
@@ -494,7 +518,7 @@ async function copyTree(
|
|
|
494
518
|
// One syscall for the whole subtree beats one per file — the excluded
|
|
495
519
|
// names are stripped from the copy afterwards.
|
|
496
520
|
if (entry.isDirectory() && clone?.(source, target)) {
|
|
497
|
-
return normalizeClone(target, pack, visited)
|
|
521
|
+
return normalizeClone(target, source, pack, visited)
|
|
498
522
|
}
|
|
499
523
|
if (entry.isDirectory()) return copyTree(source, target, pack, [], visited)
|
|
500
524
|
if (entry.isFile() && shipsFile(pack, entry.name)) return linkOrCopyFile(source, target)
|
|
@@ -555,18 +579,25 @@ async function directoryCloner() {
|
|
|
555
579
|
* both paths emit the same tree. Reading directory entries costs nothing next
|
|
556
580
|
* to the per-file copies this replaces.
|
|
557
581
|
*/
|
|
558
|
-
async function normalizeClone(
|
|
559
|
-
|
|
582
|
+
async function normalizeClone(
|
|
583
|
+
dir: string,
|
|
584
|
+
// Where `dir` was cloned from. Carried so the keep-path exemption reads the same here as in the
|
|
585
|
+
// walk: the clone's own paths are inside the function and would never match a source keep path.
|
|
586
|
+
from: string,
|
|
587
|
+
pack: PackRules,
|
|
588
|
+
visited: Set<string>,
|
|
589
|
+
) {
|
|
590
|
+
const stack: [string, string][] = [[dir, from]]
|
|
560
591
|
const pending: Promise<unknown>[] = []
|
|
561
592
|
while (stack.length > 0) {
|
|
562
|
-
const current = stack.pop()!
|
|
593
|
+
const [current, currentFrom] = stack.pop()!
|
|
563
594
|
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
564
595
|
const full = path.join(current, entry.name)
|
|
596
|
+
const source = path.join(currentFrom, entry.name)
|
|
565
597
|
// Matched by name before type, as the walk does: the pages-compat shim
|
|
566
598
|
// links `node_modules` at the app root, and following it would pull the
|
|
567
|
-
// whole store into the function.
|
|
568
|
-
|
|
569
|
-
if (pack.prunedDirs.has(entry.name)) {
|
|
599
|
+
// whole store into the function.
|
|
600
|
+
if (!shipsDir(pack, entry.name, source, entry.isSymbolicLink())) {
|
|
570
601
|
rmSync(full, { recursive: true, force: true })
|
|
571
602
|
} else if (entry.isSymbolicLink()) {
|
|
572
603
|
const link = resolveLinkTarget(full)
|
|
@@ -580,7 +611,7 @@ async function normalizeClone(dir: string, pack: PackRules, visited: Set<string>
|
|
|
580
611
|
pending.push(linkOrCopyFile(link.path, full))
|
|
581
612
|
}
|
|
582
613
|
} else if (entry.isDirectory()) {
|
|
583
|
-
stack.push(full)
|
|
614
|
+
stack.push([full, source])
|
|
584
615
|
} else if (!shipsFile(pack, entry.name)) {
|
|
585
616
|
rmSync(full, { force: true })
|
|
586
617
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
5
5
|
import path from 'node:path'
|
|
6
|
-
import { pnextVersionRange } from '../../utils/fs'
|
|
6
|
+
import { pnextOptionalDependencyRange, pnextVersionRange } from '../../utils/fs'
|
|
7
7
|
import type { MigrationResult } from './report'
|
|
8
8
|
|
|
9
9
|
const SEPARATORS = new Set(['&&', '||', ';', '|', '&'])
|
|
@@ -132,6 +132,26 @@ export async function migratePackageJson(
|
|
|
132
132
|
details: [`devDependencies: @wular/pnext ${versionRange}`],
|
|
133
133
|
})
|
|
134
134
|
}
|
|
135
|
+
// next/font/google resolves its metadata from `next-font`, one of pnext's optional dependencies.
|
|
136
|
+
// A hoisted store makes it resolve locally whether or not the app declares it, so an undeclared
|
|
137
|
+
// one only shows up as a 500 from a deployed function, where the closure ships what the app asks
|
|
138
|
+
// for. Declare it here, where the migration already knows the app uses the loader.
|
|
139
|
+
const fontRange = pnextOptionalDependencyRange('next-font')
|
|
140
|
+
if (
|
|
141
|
+
result.googleFontImports.length > 0 &&
|
|
142
|
+
fontRange &&
|
|
143
|
+
!('next-font' in dependencies) &&
|
|
144
|
+
!('next-font' in devDependencies)
|
|
145
|
+
) {
|
|
146
|
+
dependencies['next-font'] = fontRange
|
|
147
|
+
result.edits.push({
|
|
148
|
+
file: 'package.json',
|
|
149
|
+
description: 'next-font added',
|
|
150
|
+
details: [
|
|
151
|
+
`dependencies: next-font ${fontRange} (next/font/google metadata, used by ${result.googleFontImports.length} file${result.googleFontImports.length === 1 ? '' : 's'})`,
|
|
152
|
+
],
|
|
153
|
+
})
|
|
154
|
+
}
|
|
135
155
|
if (!('preact' in dependencies) && !('preact' in devDependencies)) {
|
|
136
156
|
dependencies.preact = '^10'
|
|
137
157
|
result.edits.push({
|
|
@@ -27,10 +27,12 @@ export interface MigrationResult {
|
|
|
27
27
|
reports: ReportItem[]
|
|
28
28
|
/** Files reading cookies()/headers()/draftMode() synchronously (Next 14 style). */
|
|
29
29
|
syncRequestApis: string[]
|
|
30
|
+
/** Files importing next/font/google, which needs the `next-font` metadata package. */
|
|
31
|
+
googleFontImports: string[]
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export function emptyResult(): MigrationResult {
|
|
33
|
-
return { edits: [], reports: [], syncRequestApis: [] }
|
|
35
|
+
return { edits: [], reports: [], syncRequestApis: [], googleFontImports: [] }
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
export function printHeader(root: string, dryRun: boolean) {
|
package/src/cli/migrate/run.ts
CHANGED
|
@@ -36,9 +36,10 @@ export async function migrateApp(directory: string | undefined, options: { dryRu
|
|
|
36
36
|
|
|
37
37
|
printHeader(root, options.dryRun)
|
|
38
38
|
const result = emptyResult()
|
|
39
|
-
|
|
40
|
-
//
|
|
39
|
+
// The scan runs first: what it finds decides what the config enables (sync request APIs) and
|
|
40
|
+
// which dependencies the app needs (next/font/google → next-font).
|
|
41
41
|
await withSpinner('Scanning sources ...', () => scanSources(root, result))
|
|
42
|
+
if (pkg) await migratePackageJson(root, pkg, result, options.dryRun)
|
|
42
43
|
await createPnextConfig(root, result, options.dryRun)
|
|
43
44
|
await migrateTsconfig(root, result, options.dryRun)
|
|
44
45
|
await removeNextEnv(root, result, options.dryRun)
|
package/src/cli/migrate/scan.ts
CHANGED
|
@@ -53,6 +53,9 @@ export async function scanSources(root: string, result: MigrationResult) {
|
|
|
53
53
|
streaming.push(relative)
|
|
54
54
|
}
|
|
55
55
|
if (specifier === 'next/head') headImports.push(relative)
|
|
56
|
+
// next/font/local reads the app's own files; only the Google loader needs the metadata
|
|
57
|
+
// package, so only it earns the dependency.
|
|
58
|
+
if (specifier === 'next/font/google') result.googleFontImports.push(relative)
|
|
56
59
|
}
|
|
57
60
|
if (readsRequestApiSynchronously(text)) result.syncRequestApis.push(relative)
|
|
58
61
|
}
|
package/src/client/reference.ts
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every file this process stubbed as a client reference. The route manifest only records what the
|
|
6
|
+
* scan attributes to a route, so a `'use client'` module inside a dependency - which only the vendor
|
|
7
|
+
* pass sees, the package entry carrying no directive - is missing from it.
|
|
8
|
+
*/
|
|
9
|
+
const compiledClientReferences = new Set<string>()
|
|
10
|
+
|
|
11
|
+
/** @internal */
|
|
12
|
+
export function noteCompiledClientReference(file: string) {
|
|
13
|
+
compiledClientReferences.add(path.resolve(file))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** @internal Read by the vercel adapter's warm pass, so it compiles these before the deploy. */
|
|
17
|
+
export function compiledClientReferenceFiles(): ReadonlySet<string> {
|
|
18
|
+
return compiledClientReferences
|
|
19
|
+
}
|
|
2
20
|
|
|
3
21
|
export interface ClientReference {
|
|
4
22
|
id: string
|
|
@@ -98,15 +98,28 @@ interface NormalizedLocalSource {
|
|
|
98
98
|
weight?: string
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
/**
|
|
102
|
+
* State every copy of this module must agree on: the app reaches next/font through the COMPILED
|
|
103
|
+
* runtime, the framework through node_modules, so a plain module-level value exists once per copy.
|
|
104
|
+
*/
|
|
105
|
+
const fontRuntimeGlobal = globalThis as typeof globalThis & Record<symbol, unknown>
|
|
106
|
+
const sharedAcrossInstances = <T>(key: symbol, create: () => T) =>
|
|
107
|
+
(fontRuntimeGlobal[key] ??= create()) as T
|
|
108
|
+
|
|
109
|
+
const fontStorage = sharedAcrossInstances(
|
|
110
|
+
Symbol.for('pnext.compat.font.storage'),
|
|
111
|
+
() => new AsyncLocalStorage<FontScope>(),
|
|
112
|
+
)
|
|
105
113
|
const allowedDisplayValues = new Set(['auto', 'block', 'swap', 'fallback', 'optional'])
|
|
106
114
|
const require = createRequire(import.meta.url)
|
|
107
115
|
const googleCssCache = new Map<string, Promise<string>>()
|
|
108
116
|
const googleFontFileCache = new Map<string, Promise<Uint8Array>>()
|
|
109
|
-
|
|
117
|
+
// Shared for the same reason as declaredFonts below, and so a request-time render reuses the
|
|
118
|
+
// build's resolution instead of re-fetching the Google stylesheet on every cold start.
|
|
119
|
+
const resolvedFontCache = sharedAcrossInstances(
|
|
120
|
+
Symbol.for('pnext.compat.font.resolved'),
|
|
121
|
+
() => new Map<string, Promise<ResolvedFont>>(),
|
|
122
|
+
)
|
|
110
123
|
let googleMetadataCache: Record<string, GoogleFontMetadata> | undefined
|
|
111
124
|
|
|
112
125
|
/**
|
|
@@ -114,7 +127,10 @@ let googleMetadataCache: Record<string, GoogleFontMetadata> | undefined
|
|
|
114
127
|
* Definitions are created when the app's font module evaluates - well before the render that registers
|
|
115
128
|
* them - which is what makes the resolve prewarmable.
|
|
116
129
|
*/
|
|
117
|
-
const declaredFonts =
|
|
130
|
+
const declaredFonts = sharedAcrossInstances(
|
|
131
|
+
Symbol.for('pnext.compat.font.declared'),
|
|
132
|
+
() => new Map<string, FontDefinition>(),
|
|
133
|
+
)
|
|
118
134
|
|
|
119
135
|
/**
|
|
120
136
|
* Resolve the declared fonts ahead of the render that will ask for them. Fire-and-forget: this only
|
|
@@ -751,12 +767,19 @@ async function emitFontBytes(
|
|
|
751
767
|
const mediaSegments = ['_next', 'static', 'media']
|
|
752
768
|
const outDir = path.join(context.config.outPath, 'public', ...mediaSegments)
|
|
753
769
|
const file = path.join(outDir, filename)
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
770
|
+
const asset = {
|
|
771
|
+
ext,
|
|
772
|
+
file,
|
|
773
|
+
type: fontMimeType(ext),
|
|
774
|
+
url: `/${mediaSegments.join('/')}/${filename}`,
|
|
758
775
|
}
|
|
759
|
-
|
|
776
|
+
// Existence first, mkdir second: at request time the filesystem is read-only, and an
|
|
777
|
+
// unconditional mkdir throws EROFS just to discover the build already emitted these bytes.
|
|
778
|
+
if (existsSync(file)) return asset
|
|
779
|
+
await mkdir(outDir, { recursive: true })
|
|
780
|
+
if (sourceFile) await copyFile(sourceFile, file)
|
|
781
|
+
else await writeFile(file, bytes)
|
|
782
|
+
return asset
|
|
760
783
|
}
|
|
761
784
|
|
|
762
785
|
function normalizeLocalSources(
|
package/src/runtime/modules.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
clientReferenceModuleSource,
|
|
13
13
|
hasUseClientDirective,
|
|
14
14
|
} from '../client/reference-stub'
|
|
15
|
+
import { noteCompiledClientReference } from '../client/reference'
|
|
15
16
|
import { getClientActionBundler } from '../dev/client-actions'
|
|
16
17
|
import { deferredDynamicImportSpecifiers, devDynamicSplitEnabled } from '../resolve/dynamic'
|
|
17
18
|
import { noteModuleGeneration, noteModuleImported } from './module-generations'
|
|
@@ -2190,6 +2191,7 @@ async function writeClientReferenceModules(
|
|
|
2190
2191
|
await writeClientReferenceModule(stub, reference.file, [...reference.exports].sort())
|
|
2191
2192
|
specifiers.set(specifier, stub)
|
|
2192
2193
|
files.add(path.resolve(reference.file))
|
|
2194
|
+
noteCompiledClientReference(reference.file)
|
|
2193
2195
|
}),
|
|
2194
2196
|
)
|
|
2195
2197
|
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
type ServerEsbuildPluginOptions,
|
|
27
27
|
} from '../extensions'
|
|
28
28
|
import { pathToFileHref, type CompatAliasTarget, type ResolvedConfig } from '../config'
|
|
29
|
-
import { devArtifactUsable, noteDevArtifactWritten } from './module-cache'
|
|
29
|
+
import { devArtifactUsable, devSourceIdentity, noteDevArtifactWritten } from './module-cache'
|
|
30
30
|
import {
|
|
31
31
|
getExternalPackagePolicy,
|
|
32
32
|
isEsmModuleEntry,
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
clientReferenceModuleSource,
|
|
48
48
|
hasUseClientDirective,
|
|
49
49
|
} from '../client/reference-stub'
|
|
50
|
+
import { noteCompiledClientReference } from '../client/reference'
|
|
50
51
|
import { reactCompatEnabled } from '../render/hooks'
|
|
51
52
|
import { writeFileAtomic } from '../utils/fs'
|
|
52
53
|
import { formatDuration } from '../utils/verbose'
|
|
@@ -156,6 +157,11 @@ export async function externalServerPackageHref(
|
|
|
156
157
|
* Artifact identity: what the bundle is compiled FROM, never who asked. Keying on the importer's
|
|
157
158
|
* directory (what `require.resolve` fell back to when a package publishes no `require` condition)
|
|
158
159
|
* split one package into one build per importing directory.
|
|
160
|
+
*
|
|
161
|
+
* Every path in it is workspace-relative, never absolute — for the same reason the module graph
|
|
162
|
+
* names artifacts that way (see `portableSourcePath`). This key names the file, and a deployed
|
|
163
|
+
* function replays the same workspace from a different root: an absolute key misses the whole
|
|
164
|
+
* shipped vendor cache and re-vendors onto a read-only filesystem.
|
|
159
165
|
*/
|
|
160
166
|
function vendorArtifactKey(
|
|
161
167
|
config: ResolvedConfig,
|
|
@@ -163,9 +169,12 @@ function vendorArtifactKey(
|
|
|
163
169
|
conditionTarget: ServerBundleTarget,
|
|
164
170
|
source: string,
|
|
165
171
|
) {
|
|
166
|
-
return `${config.outPath}\0${target}\0${conditionTarget}\0${source}`
|
|
172
|
+
return `${vendorPortablePath(config, config.outPath)}\0${target}\0${conditionTarget}\0${source}`
|
|
167
173
|
}
|
|
168
174
|
|
|
175
|
+
const vendorPortablePath = (config: ResolvedConfig, file: string) =>
|
|
176
|
+
devSourceIdentity(file, config.workspaceRoot)
|
|
177
|
+
|
|
169
178
|
function vendorArtifactFile(config: ResolvedConfig, key: string) {
|
|
170
179
|
return path.join(config.outPath, 'cache', 'server', 'vendor', `${hashBundleSpecifier(key)}.mjs`)
|
|
171
180
|
}
|
|
@@ -214,7 +223,9 @@ function vendorBuildPlan(
|
|
|
214
223
|
config,
|
|
215
224
|
target,
|
|
216
225
|
conditionTarget,
|
|
217
|
-
entry
|
|
226
|
+
entry
|
|
227
|
+
? vendorPortablePath(config, entry)
|
|
228
|
+
: `${vendorPortablePath(config, path.resolve(resolveDir))}\0${specifier}`,
|
|
218
229
|
)
|
|
219
230
|
const file = vendorArtifactFile(config, key)
|
|
220
231
|
return {
|
|
@@ -1290,6 +1301,10 @@ function vendorClientBoundaryLoad(config: ResolvedConfig) {
|
|
|
1290
1301
|
const scan = async (file: string): Promise<OnLoadResult | undefined> => {
|
|
1291
1302
|
const source = await readFile(file, 'utf8').catch(() => undefined)
|
|
1292
1303
|
if (source === undefined || !hasUseClientDirective(source)) return undefined
|
|
1304
|
+
// A 'use client' file the vendor pass found inside a package — the render will import it as a
|
|
1305
|
+
// client module, and nothing else records it (the server-source scan only sees the package
|
|
1306
|
+
// entry, which carries no directive). The vercel warm pass reads this to compile it at build.
|
|
1307
|
+
noteCompiledClientReference(file)
|
|
1293
1308
|
return {
|
|
1294
1309
|
contents: clientReferenceModuleSource(
|
|
1295
1310
|
file,
|
package/src/utils/fs.ts
CHANGED
|
@@ -137,3 +137,16 @@ export function pnextVersionRange() {
|
|
|
137
137
|
const { version } = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string }
|
|
138
138
|
return `^${version}`
|
|
139
139
|
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The range pnext declares for one of its optional dependencies. An app that uses the feature has to
|
|
143
|
+
* declare the package itself — the vercel adapter ships pnext's optional deps only where the app
|
|
144
|
+
* depends on them too — so the range has to come from here rather than being written down twice.
|
|
145
|
+
*/
|
|
146
|
+
export function pnextOptionalDependencyRange(name: string) {
|
|
147
|
+
const pkgPath = fileURLToPath(new URL('../../package.json', import.meta.url))
|
|
148
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as {
|
|
149
|
+
optionalDependencies?: Record<string, string>
|
|
150
|
+
}
|
|
151
|
+
return pkg.optionalDependencies?.[name]
|
|
152
|
+
}
|