@wular/pnext 0.0.20 → 0.0.22
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/build/cache.ts +324 -0
- package/src/cli/build.ts +19 -3
- package/src/cli/create.ts +4 -4
- package/src/cli/migrate/package-json.ts +3 -2
- package/src/cli/serve/entry.ts +14 -4
- package/src/cli/serve/pipeline.ts +4 -0
- package/src/cli/start.ts +18 -11
- package/src/cli/typegen.ts +4 -1
- package/src/client/build.ts +13 -10
- package/src/client/entry.ts +201 -12
- package/src/client/router/runtime.ts +20 -6
- package/src/compat/metadata.ts +37 -4
- package/src/compat/next/config-loader.ts +7 -1
- package/src/compat/register/proxy.ts +3 -1
- package/src/compat/register/typed-routes.ts +3 -1
- package/src/compat/typecheck/check.ts +5 -1
- package/src/compat/validation/validate.ts +2 -0
- package/src/config.ts +7 -0
- package/src/dev/server.ts +2 -0
- package/src/extensions.ts +7 -1
- package/src/render/metadata.ts +53 -31
- package/src/render/renderer.ts +3 -2
- package/src/resolve/imports.ts +7 -1
- package/src/resolve/scan-facts.ts +7 -1
- package/src/runtime/fetch-host.ts +46 -2
- package/src/types.ts +84 -67
- package/src/utils/registry.ts +70 -0
package/package.json
CHANGED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { lstat, mkdir, readFile, readdir, readlink } from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import type { ResolvedConfig } from '../../config'
|
|
6
|
+
import { frameworkFingerprint } from '../../runtime/fingerprint'
|
|
7
|
+
import type { BuildManifest } from '../../types'
|
|
8
|
+
import { writeFileAtomic } from '../../utils/fs'
|
|
9
|
+
|
|
10
|
+
const CACHE_VERSION = 2
|
|
11
|
+
const CACHE_SEGMENTS = ['cache', 'build'] as const
|
|
12
|
+
const SKIP_INPUT_DIRS = new Set([
|
|
13
|
+
'.claude',
|
|
14
|
+
'.git',
|
|
15
|
+
'.husky',
|
|
16
|
+
'.next',
|
|
17
|
+
'.pnext',
|
|
18
|
+
'.tmp',
|
|
19
|
+
'.turbo',
|
|
20
|
+
'.vercel',
|
|
21
|
+
'.vscode',
|
|
22
|
+
'node_modules',
|
|
23
|
+
])
|
|
24
|
+
const SKIP_INPUT_FILE = /(?:^\.DS_Store$|\.(?:log|pid)$)/i
|
|
25
|
+
const LOCK_FILES = [
|
|
26
|
+
'bun.lock',
|
|
27
|
+
'bun.lockb',
|
|
28
|
+
'package-lock.json',
|
|
29
|
+
'npm-shrinkwrap.json',
|
|
30
|
+
'pnpm-lock.yaml',
|
|
31
|
+
'yarn.lock',
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
type InputRecord = [mtimeMs: number, size: number, hash: string]
|
|
35
|
+
|
|
36
|
+
interface CacheRecord {
|
|
37
|
+
version: number
|
|
38
|
+
key: string
|
|
39
|
+
inputs: Record<string, InputRecord>
|
|
40
|
+
outputHash: string
|
|
41
|
+
outputs: Record<string, string>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface BuildCacheOptions {
|
|
45
|
+
adapter?: 'vercel'
|
|
46
|
+
debugBuildPaths?: string
|
|
47
|
+
buildMode?: 'compile' | 'generate'
|
|
48
|
+
debugPrerender?: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface BuildCacheSession {
|
|
52
|
+
enabled: boolean
|
|
53
|
+
context?: string
|
|
54
|
+
inputs?: Record<string, InputRecord>
|
|
55
|
+
manifest?: BuildManifest
|
|
56
|
+
reason?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Validate the standard production build before its output directory is touched. */
|
|
60
|
+
export async function lookupBuildCache(
|
|
61
|
+
config: ResolvedConfig,
|
|
62
|
+
options: BuildCacheOptions,
|
|
63
|
+
): Promise<BuildCacheSession> {
|
|
64
|
+
if (!cacheEnabled(options)) return { enabled: false }
|
|
65
|
+
const record = await readRecord(config.outPath)
|
|
66
|
+
const inputs = await inputRecords(config, record?.inputs)
|
|
67
|
+
const context = buildContext()
|
|
68
|
+
const key = buildKey(context, inputs)
|
|
69
|
+
if (record?.key !== key) return { enabled: true, context, inputs, reason: 'inputs' }
|
|
70
|
+
const output = await outputSnapshot(config.outPath)
|
|
71
|
+
if (output.hash !== record.outputHash) {
|
|
72
|
+
const changed = changedFiles(record.outputs, output.files).slice(0, 3).join(', ')
|
|
73
|
+
return { enabled: true, context, inputs, reason: `output (${changed || 'unknown'})` }
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const manifest = JSON.parse(
|
|
77
|
+
await readFile(path.join(config.outPath, 'manifest.json'), 'utf8'),
|
|
78
|
+
) as BuildManifest
|
|
79
|
+
return { enabled: true, context, inputs, manifest, reason: 'hit' }
|
|
80
|
+
} catch {
|
|
81
|
+
return { enabled: true, context, inputs, reason: 'manifest' }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Publish a successful build only after every production artifact has landed. */
|
|
86
|
+
export async function writeBuildCache(
|
|
87
|
+
config: ResolvedConfig,
|
|
88
|
+
options: BuildCacheOptions,
|
|
89
|
+
session: BuildCacheSession,
|
|
90
|
+
) {
|
|
91
|
+
if (!session.enabled || !session.context || !cacheEnabled(options)) return
|
|
92
|
+
const inputs = await inputRecords(config, session.inputs)
|
|
93
|
+
const output = await outputSnapshot(config.outPath)
|
|
94
|
+
const record: CacheRecord = {
|
|
95
|
+
version: CACHE_VERSION,
|
|
96
|
+
key: buildKey(session.context, inputs),
|
|
97
|
+
inputs,
|
|
98
|
+
outputHash: output.hash,
|
|
99
|
+
outputs: output.files,
|
|
100
|
+
}
|
|
101
|
+
await mkdir(cacheDir(config.outPath), { recursive: true })
|
|
102
|
+
await writeFileAtomic(recordFile(config.outPath), JSON.stringify(record))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function cacheEnabled(options: BuildCacheOptions) {
|
|
106
|
+
// Opt-in until the no-change short-circuit is finished: the key currently hashes the whole
|
|
107
|
+
// environment, so cross-shell hits are rare and the cache adds bookkeeping without payoff.
|
|
108
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
109
|
+
if (process.env.PNEXT_BUILD_CACHE !== '1') return false
|
|
110
|
+
return (
|
|
111
|
+
options.adapter === undefined &&
|
|
112
|
+
options.debugBuildPaths === undefined &&
|
|
113
|
+
options.buildMode === undefined &&
|
|
114
|
+
options.debugPrerender !== true
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function cacheDir(outPath: string) {
|
|
119
|
+
return path.join(outPath, ...CACHE_SEGMENTS)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function recordFile(outPath: string) {
|
|
123
|
+
return path.join(cacheDir(outPath), 'result.json')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function readRecord(outPath: string): Promise<CacheRecord | undefined> {
|
|
127
|
+
try {
|
|
128
|
+
const parsed = JSON.parse(await readFile(recordFile(outPath), 'utf8')) as CacheRecord
|
|
129
|
+
if (
|
|
130
|
+
parsed.version === CACHE_VERSION &&
|
|
131
|
+
parsed.key &&
|
|
132
|
+
parsed.inputs &&
|
|
133
|
+
parsed.outputHash &&
|
|
134
|
+
parsed.outputs
|
|
135
|
+
) {
|
|
136
|
+
return parsed
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// A partial or older entry is a miss, never a stale hit.
|
|
140
|
+
}
|
|
141
|
+
return undefined
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function buildContext() {
|
|
145
|
+
const hash = createHash('sha256')
|
|
146
|
+
hash.update(`pnext-build-cache:${CACHE_VERSION}\0`)
|
|
147
|
+
hash.update(frameworkFingerprint())
|
|
148
|
+
hash.update(`\0bun:${Bun.version}\0`)
|
|
149
|
+
for (const [name, value] of Object.entries(process.env).sort(([a], [b]) => a.localeCompare(b))) {
|
|
150
|
+
hash.update(`${name}=${value ?? ''}\0`)
|
|
151
|
+
}
|
|
152
|
+
return hash.digest('hex')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function buildKey(context: string, inputs: Record<string, InputRecord>) {
|
|
156
|
+
const hash = createHash('sha256')
|
|
157
|
+
hash.update(context)
|
|
158
|
+
for (const [file, record] of Object.entries(inputs).sort(([a], [b]) => a.localeCompare(b))) {
|
|
159
|
+
hash.update(`${file}\0${record[2]}\0`)
|
|
160
|
+
}
|
|
161
|
+
return hash.digest('hex')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function inputRecords(
|
|
165
|
+
config: ResolvedConfig,
|
|
166
|
+
previous: Record<string, InputRecord> | undefined,
|
|
167
|
+
) {
|
|
168
|
+
const files = new Map<string, string>()
|
|
169
|
+
await collectTree(config.root, 'root', files, config.outPath)
|
|
170
|
+
await collectModuleGraphInputs(config, files)
|
|
171
|
+
|
|
172
|
+
// Dependency trees are represented by package-manager content identities,
|
|
173
|
+
// while first-party modules above are keyed by their own bytes.
|
|
174
|
+
for (const [prefix, root] of [
|
|
175
|
+
['root-deps', config.root],
|
|
176
|
+
['workspace-deps', config.workspaceRoot],
|
|
177
|
+
] as const) {
|
|
178
|
+
for (const name of LOCK_FILES) addFile(files, `${prefix}:${name}`, path.join(root, name))
|
|
179
|
+
addFile(
|
|
180
|
+
files,
|
|
181
|
+
`${prefix}:node_modules/.package-lock.json`,
|
|
182
|
+
path.join(root, 'node_modules', '.package-lock.json'),
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const frameworkRoot = path.resolve(import.meta.dirname, '..', '..')
|
|
187
|
+
addFile(files, 'framework:package.json', path.join(frameworkRoot, 'package.json'))
|
|
188
|
+
for (const name of LOCK_FILES) {
|
|
189
|
+
addFile(files, `framework:${name}`, path.join(frameworkRoot, name))
|
|
190
|
+
addFile(files, `framework-workspace:${name}`, path.join(frameworkRoot, '..', name))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const entries = await Promise.all(
|
|
194
|
+
[...files].map(async ([key, file]): Promise<[string, InputRecord] | undefined> => {
|
|
195
|
+
const stats = await lstat(file).catch(() => undefined)
|
|
196
|
+
if (!stats?.isFile() && !stats?.isSymbolicLink()) return undefined
|
|
197
|
+
const old = previous?.[key]
|
|
198
|
+
if (old?.[0] === stats.mtimeMs && old[1] === stats.size) return [key, old]
|
|
199
|
+
const contents = stats.isSymbolicLink()
|
|
200
|
+
? Buffer.from(`link:${await readlink(file)}`)
|
|
201
|
+
: await readFile(file)
|
|
202
|
+
return [key, [stats.mtimeMs, stats.size, contentHash(contents)]]
|
|
203
|
+
}),
|
|
204
|
+
)
|
|
205
|
+
return Object.fromEntries(
|
|
206
|
+
entries.filter((entry): entry is [string, InputRecord] => Boolean(entry)),
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface ModuleGraphIndex {
|
|
211
|
+
files?: Record<string, unknown>
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function collectModuleGraphInputs(config: ResolvedConfig, files: Map<string, string>) {
|
|
215
|
+
let parsed: ModuleGraphIndex
|
|
216
|
+
try {
|
|
217
|
+
parsed = JSON.parse(
|
|
218
|
+
await readFile(path.join(config.outPath, 'cache', 'server', 'graph.json'), 'utf8'),
|
|
219
|
+
) as ModuleGraphIndex
|
|
220
|
+
} catch {
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
const frameworkRoot = path.resolve(import.meta.dirname, '..', '..')
|
|
224
|
+
for (const source of Object.keys(parsed.files ?? {})) {
|
|
225
|
+
const value = source.slice(2).split('/').join(path.sep)
|
|
226
|
+
const file = source.startsWith('w:')
|
|
227
|
+
? path.resolve(config.workspaceRoot, value)
|
|
228
|
+
: source.startsWith('f:')
|
|
229
|
+
? path.resolve(frameworkRoot, value)
|
|
230
|
+
: source.startsWith('a:')
|
|
231
|
+
? value
|
|
232
|
+
: undefined
|
|
233
|
+
if (file) addFile(files, `module:${source}`, file)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function collectTree(
|
|
238
|
+
root: string,
|
|
239
|
+
prefix: string,
|
|
240
|
+
files: Map<string, string>,
|
|
241
|
+
outPath: string,
|
|
242
|
+
dir = root,
|
|
243
|
+
): Promise<void> {
|
|
244
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
245
|
+
await Promise.all(
|
|
246
|
+
entries.map(async entry => {
|
|
247
|
+
const file = path.join(dir, entry.name)
|
|
248
|
+
if (file === outPath || file.startsWith(`${outPath}${path.sep}`)) return
|
|
249
|
+
if (entry.isDirectory()) {
|
|
250
|
+
if (!SKIP_INPUT_DIRS.has(entry.name)) await collectTree(root, prefix, files, outPath, file)
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) return
|
|
254
|
+
const relative = path.relative(root, file)
|
|
255
|
+
// Local server logs and top-level response captures are not compiler
|
|
256
|
+
// inputs; watching processes commonly rewrite them during a build.
|
|
257
|
+
if (
|
|
258
|
+
SKIP_INPUT_FILE.test(entry.name) ||
|
|
259
|
+
(!relative.includes(path.sep) && /\.html?$/i.test(entry.name))
|
|
260
|
+
) {
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
addFile(files, `${prefix}:${toPosix(relative)}`, file)
|
|
264
|
+
}),
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function addFile(files: Map<string, string>, key: string, file: string) {
|
|
269
|
+
if (existsSync(file)) files.set(key, file)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function outputSnapshot(outPath: string) {
|
|
273
|
+
const files = new Map<string, string>()
|
|
274
|
+
const entries = await readdir(outPath, { withFileTypes: true }).catch(() => [])
|
|
275
|
+
await Promise.all(
|
|
276
|
+
entries.map(async entry => {
|
|
277
|
+
if (entry.name === 'cache' || entry.name === 'dev') return
|
|
278
|
+
const file = path.join(outPath, entry.name)
|
|
279
|
+
if (entry.isDirectory()) await collectOutputTree(outPath, file, files)
|
|
280
|
+
else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
281
|
+
files.set(toPosix(entry.name), file)
|
|
282
|
+
}
|
|
283
|
+
}),
|
|
284
|
+
)
|
|
285
|
+
const hash = createHash('sha256')
|
|
286
|
+
const hashes: Record<string, string> = {}
|
|
287
|
+
for (const [name, file] of [...files].sort(([a], [b]) => a.localeCompare(b))) {
|
|
288
|
+
const stats = await lstat(file)
|
|
289
|
+
const contents = stats.isSymbolicLink()
|
|
290
|
+
? Buffer.from(`link:${await readlink(file)}`)
|
|
291
|
+
: await readFile(file)
|
|
292
|
+
const contentsHash = contentHash(contents)
|
|
293
|
+
hashes[name] = contentsHash
|
|
294
|
+
hash.update(`${name}\0${contentsHash}\0`)
|
|
295
|
+
}
|
|
296
|
+
return { hash: hash.digest('hex'), files: hashes }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function changedFiles(before: Record<string, string>, after: Record<string, string>) {
|
|
300
|
+
return [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(
|
|
301
|
+
file => before[file] !== after[file],
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function collectOutputTree(root: string, dir: string, files: Map<string, string>) {
|
|
306
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
307
|
+
await Promise.all(
|
|
308
|
+
entries.map(async entry => {
|
|
309
|
+
const file = path.join(dir, entry.name)
|
|
310
|
+
if (entry.isDirectory()) await collectOutputTree(root, file, files)
|
|
311
|
+
else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
312
|
+
files.set(toPosix(path.relative(root, file)), file)
|
|
313
|
+
}
|
|
314
|
+
}),
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function contentHash(contents: Uint8Array) {
|
|
319
|
+
return createHash('sha256').update(contents).digest('hex')
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function toPosix(file: string) {
|
|
323
|
+
return path.sep === '/' ? file : file.split(path.sep).join('/')
|
|
324
|
+
}
|
package/src/cli/build.ts
CHANGED
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
} from '../routing/routes'
|
|
85
85
|
import { interceptionMarkerLevels } from '../routing/slots'
|
|
86
86
|
import { writeTypegen } from './typegen'
|
|
87
|
+
import { lookupBuildCache, writeBuildCache } from './build/cache'
|
|
87
88
|
import { createVerboseLogger, type VerboseLogger } from '../utils/verbose'
|
|
88
89
|
import { bold, cyan, dim, green } from '../utils/ansi'
|
|
89
90
|
import type {
|
|
@@ -179,9 +180,7 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
|
|
|
179
180
|
`${cyan('▲')} ${bold('pnext')} ${dim('— Creating an optimized production build ...\n')}`,
|
|
180
181
|
)
|
|
181
182
|
const config = await loadConfig(root)
|
|
182
|
-
|
|
183
|
-
// stylesheet doesn't pay its cold boot.
|
|
184
|
-
warmCssPipeline(config)
|
|
183
|
+
const buildCache = lookupBuildCache(config, options)
|
|
185
184
|
// Compat plugin loader: the single gated seam that populates the core
|
|
186
185
|
// extension registries when compat is enabled (no-op for pure-core apps).
|
|
187
186
|
// A build compiles immediately, so it takes both tiers up front.
|
|
@@ -192,6 +191,21 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
|
|
|
192
191
|
// server-boot-only hooks out of the build - Next never runs register() during `next build`, so
|
|
193
192
|
// build prerenders must see a noop tracer.
|
|
194
193
|
runInitHooks(config, { build: true })
|
|
194
|
+
const cached = await buildCache
|
|
195
|
+
log.log(`build cache ${cached.reason ?? 'disabled'}`)
|
|
196
|
+
if (cached.manifest) {
|
|
197
|
+
printBuildSummary(
|
|
198
|
+
config,
|
|
199
|
+
cached.manifest.routes,
|
|
200
|
+
cached.manifest.staticFiles ?? {},
|
|
201
|
+
performance.now() - startedAt,
|
|
202
|
+
Boolean(cached.manifest.proxyModule),
|
|
203
|
+
)
|
|
204
|
+
return cached.manifest
|
|
205
|
+
}
|
|
206
|
+
// Load Tailwind on the CSS worker while the steps below run, so the first
|
|
207
|
+
// stylesheet doesn't pay its cold boot.
|
|
208
|
+
warmCssPipeline(config)
|
|
195
209
|
// The vercel adapter's warm pass runs in its own process; start it here so
|
|
196
210
|
// its boot overlaps the build and only the warming itself lands on the
|
|
197
211
|
// adapter step.
|
|
@@ -917,6 +931,8 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
|
|
|
917
931
|
// (typecheck) are awaited next and reported on their own lines.
|
|
918
932
|
const buildDurationMs = performance.now() - startedAt
|
|
919
933
|
const phases = await settleBuildParallelPhases()
|
|
934
|
+
flushDevModuleCaches()
|
|
935
|
+
await writeBuildCache(config, options, cached)
|
|
920
936
|
|
|
921
937
|
printBuildSummary(
|
|
922
938
|
config,
|
package/src/cli/create.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { mkdir, readdir } from 'node:fs/promises'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { bold, cyan, dim, green } from '../utils/ansi'
|
|
5
|
-
import {
|
|
5
|
+
import { latestPnextVersionRange } from '../utils/registry'
|
|
6
6
|
import { formatDuration } from '../utils/verbose'
|
|
7
7
|
|
|
8
8
|
export interface CreateAppOptions {
|
|
@@ -27,7 +27,7 @@ export async function createApp(dir: string | undefined, options: CreateAppOptio
|
|
|
27
27
|
const name = sanitizePackageName(path.basename(target))
|
|
28
28
|
console.log(`${cyan('▲')} ${bold('pnext create')} ${dim(`— scaffolding ${name}`)}\n`)
|
|
29
29
|
|
|
30
|
-
const files = scaffoldFiles(name)
|
|
30
|
+
const files = scaffoldFiles(name, await latestPnextVersionRange('create'))
|
|
31
31
|
await Promise.all(
|
|
32
32
|
Object.entries(files).map(([file, source]) => Bun.write(path.join(target, file), source)),
|
|
33
33
|
)
|
|
@@ -112,7 +112,7 @@ function printNextSteps(dir: string, installed: boolean) {
|
|
|
112
112
|
console.log(dim(`└${'─'.repeat(width + 2)}┘`))
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
function scaffoldFiles(name: string): Record<string, string> {
|
|
115
|
+
function scaffoldFiles(name: string, pnextRange: string): Record<string, string> {
|
|
116
116
|
return {
|
|
117
117
|
'package.json': `${JSON.stringify(
|
|
118
118
|
{
|
|
@@ -126,7 +126,7 @@ function scaffoldFiles(name: string): Record<string, string> {
|
|
|
126
126
|
},
|
|
127
127
|
dependencies: { preact: '^10' },
|
|
128
128
|
devDependencies: {
|
|
129
|
-
'@wular/pnext':
|
|
129
|
+
'@wular/pnext': pnextRange,
|
|
130
130
|
typescript: '^5',
|
|
131
131
|
'@types/bun': '^1',
|
|
132
132
|
},
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
5
5
|
import path from 'node:path'
|
|
6
|
-
import { pnextOptionalDependencyRange
|
|
6
|
+
import { pnextOptionalDependencyRange } from '../../utils/fs'
|
|
7
|
+
import { latestPnextVersionRange } from '../../utils/registry'
|
|
7
8
|
import type { MigrationResult } from './report'
|
|
8
9
|
|
|
9
10
|
const SEPARATORS = new Set(['&&', '||', ';', '|', '&'])
|
|
@@ -124,7 +125,7 @@ export async function migratePackageJson(
|
|
|
124
125
|
}
|
|
125
126
|
}
|
|
126
127
|
if (!('@wular/pnext' in devDependencies) && !('@wular/pnext' in dependencies)) {
|
|
127
|
-
const versionRange =
|
|
128
|
+
const versionRange = await latestPnextVersionRange('migrate')
|
|
128
129
|
devDependencies['@wular/pnext'] = versionRange
|
|
129
130
|
result.edits.push({
|
|
130
131
|
file: 'package.json',
|
package/src/cli/serve/entry.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
7
7
|
import path from 'node:path'
|
|
8
8
|
import { pathToFileURL } from 'node:url'
|
|
9
9
|
import type { Plugin as EsbuildPlugin } from 'esbuild'
|
|
10
|
+
import { frameworkFingerprint } from '../../runtime/fingerprint'
|
|
10
11
|
|
|
11
12
|
const frameworkRoot = path.resolve(import.meta.dirname, '..', '..', '..')
|
|
12
13
|
|
|
@@ -20,8 +21,17 @@ function frameworkVersion(): string {
|
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Version AND framework fingerprint: the version alone let an edited `src` keep serving the bundle
|
|
26
|
+
* built from the previous source, so a fix appeared to have no effect until the app was rebuilt.
|
|
27
|
+
* The fingerprint is the same one every other compiled artifact is keyed on, taken through the
|
|
28
|
+
* cache-root record so a restart pays one stat per file rather than re-reading the tree.
|
|
29
|
+
*/
|
|
30
|
+
export function serverEntryDir(outPath: string): string {
|
|
31
|
+
// Cache root spelled inline, not imported: this module is parsed before anything else on the
|
|
32
|
+
// start path, and pulling in the module cache for one path.join would drag its whole graph along.
|
|
33
|
+
const generation = frameworkFingerprint(path.join(outPath, 'cache', 'server'))
|
|
34
|
+
return path.join(outPath, 'server', `bundle-${frameworkVersion()}-${generation}`)
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
function serverEntryFile(outPath: string): string {
|
|
@@ -30,7 +40,7 @@ function serverEntryFile(outPath: string): string {
|
|
|
30
40
|
|
|
31
41
|
/**
|
|
32
42
|
* The prebuilt entry for a project root, or undefined when there is none to
|
|
33
|
-
* use (no build, a custom `outDir`, a version mismatch, or the opt-out). The
|
|
43
|
+
* use (no build, a custom `outDir`, a version or source-generation mismatch, or the opt-out). The
|
|
34
44
|
* caller then falls back to importing src/cli/start.ts directly, so a miss only
|
|
35
45
|
* costs speed. Deliberately cheap: one existsSync and one package.json read,
|
|
36
46
|
* because it runs before anything else on the start path.
|
|
@@ -43,7 +53,7 @@ export function prebuiltServerEntry(root = process.cwd()): string | undefined {
|
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
/**
|
|
46
|
-
* Bundle src/cli/start.ts under `<outPath>/server/bundle-<version>/`. Bare
|
|
56
|
+
* Bundle src/cli/start.ts under `<outPath>/server/bundle-<version>-<fingerprint>/`. Bare
|
|
47
57
|
* specifiers stay external (the framework's deps are already plain JS), so only
|
|
48
58
|
* the framework's own source is inlined. Splitting is on so the graph behind a
|
|
49
59
|
* dynamic import (compat, dev, build) stays in its own chunk — folding it into
|
|
@@ -15,6 +15,7 @@ import type { ResolvedConfig } from '../../config'
|
|
|
15
15
|
import { bootstrapCompat } from '../../compat-bootstrap'
|
|
16
16
|
import { registerServerRuntime, serverBundleTargetForRuntime } from '../../runtime/loader'
|
|
17
17
|
import { importModuleOnce } from '../../runtime/modules'
|
|
18
|
+
import { abortUpstreamFetchOnDisconnect } from '../../runtime/fetch-host'
|
|
18
19
|
import { applyProxyResponse, createProxyRunner } from '../../routing/proxy'
|
|
19
20
|
import {
|
|
20
21
|
isPprShellUpgradeEligible,
|
|
@@ -287,6 +288,9 @@ export async function createRequestHandler(
|
|
|
287
288
|
},
|
|
288
289
|
)
|
|
289
290
|
const response = maybeCloseNodeFetchConnection(finalized, request)
|
|
291
|
+
// Wired before the close-observer wraps the body: the stream a handler
|
|
292
|
+
// passed through from fetch is still the one the abort map knows.
|
|
293
|
+
abortUpstreamFetchOnDisconnect(response.body, request.signal)
|
|
290
294
|
return flushWorkUnitOnClose(response, unit, request.signal)
|
|
291
295
|
} catch (error) {
|
|
292
296
|
// A dropped promise here would close the socket without a response
|
package/src/cli/start.ts
CHANGED
|
@@ -48,14 +48,9 @@ export async function start(options: StartOptions = {}) {
|
|
|
48
48
|
const startedAt = Date.now()
|
|
49
49
|
const port = options.port ?? 3000
|
|
50
50
|
const hostname = options.hostname ?? 'localhost'
|
|
51
|
-
// Export builds are not served
|
|
52
|
-
// Next only WARNS, pointing at the standalone launcher, and the launcher re-execs this path with
|
|
53
|
-
// `__NEXT_PRIVATE_STANDALONE_CONFIG` set to suppress that warning. Handle both before binding a
|
|
54
|
-
// port, matching Next's messages.
|
|
51
|
+
// Export builds are not served; pnext serves its standalone output directly.
|
|
55
52
|
const config = await guardUnservableOutputMode(options.root)
|
|
56
|
-
const manifest =
|
|
57
|
-
await readFile(path.join(config.outPath, 'manifest.json'), 'utf8'),
|
|
58
|
-
) as BuildManifest
|
|
53
|
+
const manifest = await readProductionManifest(config)
|
|
59
54
|
await ensurePortFree(port, hostname)
|
|
60
55
|
|
|
61
56
|
// The pipeline import starts right after listen; a request that beats it
|
|
@@ -110,10 +105,20 @@ function installProcessErrorHandlers(): void {
|
|
|
110
105
|
process.on('uncaughtException', report)
|
|
111
106
|
}
|
|
112
107
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
108
|
+
export async function readProductionManifest(config: ResolvedConfig): Promise<BuildManifest> {
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(
|
|
111
|
+
await readFile(path.join(config.outPath, 'manifest.json'), 'utf8'),
|
|
112
|
+
) as BuildManifest
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
115
|
+
throw new Error("No production build found. Run 'pnext build' first.")
|
|
116
|
+
}
|
|
117
|
+
throw error
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Export output is unservable; pnext production output remains serveable for standalone apps.
|
|
117
122
|
async function guardUnservableOutputMode(root?: string): Promise<ResolvedConfig> {
|
|
118
123
|
const config = await loadConfig(root, { serve: true })
|
|
119
124
|
await bootstrapCompat(config)
|
|
@@ -125,6 +130,8 @@ async function guardUnservableOutputMode(root?: string): Promise<ResolvedConfig>
|
|
|
125
130
|
)
|
|
126
131
|
process.exit(1)
|
|
127
132
|
}
|
|
133
|
+
// Next warns on `next start` with standalone output; its standalone launcher re-execs this path
|
|
134
|
+
// with __NEXT_PRIVATE_STANDALONE_CONFIG set to suppress the warning. Mirror both.
|
|
128
135
|
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
129
136
|
if (mode === 'standalone' && !process.env.__NEXT_PRIVATE_STANDALONE_CONFIG) {
|
|
130
137
|
console.warn(
|
package/src/cli/typegen.ts
CHANGED
|
@@ -57,7 +57,10 @@ function routeEntry(route: RouteManifestEntry) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
async function writeChecks(config: ResolvedConfig, routes: RouteManifestEntry[]) {
|
|
60
|
-
const checksDir =
|
|
60
|
+
const checksDir = config.checksPath
|
|
61
|
+
// Checks used to live under typesPath, which the app's own tsconfig includes; a stale
|
|
62
|
+
// copy there fails the app's compiler on pnext internals, so heal that on every generate.
|
|
63
|
+
await rm(path.join(config.typesPath, 'checks'), { recursive: true, force: true })
|
|
61
64
|
await rm(checksDir, { recursive: true, force: true })
|
|
62
65
|
await ensureDir(checksDir)
|
|
63
66
|
|
package/src/client/build.ts
CHANGED
|
@@ -479,16 +479,19 @@ export async function buildClientEntry({ config, route, outDir, dev }: ClientBui
|
|
|
479
479
|
const outfile = path.join(outDir, `${entryName}.js`)
|
|
480
480
|
const pipeline = createClientSourcePipeline(config, dev === true, route.id)
|
|
481
481
|
pipeline.warmRoutes([route])
|
|
482
|
-
const runtimeFacts =
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
482
|
+
const runtimeFacts: ClientRuntimeFacts = {
|
|
483
|
+
...clientRuntimeFacts(
|
|
484
|
+
[
|
|
485
|
+
{
|
|
486
|
+
route,
|
|
487
|
+
shell: hasClientRootLayout(config, route),
|
|
488
|
+
},
|
|
489
|
+
],
|
|
490
|
+
nextCompatEnabled(config),
|
|
491
|
+
suspense,
|
|
492
|
+
),
|
|
493
|
+
dev: dev === true,
|
|
494
|
+
}
|
|
492
495
|
const prebuilt = await clientProfile.timeAsync('prebuilt', () =>
|
|
493
496
|
preparePrebuilt(
|
|
494
497
|
config,
|