@wular/pnext 0.0.24 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/pnext +1 -1
- package/package.json +2 -2
- package/reference/data/bench.json +398 -223
- package/reference/getting-started.md +1 -1
- package/src/api/client-navigation.ts +7 -5
- package/src/cli/adapters/vercel-warm.ts +120 -28
- package/src/cli/build.ts +56 -15
- package/src/cli/create.ts +1 -1
- package/src/cli/dev.ts +2 -1
- package/src/cli/index.ts +146 -10
- package/src/cli/migrate/report.ts +1 -1
- package/src/cli/serve/pipeline.ts +23 -6
- package/src/cli/serve/ui.ts +1 -1
- package/src/cli/start.ts +2 -1
- package/src/cli/typegen.ts +5 -2
- package/src/client/build.ts +217 -58
- package/src/client/chunk-fold.ts +7 -1
- package/src/client/entry.ts +44 -7
- package/src/client/router/runtime.ts +1016 -230
- package/src/client/router/types.ts +15 -0
- package/src/compat/actions/client-plugin.ts +10 -0
- package/src/compat/actions/rewrite.ts +1 -0
- package/src/compat/bundler/optimize-package-imports.ts +375 -28
- package/src/compat/client/navigation-scroll.ts +29 -3
- package/src/compat/client/optimistic-routing.ts +2 -2
- package/src/compat/client/segment-cache-policy.ts +4 -18
- package/src/compat/client/segment-cache.ts +104 -0
- package/src/compat/client/segment-prefetch.ts +6 -6
- package/src/compat/next/config-loader.ts +6 -4
- package/src/compat/next/navigation.ts +16 -2
- package/src/compat/pages/router.ts +102 -9
- package/src/compat/protocol.ts +10 -1
- package/src/compat/register/actions.ts +2 -0
- package/src/compat/register/bundler.ts +4 -6
- package/src/compat/register/routing.ts +3 -4
- package/src/compat/tsconfig-defaults.ts +3 -2
- package/src/config.ts +3 -3
- package/src/css/postcss.ts +450 -17
- package/src/dev/client-actions.ts +2 -0
- package/src/dev/server.ts +87 -14
- package/src/render/island-context.ts +3 -0
- package/src/render/ppr.ts +14 -0
- package/src/render/renderer.ts +268 -42
- package/src/request/context.ts +32 -11
- package/src/resolve/scan-facts.ts +23 -4
- package/src/routing/proxy.ts +29 -25
- package/src/routing/routes.ts +1 -1
- package/src/runtime/loader.ts +47 -1
- package/src/runtime/modules.ts +546 -65
- package/src/runtime/vendor-build.ts +109 -22
- package/src/runtime/vendor.ts +7 -3
- package/src/utils/ansi.ts +3 -1
- package/src/utils/fs.ts +17 -3
|
@@ -89,24 +89,6 @@ function record(input: SegmentCacheRecord): void {
|
|
|
89
89
|
complete: input.complete,
|
|
90
90
|
runtime: input.runtime,
|
|
91
91
|
})
|
|
92
|
-
// App Shells: the per-URL prerender carried the route's shared shell. File it
|
|
93
|
-
// at the route's fallback vary path (empty vary = every param value) as a
|
|
94
|
-
// SHELL — it paints, but a navigation still fetches the per-URL remainder.
|
|
95
|
-
if (decoded.shell) {
|
|
96
|
-
recordSegment({
|
|
97
|
-
segmentPath: input.segmentPath,
|
|
98
|
-
pathname: input.pathname,
|
|
99
|
-
search: '',
|
|
100
|
-
html: decoded.shell.html,
|
|
101
|
-
staleTimeMs: input.staleTimeMs,
|
|
102
|
-
complete: false,
|
|
103
|
-
static: true,
|
|
104
|
-
runtime: false,
|
|
105
|
-
vary: [],
|
|
106
|
-
route: decoded.shell.route,
|
|
107
|
-
params: decoded.params,
|
|
108
|
-
})
|
|
109
|
-
}
|
|
110
92
|
recordSegment({
|
|
111
93
|
segmentPath: input.segmentPath,
|
|
112
94
|
pathname: input.pathname,
|
|
@@ -116,6 +98,7 @@ function record(input: SegmentCacheRecord): void {
|
|
|
116
98
|
complete: input.complete,
|
|
117
99
|
static: input.static,
|
|
118
100
|
runtime: input.runtime,
|
|
101
|
+
...(input.postponedShell ? { postponedShell: true } : {}),
|
|
119
102
|
vary: decoded.vary,
|
|
120
103
|
// w9-segment-split: pass the layout/page vary sets through so the cache can
|
|
121
104
|
// file the two frames alongside the whole-document entry.
|
|
@@ -124,6 +107,7 @@ function record(input: SegmentCacheRecord): void {
|
|
|
124
107
|
// The route's static siblings, so a learned dynamic pattern never keys onto
|
|
125
108
|
// a literal sibling's URL.
|
|
126
109
|
...(decoded.staticSiblings ? { staticSiblings: decoded.staticSiblings } : {}),
|
|
110
|
+
...(decoded.shell ? { shell: decoded.shell } : {}),
|
|
127
111
|
route: decoded.route,
|
|
128
112
|
params: decoded.params,
|
|
129
113
|
})
|
|
@@ -140,6 +124,7 @@ function take(input: {
|
|
|
140
124
|
html: hit.html,
|
|
141
125
|
networkFree: hit.networkFree,
|
|
142
126
|
prefetchSatisfied: hit.prefetchSatisfied,
|
|
127
|
+
postponedShell: hit.postponedShell,
|
|
143
128
|
}
|
|
144
129
|
}
|
|
145
130
|
// w9-segment-split: the whole-document entry missed because ONE of the two
|
|
@@ -154,6 +139,7 @@ function take(input: {
|
|
|
154
139
|
html: composed.html,
|
|
155
140
|
networkFree: composed.networkFree,
|
|
156
141
|
prefetchSatisfied: composed.prefetchSatisfied,
|
|
142
|
+
postponedShell: composed.postponedShell,
|
|
157
143
|
}
|
|
158
144
|
: null
|
|
159
145
|
}
|
|
@@ -96,6 +96,10 @@ export interface SegmentRecordInput extends SegmentPayloadMeta {
|
|
|
96
96
|
static: boolean
|
|
97
97
|
/** True when the payload came from a runtime (request-sampled) prefetch. */
|
|
98
98
|
runtime: boolean
|
|
99
|
+
/** True when the server marked the payload its POSTPONED (PPR) shell for this URL. */
|
|
100
|
+
postponedShell?: boolean
|
|
101
|
+
/** True when this is an extracted route-wide App Shell, valid for sibling params too. */
|
|
102
|
+
sharedAppShell?: boolean
|
|
99
103
|
/** True when the route's catch-all is optional (`[[...slug]]`). */
|
|
100
104
|
catchAllOptional?: boolean
|
|
101
105
|
}
|
|
@@ -110,6 +114,8 @@ interface SegmentEntry {
|
|
|
110
114
|
complete: boolean
|
|
111
115
|
static: boolean
|
|
112
116
|
runtime: boolean
|
|
117
|
+
postponedShell: boolean
|
|
118
|
+
sharedAppShell: boolean
|
|
113
119
|
}
|
|
114
120
|
|
|
115
121
|
export interface SegmentHit {
|
|
@@ -123,6 +129,11 @@ export interface SegmentHit {
|
|
|
123
129
|
*/
|
|
124
130
|
prefetchSatisfied: boolean
|
|
125
131
|
runtime: boolean
|
|
132
|
+
/**
|
|
133
|
+
* The entry is an authorised static shell stage: either this URL's own
|
|
134
|
+
* postponed PPR shell, or an extracted route-wide App Shell.
|
|
135
|
+
*/
|
|
136
|
+
postponedShell: boolean
|
|
126
137
|
}
|
|
127
138
|
|
|
128
139
|
interface LearnedRoute {
|
|
@@ -158,6 +169,26 @@ function now(): number {
|
|
|
158
169
|
// ---------------------------------------------------------------------------
|
|
159
170
|
|
|
160
171
|
export function recordSegment(input: SegmentRecordInput): void {
|
|
172
|
+
// A fully-static prerender may carry the route's params-hanging App Shell
|
|
173
|
+
// alongside its concrete per-URL body. File that extracted shell through the
|
|
174
|
+
// same cache seam as every other response so all producers (the router policy
|
|
175
|
+
// and the standalone segment-prefetch scheduler) prime the fallback vary
|
|
176
|
+
// path consistently.
|
|
177
|
+
if (input.shell) {
|
|
178
|
+
recordAppShell({
|
|
179
|
+
segmentPath: input.segmentPath,
|
|
180
|
+
pathname: input.pathname,
|
|
181
|
+
search: '',
|
|
182
|
+
html: input.shell.html,
|
|
183
|
+
staleTimeMs: input.staleTimeMs,
|
|
184
|
+
complete: false,
|
|
185
|
+
static: true,
|
|
186
|
+
runtime: false,
|
|
187
|
+
vary: [],
|
|
188
|
+
route: input.shell.route,
|
|
189
|
+
params: input.params,
|
|
190
|
+
})
|
|
191
|
+
}
|
|
161
192
|
recordOneSegment(input)
|
|
162
193
|
// w9-segment-split: a body response the server split also files its two
|
|
163
194
|
// frames, each under its OWN vary set. Additive — the whole-document entry
|
|
@@ -202,6 +233,27 @@ export function recordSegment(input: SegmentRecordInput): void {
|
|
|
202
233
|
}
|
|
203
234
|
}
|
|
204
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Extract and file a route-wide App Shell from a prerender response.
|
|
238
|
+
*
|
|
239
|
+
* Next publishes a byte boundary and decodes the Flight prefix again. PNext's
|
|
240
|
+
* segment payload carries HTML, whose equivalent boundary is the first
|
|
241
|
+
* streamed continuation: removing those resolved chunks leaves the original
|
|
242
|
+
* params-hanging Suspense shell.
|
|
243
|
+
*/
|
|
244
|
+
export function recordAppShell(input: SegmentRecordInput): void {
|
|
245
|
+
recordSegment({
|
|
246
|
+
...input,
|
|
247
|
+
html: stripStreamedContinuation(input.html),
|
|
248
|
+
complete: false,
|
|
249
|
+
static: true,
|
|
250
|
+
runtime: false,
|
|
251
|
+
postponedShell: true,
|
|
252
|
+
sharedAppShell: true,
|
|
253
|
+
vary: [],
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
205
257
|
/**
|
|
206
258
|
* A payload is REWRITE-PRODUCED when the route the server resolved does not describe the URL it was
|
|
207
259
|
* served at - either structurally, or in its concrete param values.
|
|
@@ -282,6 +334,22 @@ function recordOneSegment(rawInput: SegmentRecordInput): void {
|
|
|
282
334
|
// map without limit (memory-pressure leak-slope test).
|
|
283
335
|
trimLearnedRoutes(byRoute)
|
|
284
336
|
}
|
|
337
|
+
const sharedAppShell = input.sharedAppShell === true || isRouteWideAppShell(input)
|
|
338
|
+
const existing = entries.get(key)
|
|
339
|
+
// A fully-static response can extract and cache this route shell with its
|
|
340
|
+
// own static window, then a concurrent runtime-shell response for the same
|
|
341
|
+
// fallback key can arrive with the full response's zero window. Next tracks
|
|
342
|
+
// shell-stage freshness independently; do not let that duplicate erase the
|
|
343
|
+
// already-fresh extracted shell.
|
|
344
|
+
if (
|
|
345
|
+
sharedAppShell &&
|
|
346
|
+
input.staleTimeMs <= 0 &&
|
|
347
|
+
existing?.sharedAppShell &&
|
|
348
|
+
now() - existing.time < existing.staleTimeMs
|
|
349
|
+
) {
|
|
350
|
+
touch(key)
|
|
351
|
+
return
|
|
352
|
+
}
|
|
285
353
|
entries.set(key, {
|
|
286
354
|
key,
|
|
287
355
|
html: input.html,
|
|
@@ -291,11 +359,39 @@ function recordOneSegment(rawInput: SegmentRecordInput): void {
|
|
|
291
359
|
complete: input.complete,
|
|
292
360
|
static: input.static,
|
|
293
361
|
runtime: input.runtime,
|
|
362
|
+
postponedShell: input.postponedShell === true,
|
|
363
|
+
sharedAppShell,
|
|
294
364
|
})
|
|
295
365
|
touch(key)
|
|
296
366
|
trim()
|
|
297
367
|
}
|
|
298
368
|
|
|
369
|
+
/**
|
|
370
|
+
* A runtime App Shell response renders the route with its params hanging. Its
|
|
371
|
+
* empty fallback vary path and absent concrete route params distinguish it
|
|
372
|
+
* from an ordinary postponed response for one URL.
|
|
373
|
+
*/
|
|
374
|
+
function isRouteWideAppShell(input: SegmentRecordInput): boolean {
|
|
375
|
+
if (!input.postponedShell || !input.static || !input.route || input.vary?.length !== 0)
|
|
376
|
+
return false
|
|
377
|
+
const concrete = matchRoutePattern(input.route, input.pathname, true)
|
|
378
|
+
if (concrete !== null && Object.keys(concrete).some(name => input.params[name] === undefined)) {
|
|
379
|
+
return true
|
|
380
|
+
}
|
|
381
|
+
// PNext restamps concrete params onto the shell payload for client state,
|
|
382
|
+
// even though the render itself hung them. The navigation-state child keeps
|
|
383
|
+
// the params-hanging route pattern, unlike an ordinary exact-URL PPR shell.
|
|
384
|
+
const match = /<script id="__PNEXT_NAV_STATE__" type="application\/json">(.*?)<\/script>/s.exec(
|
|
385
|
+
input.html,
|
|
386
|
+
)
|
|
387
|
+
if (!match?.[1]) return false
|
|
388
|
+
try {
|
|
389
|
+
return (JSON.parse(match[1]) as { children?: unknown }).children === input.route
|
|
390
|
+
} catch {
|
|
391
|
+
return false
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
299
395
|
// ---------------------------------------------------------------------------
|
|
300
396
|
// Lookup.
|
|
301
397
|
// ---------------------------------------------------------------------------
|
|
@@ -371,6 +467,11 @@ export function takeSegment(options: {
|
|
|
371
467
|
// match. The navigation that follows still fetches the dynamic remainder.
|
|
372
468
|
prefetchSatisfied: !shared || (optimisticRoutingEnabled() && (entry.static || entry.runtime)),
|
|
373
469
|
runtime: entry.runtime,
|
|
470
|
+
// An ordinary postponed response only describes the URL it rendered, so
|
|
471
|
+
// sharing clears its paint licence. An extracted App Shell was rendered
|
|
472
|
+
// with route params deliberately hanging and is the server's shell stage
|
|
473
|
+
// for every sibling param, so it retains that licence.
|
|
474
|
+
postponedShell: entry.postponedShell && (!shared || entry.sharedAppShell),
|
|
374
475
|
}
|
|
375
476
|
}
|
|
376
477
|
return null
|
|
@@ -472,6 +573,9 @@ export function takeComposedSegment(options: {
|
|
|
472
573
|
networkFree: layout.networkFree && page.networkFree,
|
|
473
574
|
prefetchSatisfied: layout.prefetchSatisfied && page.prefetchSatisfied,
|
|
474
575
|
runtime: layout.runtime || page.runtime,
|
|
576
|
+
// Composed from two frames: it is the destination's own static stage only when both
|
|
577
|
+
// halves are.
|
|
578
|
+
postponedShell: layout.postponedShell && page.postponedShell,
|
|
475
579
|
}
|
|
476
580
|
}
|
|
477
581
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
decodeSegmentPayload,
|
|
3
|
+
recordAppShell,
|
|
3
4
|
recordSegment,
|
|
4
5
|
segmentPrefetchCovered,
|
|
5
6
|
takeSegment,
|
|
@@ -209,6 +210,7 @@ function recordBodySegment(url: URL, response: Response, body: string, staleTime
|
|
|
209
210
|
static: response.headers.get('x-nextjs-prerender') === '1',
|
|
210
211
|
runtime,
|
|
211
212
|
vary: decoded.vary,
|
|
213
|
+
...(decoded.shell ? { shell: decoded.shell } : {}),
|
|
212
214
|
route: decoded.route,
|
|
213
215
|
params: decoded.params,
|
|
214
216
|
})
|
|
@@ -332,23 +334,21 @@ async function fetchAppShell(pathname: string): Promise<void> {
|
|
|
332
334
|
if (!response.ok) return
|
|
333
335
|
const decoded = decodeSegmentPayload(await response.text(), INDEX_SEGMENT_PATH)
|
|
334
336
|
if (!decoded) return
|
|
335
|
-
|
|
337
|
+
recordAppShell({
|
|
336
338
|
segmentPath: INDEX_SEGMENT_PATH,
|
|
337
339
|
pathname,
|
|
338
340
|
search: '',
|
|
339
|
-
html: decoded.html,
|
|
341
|
+
html: decoded.shell?.html ?? decoded.html,
|
|
340
342
|
// Recorded with the response's own window (NaN → the old 300s fallback):
|
|
341
343
|
// the 30s floor is a re-prefetch dedupe rule, not a payload lifetime.
|
|
342
344
|
staleTimeMs: (seconds => (Number.isFinite(seconds) ? seconds : 300) * 1000)(
|
|
343
345
|
headerStaleTimeSeconds(response),
|
|
344
346
|
),
|
|
345
|
-
// A shell, by construction: the params-dependent subtree is still a hole,
|
|
346
|
-
// so a navigation paints it and then fetches the per-URL remainder.
|
|
347
347
|
complete: false,
|
|
348
348
|
static: response.headers.get('x-nextjs-prerender') === '1',
|
|
349
349
|
runtime: false,
|
|
350
|
-
vary:
|
|
351
|
-
route: decoded.route,
|
|
350
|
+
vary: [],
|
|
351
|
+
route: decoded.shell?.route ?? decoded.route,
|
|
352
352
|
params: decoded.params,
|
|
353
353
|
})
|
|
354
354
|
}
|
|
@@ -558,8 +558,8 @@ const DEPRECATED_CONFIG_OPTIONS: readonly { key: string; reason: (file: string)
|
|
|
558
558
|
// re-run every call - it throws.
|
|
559
559
|
const warnedConfigPaths = new Set<string>()
|
|
560
560
|
|
|
561
|
-
function validateConfig(config: NextConfigObject, configPath: string): void {
|
|
562
|
-
if (!warnedConfigPaths.has(configPath)) {
|
|
561
|
+
function validateConfig(config: NextConfigObject, configPath: string, emitWarnings: boolean): void {
|
|
562
|
+
if (emitWarnings && !warnedConfigPaths.has(configPath)) {
|
|
563
563
|
warnedConfigPaths.add(configPath)
|
|
564
564
|
const configFileName = path.basename(configPath)
|
|
565
565
|
warnUnknownConfigKeys(config)
|
|
@@ -647,7 +647,7 @@ function validateDevIndicatorPosition(config: NextConfigObject): void {
|
|
|
647
647
|
*/
|
|
648
648
|
export async function loadNextConfig(
|
|
649
649
|
root: string,
|
|
650
|
-
options: { dev?: boolean; serve?: boolean } = {},
|
|
650
|
+
options: { dev?: boolean; serve?: boolean; warnings?: boolean } = {},
|
|
651
651
|
): Promise<LoadNextConfigResult> {
|
|
652
652
|
const configPath = findConfigPath(root)
|
|
653
653
|
if (!configPath) {
|
|
@@ -681,7 +681,9 @@ export async function loadNextConfig(
|
|
|
681
681
|
await resolveConfigExport(exported, Boolean(options.dev)),
|
|
682
682
|
root,
|
|
683
683
|
)
|
|
684
|
-
|
|
684
|
+
// Internal build workers need the resolved config but must not surface a
|
|
685
|
+
// second copy of CLI warnings already emitted by the owning build process.
|
|
686
|
+
validateConfig(config, configPath, options.warnings !== false)
|
|
685
687
|
setNextConfig(config)
|
|
686
688
|
applyEnv(config)
|
|
687
689
|
applyRuntimeConfig(config)
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
ReadonlyURLSearchParams,
|
|
17
17
|
redirect as pnextRedirect,
|
|
18
18
|
} from '../../api/navigation'
|
|
19
|
-
import { isPostpone } from '../../render/postpone'
|
|
19
|
+
import { isPostpone, PostponeError } from '../../render/postpone'
|
|
20
20
|
import { currentParams, currentRequest } from '../../request/context'
|
|
21
21
|
import { useContext } from 'preact/hooks'
|
|
22
22
|
import {
|
|
@@ -24,7 +24,11 @@ import {
|
|
|
24
24
|
currentParamsSnapshot,
|
|
25
25
|
hasLayoutSegmentScope,
|
|
26
26
|
} from '../../render/slots'
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
CsrBailoutContext,
|
|
29
|
+
LayoutSegmentContext,
|
|
30
|
+
RouteParamsContext,
|
|
31
|
+
} from '../../render/island-context'
|
|
28
32
|
import { canonicalUrlHref } from './canonical-url'
|
|
29
33
|
import { isUnrecognizedActionError } from '../actions/unrecognized-error'
|
|
30
34
|
import { stripBasePath } from '../client/base-path'
|
|
@@ -96,9 +100,19 @@ export function usePathname() {
|
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
export function useSearchParams(): ReadonlyURLSearchParams {
|
|
103
|
+
if (useContext(CsrBailoutContext) && isStaticCsrBailout()) {
|
|
104
|
+
throw new PostponeError('useSearchParams')
|
|
105
|
+
}
|
|
99
106
|
return new ReadonlyURLSearchParams(currentUrl().search)
|
|
100
107
|
}
|
|
101
108
|
|
|
109
|
+
function isStaticCsrBailout(): boolean {
|
|
110
|
+
const storage = (globalThis as Record<PropertyKey, unknown>)[
|
|
111
|
+
Symbol.for('pnext.csrBailoutStorage')
|
|
112
|
+
] as { getStore?: () => boolean | undefined } | undefined
|
|
113
|
+
return storage?.getStore?.() === true
|
|
114
|
+
}
|
|
115
|
+
|
|
102
116
|
export function useParams<Route extends RoutePath = RoutePath>() {
|
|
103
117
|
return resolveParams() as RouteParams<Route>
|
|
104
118
|
}
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { existsSync, statSync } from 'node:fs'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
copyFile,
|
|
5
|
+
mkdir,
|
|
6
|
+
readdir,
|
|
7
|
+
readFile,
|
|
8
|
+
rename,
|
|
9
|
+
rm,
|
|
10
|
+
symlink,
|
|
11
|
+
writeFile,
|
|
12
|
+
} from 'node:fs/promises'
|
|
4
13
|
import path from 'node:path'
|
|
5
14
|
import { tmpdir } from 'node:os'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
6
16
|
import { hasUseClientDirective } from '../../client/reference-stub'
|
|
7
|
-
import { listFiles, toPosixPath } from '../../utils/fs'
|
|
17
|
+
import { listFiles, listFilesSync, toPosixPath } from '../../utils/fs'
|
|
8
18
|
import { getNextConfig } from '../next/config-loader'
|
|
9
19
|
|
|
10
20
|
const basePageExtensions = ['tsx', 'ts', 'jsx', 'js', 'mjs']
|
|
@@ -51,17 +61,103 @@ export async function materializePagesApp(root: string): Promise<string | undefi
|
|
|
51
61
|
const pagesPath = path.join(root, 'pages')
|
|
52
62
|
if (!existsSync(pagesPath)) return undefined
|
|
53
63
|
|
|
64
|
+
const nativeApp = path.join(root, 'app')
|
|
65
|
+
const pages = await discoverPages(pagesPath)
|
|
66
|
+
const apiRoutes = await discoverPagesApi(pagesPath)
|
|
67
|
+
if (pages.length === 0 && apiRoutes.length === 0) return undefined
|
|
68
|
+
|
|
69
|
+
// Content-addressed, never rebuilt in place. Every pnext process that resolves this app's config
|
|
70
|
+
// materializes — a build and the warm child it spawns do so concurrently — so wiping and repopulating
|
|
71
|
+
// one shared directory let one process scan a half-materialized tree ("page.js doesn't have a root
|
|
72
|
+
// layout" on a build that passes on the next run). Naming the directory after what goes in it makes
|
|
73
|
+
// the second writer a no-op, and staging + rename means a reader sees the whole tree or nothing.
|
|
54
74
|
const generatedRoot = path.join(
|
|
55
75
|
tmpdir(),
|
|
56
76
|
'pnext-pages-compat',
|
|
57
|
-
|
|
77
|
+
materializationKey(root, nativeApp, pages, apiRoutes),
|
|
58
78
|
)
|
|
79
|
+
const appPath = path.join(generatedRoot, 'app')
|
|
80
|
+
if (existsSync(appPath)) return appPath
|
|
81
|
+
|
|
82
|
+
const staging = `${generatedRoot}.${process.pid.toString(36)}.staging`
|
|
83
|
+
await buildMaterializedApp(staging, root, pagesPath, nativeApp, pages, apiRoutes)
|
|
84
|
+
try {
|
|
85
|
+
await rename(staging, generatedRoot)
|
|
86
|
+
} catch {
|
|
87
|
+
// Another process materialized the same key first; its tree is byte-identical to ours.
|
|
88
|
+
await rm(staging, { recursive: true, force: true })
|
|
89
|
+
if (!existsSync(appPath)) throw new Error(`pnext: could not materialize ${root}/pages`)
|
|
90
|
+
}
|
|
91
|
+
void sweepStaleMaterializations(path.dirname(generatedRoot))
|
|
92
|
+
return appPath
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Everything the materialized tree is derived from: the pages facts (already parsed out of the
|
|
97
|
+
* sources), the native app tree's shape, and this module — whose `wrapperSource` writes the files.
|
|
98
|
+
*/
|
|
99
|
+
function materializationKey(
|
|
100
|
+
root: string,
|
|
101
|
+
nativeApp: string,
|
|
102
|
+
pages: PagesRoute[],
|
|
103
|
+
apiRoutes: PagesApiRoute[],
|
|
104
|
+
): string {
|
|
105
|
+
const stamp = (file: string) => {
|
|
106
|
+
const info = statSync(file, { throwIfNoEntry: false })
|
|
107
|
+
return info ? `${info.size}:${info.mtimeMs}` : ''
|
|
108
|
+
}
|
|
109
|
+
const nativeFiles = existsSync(nativeApp)
|
|
110
|
+
? listFilesSync(nativeApp)
|
|
111
|
+
.map(file => `${toPosixPath(path.relative(nativeApp, file))}\0${stamp(file)}`)
|
|
112
|
+
.sort()
|
|
113
|
+
: []
|
|
114
|
+
// Sorted: discovery follows readdir order, and two processes that disagreed on it would key the
|
|
115
|
+
// same tree twice.
|
|
116
|
+
const byRelative = <T extends { relative: string }>(routes: T[]) =>
|
|
117
|
+
[...routes].sort((a, b) => a.relative.localeCompare(b.relative))
|
|
118
|
+
return createHash('sha256')
|
|
119
|
+
.update(
|
|
120
|
+
JSON.stringify([
|
|
121
|
+
root,
|
|
122
|
+
materializeExtensions(),
|
|
123
|
+
byRelative(pages),
|
|
124
|
+
byRelative(apiRoutes),
|
|
125
|
+
nativeFiles,
|
|
126
|
+
stamp(fileURLToPath(import.meta.url)),
|
|
127
|
+
]),
|
|
128
|
+
)
|
|
129
|
+
.digest('hex')
|
|
130
|
+
.slice(0, 16)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Abandoned stagings and superseded trees, swept on an age that no live process can still be using:
|
|
134
|
+
// a dev server holds its materialized tree open for as long as it runs.
|
|
135
|
+
const STALE_MATERIALIZATION_MS = 7 * 24 * 60 * 60_000
|
|
136
|
+
|
|
137
|
+
async function sweepStaleMaterializations(base: string) {
|
|
138
|
+
try {
|
|
139
|
+
const cutoff = Date.now() - STALE_MATERIALIZATION_MS
|
|
140
|
+
for (const entry of await readdir(base)) {
|
|
141
|
+
const dir = path.join(base, entry)
|
|
142
|
+
const info = statSync(dir, { throwIfNoEntry: false })
|
|
143
|
+
if (info && info.mtimeMs < cutoff) await rm(dir, { recursive: true, force: true })
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// Best-effort: a stale tree only costs a few KB of temp space.
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function buildMaterializedApp(
|
|
151
|
+
generatedRoot: string,
|
|
152
|
+
root: string,
|
|
153
|
+
pagesPath: string,
|
|
154
|
+
nativeApp: string,
|
|
155
|
+
pages: PagesRoute[],
|
|
156
|
+
apiRoutes: PagesApiRoute[],
|
|
157
|
+
): Promise<void> {
|
|
59
158
|
const appPath = path.join(generatedRoot, 'app')
|
|
60
159
|
const sourcePages = path.join(generatedRoot, 'source-pages')
|
|
61
160
|
const sourceApp = path.join(generatedRoot, 'source-app')
|
|
62
|
-
const pages = await discoverPages(pagesPath)
|
|
63
|
-
const apiRoutes = await discoverPagesApi(pagesPath)
|
|
64
|
-
if (pages.length === 0 && apiRoutes.length === 0) return undefined
|
|
65
161
|
|
|
66
162
|
await rm(generatedRoot, { recursive: true, force: true })
|
|
67
163
|
await mkdir(appPath, { recursive: true })
|
|
@@ -77,7 +173,6 @@ export async function materializePagesApp(root: string): Promise<string | undefi
|
|
|
77
173
|
const publicDir = path.join(root, 'public')
|
|
78
174
|
if (existsSync(publicDir)) await symlink(publicDir, path.join(generatedRoot, 'public'), 'dir')
|
|
79
175
|
|
|
80
|
-
const nativeApp = path.join(root, 'app')
|
|
81
176
|
if (existsSync(nativeApp)) {
|
|
82
177
|
await symlink(nativeApp, sourceApp, 'dir')
|
|
83
178
|
await materializeNativeApp(sourceApp, appPath)
|
|
@@ -135,8 +230,6 @@ export async function materializePagesApp(root: string): Promise<string | undefi
|
|
|
135
230
|
),
|
|
136
231
|
)
|
|
137
232
|
}
|
|
138
|
-
|
|
139
|
-
return appPath
|
|
140
233
|
}
|
|
141
234
|
|
|
142
235
|
async function materializeNativeApp(sourceApp: string, appPath: string): Promise<void> {
|
package/src/compat/protocol.ts
CHANGED
|
@@ -71,12 +71,21 @@ const APP_ROUTE_KINDS = new Set<ResponseFinalizerContext['routeKind']>([
|
|
|
71
71
|
|
|
72
72
|
const protocolFinalizer: ResponseFinalizer = ctx => {
|
|
73
73
|
if (!APP_ROUTE_KINDS.has(ctx.routeKind)) return
|
|
74
|
-
mergeVary(ctx.headers, ROUTER_VARY)
|
|
74
|
+
if (!isPlainRedirect(ctx)) mergeVary(ctx.headers, ROUTER_VARY)
|
|
75
75
|
normalizeRscRedirectStatus(ctx)
|
|
76
76
|
applyRewrittenHeaders(ctx)
|
|
77
77
|
applyEdgeRuntimeHeader(ctx)
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
function isPlainRedirect(ctx: ResponseFinalizerContext): boolean {
|
|
81
|
+
if (ctx.status < 300 || ctx.status >= 400 || !ctx.headers.has('location')) return false
|
|
82
|
+
return (
|
|
83
|
+
ctx.request.headers.get('rsc') !== '1' &&
|
|
84
|
+
!ctx.request.headers.has('next-router-prefetch') &&
|
|
85
|
+
!ctx.request.headers.has('next-router-segment-prefetch')
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
80
89
|
// Next's edge SSR/route wrappers stamp `x-edge-runtime: 1` on every response
|
|
81
90
|
// from a route whose segment config selects the edge runtime (edge-ssr-app.ts).
|
|
82
91
|
// The matched route's runtime rides on the work-unit response hints.
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
actionStubSourceFor,
|
|
34
34
|
clientActionModulesRoot,
|
|
35
35
|
isClientActionModule,
|
|
36
|
+
loadClientActionSource,
|
|
36
37
|
} from '../actions/client-plugin'
|
|
37
38
|
import { validateServerActionsConfigSync } from '../actions/config'
|
|
38
39
|
import { hoistNonCapturingInlineActions } from '../actions/hoist'
|
|
@@ -102,5 +103,6 @@ export function registerActionExtensions(_config: ResolvedConfig): void {
|
|
|
102
103
|
},
|
|
103
104
|
isClientActionModule,
|
|
104
105
|
stubSource: file => actionStubSourceFor(file, clientActionModulesRoot() ?? undefined),
|
|
106
|
+
loadClientSource: (file, root) => loadClientActionSource(file, root),
|
|
105
107
|
})
|
|
106
108
|
}
|
|
@@ -67,6 +67,7 @@ import {
|
|
|
67
67
|
import { createModularizeImportsTransform } from '../bundler/modularize-imports'
|
|
68
68
|
import {
|
|
69
69
|
createOptimizePackageImportsTransform,
|
|
70
|
+
optimizePackageImportsSniffTokens,
|
|
70
71
|
optimizedPackageSet,
|
|
71
72
|
} from '../bundler/optimize-package-imports'
|
|
72
73
|
import { createRelayTransform } from '../bundler/relay-transform'
|
|
@@ -207,6 +208,7 @@ export function registerBundlerExtensions(config: ResolvedConfig): void {
|
|
|
207
208
|
// route named. Same chain position as modularizeImports, so the emitted leaf specifiers still flow
|
|
208
209
|
// through alias/path resolution.
|
|
209
210
|
const optimizedPackages = optimizedPackageSet(optimizePackageImports())
|
|
211
|
+
const optimizeSniff = optimizePackageImportsSniffTokens(optimizedPackages)
|
|
210
212
|
const optimizeTransform = createOptimizePackageImportsTransform(optimizedPackages, config.root)
|
|
211
213
|
registerServerSourcePreTransforms(optimizeTransform)
|
|
212
214
|
registerClientSourceTransforms(optimizeTransform)
|
|
@@ -363,7 +365,7 @@ export function registerBundlerExtensions(config: ResolvedConfig): void {
|
|
|
363
365
|
// a vendor pass runs over the same workspace sources.
|
|
364
366
|
const firstPartyRewrite = createFirstPartySourceRewrite(config, {
|
|
365
367
|
transform: optimizeTransform,
|
|
366
|
-
sniff:
|
|
368
|
+
sniff: optimizeSniff,
|
|
367
369
|
})
|
|
368
370
|
const priorClient = getBundlerExtensions().clientEsbuildPlugins
|
|
369
371
|
const priorServer = getBundlerExtensions().serverEsbuildPlugins
|
|
@@ -411,11 +413,7 @@ export function registerBundlerExtensions(config: ResolvedConfig): void {
|
|
|
411
413
|
...optional(clientExternalPackageResolvePlugin(cfg, externals)),
|
|
412
414
|
// First-party source outside every source root gets no
|
|
413
415
|
// registerClientSourceTransforms pass — the OPI rewrite must live here too.
|
|
414
|
-
firstPartyClientOptimizeImportsLoadPlugin(
|
|
415
|
-
cfg,
|
|
416
|
-
optimizeTransform,
|
|
417
|
-
specifierSniffTokens(optimizedPackages),
|
|
418
|
-
),
|
|
416
|
+
firstPartyClientOptimizeImportsLoadPlugin(cfg, optimizeTransform, optimizeSniff),
|
|
419
417
|
clientDynamicCommonJsImportsPlugin(cfg.root),
|
|
420
418
|
...priorClient(cfg),
|
|
421
419
|
],
|
|
@@ -297,10 +297,9 @@ function usesNextRequestImport(source: string): boolean {
|
|
|
297
297
|
// __prerender_bypass cookie opts into a fresh dynamic render at request time.
|
|
298
298
|
const draftModeOnlyImport =
|
|
299
299
|
/\bimport\s+\{\s*draftMode\s*(?:as\s+[\w$]+\s*)?,?\s*\}\s*from\s*['"]next\/headers(?:\.js)?['"]/g
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
)
|
|
300
|
+
const headers = nextHeadersImportPattern().test(withoutComments.replace(draftModeOnlyImport, ''))
|
|
301
|
+
const client = /^\s*['"]use client['"]\s*;?/.test(withoutComments)
|
|
302
|
+
return headers || (!client && importsNextNavigationRequestHook(withoutComments))
|
|
304
303
|
}
|
|
305
304
|
|
|
306
305
|
function nextHeadersImportPattern() {
|
|
@@ -171,13 +171,14 @@ function typeDefinitionGlobPatterns(distDir: string): string[] {
|
|
|
171
171
|
*/
|
|
172
172
|
export async function writeTsconfigDefaults(config: ResolvedConfig): Promise<void> {
|
|
173
173
|
const tsConfigPath = path.join(config.root, 'tsconfig.json')
|
|
174
|
+
const tempDirectory = path.dirname(config.root)
|
|
174
175
|
let isFirstTimeSetup = false
|
|
175
176
|
if (!existsSync(tsConfigPath)) {
|
|
176
177
|
if (!hasTypescriptSources(config.root)) return
|
|
177
178
|
isFirstTimeSetup = true
|
|
178
179
|
// Atomic: the client build's tsconfig-paths reader runs in a parallel phase
|
|
179
180
|
// and must never see a truncated file.
|
|
180
|
-
await writeFileAtomic(tsConfigPath, '{}\n')
|
|
181
|
+
await writeFileAtomic(tsConfigPath, '{}\n', tempDirectory)
|
|
181
182
|
}
|
|
182
183
|
|
|
183
184
|
let userTsConfig: TsconfigShape
|
|
@@ -271,7 +272,7 @@ export async function writeTsconfigDefaults(config: ResolvedConfig): Promise<voi
|
|
|
271
272
|
|
|
272
273
|
if (suggestedActions.length === 0 && requiredActions.length === 0) return
|
|
273
274
|
|
|
274
|
-
await writeFileAtomic(tsConfigPath, `${JSON.stringify(userTsConfig, null, 2)}\n
|
|
275
|
+
await writeFileAtomic(tsConfigPath, `${JSON.stringify(userTsConfig, null, 2)}\n`, tempDirectory)
|
|
275
276
|
|
|
276
277
|
if (isFirstTimeSetup) {
|
|
277
278
|
console.log(
|
package/src/config.ts
CHANGED
|
@@ -61,7 +61,7 @@ export interface ConfigSourceOverrides {
|
|
|
61
61
|
}
|
|
62
62
|
export type ConfigSource = (
|
|
63
63
|
root: string,
|
|
64
|
-
options: { dev?: boolean; serve?: boolean },
|
|
64
|
+
options: { dev?: boolean; serve?: boolean; warnings?: boolean },
|
|
65
65
|
) => Promise<ConfigSourceOverrides>
|
|
66
66
|
|
|
67
67
|
let configSource: ConfigSource | undefined
|
|
@@ -73,7 +73,7 @@ export function registerConfigSource(source: ConfigSource): void {
|
|
|
73
73
|
async function resolveConfigSource(
|
|
74
74
|
config: PNextConfig,
|
|
75
75
|
root: string,
|
|
76
|
-
options: { dev?: boolean; serve?: boolean },
|
|
76
|
+
options: { dev?: boolean; serve?: boolean; warnings?: boolean },
|
|
77
77
|
): Promise<ConfigSourceOverrides> {
|
|
78
78
|
if (!config.compat?.next) return {}
|
|
79
79
|
if (!configSource) {
|
|
@@ -87,7 +87,7 @@ async function resolveConfigSource(
|
|
|
87
87
|
// then consume what the build emitted instead of regenerating it, since request time can be read-only.
|
|
88
88
|
export async function loadConfig(
|
|
89
89
|
rootInput = process.cwd(),
|
|
90
|
-
options: { dev?: boolean; serve?: boolean } = {},
|
|
90
|
+
options: { dev?: boolean; serve?: boolean; warnings?: boolean } = {},
|
|
91
91
|
): Promise<ResolvedConfig> {
|
|
92
92
|
const root = canonicalRoot(path.resolve(rootInput))
|
|
93
93
|
await loadEnv(root, options)
|