@wular/pnext 0.0.12 → 0.0.13
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.ts +5 -3
- package/src/cli/serve/pipeline.ts +25 -7
- package/src/cli/start.ts +1 -1
- package/src/compat/next/config-loader.ts +111 -56
- package/src/config.ts +13 -4
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, readdirSync, realpathSync, rmSync, statSync }
|
|
|
3
3
|
import { builtinModules } from 'node:module'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { fileURLToPath } from 'node:url'
|
|
6
|
-
import { pathToFileHref } from '../../config'
|
|
6
|
+
import { DEFAULT_OUT_DIR, pathToFileHref } from '../../config'
|
|
7
7
|
import type { ResolvedConfig } from '../../config'
|
|
8
8
|
import { globalCssSources } from '../../css/build'
|
|
9
9
|
import { getImportAliasExtensions } from '../../extensions'
|
|
@@ -109,8 +109,10 @@ function packRules(config: ResolvedConfig): PackRules {
|
|
|
109
109
|
suffix => !keep.has(suffix),
|
|
110
110
|
),
|
|
111
111
|
// The app's outDir shares its name with the prune list; it holds the
|
|
112
|
-
// compiled module cache the function serves from and must survive.
|
|
113
|
-
|
|
112
|
+
// compiled module cache the function serves from and must survive. The
|
|
113
|
+
// default out root goes with it: a `distDir` app moved its output away, but
|
|
114
|
+
// the next.config bundle the function imports at boot still lives there.
|
|
115
|
+
keepPaths: new Set([path.resolve(config.outPath), path.resolve(config.root, DEFAULT_OUT_DIR)]),
|
|
114
116
|
}
|
|
115
117
|
}
|
|
116
118
|
|
|
@@ -88,7 +88,7 @@ export async function createRequestHandler(
|
|
|
88
88
|
// `start` already resolved (and bootstrapped) the config for its output-mode
|
|
89
89
|
// guard; reusing it skips a second loadConfig — env load, pnext.config +
|
|
90
90
|
// next.config imports, app-path resolution — before the ready banner.
|
|
91
|
-
const config = options.config ?? (await loadConfig(options.root))
|
|
91
|
+
const config = options.config ?? (await loadConfig(options.root, { serve: true }))
|
|
92
92
|
// Compat plugin loader: the single gated seam that populates the core
|
|
93
93
|
// extension registries when compat is enabled (no-op for pure-core apps).
|
|
94
94
|
// A prod server answers the very next request, so it takes both tiers here.
|
|
@@ -578,8 +578,13 @@ export async function createRequestHandler(
|
|
|
578
578
|
method === 'GET' &&
|
|
579
579
|
handlerResponse.status === 200
|
|
580
580
|
) {
|
|
581
|
-
|
|
582
|
-
|
|
581
|
+
try {
|
|
582
|
+
await writeFile(built.file, new Uint8Array(await handlerResponse.clone().arrayBuffer()))
|
|
583
|
+
handlerResponse.headers.set('x-nextjs-cache', 'MISS')
|
|
584
|
+
} catch {
|
|
585
|
+
// Best-effort like the page path below: a read-only serving filesystem
|
|
586
|
+
// (a serverless host) costs the cache write, never the response.
|
|
587
|
+
}
|
|
583
588
|
}
|
|
584
589
|
if (
|
|
585
590
|
!built &&
|
|
@@ -593,7 +598,9 @@ export async function createRequestHandler(
|
|
|
593
598
|
handlerResponse.status < 500
|
|
594
599
|
) {
|
|
595
600
|
const file = lazyStaticHandlerPath(config.outPath, url.pathname)
|
|
596
|
-
|
|
601
|
+
// Same best-effort contract as the page path below.
|
|
602
|
+
try {
|
|
603
|
+
if (!file) throw new Error('unsafe path')
|
|
597
604
|
await mkdir(path.dirname(file), { recursive: true })
|
|
598
605
|
await writeFile(file, new Uint8Array(await handlerResponse.clone().arrayBuffer()))
|
|
599
606
|
const relative = path
|
|
@@ -617,6 +624,8 @@ export async function createRequestHandler(
|
|
|
617
624
|
}
|
|
618
625
|
await persistManifest()
|
|
619
626
|
handlerResponse.headers.set('x-nextjs-cache', 'MISS')
|
|
627
|
+
} catch {
|
|
628
|
+
// Lazy caching is best-effort; the response is still served.
|
|
620
629
|
}
|
|
621
630
|
}
|
|
622
631
|
return applyProxyResponse(handlerResponse, proxyResponse)
|
|
@@ -831,8 +840,12 @@ export async function createRequestHandler(
|
|
|
831
840
|
pageResponse.status === 200 &&
|
|
832
841
|
(pageResponse.headers.get('content-type') ?? '').includes('text/html')
|
|
833
842
|
) {
|
|
834
|
-
|
|
835
|
-
|
|
843
|
+
try {
|
|
844
|
+
await writeFile(stalePage, await pageResponse.clone().text())
|
|
845
|
+
pageResponse.headers.set('x-nextjs-cache', 'MISS')
|
|
846
|
+
} catch {
|
|
847
|
+
// Best-effort write-back; the stale copy stays and the fresh render still serves.
|
|
848
|
+
}
|
|
836
849
|
}
|
|
837
850
|
// On-demand revalidation (valid x-prerender-revalidate): the fresh render
|
|
838
851
|
// replaces any prebuilt copy and is reported as REVALIDATED (Next's
|
|
@@ -844,7 +857,12 @@ export async function createRequestHandler(
|
|
|
844
857
|
pageResponse.status === 200 &&
|
|
845
858
|
(pageResponse.headers.get('content-type') ?? '').includes('text/html')
|
|
846
859
|
) {
|
|
847
|
-
|
|
860
|
+
try {
|
|
861
|
+
await writeFile(built.file, await pageResponse.clone().text())
|
|
862
|
+
} catch {
|
|
863
|
+
// Best-effort: an on-demand revalidation still returns the fresh
|
|
864
|
+
// render when the prebuilt copy cannot be replaced.
|
|
865
|
+
}
|
|
848
866
|
}
|
|
849
867
|
pageResponse.headers.set('x-nextjs-cache', 'REVALIDATED')
|
|
850
868
|
}
|
package/src/cli/start.ts
CHANGED
|
@@ -113,7 +113,7 @@ function installProcessErrorHandlers(): void {
|
|
|
113
113
|
// `__NEXT_PRIVATE_STANDALONE_CONFIG` set. pnext mirrors that: load the compat config, read the output
|
|
114
114
|
// mode from the build-compat registry, and reproduce Next's exact messages.
|
|
115
115
|
async function guardUnservableOutputMode(root?: string): Promise<ResolvedConfig> {
|
|
116
|
-
const config = await loadConfig(root)
|
|
116
|
+
const config = await loadConfig(root, { serve: true })
|
|
117
117
|
await bootstrapCompat(config)
|
|
118
118
|
const mode = getBuildExtensions().compat.nextOutputMode()
|
|
119
119
|
if (mode === 'export') {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// export / top-level await / dynamic import() and CJS+ESM authoring styles.
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
|
|
16
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
17
17
|
import { createRequire } from 'node:module'
|
|
18
18
|
import path from 'node:path'
|
|
19
19
|
import { pathToFileURL } from 'node:url'
|
|
@@ -130,11 +130,17 @@ function findTsconfig(root: string): string | undefined {
|
|
|
130
130
|
* Bundle the config with esbuild (external node_modules) and import the result.
|
|
131
131
|
* The emitted file is written next to the config so Node resolves the app's
|
|
132
132
|
* node_modules and relative assets from the original location.
|
|
133
|
+
*
|
|
134
|
+
* The bundle is also a BUILD artifact: the build leaves it on disk, deployment adapters ship it with
|
|
135
|
+
* the app's out dir, and a serve imports that file rather than re-running the pass. Request time is
|
|
136
|
+
* read-only on a serverless host (Vercel's `/var/task`), where the pass cannot write at all - and
|
|
137
|
+
* where it could, it would still put an esbuild spawn on every cold start. A serve whose build left
|
|
138
|
+
* no bundle (the config changed since, matching `next start` re-reading next.config) still bundles.
|
|
133
139
|
*/
|
|
134
140
|
async function importConfigModule(
|
|
135
141
|
configPath: string,
|
|
136
142
|
root: string,
|
|
137
|
-
dev: boolean,
|
|
143
|
+
options: { dev: boolean; serve: boolean },
|
|
138
144
|
): Promise<NextConfigExport> {
|
|
139
145
|
const cjs = cjsConfigFormat(configPath, root)
|
|
140
146
|
if (process.env.PNEXT_CONFIG_FAST !== '0' && selfContainedConfig(configPath)) {
|
|
@@ -147,66 +153,112 @@ async function importConfigModule(
|
|
|
147
153
|
}
|
|
148
154
|
// Dev keeps its scratch bundle in its own subtree; a concurrent build wipes
|
|
149
155
|
// only the build-owned `config/`.
|
|
150
|
-
const outDir = path.join(root, '.pnext', ...(dev ? ['dev'] : []), 'config')
|
|
151
|
-
mkdirSync(outDir, { recursive: true })
|
|
156
|
+
const outDir = path.join(root, '.pnext', ...(options.dev ? ['dev'] : []), 'config')
|
|
152
157
|
// Content-addressed, not timestamped: a per-boot name leaked one bundle per
|
|
153
158
|
// dev start and put a path that changes every boot into the module graph,
|
|
154
|
-
// which renamed every artifact that could reach it.
|
|
159
|
+
// which renamed every artifact that could reach it. It also makes the name a
|
|
160
|
+
// pure function of the config source, so a serve recomputes what its build wrote.
|
|
155
161
|
const outfile = path.join(
|
|
156
162
|
outDir,
|
|
157
163
|
`next.config.${configSourceKey(configPath)}.${cjs ? 'cjs' : 'mjs'}`,
|
|
158
164
|
)
|
|
165
|
+
if (options.serve && existsSync(outfile)) return importConfigBundle(outfile, cjs)
|
|
159
166
|
try {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
167
|
+
mkdirSync(outDir, { recursive: true })
|
|
168
|
+
await bundleConfig({ configPath, root, outDir, outfile, cjs })
|
|
169
|
+
} catch (error) {
|
|
170
|
+
// The only way here on a serve is a build that shipped no bundle for this config, and the most
|
|
171
|
+
// likely reason the rebuild then failed is the read-only request-time filesystem it was meant to
|
|
172
|
+
// avoid. Name the artifact rather than surfacing an esbuild write error from a boot path.
|
|
173
|
+
if (!options.serve) throw error
|
|
174
|
+
throw new Error(
|
|
175
|
+
`pnext: ${path.relative(root, outfile)} is missing and could not be rebuilt here — run ` +
|
|
176
|
+
'`pnext build` and deploy its output dir, which carries the config bundle. Cause: ' +
|
|
177
|
+
(error instanceof Error ? error.message : String(error)),
|
|
178
|
+
{ cause: error },
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
pruneStaleConfigBundles(outDir, outfile)
|
|
182
|
+
return importConfigBundle(outfile, cjs)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function bundleConfig({
|
|
186
|
+
configPath,
|
|
187
|
+
root,
|
|
188
|
+
outDir,
|
|
189
|
+
outfile,
|
|
190
|
+
cjs,
|
|
191
|
+
}: {
|
|
192
|
+
configPath: string
|
|
193
|
+
root: string
|
|
194
|
+
outDir: string
|
|
195
|
+
outfile: string
|
|
196
|
+
cjs: boolean
|
|
197
|
+
}) {
|
|
198
|
+
return build({
|
|
199
|
+
entryPoints: [configPath],
|
|
200
|
+
outfile,
|
|
201
|
+
bundle: true,
|
|
202
|
+
platform: 'node',
|
|
203
|
+
format: cjs ? 'cjs' : 'esm',
|
|
204
|
+
target: 'esnext',
|
|
205
|
+
// Keep bare package imports external so Node resolves them from the app's
|
|
206
|
+
// node_modules at import time (preserves CJS/ESM interop and identity).
|
|
207
|
+
packages: 'external',
|
|
208
|
+
// Fixture/app configs may require() or require.resolve() Next internals
|
|
209
|
+
// (next/dist/*) that don't exist without a real `next` install. Stub them
|
|
210
|
+
// so the config still loads; features pointing at the stubs degrade
|
|
211
|
+
// gracefully instead of crashing config loading.
|
|
212
|
+
plugins: [nextDistStubPlugin(outDir), nextMdxConfigStubPlugin()],
|
|
213
|
+
banner: cjs
|
|
214
|
+
? {
|
|
215
|
+
js:
|
|
216
|
+
'var __pnextNodeRequire = module.require.bind(module);\n' +
|
|
217
|
+
`var require = __pnextNodeRequire("node:module").createRequire(${JSON.stringify(pathToFileURL(configPath).href)});`,
|
|
218
|
+
}
|
|
219
|
+
: {
|
|
220
|
+
js:
|
|
221
|
+
'import { createRequire as __pnextCreateRequire } from "node:module";\n' +
|
|
222
|
+
`const require = __pnextCreateRequire(${JSON.stringify(pathToFileURL(configPath).href)});`,
|
|
223
|
+
},
|
|
224
|
+
// Relative/absolute imports (incl. .ts/.cts/.mts/.cjs/.mjs and tsconfig
|
|
225
|
+
// `paths` aliases) are bundled in. esbuild reads `paths`/`extends` and
|
|
226
|
+
// `baseUrl` from the app tsconfig.
|
|
227
|
+
tsconfig: findTsconfig(root),
|
|
228
|
+
// __dirname / __filename in CJS-authored .ts configs (node-api-cjs) and
|
|
229
|
+
// import.meta.url in ESM configs (node-api-esm) must point at the config's
|
|
230
|
+
// original location, not the emitted file under .pnext/config.
|
|
231
|
+
define: {
|
|
232
|
+
__dirname: JSON.stringify(path.dirname(configPath)),
|
|
233
|
+
__filename: JSON.stringify(configPath),
|
|
234
|
+
'import.meta.url': JSON.stringify(pathToFileURL(configPath).href),
|
|
235
|
+
},
|
|
236
|
+
logLevel: 'silent',
|
|
237
|
+
sourcemap: false,
|
|
238
|
+
metafile: false,
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function importConfigBundle(outfile: string, cjs: boolean): Promise<NextConfigExport> {
|
|
243
|
+
if (cjs) return createRequire(pathToFileURL(outfile))(outfile) as NextConfigExport
|
|
244
|
+
const module = (await import(pathToFileURL(outfile).href)) as { default?: NextConfigExport }
|
|
245
|
+
return module.default ?? {}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Drop bundles left by an earlier config source. The emitted file used to be deleted right after it
|
|
250
|
+
* was imported, which is what made a serve re-bundle; keeping only the current key holds the line
|
|
251
|
+
* that motivated the delete - one bundle per app, not one per edit.
|
|
252
|
+
*/
|
|
253
|
+
function pruneStaleConfigBundles(outDir: string, outfile: string): void {
|
|
254
|
+
try {
|
|
255
|
+
for (const entry of readdirSync(outDir)) {
|
|
256
|
+
if (!/^next\.config\..+\.[cm]js$/.test(entry)) continue
|
|
257
|
+
if (path.join(outDir, entry) === outfile) continue
|
|
258
|
+
rmSync(path.join(outDir, entry), { force: true })
|
|
205
259
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
} finally {
|
|
209
|
-
rmSync(outfile, { force: true })
|
|
260
|
+
} catch {
|
|
261
|
+
// Housekeeping only: a missing or unreadable out dir costs nothing here.
|
|
210
262
|
}
|
|
211
263
|
}
|
|
212
264
|
|
|
@@ -589,7 +641,7 @@ function validateDevIndicatorPosition(config: NextConfigObject): void {
|
|
|
589
641
|
*/
|
|
590
642
|
export async function loadNextConfig(
|
|
591
643
|
root: string,
|
|
592
|
-
options: { dev?: boolean } = {},
|
|
644
|
+
options: { dev?: boolean; serve?: boolean } = {},
|
|
593
645
|
): Promise<LoadNextConfigResult> {
|
|
594
646
|
const configPath = findConfigPath(root)
|
|
595
647
|
if (!configPath) {
|
|
@@ -599,7 +651,10 @@ export async function loadNextConfig(
|
|
|
599
651
|
}
|
|
600
652
|
let exported: NextConfigExport
|
|
601
653
|
try {
|
|
602
|
-
exported = await importConfigModule(configPath, root,
|
|
654
|
+
exported = await importConfigModule(configPath, root, {
|
|
655
|
+
dev: Boolean(options.dev),
|
|
656
|
+
serve: Boolean(options.serve),
|
|
657
|
+
})
|
|
603
658
|
} catch (error) {
|
|
604
659
|
// A next.config that pulls in an optional wrapper dependency not installed
|
|
605
660
|
// in this app (e.g. @next/mdx when deps are declared but not installed)
|
package/src/config.ts
CHANGED
|
@@ -23,8 +23,15 @@ export type ResolvedConfig = Required<Pick<PNextConfig, 'outDir' | 'basePath'>>
|
|
|
23
23
|
typesPath: string
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The out root before any `outDir`/`distDir` override. Also where a config source keeps the build
|
|
28
|
+
* artifacts it must emit BEFORE the override it carries is known — the next.config bundle sits here
|
|
29
|
+
* even for an app whose own output moved elsewhere, so deployment adapters ship it from here.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_OUT_DIR = '.pnext'
|
|
32
|
+
|
|
26
33
|
const defaultConfig = {
|
|
27
|
-
outDir:
|
|
34
|
+
outDir: DEFAULT_OUT_DIR,
|
|
28
35
|
basePath: '',
|
|
29
36
|
} satisfies Required<Pick<PNextConfig, 'outDir' | 'basePath'>>
|
|
30
37
|
|
|
@@ -48,7 +55,7 @@ export interface ConfigSourceOverrides {
|
|
|
48
55
|
}
|
|
49
56
|
export type ConfigSource = (
|
|
50
57
|
root: string,
|
|
51
|
-
options: { dev?: boolean },
|
|
58
|
+
options: { dev?: boolean; serve?: boolean },
|
|
52
59
|
) => Promise<ConfigSourceOverrides>
|
|
53
60
|
|
|
54
61
|
let configSource: ConfigSource | undefined
|
|
@@ -60,7 +67,7 @@ export function registerConfigSource(source: ConfigSource): void {
|
|
|
60
67
|
async function resolveConfigSource(
|
|
61
68
|
config: PNextConfig,
|
|
62
69
|
root: string,
|
|
63
|
-
options: { dev?: boolean },
|
|
70
|
+
options: { dev?: boolean; serve?: boolean },
|
|
64
71
|
): Promise<ConfigSourceOverrides> {
|
|
65
72
|
if (!config.compat?.next) return {}
|
|
66
73
|
if (!configSource) {
|
|
@@ -70,9 +77,11 @@ async function resolveConfigSource(
|
|
|
70
77
|
return configSource(root, options)
|
|
71
78
|
}
|
|
72
79
|
|
|
80
|
+
// `serve` marks a production start (including a deployment adapter's request handler): config sources
|
|
81
|
+
// then consume what the build emitted instead of regenerating it, since request time can be read-only.
|
|
73
82
|
export async function loadConfig(
|
|
74
83
|
rootInput = process.cwd(),
|
|
75
|
-
options: { dev?: boolean } = {},
|
|
84
|
+
options: { dev?: boolean; serve?: boolean } = {},
|
|
76
85
|
): Promise<ResolvedConfig> {
|
|
77
86
|
const root = canonicalRoot(path.resolve(rootInput))
|
|
78
87
|
await loadEnv(root, options)
|