@wular/pnext 0.0.2 → 0.0.4
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/README.md +76 -20
- package/package.json +3 -2
- package/reference/data/bench.json +513 -0
- package/reference/performance.md +75 -48
- package/src/api/router/runtime.ts +60 -21
- package/src/cache/context.ts +4 -1
- package/src/cli/build.ts +37 -5
- package/src/cli/dev.ts +6 -0
- package/src/cli/index.ts +17 -3
- package/src/cli/request-pipeline.ts +1340 -0
- package/src/cli/server-entry.ts +180 -0
- package/src/cli/start.ts +39 -1311
- package/src/client/build.ts +59 -20
- package/src/client/chunk-fold.ts +40 -0
- package/src/client/compat-surface.ts +175 -0
- package/src/client/entry.ts +67 -52
- package/src/compat/actions/action-client.ts +8 -1
- package/src/compat/actions/action-dispatch.ts +11 -1
- package/src/compat/actions/discovery.ts +23 -6
- package/src/compat/bundler/optimize-package-imports.ts +5 -1
- package/src/compat/bundler/worker.ts +2 -1
- package/src/compat/client/errors/bare-boundary.ts +32 -0
- package/src/compat/client/errors/error-boundary.ts +1 -15
- package/src/compat/client/errors/primitive-throw.ts +16 -0
- package/src/compat/client/link-status.ts +1 -1
- package/src/compat/css/lightningcss.ts +2 -1
- package/src/compat/css/modules.ts +4 -3
- package/src/compat/lifecycle/instrumentation-client.ts +1 -1
- package/src/compat/lifecycle/instrumentation.ts +5 -2
- package/src/compat/next/config-loader.ts +33 -5
- package/src/compat/next/dynamic.tsx +9 -5
- package/src/compat/next/link-validation-transform.ts +5 -1
- package/src/compat/next/link.tsx +51 -58
- package/src/compat/pages/client-plugin.ts +2 -1
- package/src/compat/react/action-state.ts +159 -0
- package/src/compat/react/client-lite.ts +74 -0
- package/src/compat/react/hooks-extra.ts +92 -0
- package/src/compat/react/parity.ts +128 -0
- package/src/compat/react/preact.ts +33 -420
- package/src/compat/react/server-inserted-html.ts +14 -7
- package/src/compat/react/use.ts +72 -0
- package/src/compat/register/actions.ts +27 -7
- package/src/compat/register/segment.ts +16 -6
- package/src/config.ts +15 -1
- package/src/css/build.ts +13 -2
- package/src/dev/imports.ts +34 -5
- package/src/dev/module-cache.ts +19 -0
- package/src/dev/module-transform.ts +7 -1
- package/src/dev/server.ts +91 -22
- package/src/dynamic/source.ts +36 -27
- package/src/ppr.ts +5 -4
- package/src/proxy.ts +5 -1
- package/src/render/island-context.ts +21 -3
- package/src/render/renderer.ts +102 -26
- package/src/resolve/engine.ts +12 -2
- package/src/resolve/scan-facts.ts +239 -1
- package/src/routing/href.ts +4 -5
- package/src/routing/routes.ts +26 -41
- package/src/runtime/server.ts +8 -4
- package/src/runtime/vendor.ts +1 -1
- package/src/typegen.ts +3 -3
- package/src/utils/esbuild.ts +58 -0
- package/src/utils/fs.ts +14 -2
- package/src/utils/native-require.ts +28 -0
package/reference/performance.md
CHANGED
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
# Performance
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
Measured against Next.js: the same fixture source runs under both frameworks and `bun bench` measures them side by side, on a Blacksmith 4-vCPU CI runner (full machine details below). The ranges below span the fixtures — hello-world, an SSR site, and a mid-size admin dashboard (30 routes, 18 client islands):
|
|
4
|
+
|
|
5
|
+
| | pnext vs Next.js |
|
|
6
|
+
| --------------------------- | ---------------------------------: |
|
|
7
|
+
| Dev server ready | **3.8–4.3× faster** |
|
|
8
|
+
| First page, cold | **10–12× faster** |
|
|
9
|
+
| Warm request (dev) | **1.6–4.7× faster** |
|
|
10
|
+
| HMR save → visible | **0.3–2×** ¹ |
|
|
11
|
+
| Production build | **7.2–8.8× faster** |
|
|
12
|
+
| Warm request (prod) | **2–3.4× faster** |
|
|
13
|
+
| Prod server ready | **1.8–2.3× faster** |
|
|
14
|
+
| Dev server memory | **3.4–4.1× less** |
|
|
15
|
+
| Build peak memory | **3–3.7× less** |
|
|
16
|
+
| Framework install size | **59× smaller** (7.1 MB vs 421 MB) |
|
|
17
|
+
| First-page client JS (gzip) | **9.5–65× less** |
|
|
18
|
+
|
|
19
|
+
¹ pnext live-reloads (no client HMR runtime; state doesn't survive a save) — the metric
|
|
20
|
+
is save → fresh HTML, which both models support. Scoped invalidation fixed the small
|
|
21
|
+
fixture (21.9 ms, ahead of Next); the larger fixtures still trail on this runner
|
|
22
|
+
(264/300 ms vs 68/98 ms) and the remaining cost is under investigation.
|
|
23
|
+
|
|
24
|
+
Nothing is prebundled — the dev server compiles what a request needs and caches it content-addressed, which is why readiness doesn't scale with app size. The production server idles at parity with Next (~85–87 MB) while answering warm requests 2–3.4× faster.
|
|
25
|
+
|
|
26
|
+
The tables below are one full run's absolute numbers, pasted from `bun bench` output (the CI run's `bench-output` artifact carries the same data as `bench.json`).
|
|
27
|
+
|
|
28
|
+
- Date: 2026-08-20 (actions run 32364720143)
|
|
29
|
+
- Machine: CI — Blacksmith 4 vCPU (Intel Xeon), 16 GB RAM, Ubuntu 22.04 x64
|
|
30
|
+
- Bun: 1.3.10
|
|
8
31
|
- Next.js: 16.2.12
|
|
9
|
-
- Runs per metric:
|
|
32
|
+
- Runs per metric: 5 (first discarded), medians reported
|
|
10
33
|
|
|
11
34
|
Every fixture under `bench/fixtures/` runs unmodified on both frameworks, so the
|
|
12
35
|
two columns render the same app from the same source.
|
|
@@ -15,19 +38,19 @@ two columns render the same app from the same source.
|
|
|
15
38
|
|
|
16
39
|
| Metric | pnext | Next.js | Ratio |
|
|
17
40
|
| --- | --- | --- | --- |
|
|
18
|
-
| Dev cold start (ready) |
|
|
19
|
-
| Dev first page HTML |
|
|
20
|
-
| Dev warm request (p50 of 7) |
|
|
21
|
-
| Dev server RSS (ready + 7 warm) |
|
|
22
|
-
| HMR save → visible |
|
|
23
|
-
| Prod build (wall) |
|
|
24
|
-
| Prod build peak RSS |
|
|
25
|
-
| Prod start (ready) |
|
|
26
|
-
| Prod warm request (p50 of 7) | 0.6 ms | 1.
|
|
27
|
-
| Prod server RSS (ready + 7 warm) |
|
|
28
|
-
| Framework install size |
|
|
41
|
+
| Dev cold start (ready) | 56.4 ms | 212.3 ms | 3.76x |
|
|
42
|
+
| Dev first page HTML | 79.9 ms | 993.5 ms | 12.44x |
|
|
43
|
+
| Dev warm request (p50 of 7) | 3.0 ms | 14.0 ms | 4.69x |
|
|
44
|
+
| Dev server RSS (ready + 7 warm) | 130.8 MB | 536.4 MB | 4.10x |
|
|
45
|
+
| HMR save → visible | 21.9 ms | 44.8 ms | 2.04x |
|
|
46
|
+
| Prod build (wall) | 291.3 ms | 2569.4 ms | 8.82x |
|
|
47
|
+
| Prod build peak RSS | 124.2 MB | 455.8 MB | 3.67x |
|
|
48
|
+
| Prod start (ready) | 60.7 ms | 110.9 ms | 1.83x |
|
|
49
|
+
| Prod warm request (p50 of 7) | 0.6 ms | 1.2 ms | 2.05x |
|
|
50
|
+
| Prod server RSS (ready + 7 warm) | 85.2 MB | 85.7 MB | 1.01x |
|
|
51
|
+
| Framework install size | 7.1 MB | 420.7 MB | 59.14x |
|
|
29
52
|
| First-page client JS (raw) | 4.43 KB | 502.47 KB | 113.36x |
|
|
30
|
-
| First-page client JS (gzip) | 2.19 KB | 141.78 KB | 64.
|
|
53
|
+
| First-page client JS (gzip) | 2.19 KB | 141.78 KB | 64.67x |
|
|
31
54
|
| First-page JS files | 3 | 5 | 1.67x |
|
|
32
55
|
| Zero-island route client JS | 4.43 KB | 502.47 KB | 113.36x |
|
|
33
56
|
|
|
@@ -37,19 +60,19 @@ Ratio is Next.js / pnext, so above `1.00x` means pnext is ahead.
|
|
|
37
60
|
|
|
38
61
|
| Metric | pnext | Next.js | Ratio |
|
|
39
62
|
| --- | --- | --- | --- |
|
|
40
|
-
| Dev cold start (ready) |
|
|
41
|
-
| Dev first page HTML |
|
|
42
|
-
| Dev warm request (p50 of 7) |
|
|
43
|
-
| Dev server RSS (ready + 7 warm) |
|
|
44
|
-
| HMR save → visible |
|
|
45
|
-
| Prod build (wall) |
|
|
46
|
-
| Prod build peak RSS | 136.
|
|
47
|
-
| Prod start (ready) |
|
|
48
|
-
| Prod warm request (p50 of 7) | 0.5 ms | 1.
|
|
49
|
-
| Prod server RSS (ready + 7 warm) |
|
|
50
|
-
| Framework install size |
|
|
51
|
-
| First-page client JS (raw) |
|
|
52
|
-
| First-page client JS (gzip) |
|
|
63
|
+
| Dev cold start (ready) | 56.0 ms | 212.8 ms | 3.80x |
|
|
64
|
+
| Dev first page HTML | 93.5 ms | 1043.3 ms | 11.16x |
|
|
65
|
+
| Dev warm request (p50 of 7) | 16.5 ms | 26.4 ms | 1.60x |
|
|
66
|
+
| Dev server RSS (ready + 7 warm) | 151.3 MB | 553.6 MB | 3.66x |
|
|
67
|
+
| HMR save → visible | 264.3 ms | 68.3 ms | 0.26x |
|
|
68
|
+
| Prod build (wall) | 310.5 ms | 2738.8 ms | 8.82x |
|
|
69
|
+
| Prod build peak RSS | 136.8 MB | 456.6 MB | 3.34x |
|
|
70
|
+
| Prod start (ready) | 57.1 ms | 105.5 ms | 1.85x |
|
|
71
|
+
| Prod warm request (p50 of 7) | 0.5 ms | 1.3 ms | 2.50x |
|
|
72
|
+
| Prod server RSS (ready + 7 warm) | 86.0 MB | 85.9 MB | 1.00x |
|
|
73
|
+
| Framework install size | 7.1 MB | 420.7 MB | 59.14x |
|
|
74
|
+
| First-page client JS (raw) | 27.36 KB | 502.78 KB | 18.37x |
|
|
75
|
+
| First-page client JS (gzip) | 11.39 KB | 142.03 KB | 12.47x |
|
|
53
76
|
| First-page JS files | 4 | 6 | 1.50x |
|
|
54
77
|
| Zero-island route client JS | 4.46 KB | 502.47 KB | 112.56x |
|
|
55
78
|
|
|
@@ -59,20 +82,20 @@ Ratio is Next.js / pnext, so above `1.00x` means pnext is ahead.
|
|
|
59
82
|
|
|
60
83
|
| Metric | pnext | Next.js | Ratio |
|
|
61
84
|
| --- | --- | --- | --- |
|
|
62
|
-
| Dev cold start (ready) |
|
|
63
|
-
| Dev first page HTML |
|
|
64
|
-
| Dev warm request (p50 of 7) |
|
|
65
|
-
| Dev server RSS (ready + 7 warm) |
|
|
66
|
-
| HMR save → visible |
|
|
67
|
-
| Prod build (wall) |
|
|
68
|
-
| Prod build peak RSS |
|
|
69
|
-
| Prod start (ready) |
|
|
70
|
-
| Prod warm request (p50 of 7) |
|
|
71
|
-
| Prod server RSS (ready + 7 warm) |
|
|
72
|
-
| Framework install size |
|
|
73
|
-
| First-page client JS (raw) |
|
|
74
|
-
| First-page client JS (gzip) |
|
|
75
|
-
| First-page JS files |
|
|
85
|
+
| Dev cold start (ready) | 57.1 ms | 246.4 ms | 4.31x |
|
|
86
|
+
| Dev first page HTML | 127.0 ms | 1285.8 ms | 10.12x |
|
|
87
|
+
| Dev warm request (p50 of 7) | 21.6 ms | 34.1 ms | 1.58x |
|
|
88
|
+
| Dev server RSS (ready + 7 warm) | 168.6 MB | 577.1 MB | 3.42x |
|
|
89
|
+
| HMR save → visible | 300.4 ms | 97.6 ms | 0.32x |
|
|
90
|
+
| Prod build (wall) | 637.5 ms | 4566.3 ms | 7.16x |
|
|
91
|
+
| Prod build peak RSS | 188.0 MB | 569.0 MB | 3.03x |
|
|
92
|
+
| Prod start (ready) | 55.6 ms | 128.8 ms | 2.32x |
|
|
93
|
+
| Prod warm request (p50 of 7) | 0.5 ms | 1.7 ms | 3.41x |
|
|
94
|
+
| Prod server RSS (ready + 7 warm) | 87.3 MB | 90.6 MB | 1.04x |
|
|
95
|
+
| Framework install size | 7.1 MB | 420.7 MB | 59.14x |
|
|
96
|
+
| First-page client JS (raw) | 38.51 KB | 507.42 KB | 13.18x |
|
|
97
|
+
| First-page client JS (gzip) | 15.26 KB | 144.85 KB | 9.50x |
|
|
98
|
+
| First-page JS files | 6 | 9 | 1.50x |
|
|
76
99
|
|
|
77
100
|
Ratio is Next.js / pnext, so above `1.00x` means pnext is ahead.
|
|
78
101
|
|
|
@@ -87,7 +110,7 @@ Ratio is Next.js / pnext, so above `1.00x` means pnext is ahead.
|
|
|
87
110
|
|
|
88
111
|
| Target | Limit | Measured | Status |
|
|
89
112
|
| --- | --- | --- | --- |
|
|
90
|
-
| Dev cold start, ssr fixture (pnext) | <=
|
|
113
|
+
| Dev cold start, ssr fixture (pnext) | <= 100 ms | 56.0 ms | PASS |
|
|
91
114
|
| Router runtime | <= 1.00 KB gzip | 348 B gzip | PASS |
|
|
92
115
|
| Hydrated-route framework tax | <= 5.00 KB gzip | 4.47 KB gzip | PASS |
|
|
93
116
|
| Zero-island route client JS (ssr `/about`) | 0 B (core pnext) | 4.46 KB (compat.next) | not exercised |
|
|
@@ -101,7 +124,9 @@ Ratio is Next.js / pnext, so above `1.00x` means pnext is ahead.
|
|
|
101
124
|
stops at the readiness banner; first page HTML is the GET that follows it, so it
|
|
102
125
|
includes on-demand compilation.
|
|
103
126
|
- HMR appends a marker to a rendered string in a page component and polls until the
|
|
104
|
-
page serves it back. A 200 with stale HTML does not count.
|
|
127
|
+
page serves it back. A 200 with stale HTML does not count. pnext applies the save via
|
|
128
|
+
live-reload (full document refresh, no client HMR runtime); Next applies it via Fast
|
|
129
|
+
Refresh. The metric is model-agnostic: time until fresh content is served.
|
|
105
130
|
- Fixtures enable `compat.next` so one source tree runs on both frameworks. That ships
|
|
106
131
|
pnext's Next-compat navigation client, which a core pnext app does not carry — the
|
|
107
132
|
0 B zero-island budget is a core-pnext invariant this suite does not exercise.
|
|
@@ -113,7 +138,9 @@ Ratio is Next.js / pnext, so above `1.00x` means pnext is ahead.
|
|
|
113
138
|
It is never sampled at an arbitrary time, since RSS is pressure-sensitive.
|
|
114
139
|
- Build peak RSS comes from `/usr/bin/time` wrapping the build process directly (`-l` on
|
|
115
140
|
macOS, `-v` on Linux), not the tree-sum helper — it is the OS-reported peak over the
|
|
116
|
-
whole build, not a single snapshot.
|
|
141
|
+
whole build, not a single snapshot. The framework server-entry prebundle is emitted by
|
|
142
|
+
a short-lived child whose transient RSS (~15–25 MB, returned at exit) this number
|
|
143
|
+
excludes.
|
|
117
144
|
- Framework install size is each framework's own package cost, not the fixture's total
|
|
118
145
|
`node_modules`, which both frameworks share: `next` + its platform `@next/swc-*` binary,
|
|
119
146
|
or `@wular/pnext`'s npm-publish footprint (its `package.json` "files" list, since this
|
|
@@ -437,11 +437,18 @@ async function warmPageAssets(html: string) {
|
|
|
437
437
|
for (const link of doc.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"][href]')) {
|
|
438
438
|
preloadStylesheet(link.getAttribute('href') ?? '');
|
|
439
439
|
}
|
|
440
|
+
warmEntryChunks(doc);
|
|
441
|
+
const entrySrc = entryScriptSrc(doc);
|
|
442
|
+
if (entrySrc) await importEntry(entrySrc);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// The destination entry's static chunks, kicked off before the entry itself is imported: without
|
|
446
|
+
// this the browser only discovers them after the entry parses, so every chunk the split put behind
|
|
447
|
+
// the entry costs another serial round trip on the navigation's critical path.
|
|
448
|
+
function warmEntryChunks(doc: Document) {
|
|
440
449
|
for (const link of doc.querySelectorAll<HTMLLinkElement>('link[rel="modulepreload"][href]')) {
|
|
441
450
|
preloadModule(link.getAttribute('href') ?? '');
|
|
442
451
|
}
|
|
443
|
-
const entrySrc = entryScriptSrc(doc);
|
|
444
|
-
if (entrySrc) await importEntry(entrySrc);
|
|
445
452
|
}
|
|
446
453
|
|
|
447
454
|
function preloadStylesheet(href: string) {
|
|
@@ -3253,40 +3260,66 @@ async function fetchPage(
|
|
|
3253
3260
|
}
|
|
3254
3261
|
if (segmentPayload) {
|
|
3255
3262
|
segmentCacheKey = segmentDocumentCacheKey(treeResponse, href, options.prefetch!);
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
//
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3263
|
+
// The tree PAYLOAD is only bookkeeping (route learning, headFirst): read it
|
|
3264
|
+
// without blocking the segment phase, so the body request goes on the wire in
|
|
3265
|
+
// the same task the tree HEADERS resolve in — before the tree body's own
|
|
3266
|
+
// stream-close task, where a harness batch (router-act) may already drain.
|
|
3267
|
+
const treeText =
|
|
3268
|
+
treeResponse.body && !treeResponse.bodyUsed ? treeResponse.text() : null;
|
|
3269
|
+
const finishTree = async () => {
|
|
3270
|
+
if (treeText) treePayload = parseSegmentTreePayload(await treeText);
|
|
3271
|
+
// Remember this route's tree so a SIBLING URL of the same route never
|
|
3272
|
+
// asks for it again (Next keys the route tree by route, not by URL).
|
|
3273
|
+
const treeUrl = new URL(href, location.href);
|
|
3274
|
+
learnRouteTree(
|
|
3275
|
+
treeUrl.pathname,
|
|
3276
|
+
treeUrl.search,
|
|
3277
|
+
treePayload?.route,
|
|
3278
|
+
treeResponse.headers.has('x-nextjs-rewritten-path') ||
|
|
3279
|
+
treeResponse.headers.has('x-nextjs-rewritten-query'),
|
|
3280
|
+
);
|
|
3281
|
+
};
|
|
3269
3282
|
releaseSlot();
|
|
3270
3283
|
const cached = getSegmentDocument(segmentCacheKey, href, options.prefetch!);
|
|
3271
|
-
if (cached)
|
|
3284
|
+
if (cached) {
|
|
3285
|
+
await finishTree();
|
|
3286
|
+
return cached;
|
|
3287
|
+
}
|
|
3272
3288
|
// The link left the viewport while the tree was in flight: stop before
|
|
3273
3289
|
// the segment phase (the in-flight tree request is never aborted, but no
|
|
3274
3290
|
// follow-up request may be issued — Next's cancellation contract).
|
|
3275
|
-
if (task?.cancelled)
|
|
3276
|
-
|
|
3291
|
+
if (task?.cancelled) {
|
|
3292
|
+
await finishTree();
|
|
3293
|
+
return null;
|
|
3294
|
+
}
|
|
3295
|
+
// Head-before-body order is preserved: the tree response HEADERS announce
|
|
3296
|
+
// outlining (x-pnext-head-outlined), so no tree-body read is needed here.
|
|
3297
|
+
const treeHeadOutlined = treeResponse.headers.get('x-pnext-head-outlined');
|
|
3298
|
+
if (needsOutlinedHeadFirst(href, treeHeadOutlined === 'first')) {
|
|
3299
|
+
await finishTree();
|
|
3277
3300
|
outlinedHeadHtml = await fetchOutlinedHead(href, init, headers, rscVariant);
|
|
3278
3301
|
}
|
|
3279
3302
|
const covered = segmentBodyCovered(href, options.prefetch);
|
|
3280
|
-
if (covered)
|
|
3303
|
+
if (covered) {
|
|
3304
|
+
await finishTree();
|
|
3305
|
+
return withOutlinedHead(covered, outlinedHeadHtml);
|
|
3306
|
+
}
|
|
3281
3307
|
bodySegment = prefetchBodySegmentPath(href);
|
|
3282
3308
|
headers[SEGMENT_PREFETCH_HEADER] = bodySegment;
|
|
3283
3309
|
requestHref = withRscQuery(href, `${rscVariant}:${bodySegment}`);
|
|
3284
|
-
if (!(await acquireSlot(PREFETCH_PHASE_SEGMENT)))
|
|
3285
|
-
|
|
3310
|
+
if (!(await acquireSlot(PREFETCH_PHASE_SEGMENT))) {
|
|
3311
|
+
await finishTree();
|
|
3312
|
+
return null;
|
|
3313
|
+
}
|
|
3314
|
+
const bodyPromise = fetchWithRedirectReplay(
|
|
3286
3315
|
requestHref,
|
|
3287
3316
|
init,
|
|
3288
3317
|
`${rscVariant}:${bodySegment}`,
|
|
3289
3318
|
);
|
|
3319
|
+
// A rejection before the await below must not surface as unhandled.
|
|
3320
|
+
bodyPromise.catch(() => undefined);
|
|
3321
|
+
await finishTree();
|
|
3322
|
+
response = await bodyPromise;
|
|
3290
3323
|
} else {
|
|
3291
3324
|
response = treeResponse;
|
|
3292
3325
|
}
|
|
@@ -4029,6 +4062,11 @@ async function pageForNavigation(
|
|
|
4029
4062
|
// prerendered document for the SAME pathname is already cached: a static prerender never
|
|
4030
4063
|
// rendered search params server-side, so only the client-visible URL differs.
|
|
4031
4064
|
if (!cached.settled) {
|
|
4065
|
+
// Same principle for a networkFree per-segment hit: it commits with zero
|
|
4066
|
+
// network, so waiting out the in-flight prefetch would be pure latency.
|
|
4067
|
+
if (segmentHit?.networkFree && !slotStateSensitive(departureNavState, segmentHit.html)) {
|
|
4068
|
+
return { html: segmentHit.html, finalUrl: url.href, ok: true };
|
|
4069
|
+
}
|
|
4032
4070
|
const shared = await samePathnamePrerenderedPage(url, now, departureNavState);
|
|
4033
4071
|
if (shared) return shared;
|
|
4034
4072
|
}
|
|
@@ -4676,6 +4714,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
|
|
|
4676
4714
|
// Warm the new document's assets before touching the current one: the swap
|
|
4677
4715
|
// then paints styled content immediately instead of flashing unstyled HTML.
|
|
4678
4716
|
const entrySrc = entryScriptSrc(doc);
|
|
4717
|
+
warmEntryChunks(doc);
|
|
4679
4718
|
const [entryModule] = await Promise.all([
|
|
4680
4719
|
entrySrc ? importEntry(entrySrc) : Promise.resolve(null),
|
|
4681
4720
|
addStylesheets(doc),
|
package/src/cache/context.ts
CHANGED
|
@@ -22,7 +22,10 @@ export interface CacheScope {
|
|
|
22
22
|
controller?: AbortController;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// globalThis-anchored: the prebundled server entry inlines its own copy of this module.
|
|
26
|
+
const CACHE_SCOPE_STORAGE = Symbol.for('pnext.cacheScopeStorage');
|
|
27
|
+
const storage = ((globalThis as Record<PropertyKey, unknown>)[CACHE_SCOPE_STORAGE] ??=
|
|
28
|
+
new AsyncLocalStorage<CacheScope>()) as AsyncLocalStorage<CacheScope>;
|
|
26
29
|
|
|
27
30
|
export function currentCacheScope() {
|
|
28
31
|
return storage.getStore();
|
package/src/cli/build.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { copyFile, mkdir, rename, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { writeVercelOutput } from './adapters/vercel';
|
|
7
7
|
import { startWarmChild } from './adapters/vercel-warm';
|
|
8
|
-
import { loadConfig, pathToFileHref } from '../config';
|
|
8
|
+
import { devOutSegment, loadConfig, pathToFileHref } from '../config';
|
|
9
9
|
import { bootstrapCompat } from '../compat-bootstrap';
|
|
10
10
|
import {
|
|
11
11
|
buildParallelPhaseError,
|
|
@@ -100,6 +100,9 @@ interface BuildStepState {
|
|
|
100
100
|
/** Root-relative 'use server' module paths — known from discovery, so the
|
|
101
101
|
* client stage keys off this instead of waiting for their compile. */
|
|
102
102
|
actionSources?: string[];
|
|
103
|
+
/** First-party files that import a node_modules action module (absolute paths) — see
|
|
104
|
+
* ActionDiscovery.actionImporters; route.sourceFiles never reaches into node_modules itself. */
|
|
105
|
+
actionImporters?: string[];
|
|
103
106
|
/** Work a step handed back rather than finishing inline; awaited before the
|
|
104
107
|
* build manifest is written, so it lands under the client stage. */
|
|
105
108
|
deferred?: Promise<void>;
|
|
@@ -192,9 +195,20 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
|
|
|
192
195
|
// adapter step.
|
|
193
196
|
const warm = options.adapter === 'vercel' ? startWarmChild(config) : undefined;
|
|
194
197
|
await log.step('prepare output directory', async () => {
|
|
195
|
-
|
|
198
|
+
// Build-owned outputs only: `<outRoot>/dev` belongs to a possibly-running
|
|
199
|
+
// dev server and must survive.
|
|
200
|
+
await ensureEmptyDir(config.outPath, [devOutSegment]);
|
|
196
201
|
await copyPublicDir(config.publicPath, path.join(config.outPath, 'public'));
|
|
197
202
|
});
|
|
203
|
+
// Prebundled server entry for `pnext start`: framework-only, independent of the
|
|
204
|
+
// app build, so it runs in a child process for the whole build — its bundling
|
|
205
|
+
// heap never stacks on the build's peak RSS and its wall hides under the build.
|
|
206
|
+
// Best-effort — a failure only costs start time. Awaited before the summary.
|
|
207
|
+
const serverEntryDone = import('./server-entry')
|
|
208
|
+
.then(entry => entry.emitServerEntryChild(config.outPath))
|
|
209
|
+
.catch((error: Error) => {
|
|
210
|
+
console.warn(`pnext build: server entry bundling skipped — ${error.message}`);
|
|
211
|
+
});
|
|
198
212
|
// The document-level stylesheets run their postcss/Tailwind pass on the CSS
|
|
199
213
|
// worker, so they overlap with the route scan below instead of serializing
|
|
200
214
|
// ahead of it. Awaited before prepareRouteCssChunks — route CSS still builds
|
|
@@ -321,11 +335,19 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
|
|
|
321
335
|
);
|
|
322
336
|
|
|
323
337
|
const actionSources = buildState.actionSources ?? buildState.actions.map(a => a.sourceKey);
|
|
324
|
-
|
|
338
|
+
// realpath, not resolve: a hybrid app's routes are scanned through the pages-compat mirror, whose
|
|
339
|
+
// `source-app` is a symlink to the real app dir - the two spellings of one file must compare equal.
|
|
340
|
+
const actionFiles = new Set(actionSources.map(source => realFilePath(path.resolve(config.root, source))));
|
|
341
|
+
// A node_modules action module never lands in route.sourceFiles itself (that walk stops at the
|
|
342
|
+
// package boundary), so a route reaching one only through a first-party importer is matched here.
|
|
343
|
+
const actionImporters = new Set((buildState.actionImporters ?? []).map(realFilePath));
|
|
325
344
|
for (const route of routes) {
|
|
326
345
|
if (
|
|
327
346
|
route.kind === 'page' &&
|
|
328
|
-
route.sourceFiles.some(file =>
|
|
347
|
+
route.sourceFiles.some(file => {
|
|
348
|
+
const real = realFilePath(file);
|
|
349
|
+
return actionFiles.has(real) || actionImporters.has(real);
|
|
350
|
+
})
|
|
329
351
|
) {
|
|
330
352
|
addClientEntryReason(route, 'actions');
|
|
331
353
|
}
|
|
@@ -873,6 +895,7 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
|
|
|
873
895
|
};
|
|
874
896
|
await writeBuildManifest(config.outPath, manifest);
|
|
875
897
|
log.log(`wrote manifest.json (${routes.length} route${routes.length === 1 ? '' : 's'})`);
|
|
898
|
+
await log.step('server entry', () => serverEntryDone);
|
|
876
899
|
for (const hook of getBuildExtensions().completeHooks) {
|
|
877
900
|
await hook({ config, manifest, log });
|
|
878
901
|
}
|
|
@@ -3055,6 +3078,15 @@ function isAfterPrerenderError(error: unknown): boolean {
|
|
|
3055
3078
|
);
|
|
3056
3079
|
}
|
|
3057
3080
|
|
|
3081
|
+
/** Symlink-resolved path, for comparing two spellings of one file. Missing files keep their path. */
|
|
3082
|
+
function realFilePath(file: string): string {
|
|
3083
|
+
try {
|
|
3084
|
+
return realpathSync.native(file);
|
|
3085
|
+
} catch {
|
|
3086
|
+
return path.resolve(file);
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3058
3090
|
function safePublicPath(outPath: string, ...segments: string[]) {
|
|
3059
3091
|
const publicPath = path.join(outPath, 'public');
|
|
3060
3092
|
const file = path.join(publicPath, ...segments);
|
package/src/cli/dev.ts
CHANGED
|
@@ -57,6 +57,12 @@ export async function dev(options: DevOptions = {}) {
|
|
|
57
57
|
const url = `http://${browserHost(hostname)}:${server.port ?? port}`;
|
|
58
58
|
printServerReady({ mode: 'dev', hostname, port: server.port ?? port, elapsedMs });
|
|
59
59
|
printBootTrace();
|
|
60
|
+
// Boot compiles nothing (config loads natively), so the first compile would pay esbuild's service
|
|
61
|
+
// spawn. Warm it here, after the banner - readiness must not wait on it. PNEXT_DEV_ESBUILD_WARM=0 opts out.
|
|
62
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
63
|
+
if (process.env.PNEXT_DEV_ESBUILD_WARM !== '0') {
|
|
64
|
+
void import('../utils/esbuild').then(module => module.warmEsbuildService());
|
|
65
|
+
}
|
|
60
66
|
registerShutdown(server);
|
|
61
67
|
watchServerMemory(server);
|
|
62
68
|
watchStaleModules(server);
|
package/src/cli/index.ts
CHANGED
|
@@ -33,15 +33,26 @@ try {
|
|
|
33
33
|
buildMode: buildModeOption(args),
|
|
34
34
|
debugPrerender: args.includes('--debug-prerender'),
|
|
35
35
|
});
|
|
36
|
+
// A live CSS worker thread races Bun's exit teardown (Linux: silent exit 1
|
|
37
|
+
// after a successful build); stop it before force-exiting.
|
|
38
|
+
await (await import('../css/build')).stopCssWorker();
|
|
36
39
|
// Nothing here keeps the event loop alive on success, but a project's
|
|
37
40
|
// next.config can (e.g. a stray setInterval) and Next's own `next build`
|
|
38
41
|
// force-exits regardless of such handles. Do the same so `build` always
|
|
39
42
|
// terminates once buildProject resolves.
|
|
40
43
|
process.exit(0);
|
|
41
44
|
} else if (command === 'start') {
|
|
42
|
-
const
|
|
45
|
+
const root = positionalRoot(args);
|
|
46
|
+
// The build emits src/cli/start.ts prebundled into one file; parsing that
|
|
47
|
+
// instead of walking the framework's source graph is most of `start`'s
|
|
48
|
+
// spawn→first-200. Absent (no build yet, custom outDir) → source path.
|
|
49
|
+
const { prebuiltServerEntry } = await import('./server-entry');
|
|
50
|
+
const prebuilt = prebuiltServerEntry(root);
|
|
51
|
+
const { start } = prebuilt
|
|
52
|
+
? ((await import(prebuilt)) as typeof import('./start'))
|
|
53
|
+
: await import('./start');
|
|
43
54
|
await start({
|
|
44
|
-
root
|
|
55
|
+
root,
|
|
45
56
|
port: optionNumber(args, '--port'),
|
|
46
57
|
hostname: optionString(args, '--hostname'),
|
|
47
58
|
});
|
|
@@ -81,7 +92,10 @@ try {
|
|
|
81
92
|
}
|
|
82
93
|
} catch (error) {
|
|
83
94
|
const formatted = formatCliError(error);
|
|
84
|
-
|
|
95
|
+
// A blank formatted message exits 1 with no explanation; fall back to the
|
|
96
|
+
// raw error so the failure is never silent.
|
|
97
|
+
if (formatted.message.trim()) console.error(formatted.message);
|
|
98
|
+
else console.error(error instanceof Error ? (error.stack ?? `${error.name}: ${error.message}`) : String(error));
|
|
85
99
|
if (formatted.trace) console.log(`\n${formatted.trace}`);
|
|
86
100
|
process.exit(1);
|
|
87
101
|
}
|