@wular/pnext 0.0.20 → 0.0.21
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/start.ts +16 -18
- package/src/client/build.ts +13 -10
- package/src/client/entry.ts +201 -12
- package/src/compat/metadata.ts +0 -9
- package/src/compat/register/proxy.ts +3 -1
- package/src/compat/register/typed-routes.ts +0 -2
- package/src/compat/validation/validate.ts +2 -0
- package/src/render/renderer.ts +3 -2
- 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/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,13 +130,6 @@ async function guardUnservableOutputMode(root?: string): Promise<ResolvedConfig>
|
|
|
125
130
|
)
|
|
126
131
|
process.exit(1)
|
|
127
132
|
}
|
|
128
|
-
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
129
|
-
if (mode === 'standalone' && !process.env.__NEXT_PRIVATE_STANDALONE_CONFIG) {
|
|
130
|
-
console.warn(
|
|
131
|
-
'"next start" does not work with "output: standalone" configuration. ' +
|
|
132
|
-
'Use "node .next/standalone/server.js" instead.',
|
|
133
|
-
)
|
|
134
|
-
}
|
|
135
133
|
return config
|
|
136
134
|
}
|
|
137
135
|
|
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,
|
package/src/client/entry.ts
CHANGED
|
@@ -139,6 +139,142 @@ function islandVNodeExpr(
|
|
|
139
139
|
: `h(${componentExpr}, ${propsExpr}, ${childrenExpr})`
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* A streamed Suspense hole is the one region a live island stops owning: `$RC` replaces the hole's
|
|
144
|
+
* children wholesale before the graft's materialize pass turns the arriving island markers into
|
|
145
|
+
* hosts. Rendering the hole through this component leaves a seam that pass can re-adopt through -
|
|
146
|
+
* INSIDE the parent island's tree, so the new islands keep the parent's whole context chain. Mounting
|
|
147
|
+
* such a host as its own preact root instead severs it from every provider the parent renders.
|
|
148
|
+
*/
|
|
149
|
+
function holeSeamSource() {
|
|
150
|
+
return `
|
|
151
|
+
class PnextHole extends Component {
|
|
152
|
+
componentDidMount() {
|
|
153
|
+
if (this.base) this.base.__pnextHole = this;
|
|
154
|
+
}
|
|
155
|
+
componentWillUnmount() {
|
|
156
|
+
if (this.base && this.base.__pnextHole === this) this.base.__pnextHole = undefined;
|
|
157
|
+
}
|
|
158
|
+
componentWillReceiveProps(next) {
|
|
159
|
+
// A soft navigation re-adopts the parent from the incoming document's DOM;
|
|
160
|
+
// the previous document's grafted content must not outlive its children.
|
|
161
|
+
if (next.children !== this.props.children) this.setState({ adopted: undefined });
|
|
162
|
+
}
|
|
163
|
+
render(props, state) {
|
|
164
|
+
return h('pnext-hole', props.attrs, state.adopted !== undefined ? state.adopted : props.children);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
`
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The late-host half of the seam. `adoptHoleSeam` re-renders one hole through its live parent;
|
|
172
|
+
* `claimNestedIslandRoot` is what the island scan calls instead of mounting a nested host.
|
|
173
|
+
*/
|
|
174
|
+
function lateIslandClaimSource() {
|
|
175
|
+
return `
|
|
176
|
+
// The graft ($RC) swapped this hole's DOM wholesale, so re-adopting ALL of it is total: nothing in
|
|
177
|
+
// the hole is still preact-owned. The re-render lands inside the parent island's tree, which is what
|
|
178
|
+
// makes the new islands inherit its context.
|
|
179
|
+
// Detaching an element costs it every piece of state the browser keeps outside the DOM - a lazy
|
|
180
|
+
// image's deferred load observation, an iframe's document, playing media, focus - and re-inserting
|
|
181
|
+
// it does not bring any of that back. moveBefore is the state-preserving move (Chrome 133+); it
|
|
182
|
+
// needs both ends connected and refuses some moves outright, so a plain insert stays the fallback.
|
|
183
|
+
function movePnextNode(parent, node) {
|
|
184
|
+
if (parent.moveBefore && parent.isConnected && node.isConnected) {
|
|
185
|
+
try {
|
|
186
|
+
parent.moveBefore(node, null);
|
|
187
|
+
return;
|
|
188
|
+
} catch (error) {}
|
|
189
|
+
}
|
|
190
|
+
parent.append(node);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// A subtree rebuilt from adopted vnodes is made of freshly created elements, and a graft can bring
|
|
194
|
+
// in ones the browser had already deferred. Either way a lazy image can end up in the document with
|
|
195
|
+
// no live intersection observation - no request ever fires, and it stays blank forever. Re-running
|
|
196
|
+
// its source selection re-arms it WITHOUT giving up laziness; one already loading (currentSrc set)
|
|
197
|
+
// or already loaded is left alone, so this never forces a fetch or duplicates one.
|
|
198
|
+
// Drop the capture holder, moving its nodes into \`into\` first when the adoption cannot use them.
|
|
199
|
+
function releaseHoleCapture(hole, into) {
|
|
200
|
+
const captured = hole.__pnextCaptured;
|
|
201
|
+
if (!captured) return;
|
|
202
|
+
hole.__pnextCaptured = undefined;
|
|
203
|
+
if (into) for (const node of [...captured.childNodes]) movePnextNode(into, node);
|
|
204
|
+
captured.remove();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function rekickLazyLoadables(root) {
|
|
208
|
+
for (const img of root.querySelectorAll('img[loading=lazy]')) {
|
|
209
|
+
if (img.complete || img.currentSrc) continue;
|
|
210
|
+
for (const name of ['src', 'srcset']) {
|
|
211
|
+
const value = img.getAttribute(name);
|
|
212
|
+
if (value === null) continue;
|
|
213
|
+
img.removeAttribute(name);
|
|
214
|
+
img.setAttribute(name, value);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function adoptHoleSeam(hole) {
|
|
220
|
+
const pending = hole.__pnextAdopting;
|
|
221
|
+
if (pending) return pending;
|
|
222
|
+
const seam = hole.__pnextHole;
|
|
223
|
+
// No seam ever appeared: put the captured nodes back rather than losing them.
|
|
224
|
+
if (!seam) return releaseHoleCapture(hole, hole);
|
|
225
|
+
const adopting = (async () => {
|
|
226
|
+
const captured = hole.__pnextCaptured;
|
|
227
|
+
const children = await domChildren(captured ?? hole);
|
|
228
|
+
// Preact rebuilds the subtree from the adopted vnodes, so the server nodes they were read from
|
|
229
|
+
// would survive as unowned duplicates. Clearing and re-rendering within one task keeps the swap
|
|
230
|
+
// inside a single frame - preact's rerender is a microtask, so no paint sees the empty hole.
|
|
231
|
+
releaseHoleCapture(hole);
|
|
232
|
+
hole.replaceChildren();
|
|
233
|
+
seam.setState({ adopted: children ?? null }, () => rekickLazyLoadables(hole));
|
|
234
|
+
})();
|
|
235
|
+
hole.__pnextAdopting = adopting;
|
|
236
|
+
try {
|
|
237
|
+
await adopting;
|
|
238
|
+
} finally {
|
|
239
|
+
if (hole.__pnextAdopting === adopting) hole.__pnextAdopting = undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// A nested host belongs to its parent island's DOM adoption pass, never to a mount of its own. Wait
|
|
244
|
+
// out an adoption already in flight (its walk may still reach this host), and claim only what the
|
|
245
|
+
// parent can no longer see - a host the graft materialized after the parent went live - through the
|
|
246
|
+
// hole seam that owns the grafted DOM. A host under a parent that has not mounted yet stays put.
|
|
247
|
+
async function claimNestedIslandRoot(root, parent) {
|
|
248
|
+
// The innermost hole whose seam is live owns this host. A nearer hole with no seam is raw grafted
|
|
249
|
+
// DOM inside an outer seam's region, so that outer one is the tree to re-adopt through.
|
|
250
|
+
let hole;
|
|
251
|
+
for (let node = root.parentElement; node && node !== parent; node = node.parentElement) {
|
|
252
|
+
if (node.localName !== 'pnext-hole') continue;
|
|
253
|
+
if (node.__pnextHole) { hole = node; break; }
|
|
254
|
+
hole ??= node;
|
|
255
|
+
}
|
|
256
|
+
// The parent is mid-adoption and has no seam here yet: its walk may already have snapshotted this
|
|
257
|
+
// hole's fallback, and its hydration then drops the DOM the graft just landed. Park the grafted
|
|
258
|
+
// nodes synchronously - ahead of that render - and adopt them from the holder once the tree is
|
|
259
|
+
// live. A walk that had NOT reached the hole yet simply adopts an empty one and gets the same
|
|
260
|
+
// content back through the seam. The holder is a hidden ELEMENT rather than a fragment: a
|
|
261
|
+
// disconnected parent is exactly what strips browser state, and moveBefore refuses one.
|
|
262
|
+
if (hole && !hole.__pnextHole && !hole.__pnextCaptured && parent.__pnextMounting) {
|
|
263
|
+
const holder = document.createElement('pnext-hole-capture');
|
|
264
|
+
holder.hidden = true;
|
|
265
|
+
document.body.append(holder);
|
|
266
|
+
hole.__pnextCaptured = holder;
|
|
267
|
+
for (const node of [...hole.childNodes]) movePnextNode(holder, node);
|
|
268
|
+
}
|
|
269
|
+
// A parent whose module failed still settles: the claim has to release its capture rather than
|
|
270
|
+
// leave the grafted nodes parked in a holder nobody will ever adopt from.
|
|
271
|
+
await parent.__pnextMounting?.catch(() => {});
|
|
272
|
+
if (!hole || (!hole.__pnextCaptured && !root.isConnected)) return;
|
|
273
|
+
return adoptHoleSeam(hole);
|
|
274
|
+
}
|
|
275
|
+
`
|
|
276
|
+
}
|
|
277
|
+
|
|
142
278
|
// Two mount passes can reach the same island root concurrently - mountRoute and the streamed-Suspense
|
|
143
279
|
// re-scan both walk the document, and their "already live" guard runs BEFORE mountIsland awaits the
|
|
144
280
|
// component module. Without a synchronous claim both passes mount: the first tree is orphaned over the
|
|
@@ -517,15 +653,21 @@ function keepsMountedRoot(root, keep) {
|
|
|
517
653
|
// The per-route half of the lifecycle, closing over the route's own mount
|
|
518
654
|
// state. mountRoute runs the steps the route actually has: an island scan, the
|
|
519
655
|
// client-shell hydration, the client page.
|
|
520
|
-
function routeLifecycleSource({ nextCompat, islands, page, shell }: ClientRuntimeFacts) {
|
|
656
|
+
function routeLifecycleSource({ nextCompat, islands, page, shell, dev }: ClientRuntimeFacts) {
|
|
521
657
|
const gate = nextCompat
|
|
522
658
|
? ' pnextGate(markRouteHydrated, pnextIslandMounts, pnextVisibleMount);\n'
|
|
523
659
|
: ''
|
|
660
|
+
const islandMounts = dev
|
|
661
|
+
? `pnextIslandScan.done.then(() => {
|
|
662
|
+
diagnoseSkippedIslands();
|
|
663
|
+
return pnextIslandScan.mounted;
|
|
664
|
+
})`
|
|
665
|
+
: 'pnextIslandScan.done.then(() => pnextIslandScan.mounted)'
|
|
524
666
|
const mountBody = [
|
|
525
667
|
islands
|
|
526
668
|
? ` if (islands.length > 0) {
|
|
527
669
|
pnextIslandScan = scanIslands();
|
|
528
|
-
pnextIslandMounts =
|
|
670
|
+
pnextIslandMounts = ${islandMounts};
|
|
529
671
|
}`
|
|
530
672
|
: '',
|
|
531
673
|
shell ? ' if (shell) pnextVisibleMount = hydrateClientShell();' : '',
|
|
@@ -655,6 +797,8 @@ ${overlay}installClientErrors({ errorComponent: ${
|
|
|
655
797
|
* ships only when some route in the app can actually use it.
|
|
656
798
|
*/
|
|
657
799
|
export interface ClientRuntimeFacts {
|
|
800
|
+
/** Development-only diagnostics; omitted entirely from production bundles. */
|
|
801
|
+
dev?: boolean
|
|
658
802
|
nextCompat?: boolean
|
|
659
803
|
/** Any route mounts islands (island scan, revival, static children). */
|
|
660
804
|
islands?: boolean
|
|
@@ -710,7 +854,7 @@ export function clientRuntimeSource(facts: ClientRuntimeFacts) {
|
|
|
710
854
|
// it revives island DOM exactly like an island mount does.
|
|
711
855
|
const revival = Boolean(facts.islands || shell)
|
|
712
856
|
return `
|
|
713
|
-
import { h, hydrate, render } from 'preact';
|
|
857
|
+
import { ${revival ? 'Component, ' : ''}h, hydrate, render } from 'preact';
|
|
714
858
|
${nextCompat ? "import { options as __pnextPreactOptions } from 'preact';" : ''}
|
|
715
859
|
${deferredIslands ? "import { useEffect, useState } from 'preact/hooks';" : ''}
|
|
716
860
|
${suspense ? "import { Suspense } from 'preact/compat';" : ''}
|
|
@@ -1137,6 +1281,10 @@ function domProps(element) {
|
|
|
1137
1281
|
return props;
|
|
1138
1282
|
}
|
|
1139
1283
|
|
|
1284
|
+
// A nested root belongs to its parent island's DOM adoption pass, never to a
|
|
1285
|
+
// mount of its own. This entry carries no streamed-hole seam (see the shared
|
|
1286
|
+
// runtime): its whole point is a preact-free first paint, and a host grafted in
|
|
1287
|
+
// after a visible island mounted would be dead weight in every bundle.
|
|
1140
1288
|
function isNestedIslandRoot(root) {
|
|
1141
1289
|
return root.parentElement?.closest('pnext-client') != null;
|
|
1142
1290
|
}
|
|
@@ -1343,9 +1491,39 @@ function clientBoundaryHelperSource(nextCompat?: boolean, suspense?: boolean) {
|
|
|
1343
1491
|
`
|
|
1344
1492
|
}
|
|
1345
1493
|
|
|
1346
|
-
function hydrateIslandsSource({
|
|
1494
|
+
function hydrateIslandsSource({
|
|
1495
|
+
nextCompat,
|
|
1496
|
+
visibleIslands,
|
|
1497
|
+
deferredIslands,
|
|
1498
|
+
dev,
|
|
1499
|
+
}: ClientRuntimeFacts) {
|
|
1500
|
+
const diagnostic = dev
|
|
1501
|
+
? `
|
|
1502
|
+
function diagnoseSkippedIslands() {
|
|
1503
|
+
for (const root of document.querySelectorAll('pnext-client[data-pnext-client]')) {
|
|
1504
|
+
if (root.__pnextLive) continue;
|
|
1505
|
+
const id = root.getAttribute('data-pnext-client');
|
|
1506
|
+
const island = id ? islandById.get(id) : undefined;
|
|
1507
|
+
const parent = nestedIslandParent(root);
|
|
1508
|
+
// A visible island is deliberately inert until intersection. A nested
|
|
1509
|
+
// island whose parent is still pending is likewise owned by that parent's
|
|
1510
|
+
// adoption pass, one parked in a capture holder is mid-adoption, and one the
|
|
1511
|
+
// claim pass adopted is no longer in the document. Everything else is a host
|
|
1512
|
+
// this route can never hydrate.
|
|
1513
|
+
if (island?.options?.load === 'visible') continue;
|
|
1514
|
+
if (root.closest('pnext-hole-capture')) continue;
|
|
1515
|
+
if (parent && !parent.__pnextLive) continue;
|
|
1516
|
+
const reason = island
|
|
1517
|
+
? 'was materialized after its parent island mounted, outside any streamed hole this route can re-adopt'
|
|
1518
|
+
: 'has no entry in the client island manifest';
|
|
1519
|
+
console.warn('pnext: client island ' + (id ?? '<missing id>') + ' ' + reason);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
`
|
|
1523
|
+
: ''
|
|
1347
1524
|
return `
|
|
1348
1525
|
const islandById = new Map(islands.map(island => [island.id, island]));
|
|
1526
|
+
${lateIslandClaimSource()}${diagnostic}
|
|
1349
1527
|
|
|
1350
1528
|
// The mounted count is known as soon as the scan loop finishes, before any
|
|
1351
1529
|
// island module resolves — so the client shell can decide whether it owns the
|
|
@@ -1364,7 +1542,11 @@ for (const island of islands) {
|
|
|
1364
1542
|
}
|
|
1365
1543
|
for (const island of islands) {
|
|
1366
1544
|
for (const root of document.querySelectorAll(\`[data-pnext-client="\${island.id}"]\`)) {
|
|
1367
|
-
|
|
1545
|
+
const parent = nestedIslandParent(root);
|
|
1546
|
+
if (parent) {
|
|
1547
|
+
pending.push(claimNestedIslandRoot(root, parent));
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1368
1550
|
// Already mounted (e.g. a re-scan after a streamed Suspense hole materialized new islands):
|
|
1369
1551
|
// mount() stamps __pnextLive, so skipping these leaves the shell's live islands untouched. A root
|
|
1370
1552
|
// preserved across a soft navigation is ALSO live, but carries the incoming placeholder (fresh SSR
|
|
@@ -1476,6 +1658,10 @@ async function domNode(node) {
|
|
|
1476
1658
|
|
|
1477
1659
|
const element = node;
|
|
1478
1660
|
if (element.localName === ${JSON.stringify(ISLAND_BOUNDARY_ERROR_ELEMENT)}) return pnextBoundaryErrorVNode(element);
|
|
1661
|
+
// The seam a late streamed graft re-adopts through (see holeSeamSource).
|
|
1662
|
+
if (element.localName === 'pnext-hole') {
|
|
1663
|
+
return h(PnextHole, { attrs: domProps(element) }, await domChildren(element));
|
|
1664
|
+
}
|
|
1479
1665
|
if (element.localName === 'pnext-client') {
|
|
1480
1666
|
const id = element.getAttribute('data-pnext-client');
|
|
1481
1667
|
const island = id ? islandById.get(id) : undefined;
|
|
@@ -1522,12 +1708,15 @@ function domProps(element) {
|
|
|
1522
1708
|
return props;
|
|
1523
1709
|
}
|
|
1524
1710
|
|
|
1525
|
-
|
|
1526
|
-
|
|
1711
|
+
// The island host that owns this root's DOM, if any. A nested root is never
|
|
1712
|
+
// mounted here: the parent adopts it, or claimNestedIslandRoot routes it through
|
|
1713
|
+
// the parent's hole seam.
|
|
1714
|
+
function nestedIslandParent(root) {
|
|
1715
|
+
return root.parentElement?.closest('pnext-client');
|
|
1527
1716
|
}
|
|
1528
|
-
${
|
|
1529
|
-
|
|
1530
|
-
|
|
1717
|
+
${holeSeamSource()}${
|
|
1718
|
+
visibleIslands
|
|
1719
|
+
? `
|
|
1531
1720
|
function visibleTarget(root) {
|
|
1532
1721
|
// The host's OWN box: hostMeasureSource delegates the patched one to the
|
|
1533
1722
|
// parent, and IntersectionObserver never fires for a display:contents target.
|
|
@@ -1536,8 +1725,8 @@ function visibleTarget(root) {
|
|
|
1536
1725
|
return root.parentElement ?? root;
|
|
1537
1726
|
}
|
|
1538
1727
|
`
|
|
1539
|
-
|
|
1540
|
-
}${mountOnceSource()}${
|
|
1728
|
+
: ''
|
|
1729
|
+
}${mountOnceSource()}${
|
|
1541
1730
|
deferredIslands
|
|
1542
1731
|
? `
|
|
1543
1732
|
function DeferredIsland({ load, props, children }) {
|
package/src/compat/metadata.ts
CHANGED
|
@@ -192,13 +192,6 @@ export async function warnNextMetadataBuildIssues(
|
|
|
192
192
|
if (route.kind !== 'page') continue
|
|
193
193
|
const routeFacts = await Promise.all(routeSourceFiles(appPath, route).map(factsFor))
|
|
194
194
|
const some = (pick: (fact: MetadataSourceFacts) => boolean) => routeFacts.some(pick)
|
|
195
|
-
if (some(fact => fact.themeColor)) {
|
|
196
|
-
warnOnce(
|
|
197
|
-
warned,
|
|
198
|
-
`themeColor:${route.route}`,
|
|
199
|
-
`Unsupported metadata themeColor is configured in metadata export in ${route.route || '/'}. Please move it to viewport\nRead more: https://nextjs.org/docs/app/api-reference/functions/generate-viewport`,
|
|
200
|
-
)
|
|
201
|
-
}
|
|
202
195
|
if (some(fact => fact.appleTouchFullscreen)) {
|
|
203
196
|
warnOnce(
|
|
204
197
|
warned,
|
|
@@ -246,7 +239,6 @@ async function readSourceTextOrEmpty(file: string) {
|
|
|
246
239
|
* nearly every file has none of these keys.
|
|
247
240
|
*/
|
|
248
241
|
interface MetadataSourceFacts {
|
|
249
|
-
themeColor: boolean
|
|
250
242
|
appleTouchFullscreen: boolean
|
|
251
243
|
appleTouchIconPrecomposed: boolean
|
|
252
244
|
metadataBase: boolean
|
|
@@ -255,7 +247,6 @@ interface MetadataSourceFacts {
|
|
|
255
247
|
|
|
256
248
|
function metadataSourceFacts(source: string): MetadataSourceFacts {
|
|
257
249
|
return {
|
|
258
|
-
themeColor: source.includes('themeColor') && /\bthemeColor\s*:/.test(source),
|
|
259
250
|
appleTouchFullscreen:
|
|
260
251
|
source.includes('apple-touch-fullscreen') && /['"]apple-touch-fullscreen['"]/.test(source),
|
|
261
252
|
appleTouchIconPrecomposed:
|
|
@@ -8,6 +8,7 @@ import { recordRewrite } from '../protocol'
|
|
|
8
8
|
import { recordCanonicalUrl } from '../next/canonical-url'
|
|
9
9
|
import { getNextConfig } from '../next/config-loader'
|
|
10
10
|
import { activateTestProxy } from '../lifecycle/testmode'
|
|
11
|
+
import { legacyRequestAPIs } from '../../request/context'
|
|
11
12
|
|
|
12
13
|
export function registerProxyExtensions(_config: ResolvedConfig): void {
|
|
13
14
|
setProxyResponseProtocol({
|
|
@@ -72,7 +73,8 @@ async function validateNextProxyFiles(config: ResolvedConfig) {
|
|
|
72
73
|
`Both middleware file "${relativeProxyPath(config, middleware)}" and proxy file "${relativeProxyPath(config, proxy)}" are detected. Please use "${relativeProxyPath(config, proxy)}" only.`,
|
|
73
74
|
)
|
|
74
75
|
}
|
|
75
|
-
|
|
76
|
+
// Proxy is a Next 16 migration; legacy request APIs explicitly target the middleware era.
|
|
77
|
+
if (middleware && !proxy && !legacyRequestAPIs()) {
|
|
76
78
|
console.warn('The "middleware" file convention is deprecated. Please use "proxy" instead.')
|
|
77
79
|
}
|
|
78
80
|
await Promise.all(files.map(file => validateNextProxyFile(config, file)))
|
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
} from '../../extensions'
|
|
12
12
|
import type { RouteManifestEntry } from '../../types'
|
|
13
13
|
import { nextCompatEnabled } from '../aliases'
|
|
14
|
-
import { writeTsconfigDefaults } from '../tsconfig-defaults'
|
|
15
14
|
import { typecheckDisabled, validateTypesOffThread } from '../typecheck/check'
|
|
16
15
|
import { generateTypedRoutes } from '../typed-routes/generate'
|
|
17
16
|
|
|
@@ -21,7 +20,6 @@ export function registerTypedRoutesExtensions(_config: ResolvedConfig): void {
|
|
|
21
20
|
|
|
22
21
|
async function runTypedRoutesBuildStep(ctx: BuildStepContext): Promise<void> {
|
|
23
22
|
if (!nextCompatEnabled(ctx.config)) return
|
|
24
|
-
await writeTsconfigDefaults(ctx.config)
|
|
25
23
|
await generateTypedRoutes(ctx.config, ctx.routes as RouteManifestEntry[])
|
|
26
24
|
// The typecheck reads only files that already exist on disk, so it runs on a worker thread
|
|
27
25
|
// alongside client bundling and prerendering rather than ahead of them; the CLI awaits it before
|
|
@@ -15,6 +15,7 @@ import path from 'node:path'
|
|
|
15
15
|
import type { ResolvedConfig } from '../../config'
|
|
16
16
|
import { extraPageExtensions } from '../../extensions'
|
|
17
17
|
import type { RouteManifestEntry } from '../../types'
|
|
18
|
+
import { legacyRequestAPIs } from '../../request/context'
|
|
18
19
|
import { getNextConfig } from '../next/config-loader'
|
|
19
20
|
import { stripComments } from '../../utils/code'
|
|
20
21
|
import {
|
|
@@ -271,6 +272,7 @@ function warnBuildDiagnostics(config: ResolvedConfig, routes: RouteManifestEntry
|
|
|
271
272
|
console.warn('Disabled SWC as replacement for Babel because of custom Babel configuration')
|
|
272
273
|
}
|
|
273
274
|
if (
|
|
275
|
+
!legacyRequestAPIs() &&
|
|
274
276
|
typeof nextConfig.outputFileTracingRoot !== 'string' &&
|
|
275
277
|
!(
|
|
276
278
|
nextConfig.turbopack &&
|
package/src/render/renderer.ts
CHANGED
|
@@ -4470,6 +4470,7 @@ export function errorLogTrace(error: Error): string {
|
|
|
4470
4470
|
}
|
|
4471
4471
|
|
|
4472
4472
|
function logDevError(options: RenderOptions, error: Error, clientSsrError = false) {
|
|
4473
|
+
if (error.name === 'DynamicServerError') return
|
|
4473
4474
|
const seen = isBuildError(error) ? loggedBuildErrors : loggedRuntimeErrors
|
|
4474
4475
|
if (seen.has(error)) return
|
|
4475
4476
|
seen.add(error)
|
|
@@ -6733,7 +6734,7 @@ function anchorInlineSuspenseHoles(html: string): string {
|
|
|
6733
6734
|
.replace(/<!--\/\$s:[^>]+?-->/g, '</pnext-hole>')
|
|
6734
6735
|
}
|
|
6735
6736
|
|
|
6736
|
-
function clientSuspenseRefreshScript(nonce: string | undefined) {
|
|
6737
|
+
export function clientSuspenseRefreshScript(nonce: string | undefined) {
|
|
6737
6738
|
const nonceAttribute = nonce ? ` nonce="${escapeHtml(nonce)}"` : ''
|
|
6738
6739
|
// Graft each streamed island's content over its hole. The hole is a `<pnext-hole>` element rather
|
|
6739
6740
|
// than preact's `$s:ID` comment markers: hydration of an ancestor client layout removes comment
|
|
@@ -6873,7 +6874,7 @@ function streamPageSlotMode(options: RenderOptions): PageSlotMarkerMode {
|
|
|
6873
6874
|
return pageSlotIsMountTarget(options) ? 'materialize' : 'keep'
|
|
6874
6875
|
}
|
|
6875
6876
|
|
|
6876
|
-
function clientIslandMarkerBootstrapSource(mode: PageSlotMarkerMode = 'materialize') {
|
|
6877
|
+
export function clientIslandMarkerBootstrapSource(mode: PageSlotMarkerMode = 'materialize') {
|
|
6877
6878
|
// Pages that mount nothing on #pnext-page never need the wrapper div: the
|
|
6878
6879
|
// page's children stay direct children of <body>, matching Next's document
|
|
6879
6880
|
// structure (tests assert structural selectors like `body > p`). `keep`
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Which @wular/pnext a generated project depends on. `bunx @wular/pnext` reuses a cached copy of the
|
|
2
|
+
// package instead of re-resolving the dist-tag, so a stale laptop runs an old CLI — and an old CLI
|
|
3
|
+
// stamping its own version pins the new project to it. The range comes from the registry instead,
|
|
4
|
+
// with the running version as the offline fallback.
|
|
5
|
+
|
|
6
|
+
import { bold, cyan, dim } from './ansi'
|
|
7
|
+
import { pnextVersionRange } from './fs'
|
|
8
|
+
|
|
9
|
+
const REGISTRY_URL = 'https://registry.npmjs.org/@wular%2Fpnext'
|
|
10
|
+
// Short: create/migrate must not hang behind a slow or captive network.
|
|
11
|
+
const TIMEOUT_MS = 2000
|
|
12
|
+
|
|
13
|
+
async function fetchLatestPublishedVersion() {
|
|
14
|
+
const response = await fetch(REGISTRY_URL, {
|
|
15
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
16
|
+
// The abbreviated document — dist-tags plus versions, without the full metadata.
|
|
17
|
+
headers: { accept: 'application/vnd.npm.install-v1+json' },
|
|
18
|
+
})
|
|
19
|
+
if (!response.ok) return undefined
|
|
20
|
+
const body = (await response.json()) as { 'dist-tags'?: { latest?: string } }
|
|
21
|
+
return body['dist-tags']?.latest
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Test seam: the create/migrate suites assert the stamped range and must never hit the network.
|
|
25
|
+
let fetchLatestVersion: () => Promise<string | undefined> = fetchLatestPublishedVersion
|
|
26
|
+
|
|
27
|
+
/** @internal Test-only. Returns a restore function. */
|
|
28
|
+
export function setLatestVersionFetcher(fetcher: () => Promise<string | undefined>) {
|
|
29
|
+
const previous = fetchLatestVersion
|
|
30
|
+
fetchLatestVersion = fetcher
|
|
31
|
+
return () => {
|
|
32
|
+
fetchLatestVersion = previous
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* '^' + the latest published @wular/pnext, for a project this CLI generates. Never throws and never
|
|
38
|
+
* blocks for long — any failure falls back to the running version. Warns when the CLI itself is
|
|
39
|
+
* stale, which is the symptom the user actually has to fix.
|
|
40
|
+
*/
|
|
41
|
+
export async function latestPnextVersionRange(command: 'create' | 'migrate') {
|
|
42
|
+
const running = pnextVersionRange().slice(1)
|
|
43
|
+
const latest = await fetchLatestVersion().catch(() => undefined)
|
|
44
|
+
if (!latest) return `^${running}`
|
|
45
|
+
if (isNewer(latest, running)) warnStaleCli(command, running, latest)
|
|
46
|
+
return `^${latest}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function warnStaleCli(command: string, running: string, latest: string) {
|
|
50
|
+
console.error(
|
|
51
|
+
`\n${bold('⚠ Your pnext CLI is out of date')} — running ${running}, latest is ${latest}.\n` +
|
|
52
|
+
dim(
|
|
53
|
+
` bunx reused a cached copy. This project depends on ^${latest}, but ${command} ran with ${running}.\n`,
|
|
54
|
+
) +
|
|
55
|
+
` Re-run with: ${cyan(`bunx @wular/pnext@latest ${command}`)}\n`,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Numeric major.minor.patch only; anything unparseable counts as "not newer". */
|
|
60
|
+
function isNewer(candidate: string, current: string) {
|
|
61
|
+
const parts = (value: string) => value.split('-')[0]!.split('.').map(Number)
|
|
62
|
+
const [a, b] = [parts(candidate), parts(current)]
|
|
63
|
+
for (let index = 0; index < 3; index += 1) {
|
|
64
|
+
const left = a[index] ?? 0
|
|
65
|
+
const right = b[index] ?? 0
|
|
66
|
+
if (!Number.isFinite(left) || !Number.isFinite(right)) return false
|
|
67
|
+
if (left !== right) return left > right
|
|
68
|
+
}
|
|
69
|
+
return false
|
|
70
|
+
}
|