@wular/pnext 0.0.14 → 0.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/pnext +3 -1
- package/package.json +4 -1
- package/reference/getting-started.md +1 -1
- package/src/cli/adapters/vercel-warm.ts +19 -102
- package/src/cli/adapters/vercel.ts +37 -126
- package/src/cli/index.ts +21 -1
- package/src/cli/serve/pipeline.ts +5 -3
- package/src/compat/next/font/cache.ts +3 -5
- package/src/compat/next/font/runtime.ts +15 -60
- package/src/render/renderer.ts +10 -5
- package/src/runtime/modules.ts +21 -8
package/bin/pnext
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
2
|
|
|
3
|
+
# Presence only. The version floor is enforced in src/cli/index.ts, where
|
|
4
|
+
# Bun.version costs nothing — asking `bun --version` here would fork per command.
|
|
3
5
|
if ! command -v bun >/dev/null 2>&1; then
|
|
4
|
-
echo "pnext requires Bun. Install it from https://bun.sh/get" >&2
|
|
6
|
+
echo "pnext requires Bun >=1.3.6. Install it from https://bun.sh/get" >&2
|
|
5
7
|
exit 1
|
|
6
8
|
fi
|
|
7
9
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wular/pnext",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.16",
|
|
4
4
|
"description": "A fast little framework for server-first React apps, fully compatible with Next.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -33,6 +33,9 @@
|
|
|
33
33
|
"publishConfig": {
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"bun": ">=1.3.6"
|
|
38
|
+
},
|
|
36
39
|
"exports": {
|
|
37
40
|
".": "./src/index.ts",
|
|
38
41
|
"./internal": "./src/internal.ts",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
pnext is a Preact framework for file-routed apps. It renders on the server by default and ships client JavaScript only where a component opts in.
|
|
4
4
|
|
|
5
|
-
> pnext requires [Bun](https://bun.sh/get) - make sure it is installed first.
|
|
5
|
+
> pnext requires [Bun](https://bun.sh/get) 1.3.6 or newer - make sure it is installed first.
|
|
6
6
|
|
|
7
7
|
<!-- tabs:start -->
|
|
8
8
|
|
|
@@ -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
|
|
23
|
+
import { existsSync } 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,9 +33,8 @@ import {
|
|
|
33
33
|
setEmitCompiledSpecifiersManifest,
|
|
34
34
|
} from '../../runtime/modules'
|
|
35
35
|
import { getCompatModeExtensions } from '../../extensions'
|
|
36
|
-
import { getFontExtensions } from '../../render/hooks'
|
|
37
36
|
import { findConventionFiles, findLayouts } from '../../routing/routes'
|
|
38
|
-
import { registerServerRuntime
|
|
37
|
+
import { registerServerRuntime } from '../../runtime/loader'
|
|
39
38
|
import { namedBunBinary } from '../boot/named-bin'
|
|
40
39
|
import type { VerboseLogger } from '../../utils/verbose'
|
|
41
40
|
import type { BuildManifest } from '../../types'
|
|
@@ -253,17 +252,8 @@ function pageServerFiles(config: ResolvedConfig, routeFiles: Iterable<string>) {
|
|
|
253
252
|
return files
|
|
254
253
|
}
|
|
255
254
|
|
|
256
|
-
const compileServer = (config: ResolvedConfig, file: string
|
|
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}`
|
|
255
|
+
const compileServer = (config: ResolvedConfig, file: string) =>
|
|
256
|
+
devServerModuleHref(config, file, 'build').then(href => report(file, fileURLToPath(href)))
|
|
267
257
|
|
|
268
258
|
/**
|
|
269
259
|
* Compile the page routes' server modules from the build's route scan, long before the manifest
|
|
@@ -289,24 +279,19 @@ export async function warmRouteModules(
|
|
|
289
279
|
manifest: BuildManifest,
|
|
290
280
|
skip: ReadonlySet<string> = new Set(),
|
|
291
281
|
) {
|
|
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.
|
|
296
282
|
const clientFiles = new Set<string>()
|
|
297
283
|
const handlers: BuildManifest['routes'] = []
|
|
298
|
-
const pageRoutes =
|
|
284
|
+
const pageRoutes: string[] = []
|
|
299
285
|
|
|
300
286
|
for (const route of manifest.routes) {
|
|
301
287
|
if (route.kind === 'handler') {
|
|
302
|
-
if (!skip.has(
|
|
288
|
+
if (!skip.has(route.file)) handlers.push(route)
|
|
303
289
|
continue
|
|
304
290
|
}
|
|
305
|
-
|
|
306
|
-
pageRoutes.set(runtime, [...(pageRoutes.get(runtime) ?? []), route.file])
|
|
291
|
+
pageRoutes.push(route.file)
|
|
307
292
|
for (const reference of route.clientReferences) {
|
|
308
293
|
if (ssrClientReference(reference) && existsSync(reference.file)) {
|
|
309
|
-
clientFiles.add(
|
|
294
|
+
clientFiles.add(reference.file)
|
|
310
295
|
}
|
|
311
296
|
}
|
|
312
297
|
}
|
|
@@ -315,29 +300,16 @@ export async function warmRouteModules(
|
|
|
315
300
|
// transforms in memory); the compiled build cache is only used with compat.
|
|
316
301
|
if (getCompatModeExtensions().reactEnabled(config)) {
|
|
317
302
|
await Promise.all([
|
|
318
|
-
...[...
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
),
|
|
303
|
+
...[...pageServerFiles(config, pageRoutes)]
|
|
304
|
+
.filter(file => !skip.has(file))
|
|
305
|
+
.map(file => compileServer(config, file)),
|
|
330
306
|
...[...clientFiles]
|
|
331
|
-
.filter(
|
|
332
|
-
.map(
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
'build',
|
|
338
|
-
serverBundleTargetForRuntime(runtime || undefined),
|
|
339
|
-
).then(href => report(key, fileURLToPath(href)))
|
|
340
|
-
}),
|
|
307
|
+
.filter(file => !skip.has(file))
|
|
308
|
+
.map(file =>
|
|
309
|
+
devClientModuleHref(config, file, 'build').then(href =>
|
|
310
|
+
report(file, fileURLToPath(href)),
|
|
311
|
+
),
|
|
312
|
+
),
|
|
341
313
|
])
|
|
342
314
|
}
|
|
343
315
|
console.log(COMPILED_MARKER)
|
|
@@ -353,68 +325,15 @@ export async function warmRouteModules(
|
|
|
353
325
|
// time, so a handler that takes the process down is unambiguous and a
|
|
354
326
|
// restart must not run it again (Bun caches a failed import for the
|
|
355
327
|
// process' lifetime anyway, so a retry could not succeed either).
|
|
356
|
-
report(
|
|
328
|
+
report(route.file)
|
|
357
329
|
try {
|
|
358
330
|
await import(pathToFileHref(route.file))
|
|
359
331
|
} catch (error) {
|
|
360
332
|
console.warn(`vercel adapter: importing ${route.file} failed during warmup:`, error)
|
|
361
333
|
}
|
|
362
334
|
}
|
|
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 })
|
|
395
335
|
}
|
|
396
336
|
|
|
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
|
-
|
|
418
337
|
/**
|
|
419
338
|
* Read the parent's line-delimited requests until the `skip` message, running
|
|
420
339
|
* each prewarm batch as it arrives. Returns the final request.
|
|
@@ -443,9 +362,7 @@ async function readWarmRequests(config: ResolvedConfig): Promise<Partial<WarmReq
|
|
|
443
362
|
|
|
444
363
|
if (import.meta.main) {
|
|
445
364
|
const root = process.argv[2]
|
|
446
|
-
|
|
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 })
|
|
365
|
+
const config = await loadConfig(root)
|
|
449
366
|
await bootstrapCompat(config)
|
|
450
367
|
// This process only ever runs for a vercel-adapter build; record each
|
|
451
368
|
// 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,
|
|
6
|
+
import { DEFAULT_OUT_DIR, pathToFileHref } from '../../config'
|
|
7
7
|
import type { ResolvedConfig } from '../../config'
|
|
8
8
|
import { globalCssSources } from '../../css/build'
|
|
9
9
|
import { getImportAliasExtensions } from '../../extensions'
|
|
@@ -19,13 +19,7 @@ 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
|
-
|
|
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'
|
|
22
|
+
const RUNTIME_ROOT = '/var/task'
|
|
29
23
|
|
|
30
24
|
// The single catch-all server function: the same `pnext start` pipeline,
|
|
31
25
|
// running on Vercel's Bun runtime (`"bunVersion": "1.x"` in vercel.json).
|
|
@@ -35,20 +29,12 @@ const SERVER_FUNCTION = '_pnext'
|
|
|
35
29
|
// goes into .vc-config.json and the same target selects the native packages
|
|
36
30
|
// shipped inside it, so the two can never drift. Vercel's default instruction
|
|
37
31
|
// set is x86_64 and its function runtime is glibc-based.
|
|
38
|
-
const FUNCTION_PLATFORM =
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
libc: process.platform === 'darwin' ? '' : 'glibc',
|
|
45
|
-
}
|
|
46
|
-
: ({
|
|
47
|
-
architecture: 'x86_64',
|
|
48
|
-
os: 'linux',
|
|
49
|
-
cpu: 'x64',
|
|
50
|
-
libc: 'glibc',
|
|
51
|
-
} as const)
|
|
32
|
+
const FUNCTION_PLATFORM = {
|
|
33
|
+
architecture: 'x86_64',
|
|
34
|
+
os: 'linux',
|
|
35
|
+
cpu: 'x64',
|
|
36
|
+
libc: 'glibc',
|
|
37
|
+
} as const
|
|
52
38
|
|
|
53
39
|
// Directories that only ever hold build-tool output, pruned at *any* depth. Depth matters: pruning
|
|
54
40
|
// only a package's top level ships every nested one. node_modules and .git are here for the same
|
|
@@ -85,12 +71,6 @@ interface PackRules {
|
|
|
85
71
|
droppedSuffixes: string[]
|
|
86
72
|
/** Absolute sources exempt from the prune list — the app's own build output. */
|
|
87
73
|
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
|
|
94
74
|
}
|
|
95
75
|
|
|
96
76
|
// Test seam: the packing suite builds one app both ways and diffs the trees,
|
|
@@ -106,9 +86,6 @@ export function setPackPruningEnabled(enabled: boolean) {
|
|
|
106
86
|
}
|
|
107
87
|
}
|
|
108
88
|
|
|
109
|
-
// Build-time scratch directories at the top level of the out root.
|
|
110
|
-
const outRootScratch = [devOutSegment, 'typecheck', 'types']
|
|
111
|
-
|
|
112
89
|
function packRules(config: ResolvedConfig): PackRules {
|
|
113
90
|
if (!packPruningEnabled) {
|
|
114
91
|
return {
|
|
@@ -117,9 +94,6 @@ function packRules(config: ResolvedConfig): PackRules {
|
|
|
117
94
|
prunedDirs: new Set(['node_modules', '.git']),
|
|
118
95
|
droppedSuffixes: [],
|
|
119
96
|
keepPaths: new Set([path.resolve(config.outPath)]),
|
|
120
|
-
dropPaths: new Set(),
|
|
121
|
-
dropNames: new Set(),
|
|
122
|
-
outRootPath: path.resolve(config.outRootPath),
|
|
123
97
|
}
|
|
124
98
|
}
|
|
125
99
|
const keep = new Set(config.adapter?.keep ?? [])
|
|
@@ -139,40 +113,13 @@ function packRules(config: ResolvedConfig): PackRules {
|
|
|
139
113
|
// default out root goes with it: a `distDir` app moved its output away, but
|
|
140
114
|
// the next.config bundle the function imports at boot still lives there.
|
|
141
115
|
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),
|
|
151
116
|
}
|
|
152
117
|
}
|
|
153
118
|
|
|
154
|
-
|
|
155
|
-
|
|
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
|
+
function shipsDir(pack: PackRules, name: string, source: string) {
|
|
120
|
+
return !pack.prunedDirs.has(name) || pack.keepPaths.has(path.resolve(source))
|
|
172
121
|
}
|
|
173
122
|
|
|
174
|
-
const isOutRoot = (pack: PackRules, dir: string) => path.resolve(dir) === pack.outRootPath
|
|
175
|
-
|
|
176
123
|
function shipsFile(pack: PackRules, name: string) {
|
|
177
124
|
return !pack.droppedSuffixes.some(suffix => name.endsWith(suffix))
|
|
178
125
|
}
|
|
@@ -192,12 +139,6 @@ export async function writeVercelOutput(
|
|
|
192
139
|
options: { verbose?: boolean; warm?: WarmChild } = {},
|
|
193
140
|
) {
|
|
194
141
|
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
|
-
}
|
|
201
142
|
const outputPath = path.join(config.root, '.vercel', 'output')
|
|
202
143
|
const staticFiles = manifest.staticFiles ?? {}
|
|
203
144
|
// Warming runs in its own process (see ./vercel-warm) and normally started
|
|
@@ -394,26 +335,14 @@ async function writeServerFunction(
|
|
|
394
335
|
)
|
|
395
336
|
|
|
396
337
|
// Baked absolute paths (compiled cache imports, manifest file paths) point
|
|
397
|
-
// at the build machine's workspace; rewrite them to the runtime root.
|
|
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
|
-
)
|
|
338
|
+
// at the build machine's workspace; rewrite them to the runtime root.
|
|
406
339
|
await log.step('rewrite baked paths', () =>
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
[workspaceRoot, RUNTIME_ROOT] as const,
|
|
414
|
-
]),
|
|
415
|
-
),
|
|
416
|
-
),
|
|
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
|
+
]),
|
|
417
346
|
)
|
|
418
347
|
|
|
419
348
|
// Bun's runtime onResolve plugins never see bare specifiers, so the compat aliases the dev pipeline
|
|
@@ -452,9 +381,12 @@ export default Object.assign(handler, { fetch: handler });
|
|
|
452
381
|
`${JSON.stringify(
|
|
453
382
|
{
|
|
454
383
|
// The Bun function runtime (project-level `bunVersion` does not apply
|
|
455
|
-
// to prebuilt Build Output API functions
|
|
456
|
-
//
|
|
457
|
-
|
|
384
|
+
// to prebuilt Build Output API functions, so the version is pinned
|
|
385
|
+
// here). Not `bun1.x`: that resolves to Bun 1.3.14, which segfaults a
|
|
386
|
+
// second into serving and takes the whole function down with SIGABRT.
|
|
387
|
+
// Note: bun functions have a 150 MiB uncompressed size limit vs 250
|
|
388
|
+
// MiB for nodejs.
|
|
389
|
+
runtime: 'bun1.4.x',
|
|
458
390
|
handler: 'index.mjs',
|
|
459
391
|
launcherType: 'Nodejs',
|
|
460
392
|
architecture: FUNCTION_PLATFORM.architecture,
|
|
@@ -528,16 +460,13 @@ async function copyTree(
|
|
|
528
460
|
pack: PackRules,
|
|
529
461
|
excludeNames: string[] = [],
|
|
530
462
|
visited = new Set<string>([realDir(from) ?? path.resolve(from)]),
|
|
531
|
-
inOutRoot = pack.keepPaths.has(path.resolve(from)),
|
|
532
463
|
) {
|
|
533
464
|
const exclude = new Set(excludeNames)
|
|
534
465
|
const clone = directoryCloneEnabled ? await directoryCloner() : null
|
|
535
466
|
// Nothing to skip at this level: the whole tree clones in one syscall.
|
|
536
467
|
if (exclude.size === 0 && !existsSync(to)) {
|
|
537
468
|
await mkdir(path.dirname(to), { recursive: true })
|
|
538
|
-
if (clone?.(from, to))
|
|
539
|
-
return normalizeClone(to, pack, visited, inOutRoot, isOutRoot(pack, from))
|
|
540
|
-
}
|
|
469
|
+
if (clone?.(from, to)) return normalizeClone(to, pack, visited)
|
|
541
470
|
}
|
|
542
471
|
// The build output is still live while the walk runs — typegen rewrites
|
|
543
472
|
// `.pnext/types` under it — so a directory can vanish between the parent's
|
|
@@ -551,29 +480,23 @@ async function copyTree(
|
|
|
551
480
|
const target = path.join(to, entry.name)
|
|
552
481
|
// Matched by name before type: a symlinked `node_modules` must be pruned
|
|
553
482
|
// as the directory it points at, not followed.
|
|
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))
|
|
483
|
+
if (exclude.has(entry.name) || !shipsDir(pack, entry.name, source)) return
|
|
561
484
|
if (entry.isSymbolicLink()) {
|
|
562
485
|
const link = resolveLinkTarget(source)
|
|
563
486
|
if (!link) return
|
|
564
487
|
if (link.isDirectory) {
|
|
565
488
|
if (visited.has(link.path)) return
|
|
566
489
|
visited.add(link.path)
|
|
567
|
-
return copyTree(link.path, target, pack, [], visited
|
|
490
|
+
return copyTree(link.path, target, pack, [], visited)
|
|
568
491
|
}
|
|
569
492
|
return shipsFile(pack, entry.name) ? linkOrCopyFile(link.path, target) : undefined
|
|
570
493
|
}
|
|
571
494
|
// One syscall for the whole subtree beats one per file — the excluded
|
|
572
495
|
// names are stripped from the copy afterwards.
|
|
573
496
|
if (entry.isDirectory() && clone?.(source, target)) {
|
|
574
|
-
return normalizeClone(target, pack, visited
|
|
497
|
+
return normalizeClone(target, pack, visited)
|
|
575
498
|
}
|
|
576
|
-
if (entry.isDirectory()) return copyTree(source, target, pack, [], visited
|
|
499
|
+
if (entry.isDirectory()) return copyTree(source, target, pack, [], visited)
|
|
577
500
|
if (entry.isFile() && shipsFile(pack, entry.name)) return linkOrCopyFile(source, target)
|
|
578
501
|
}),
|
|
579
502
|
)
|
|
@@ -632,30 +555,18 @@ async function directoryCloner() {
|
|
|
632
555
|
* both paths emit the same tree. Reading directory entries costs nothing next
|
|
633
556
|
* to the per-file copies this replaces.
|
|
634
557
|
*/
|
|
635
|
-
async function normalizeClone(
|
|
636
|
-
dir
|
|
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]]
|
|
558
|
+
async function normalizeClone(dir: string, pack: PackRules, visited: Set<string>) {
|
|
559
|
+
const stack = [dir]
|
|
646
560
|
const pending: Promise<unknown>[] = []
|
|
647
561
|
while (stack.length > 0) {
|
|
648
|
-
const
|
|
562
|
+
const current = stack.pop()!
|
|
649
563
|
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
650
564
|
const full = path.join(current, entry.name)
|
|
651
|
-
// Matched by name before type, as the walk does:
|
|
652
|
-
//
|
|
653
|
-
//
|
|
565
|
+
// Matched by name before type, as the walk does: the pages-compat shim
|
|
566
|
+
// links `node_modules` at the app root, and following it would pull the
|
|
567
|
+
// whole store into the function. The clone is already inside the
|
|
654
568
|
// function, so the walk's keep-path exemption cannot apply here.
|
|
655
|
-
if (
|
|
656
|
-
(atRoot && pack.dropNames.has(entry.name)) ||
|
|
657
|
-
((!inside || entry.isSymbolicLink()) && pack.prunedDirs.has(entry.name))
|
|
658
|
-
) {
|
|
569
|
+
if (pack.prunedDirs.has(entry.name)) {
|
|
659
570
|
rmSync(full, { recursive: true, force: true })
|
|
660
571
|
} else if (entry.isSymbolicLink()) {
|
|
661
572
|
const link = resolveLinkTarget(full)
|
|
@@ -664,12 +575,12 @@ async function normalizeClone(
|
|
|
664
575
|
if (link.isDirectory) {
|
|
665
576
|
if (visited.has(link.path)) continue
|
|
666
577
|
visited.add(link.path)
|
|
667
|
-
pending.push(copyTree(link.path, full, pack, [], visited
|
|
578
|
+
pending.push(copyTree(link.path, full, pack, [], visited))
|
|
668
579
|
} else if (shipsFile(pack, entry.name)) {
|
|
669
580
|
pending.push(linkOrCopyFile(link.path, full))
|
|
670
581
|
}
|
|
671
582
|
} else if (entry.isDirectory()) {
|
|
672
|
-
stack.push(
|
|
583
|
+
stack.push(full)
|
|
673
584
|
} else if (!shipsFile(pack, entry.name)) {
|
|
674
585
|
rmSync(full, { force: true })
|
|
675
586
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -4,9 +4,29 @@ import path from 'node:path'
|
|
|
4
4
|
import { markBoot } from './boot/trace'
|
|
5
5
|
import { commandBinaryName, nameEsbuildProcess, namedBunBinary } from './boot/named-bin'
|
|
6
6
|
|
|
7
|
+
const MIN_BUN = '1.3.6'
|
|
8
|
+
// Not a floor but a hole in one: 1.3.14 segfaults about a second into serving,
|
|
9
|
+
// so a pnext server dies before its first response with no error of its own to
|
|
10
|
+
// show. 1.3.13 and 1.4.x are both fine.
|
|
11
|
+
const BROKEN_BUN = new Set(['1.3.14'])
|
|
12
|
+
|
|
7
13
|
// Reached only if something bypassed bin/pnext (which guards the same way).
|
|
8
14
|
if (typeof Bun === 'undefined') {
|
|
9
|
-
console.error(
|
|
15
|
+
console.error(`pnext requires Bun >=${MIN_BUN}. Install it from https://bun.sh/get`)
|
|
16
|
+
process.exit(1)
|
|
17
|
+
}
|
|
18
|
+
// A prerelease sorts below its own release, so compare the release: a 1.4.0-canary is a 1.4.0 here.
|
|
19
|
+
const bunVersion = Bun.version.split('-')[0] ?? Bun.version
|
|
20
|
+
if (BROKEN_BUN.has(bunVersion)) {
|
|
21
|
+
console.error(
|
|
22
|
+
`pnext does not support Bun ${Bun.version}: it crashes the process at runtime. ` +
|
|
23
|
+
'Upgrade to latest Bun with `bun upgrade`.',
|
|
24
|
+
)
|
|
25
|
+
process.exit(1)
|
|
26
|
+
} else if (!Bun.semver.satisfies(bunVersion, `>=${MIN_BUN}`)) {
|
|
27
|
+
console.error(
|
|
28
|
+
`pnext requires Bun >=${MIN_BUN}, found ${Bun.version}. Upgrade with \`bun upgrade\`.`,
|
|
29
|
+
)
|
|
10
30
|
process.exit(1)
|
|
11
31
|
}
|
|
12
32
|
|
|
@@ -14,6 +14,7 @@ import { loadConfig, pathToFileHref } from '../../config'
|
|
|
14
14
|
import type { ResolvedConfig } from '../../config'
|
|
15
15
|
import { bootstrapCompat } from '../../compat-bootstrap'
|
|
16
16
|
import { registerServerRuntime, serverBundleTargetForRuntime } from '../../runtime/loader'
|
|
17
|
+
import { importModuleOnce } from '../../runtime/modules'
|
|
17
18
|
import { applyProxyResponse, createProxyRunner } from '../../routing/proxy'
|
|
18
19
|
import {
|
|
19
20
|
isPprShellUpgradeEligible,
|
|
@@ -146,7 +147,7 @@ export async function createRequestHandler(
|
|
|
146
147
|
const href = compatModuleMode(config)
|
|
147
148
|
? await moduleHrefForRoute(config, route)
|
|
148
149
|
: pathToFileHref(route.file)
|
|
149
|
-
const module =
|
|
150
|
+
const module = await importModuleOnce<Parameters<typeof handleRouteModule>[0]>(href)
|
|
150
151
|
const rendered = await getRenderExtensions().collectRenderMeta(
|
|
151
152
|
() =>
|
|
152
153
|
withRouteRuntime(route.segmentConfig?.runtime, () =>
|
|
@@ -980,8 +981,9 @@ async function handleRoute(
|
|
|
980
981
|
const href = compatModuleMode(config)
|
|
981
982
|
? await moduleHrefForRoute(config, route)
|
|
982
983
|
: new URL(`file://${route.file}`).href
|
|
983
|
-
const imported =
|
|
984
|
-
Parameters<typeof metadataRouteHandlerModule>[0]
|
|
984
|
+
const imported = await importModuleOnce<
|
|
985
|
+
RouteHandlerModule & Parameters<typeof metadataRouteHandlerModule>[0]
|
|
986
|
+
>(href)
|
|
985
987
|
return (metadataRouteHandlerModule(imported, route) ?? imported) as RouteHandlerModule
|
|
986
988
|
})
|
|
987
989
|
return spans.withRouteHandlerSpan(route.route, () =>
|
|
@@ -7,11 +7,12 @@
|
|
|
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,
|
|
10
|
+
import { existsSync, 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'
|
|
15
16
|
import { writeFileAtomic } from '../../../utils/fs'
|
|
16
17
|
import type { MetadataLink } from '../../../types'
|
|
17
18
|
|
|
@@ -60,10 +61,6 @@ function fontIndex(config: ResolvedConfig): FontIndex {
|
|
|
60
61
|
if (!created.dirty) return
|
|
61
62
|
created.dirty = false
|
|
62
63
|
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 })
|
|
67
64
|
writeFileSync(created.file, serializeIndex(created.fonts))
|
|
68
65
|
} catch {
|
|
69
66
|
// Best effort: a missing index only costs the next boot a re-resolve.
|
|
@@ -77,6 +74,7 @@ export async function persistedFont(
|
|
|
77
74
|
config: ResolvedConfig,
|
|
78
75
|
key: string,
|
|
79
76
|
): Promise<PersistableFont | undefined> {
|
|
77
|
+
if (!restartCacheEnabled()) return undefined
|
|
80
78
|
const entry = fontIndex(config).fonts.get(key)
|
|
81
79
|
if (!entry) return undefined
|
|
82
80
|
if (!entry.files.every(file => existsSync(file))) return undefined
|
|
@@ -10,7 +10,6 @@ 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'
|
|
14
13
|
import { escapeRegex } from '../../../utils/code'
|
|
15
14
|
import {
|
|
16
15
|
familyStack,
|
|
@@ -114,16 +113,8 @@ let googleMetadataCache: Record<string, GoogleFontMetadata> | undefined
|
|
|
114
113
|
* Every font definition constructed so far, whether or not a render has read its `.className` yet.
|
|
115
114
|
* Definitions are created when the app's font module evaluates - well before the render that registers
|
|
116
115
|
* 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.
|
|
121
116
|
*/
|
|
122
|
-
const
|
|
123
|
-
const declaredFonts = ((globalThis as Record<symbol, unknown>)[DECLARED_FONTS] ??= new Map<
|
|
124
|
-
string,
|
|
125
|
-
FontDefinition
|
|
126
|
-
>()) as Map<string, FontDefinition>
|
|
117
|
+
const declaredFonts = new Map<string, FontDefinition>()
|
|
127
118
|
|
|
128
119
|
/**
|
|
129
120
|
* Resolve the declared fonts ahead of the render that will ask for them. Fire-and-forget: this only
|
|
@@ -369,31 +360,25 @@ async function resolveFont(definition: FontDefinition, context: FontResolveConte
|
|
|
369
360
|
// A `pnext build` wiping the output directory under a running server must
|
|
370
361
|
// not leave cached font URLs pointing at deleted files; re-emit them.
|
|
371
362
|
// Font bytes stay cached in memory, so this never refetches the network.
|
|
372
|
-
|
|
373
|
-
// rejects the write; there is nothing on disk to go stale.
|
|
374
|
-
if (font.files.every(file => !file || existsSync(file))) return font
|
|
363
|
+
if (font.files.every(file => existsSync(file))) return font
|
|
375
364
|
const fresh = resolveAndPersist(definition, context, key)
|
|
376
365
|
resolvedFontCache.set(key, fresh)
|
|
377
366
|
return fresh
|
|
378
367
|
}
|
|
379
368
|
|
|
380
369
|
/**
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
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.
|
|
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.
|
|
387
373
|
*/
|
|
388
374
|
async function resolveThroughCache(
|
|
389
375
|
definition: FontDefinition,
|
|
390
376
|
context: FontResolveContext,
|
|
391
377
|
key: string,
|
|
392
378
|
): Promise<ResolvedFont> {
|
|
393
|
-
if (!context.dev
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
379
|
+
if (!context.dev) return definition.resolve(context)
|
|
380
|
+
const persisted = await persistedFont(context.config, key)
|
|
381
|
+
if (persisted) return persisted
|
|
397
382
|
return resolveAndPersist(definition, context, key)
|
|
398
383
|
}
|
|
399
384
|
|
|
@@ -403,8 +388,9 @@ function resolveAndPersist(
|
|
|
403
388
|
key: string,
|
|
404
389
|
): Promise<ResolvedFont> {
|
|
405
390
|
const resolved = definition.resolve(context)
|
|
406
|
-
|
|
407
|
-
|
|
391
|
+
if (context.dev) {
|
|
392
|
+
void resolved.then(font => persistFont(context.config, key, font)).catch(() => undefined)
|
|
393
|
+
}
|
|
408
394
|
return resolved
|
|
409
395
|
}
|
|
410
396
|
|
|
@@ -731,7 +717,7 @@ async function emitRemoteFontFile(context: FontResolveContext, url: string, emit
|
|
|
731
717
|
googleFontFileCache.set(url, cached)
|
|
732
718
|
}
|
|
733
719
|
const bytes = await cached
|
|
734
|
-
return emitFontBytes(context, bytes, fontExt(url), emit
|
|
720
|
+
return emitFontBytes(context, bytes, fontExt(url), emit)
|
|
735
721
|
}
|
|
736
722
|
|
|
737
723
|
async function fetchFontFile(url: string) {
|
|
@@ -755,7 +741,6 @@ async function emitFontBytes(
|
|
|
755
741
|
ext: string,
|
|
756
742
|
emit: EmitFontOptions,
|
|
757
743
|
sourceFile?: string,
|
|
758
|
-
originUrl?: string,
|
|
759
744
|
) {
|
|
760
745
|
const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 16)
|
|
761
746
|
// Next's loader names emitted files `[hash]-s.p.[ext]`: `-s` when a
|
|
@@ -766,39 +751,12 @@ async function emitFontBytes(
|
|
|
766
751
|
const mediaSegments = ['_next', 'static', 'media']
|
|
767
752
|
const outDir = path.join(context.config.outPath, 'public', ...mediaSegments)
|
|
768
753
|
const file = path.join(outDir, filename)
|
|
769
|
-
|
|
770
|
-
|
|
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 })
|
|
754
|
+
await mkdir(outDir, { recursive: true })
|
|
755
|
+
if (!existsSync(file)) {
|
|
778
756
|
if (sourceFile) await copyFile(sourceFile, file)
|
|
779
757
|
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 }
|
|
791
758
|
}
|
|
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
|
-
)
|
|
759
|
+
return { ext, file, type: fontMimeType(ext), url: `/${mediaSegments.join('/')}/${filename}` }
|
|
802
760
|
}
|
|
803
761
|
|
|
804
762
|
function normalizeLocalSources(
|
|
@@ -875,9 +833,6 @@ function loadGoogleMetadata(): Record<string, GoogleFontMetadata> {
|
|
|
875
833
|
throw new Error(`Invalid Google font metadata file: ${override}`)
|
|
876
834
|
}
|
|
877
835
|
|
|
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.
|
|
881
836
|
const metadata =
|
|
882
837
|
tryLoadGoogleMetadata('next-font/google/font-data.json') ?? tryLoadGoogleMetadata('next-font')
|
|
883
838
|
if (metadata) return metadata
|
package/src/render/renderer.ts
CHANGED
|
@@ -26,7 +26,12 @@ import {
|
|
|
26
26
|
import { clientEntryName } from '../client/chunk-name'
|
|
27
27
|
import { pathToFileHref, type ResolvedConfig } from '../config'
|
|
28
28
|
import { markErrorLogged } from '../utils/error-log'
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
devClientModuleHref,
|
|
31
|
+
devServerModuleHref,
|
|
32
|
+
importDevModule,
|
|
33
|
+
importModuleOnce,
|
|
34
|
+
} from '../runtime/modules'
|
|
30
35
|
import { serverBundleTargetForRuntime } from '../runtime/loader'
|
|
31
36
|
import {
|
|
32
37
|
getActionModuleExtensions,
|
|
@@ -663,8 +668,8 @@ function clientModuleHref(file: string, options: ModuleLoadOptions) {
|
|
|
663
668
|
}
|
|
664
669
|
|
|
665
670
|
async function importModule(file: string, options: ModuleLoadOptions) {
|
|
666
|
-
//
|
|
667
|
-
//
|
|
671
|
+
// Re-throw a module's FIRST evaluation error on every later request rather
|
|
672
|
+
// than the downstream symptom a half-evaluated module produces.
|
|
668
673
|
let module: Record<string, unknown>
|
|
669
674
|
if (options.moduleLoader) module = await options.moduleLoader(file)
|
|
670
675
|
else if (options.dev) {
|
|
@@ -672,7 +677,7 @@ async function importModule(file: string, options: ModuleLoadOptions) {
|
|
|
672
677
|
} else {
|
|
673
678
|
const href = await moduleHref(file, options)
|
|
674
679
|
await drainPreplanBuilds()
|
|
675
|
-
module =
|
|
680
|
+
module = await importModuleOnce<Record<string, unknown>>(href)
|
|
676
681
|
}
|
|
677
682
|
// Tag any 'use server' action module's exports with their wire id so
|
|
678
683
|
// <form action={fn}> can progressively enhance. Compat-owned; a no-op for a
|
|
@@ -687,7 +692,7 @@ async function importClientModule(file: string, options: ModuleLoadOptions) {
|
|
|
687
692
|
const href = await clientModuleHref(file, options)
|
|
688
693
|
if (options.dev) return importDevModule<Record<string, unknown>>(href)
|
|
689
694
|
await drainPreplanBuilds()
|
|
690
|
-
return
|
|
695
|
+
return importModuleOnce<Record<string, unknown>>(href)
|
|
691
696
|
}
|
|
692
697
|
|
|
693
698
|
function clientScriptPath(route: RouteManifestEntry, dev?: boolean) {
|
package/src/runtime/modules.ts
CHANGED
|
@@ -185,21 +185,34 @@ const routeModuleLoaders = new Map<string, Promise<DevRouteModuleLoaders>>()
|
|
|
185
185
|
// and the entry is never consulted again.
|
|
186
186
|
const moduleEvalErrors = new Map<string, unknown>()
|
|
187
187
|
|
|
188
|
-
/**
|
|
189
|
-
|
|
188
|
+
/**
|
|
189
|
+
* Import a compiled module, re-throwing its first evaluation error on later imports.
|
|
190
|
+
*
|
|
191
|
+
* Production needs this as much as dev: a re-import resolves with a half-evaluated namespace whose
|
|
192
|
+
* `export const` bindings are still in TDZ, so the render's next read (`module.metadata`) throws
|
|
193
|
+
* `Cannot access 'metadata' before initialization` and that replaces the real error in the response.
|
|
194
|
+
* Prod hrefs are content-addressed too, so the entry keys the same way dev's does.
|
|
195
|
+
*/
|
|
196
|
+
export async function importModuleOnce<T>(href: string): Promise<T> {
|
|
190
197
|
if (moduleEvalErrors.has(href)) throw moduleEvalErrors.get(href)
|
|
191
|
-
// Pre-planned vendor builds are enqueued without an awaiter; nothing
|
|
192
|
-
// evaluates until they all settle (no-op when the pipeline is empty).
|
|
193
|
-
await drainPreplanBuilds()
|
|
194
198
|
try {
|
|
195
|
-
|
|
196
|
-
noteModuleImported(href)
|
|
197
|
-
return module
|
|
199
|
+
return (await import(href)) as T
|
|
198
200
|
} catch (error) {
|
|
199
201
|
moduleEvalErrors.set(href, error)
|
|
200
202
|
throw error
|
|
201
203
|
}
|
|
202
204
|
}
|
|
205
|
+
|
|
206
|
+
/** `importModuleOnce` plus dev's registry-generation accounting. */
|
|
207
|
+
export async function importDevModule<T>(href: string): Promise<T> {
|
|
208
|
+
if (moduleEvalErrors.has(href)) throw moduleEvalErrors.get(href)
|
|
209
|
+
// Pre-planned vendor builds are enqueued without an awaiter; nothing
|
|
210
|
+
// evaluates until they all settle (no-op when the pipeline is empty).
|
|
211
|
+
await drainPreplanBuilds()
|
|
212
|
+
const module = await importModuleOnce<T>(href)
|
|
213
|
+
noteModuleImported(href)
|
|
214
|
+
return module
|
|
215
|
+
}
|
|
203
216
|
const extensionlessDynamicImportExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
|
|
204
217
|
const externalLoadNamespaces = {
|
|
205
218
|
server: 'pnext-route-external-server',
|