@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.
Files changed (64) hide show
  1. package/README.md +76 -20
  2. package/package.json +3 -2
  3. package/reference/data/bench.json +513 -0
  4. package/reference/performance.md +75 -48
  5. package/src/api/router/runtime.ts +60 -21
  6. package/src/cache/context.ts +4 -1
  7. package/src/cli/build.ts +37 -5
  8. package/src/cli/dev.ts +6 -0
  9. package/src/cli/index.ts +17 -3
  10. package/src/cli/request-pipeline.ts +1340 -0
  11. package/src/cli/server-entry.ts +180 -0
  12. package/src/cli/start.ts +39 -1311
  13. package/src/client/build.ts +59 -20
  14. package/src/client/chunk-fold.ts +40 -0
  15. package/src/client/compat-surface.ts +175 -0
  16. package/src/client/entry.ts +67 -52
  17. package/src/compat/actions/action-client.ts +8 -1
  18. package/src/compat/actions/action-dispatch.ts +11 -1
  19. package/src/compat/actions/discovery.ts +23 -6
  20. package/src/compat/bundler/optimize-package-imports.ts +5 -1
  21. package/src/compat/bundler/worker.ts +2 -1
  22. package/src/compat/client/errors/bare-boundary.ts +32 -0
  23. package/src/compat/client/errors/error-boundary.ts +1 -15
  24. package/src/compat/client/errors/primitive-throw.ts +16 -0
  25. package/src/compat/client/link-status.ts +1 -1
  26. package/src/compat/css/lightningcss.ts +2 -1
  27. package/src/compat/css/modules.ts +4 -3
  28. package/src/compat/lifecycle/instrumentation-client.ts +1 -1
  29. package/src/compat/lifecycle/instrumentation.ts +5 -2
  30. package/src/compat/next/config-loader.ts +33 -5
  31. package/src/compat/next/dynamic.tsx +9 -5
  32. package/src/compat/next/link-validation-transform.ts +5 -1
  33. package/src/compat/next/link.tsx +51 -58
  34. package/src/compat/pages/client-plugin.ts +2 -1
  35. package/src/compat/react/action-state.ts +159 -0
  36. package/src/compat/react/client-lite.ts +74 -0
  37. package/src/compat/react/hooks-extra.ts +92 -0
  38. package/src/compat/react/parity.ts +128 -0
  39. package/src/compat/react/preact.ts +33 -420
  40. package/src/compat/react/server-inserted-html.ts +14 -7
  41. package/src/compat/react/use.ts +72 -0
  42. package/src/compat/register/actions.ts +27 -7
  43. package/src/compat/register/segment.ts +16 -6
  44. package/src/config.ts +15 -1
  45. package/src/css/build.ts +13 -2
  46. package/src/dev/imports.ts +34 -5
  47. package/src/dev/module-cache.ts +19 -0
  48. package/src/dev/module-transform.ts +7 -1
  49. package/src/dev/server.ts +91 -22
  50. package/src/dynamic/source.ts +36 -27
  51. package/src/ppr.ts +5 -4
  52. package/src/proxy.ts +5 -1
  53. package/src/render/island-context.ts +21 -3
  54. package/src/render/renderer.ts +102 -26
  55. package/src/resolve/engine.ts +12 -2
  56. package/src/resolve/scan-facts.ts +239 -1
  57. package/src/routing/href.ts +4 -5
  58. package/src/routing/routes.ts +26 -41
  59. package/src/runtime/server.ts +8 -4
  60. package/src/runtime/vendor.ts +1 -1
  61. package/src/typegen.ts +3 -3
  62. package/src/utils/esbuild.ts +58 -0
  63. package/src/utils/fs.ts +14 -2
  64. package/src/utils/native-require.ts +28 -0
@@ -1,12 +1,35 @@
1
1
  # Performance
2
2
 
3
- Auto-generated by `bun bench` — do not edit by hand.
4
-
5
- - Date: 2026-08-17
6
- - Machine: Darwin 25.6.0 (arm64), Apple M1 x8
7
- - Bun: 1.3.6
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: 3 (first discarded), medians reported
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) | 98.9 ms | 269.1 ms | 2.72x |
19
- | Dev first page HTML | 81.7 ms | 1053.6 ms | 12.89x |
20
- | Dev warm request (p50 of 7) | 2.9 ms | 13.0 ms | 4.54x |
21
- | Dev server RSS (ready + 7 warm) | 140.4 MB | 585.7 MB | 4.17x |
22
- | HMR save → visible | 12.4 ms | 40.2 ms | 3.24x |
23
- | Prod build (wall) | 329.6 ms | 3008.5 ms | 9.13x |
24
- | Prod build peak RSS | 130.2 MB | 497.1 MB | 3.82x |
25
- | Prod start (ready) | 122.3 ms | 137.5 ms | 1.12x |
26
- | Prod warm request (p50 of 7) | 0.6 ms | 1.0 ms | 1.71x |
27
- | Prod server RSS (ready + 7 warm) | 112.7 MB | 100.6 MB | 0.89x |
28
- | Framework install size | 6.7 MB | 285.6 MB | 42.43x |
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.73x |
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) | 97.6 ms | 265.3 ms | 2.72x |
41
- | Dev first page HTML | 105.1 ms | 1179.0 ms | 11.22x |
42
- | Dev warm request (p50 of 7) | 4.6 ms | 28.2 ms | 6.18x |
43
- | Dev server RSS (ready + 7 warm) | 164.7 MB | 639.8 MB | 3.89x |
44
- | HMR save → visible | 22.8 ms | 66.1 ms | 2.90x |
45
- | Prod build (wall) | 360.0 ms | 3192.1 ms | 8.87x |
46
- | Prod build peak RSS | 136.1 MB | 511.4 MB | 3.76x |
47
- | Prod start (ready) | 112.7 ms | 137.7 ms | 1.22x |
48
- | Prod warm request (p50 of 7) | 0.5 ms | 1.2 ms | 2.24x |
49
- | Prod server RSS (ready + 7 warm) | 114.0 MB | 100.8 MB | 0.88x |
50
- | Framework install size | 6.7 MB | 285.6 MB | 42.43x |
51
- | First-page client JS (raw) | 40.37 KB | 502.78 KB | 12.45x |
52
- | First-page client JS (gzip) | 16.13 KB | 142.03 KB | 8.80x |
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) | 105.8 ms | 304.9 ms | 2.88x |
63
- | Dev first page HTML | 170.8 ms | 1323.9 ms | 7.75x |
64
- | Dev warm request (p50 of 7) | 10.7 ms | 30.7 ms | 2.86x |
65
- | Dev server RSS (ready + 7 warm) | 186.4 MB | 665.7 MB | 3.57x |
66
- | HMR save → visible | 71.9 ms | 78.9 ms | 1.10x |
67
- | Prod build (wall) | 947.2 ms | 4246.6 ms | 4.48x |
68
- | Prod build peak RSS | 189.0 MB | 608.5 MB | 3.22x |
69
- | Prod start (ready) | 149.1 ms | 138.7 ms | 0.93x |
70
- | Prod warm request (p50 of 7) | 1.4 ms | 1.4 ms | 0.96x |
71
- | Prod server RSS (ready + 7 warm) | 114.8 MB | 105.7 MB | 0.92x |
72
- | Framework install size | 6.7 MB | 285.6 MB | 42.43x |
73
- | First-page client JS (raw) | 63.26 KB | 507.42 KB | 8.02x |
74
- | First-page client JS (gzip) | 23.63 KB | 144.85 KB | 6.13x |
75
- | First-page JS files | 5 | 9 | 1.80x |
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) | <= 150 ms | 97.6 ms | PASS |
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
- if (treeResponse.body && !treeResponse.bodyUsed) {
3257
- treePayload = parseSegmentTreePayload(await treeResponse.text());
3258
- }
3259
- // Remember this route's tree so a SIBLING URL of the same route never
3260
- // asks for it again (Next keys the route tree by route, not by URL).
3261
- const treeUrl = new URL(href, location.href);
3262
- learnRouteTree(
3263
- treeUrl.pathname,
3264
- treeUrl.search,
3265
- treePayload?.route,
3266
- treeResponse.headers.has('x-nextjs-rewritten-path') ||
3267
- treeResponse.headers.has('x-nextjs-rewritten-query'),
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) return 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) return null;
3276
- if (needsOutlinedHeadFirst(href, treePayload?.headFirst === true)) {
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) return withOutlinedHead(covered, outlinedHeadHtml);
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))) return null;
3285
- response = await fetchWithRedirectReplay(
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),
@@ -22,7 +22,10 @@ export interface CacheScope {
22
22
  controller?: AbortController;
23
23
  }
24
24
 
25
- const storage = new AsyncLocalStorage<CacheScope>();
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
- await ensureEmptyDir(config.outPath);
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
- const actionFiles = new Set(actionSources.map(source => path.resolve(config.root, source)));
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 => actionFiles.has(path.resolve(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 { start } = await import('./start');
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: positionalRoot(args),
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
- console.error(formatted.message);
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
  }