@thomas.f/prnext 0.1.1-alpha → 0.1.12-alpha

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 CHANGED
@@ -2,17 +2,50 @@
2
2
 
3
3
  # PRNext
4
4
 
5
- [Website](https://prnext.dev) · [GitHub](https://github.com/Lorkydey/PRNext) · [Report an issue](https://github.com/Lorkydey/PRNext/issues)
5
+ [Website](https://prnext.dev) · [npm](https://www.npmjs.com/package/@thomas.f/prnext) · [GitHub](https://github.com/Lorkydey/PRNext) · [X / @PRNextdev](https://x.com/PRNextdev) · [Report an issue](https://github.com/Lorkydey/PRNext/issues)
6
6
 
7
7
  A Rust-powered runtime for Next.js projects, with React and npm modules running on Node.js.
8
8
 
9
- > **Very early alpha — 0.1.1-alpha**
10
- >
11
- > Built for experimentation and feedback, not production applications. Next.js compatibility is partial. Expect bugs, missing features and breaking changes.
9
+ > **Very early alpha.** Built for experimentation and feedback. Next.js compatibility is partial; expect bugs, missing features and breaking changes.
12
10
 
13
- ## Install and migrate
11
+ ## Latest update
14
12
 
15
- Node.js 22+ is required. This alpha uses matching React, React DOM and React Server Components versions:
13
+ **0.1.12-alpha — stability and bounded resource usage.** Client cancellations retain healthy Node hosts; worker retirement protects tracked finalization; filesystem and proxy overload return explicit, noncacheable 503 responses. Clean installs and runtime/native/build consistency checks are part of the release path. Rebuild dynamic applications with `prnext build` after upgrading, then restart them.
14
+
15
+ The experimental eight-app `balanced`/1 GiB Windows commit configuration with `NODE_OPTIONS=--max-semi-space-size=1` still has a reproduced allocation-failure regression relative to V5. It is not a supported density target and is not fixed. Defaults remain `start=balanced` and `host=memory`. Compatibility remains partial, including the compiler and TypeScript limits below.
16
+
17
+ <details>
18
+ <summary><strong>0.1.1-alpha · October 8, 2026 · Features & fixes</strong></summary>
19
+
20
+ **README update:** `0.1.11-alpha` updates the README only; runtime features and fixes remain those of `0.1.1-alpha`.
21
+
22
+ ### Added
23
+
24
+ - **Windows x64 & ARM64:** native packages alongside macOS and Linux glibc support.
25
+ - **Persistent apps:** background processes, crash recovery, rotating logs, health-checked replacements and optional restoration at login.
26
+ - **Multi-app hosting:** hostname routing, on-demand startup, idle sleep and sampled memory budgets.
27
+ - **Migration checks:** source audits and optional GET response comparisons with `migrate --check`.
28
+ - **Inspector:** opt-in route, cache and request timing diagnostics.
29
+
30
+ ### Fixed
31
+
32
+ - Standalone dependency tracing for Windows drives, scoped packages and workspace aliases.
33
+ - Development process shutdown and file replacement on Windows.
34
+ - Cold-worker connection bursts on macOS.
35
+ - Windows package publishing and portable `prnext` migration scripts.
36
+
37
+ </details>
38
+
39
+ [Full changelog](https://github.com/Lorkydey/PRNext/blob/main/CHANGELOG.md)
40
+
41
+ ## Install & upgrade
42
+
43
+ Node.js **22+** · Windows, macOS and Linux glibc · x64 and ARM64
44
+
45
+ <details>
46
+ <summary><strong>Install · migrate an existing Next.js app</strong></summary>
47
+
48
+ Run these commands from your application directory. The current alpha requires matching React, React DOM and React Server Components versions:
16
49
 
17
50
  ```sh
18
51
  npm install @thomas.f/prnext@alpha react@19.3.0 react-dom@19.3.0 react-server-dom-webpack@19.3.0
@@ -22,25 +55,208 @@ npx --no-install prnext check
22
55
  npx --no-install prnext dev
23
56
  ```
24
57
 
25
- The package provides both `prnext` and its shortcut `prn`. On Windows 10 and Windows Server 2022, use `prnext`: [Windows reserves the name `PRN`](https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file). Migration generates portable `prnext` scripts on every platform, backs up your package files and keeps Next.js and your application source. The preflight check does not guarantee full compatibility with your application.
58
+ Review the dry run before applying migration. Migration backs up your package files and keeps Next.js and your application source. Compatibility checks help find issues; they do not guarantee that every Next.js feature works.
59
+
60
+ Install only `@thomas.f/prnext`. Keep optional dependencies enabled: npm installs the matching native component automatically. Rust is not required to use a published release.
61
+
62
+ `reactCompiler: false` and `typedRoutes: false` are supported. Enabling either option fails with an explicit diagnostic: PRNext does not yet run React Compiler or generate and validate typed route declarations. Keep builds that require these features on Next.js.
26
63
 
27
- ## Build and run
64
+ Set `typescript: {}` to check an existing `tsconfig.json` during production builds using the application's installed `typescript` package. `typescript.tsconfigPath` selects a custom configuration for type checking and compilation, including its inherited path aliases. `typescript.ignoreBuildErrors: true` explicitly skips the production check; development builds also skip it. Checks run in a temporary child process without emitting files or editing the configuration. Unlike Next.js, omitting `typescript` preserves PRNext's existing transpile-only behavior. PRNext does not install TypeScript, create `next-env.d.ts`, generate Next.js route types, or implement the Next.js TypeScript language-service plugin; retain the project's type declarations and run separate editor checks as needed. See the [Next.js option definitions](https://nextjs.org/docs/app/api-reference/config/next-config-js/typescript).
65
+
66
+ </details>
67
+
68
+ <details>
69
+ <summary><strong>Upgrade · update an existing PRNext project</strong></summary>
70
+
71
+ Stop your development or production process before changing dependencies. If you use `pstart`, follow the [persistent app upgrade guide](https://github.com/Lorkydey/PRNext#upgrade-persistent-apps) first.
28
72
 
29
73
  ```sh
74
+ npm install @thomas.f/prnext@alpha react@19.3.0 react-dom@19.3.0 react-server-dom-webpack@19.3.0
75
+ npx --no-install prnext --version
76
+ npx --no-install prnext check
30
77
  npx --no-install prnext build
78
+ ```
79
+
80
+ Then restart your app with its usual command; for a foreground production server:
81
+
82
+ ```sh
31
83
  npx --no-install prnext start
32
84
  ```
33
85
 
34
- Balanced is the default production profile. Choose a profile with:
86
+ This updates the existing `@thomas.f/prnext` dependency and its native component together. You do not need to migrate an already-migrated project again. Commit the updated package manifest and lockfile.
87
+
88
+ Use `@alpha` to select the current alpha release. `latest` is a separate npm tag; a dependency already installed in a project does not change until you update it.
89
+
90
+ </details>
91
+
92
+ <details>
93
+ <summary><strong>Build & run · development and production</strong></summary>
94
+
95
+ For development:
35
96
 
36
97
  ```sh
37
- npx --no-install prnext start --profile balanced
38
- npx --no-install prnext start --profile speed
39
- npx --no-install prnext start --profile memory
40
- npx --no-install prnext start --profile classic
98
+ npx --no-install prnext dev
41
99
  ```
42
100
 
43
- ## Migration audit, inspector and multi-app hosting
101
+ For production:
102
+
103
+ ```sh
104
+ npx --no-install prnext build
105
+ npx --no-install prnext start
106
+ ```
107
+
108
+ The portable command is `prnext`. The shortcut `prn` is available in compatible shells. On Windows 10 and Windows Server 2022, use `prnext`: [Windows reserves the name PRN](https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file).
109
+
110
+ </details>
111
+
112
+ <details>
113
+ <summary><strong>Runtime profiles · balanced, speed, memory and classic</strong></summary>
114
+
115
+ Balanced is the default. Set a profile when starting the production server:
116
+
117
+ | Profile | Command | Priority |
118
+ | --- | --- | --- |
119
+ | Balanced | `npx --no-install prnext start --profile balanced` | Memory, CPU and response times |
120
+ | Speed | `npx --no-install prnext start --profile speed` | Throughput and responsiveness |
121
+ | Memory | `npx --no-install prnext start --profile memory` | Lower memory usage |
122
+ | Classic | `npx --no-install prnext start --profile classic` | Original runtime settings |
123
+
124
+
125
+ Worker retirement can be configured in `next.config.mjs` (PRNext extension):
126
+
127
+ ```js
128
+ export default {
129
+ workerRetirement: { idleSeconds: 300, laneIdleSeconds: 30, keepWarm: false },
130
+ };
131
+ ```
132
+
133
+ Values are resolved **per field**: CLI > process environment > built configuration > profile.
134
+ The CLI options are `--worker-idle-seconds`, `--lane-idle-seconds`, and
135
+ `--keep-warm [true|false]` (a bare flag means true). The corresponding environment
136
+ variables are `PRNEXT_WORKER_IDLE_SECONDS`, `PRNEXT_LANE_IDLE_SECONDS`, and
137
+ `PRNEXT_KEEP_WARM` (`true`/`false` or `1`/`0`). Seconds must be integers from 0 to
138
+ 604800; invalid supplied values fail even when a higher-priority value exists.
139
+ Configuration-file changes require a new build; CLI/environment changes require
140
+ restarting the server.
141
+
142
+ `idleSeconds: 0` disables host idle retirement. `laneIdleSeconds: 0` disables
143
+ early retirement of surplus lanes; the host deadline still applies to those
144
+ lanes. Set both to zero to disable all idle retirement. `keepWarm: true` overrides
145
+ the resolved host deadline and retains one primary lane per used host, while
146
+ surplus lanes still retire. It creates no workers or artificial requests before
147
+ real traffic. To restore a shorter CLI deadline when a lower-priority source sets
148
+ `keepWarm: true`, also pass `--keep-warm false`. Active requests across **all**
149
+ lanes reset the host deadline; streams and tracked post-response work remain
150
+ protected. Keeping a host warm trades retained memory for lower wake latency;
151
+ it does not change the profile's response, buffer or V8 limits.
152
+
153
+ New builds include `runtime-contract.json`, separately declaring transport and
154
+ lifecycle versions and fingerprints of the runtime modules. `start` and
155
+ `host --check` reject missing, incompatible, future or mixed dynamic runtimes
156
+ before accepting traffic and print `prnext build <project>`. Rebuild and restart after
157
+ updating PRNext. Keep the selected production build immutable while it is in
158
+ use; this is a startup check, not a continuous file watcher. A copied metadata file cannot upgrade old runtime modules;
159
+ `--worker` must remain inside the verified build runtime directory. This is
160
+ artifact consistency checking, not a signature or a sandbox. Legacy builds
161
+ without any worker routes or middleware can still serve files without Node.
162
+ The multi-app `host` supervisor's whole-application `idleSeconds`/memory policy
163
+ is separate: keeping a Node worker warm does not override application sleep,
164
+ shutdown or resource-budget termination.
165
+
166
+ `prnext start` sizes Tokio's blocking I/O pool to four times the larger of its
167
+ runtime thread count and requested render workers (minimum 4, maximum 256).
168
+ `PRNEXT_BLOCKING_THREADS=1..1024` explicitly overrides it. This bounds threads,
169
+ not the number of incoming HTTP connections. Filesystem admission separately
170
+ bounds submitted blocking work and file response bodies, including slow clients:
171
+
172
+ | Environment variable | Default | Accepted range | Scope per server process |
173
+ |---|---:|---:|---|
174
+ | `PRNEXT_FILE_CONCURRENCY` | 64 | 1–4096 | File preparation and live file source bodies |
175
+ | `PRNEXT_FILE_WORK_CONCURRENCY` | 16 | 1–1024 | Submitted static/path/cache helper tasks, queued or running |
176
+ | `PRNEXT_FILE_QUEUE` | 128 | 0–4096 | Waiting callers at each admission gate; 0 rejects immediately |
177
+ | `PRNEXT_FILE_WAIT_MS` | 2000 | 1–60000 | Maximum admission wait at each gate |
178
+
179
+ Invalid values fail startup before listening. Full queues and admission timeouts
180
+ return HTTP 503 with `Retry-After: 1` and `Cache-Control: no-store`. These deadlines
181
+ apply to admission, not the duration of an accepted response. Cancelling a queued
182
+ caller releases its slot. An already running filesystem operation keeps its
183
+ permit until it settles, including a pending file read after a client disconnects;
184
+ an operating-system call cannot be forcibly cancelled safely. Large file bodies
185
+ use a one-frame channel with backpressure. No deadline cuts off an active stream.
186
+ Their asynchronous open/read operations retain a file-response permit; they are
187
+ not included in the helper-task queue counter. These controls are not a global
188
+ limit on application or background I/O.
189
+ The limits do not bound TCP/kernel buffers or all HTTP connections, and an
190
+ unresponsive disk can occupy the bounded capacity until its operation returns.
191
+ Pages cache lookups use the same bounded filesystem admission. Their overload
192
+ follows the existing page-capacity 503 path, including Pages JSON requests;
193
+ it does not invoke error-page generation or regenerate a valid cached page.
194
+
195
+ File operations stay off the async runtime threads; HTTP validators, range
196
+ handling and path confinement are unchanged. The multi-app supervisor retains
197
+ its existing pool sizing because
198
+ its long-lived child-output readers have different requirements.
199
+
200
+ For local diagnosis only, `PRNEXT_METRICS_FILE=<path>` adds one observer thread
201
+ and cumulative counters once per second (file wraps at 16 MiB). It reports
202
+ Tokio threads, instrumented filesystem tasks/queues and worker events. Nested
203
+ filesystem timings are wall time, not CPU, and must not be added together.
204
+ `fileAdmission` and `fileWorkAdmission` report occupancy, peaks, submitted and
205
+ released work, rejections, timeouts, cancelled waits and wait duration. Their
206
+ `waitingHeaderBytes` estimate counts URI and header content only; it excludes
207
+ allocation overhead, request objects, bodies and TCP/kernel buffers. Use process
208
+ memory measurements for the total footprint. Metrics never initialize admission
209
+ settings or override values selected at startup.
210
+ `activeJobs` sums transport jobs across the separate render/API pools;
211
+ `queuedJobs` excludes HTTP callers waiting before transport admission.
212
+ `laneIdleRetirements` includes release of a cancelled lane's retained host
213
+ reference, so it is not a count of distinct TCP connections closed. Host starts
214
+ and watchdog kills are separate events; RSC isolate restarts do not have an
215
+ independent cumulative counter. Normal operation has no observer thread or
216
+ sampling timer.
217
+ `--inspect` additionally records Node startup, module and RSC initialization
218
+ phases; use uninstrumented runs for throughput comparisons.
219
+
220
+ Render workers start on demand. Idle connection lanes retire after 30 seconds in `memory` and legacy `compact`. `balanced` keeps each host's primary lane warm for 300 seconds and retires surplus lanes after 30 seconds; `speed` and `classic` keep primary lanes warm and retire surplus lanes after 30 seconds. Retirement waits on idle deadlines rather than polling. Active responses and streams retain their lanes, and tracked application work keeps its Node host available even after the last lane retires. New requests can reuse that host while cache finalization continues. Once the work settles, the existing heartbeat releases this retention within one second. Releasing idle workers lowers retained memory but adds worker startup and module loading to the next request.
221
+
222
+ Client disconnections release response permits and close incomplete transport lanes while retaining a healthy shared Node process. A lane whose terminal frame was fully validated can be reused. App Router cancellation pressure pauses new admission until abandoned work settles, with bounded queues and the original request deadlines; it does not immediately terminate unrelated responses. Successful response completion ends the RSC response deadline, while cache writes remain tracked until settlement. During this post-response work only, a shared watchdog probes the RSC isolate every five seconds and retires it if an acknowledgement takes more than 30 seconds. This measures responsiveness, not the total duration of asynchronous cache work; no RSC watchdog timer runs when that work is absent. Existing limits on cancelled requests and other API or middleware background work still apply.
223
+
224
+ Explicit disposal of a worker pool starts a separate graceful shutdown: tracked work has up to 30 seconds to drain before Rust terminates and reaps the process, including a blocked JavaScript event loop. This shutdown grace does not limit normal idle cache finalization. Arbitrary detached tasks are outside the tracked lifecycle; applications that rely on them or long-lived in-process state should use `speed` or `classic` to keep their primary host warm. Such tasks are still not preserved across shutdown or worker failure.
225
+
226
+ The `memory` profile allows 28 live responses with 8 render starts, a 1 MiB response buffer budget and its existing small V8 nursery. `balanced` retains 32 live responses and `speed` retains 128. These limits bound concurrency independently of idle retirement.
227
+
228
+ `balanced` uses a 4 MiB V8 semi-space without `--optimize-for-size`, keeping its RSC young-generation setting at 16 MiB. This selection reduces measured CPU per response at the cost of higher RSS than the previous size policy. It keeps 16 render starts, a 4 MiB response buffer and 300-second host retirement; `speed` uses 32 starts, a 16 MiB buffer and keeps hosts warm. In the measured SSR/streaming fixtures, cumulative RSS remained below Next.js, but that is not a universal memory guarantee. The final Windows validation includes three 30-second measurements per scenario after warm-up and a one-hour mixed endurance run. The CPU/RSS trade-off remains workload-dependent; comparable Linux performance and Nginx benchmarks have not been run.
229
+
230
+ An explicit `NODE_OPTIONS="--max-semi-space-size=4"` (including Node's underscore spelling) overrides the profile's nursery policy. PRNext also omits `--optimize-for-size` in that case: on Node 24.19.0, V8 otherwise replaces the explicit value with 1 MiB. This opts out of V8's broader size policy as well, so CPU and memory consumption can change; measure the chosen value for your workload. It does not add an old-generation heap limit or change the profile's admission, response buffers or idle retirement. Without an explicit override, PRNext uses the defaults of the selected profile.
231
+
232
+ The V6 multi-application campaign keeps `start` on `balanced` and `host` on
233
+ `memory`. With one shared CPU on Windows, `memory` completed both short trials
234
+ at four applications / 512 MiB and eight applications / 1 GiB without allocation
235
+ limit events. Its one-hour validation used four applications / 1 GiB, not those
236
+ lowest budgets: 1,626,564 useful responses and 960 intentional cancellations,
237
+ with no HTTP or validation errors. `balanced` gave lower neighbouring-application
238
+ tail latency when the larger memory budget was available. These are independent
239
+ `start` instances, not a benchmark of the hosting proxy. Windows commit limits,
240
+ an active pagefile and process RSS sums are not Linux cgroup/PSS measurements;
241
+ equivalent Linux density and endurance measurements remain pending. The local V6 report retains failures,
242
+ variability and the small-static throughput cost of bounded admission.
243
+
244
+ The filesystem work limit remains 16, the setting validated under overload and
245
+ throughout the final endurance run. An explicit `PRNEXT_FILE_WORK_CONCURRENCY=64`
246
+ improved small-file throughput by about 5% in two short configuration trials,
247
+ but allows four times as many submitted helper tasks. It has not received the
248
+ same endurance validation and is not a universal profile improvement.
249
+
250
+ Results depend on your workload; see the [benchmark and its methodology](https://github.com/Lorkydey/PRNext#early-benchmarks-against-nextjs).
251
+
252
+ </details>
253
+
254
+ ## Guides
255
+
256
+ <details>
257
+ <summary><strong>Migration audit, inspector and multi-app hosting</strong></summary>
258
+
259
+ ### Migration audit, inspector and multi-app hosting
44
260
 
45
261
  ```sh
46
262
  prnext migrate ./my-app --check --json
@@ -60,9 +276,16 @@ The host uses separate native server/Node process trees, starts apps on demand a
60
276
 
61
277
  Memory budgets reserve capacity and monitor process-tree RSS about once per second. An excess stops the app and imposes a 30-second cooldown; this is not a kernel hard limit. Leave memory for the gateway, OS and overshoot. Sleeping loses in-process state and background work. Apps run as the same OS user, so this mode is for trusted applications. HTTP streaming is supported; WebSocket upgrades are not. Configure public DNS/TLS and OS services separately.
62
278
 
279
+ The Windows release check used four apps on one shared CPU and a 1 GiB Job commit budget. Two 30-second mixed SSR/stream/API trials measured about 333 requests/s through `host` versus 361 directly with the same V8 heap guard. Matching direct controls followed the host trials; this roughly 8% cost is specific to the fixture. Three sleep/resume cycles per trial used `idleSeconds: 3`: 32 simultaneous arrivals completed in 1.92–2.35 seconds, with one startup per app and no failures. The default whole-app deadline remains 300 seconds. The gateway's status/RSS monitor continues while apps sleep, so idle CPU is not zero. No host endurance or Linux performance result is claimed.
280
+
63
281
  See the [repository guide and example configuration](https://github.com/Lorkydey/PRNext#host-several-applications) for the full workflow.
64
282
 
65
- ## Persistent production commands
283
+ </details>
284
+
285
+ <details>
286
+ <summary><strong>Persistent production commands</strong></summary>
287
+
288
+ ### Persistent production commands
66
289
 
67
290
  `prnext start` remains a foreground command. Use `prnext pstart --name web --port 3000` to keep a built app running after the terminal closes. `prnext pstatus` (or `plist`) lists apps, `prnext plogs web --follow` tails logs, `prnext prestart web` loads the current build through a health check, and `prnext pstop web` stops it. `pdelete` removes saved settings; `pdown` stops the supervisor while preserving enabled apps for restoration. Lifecycle commands accept `--all` where shown by `prnext pstart --help`.
68
291
 
@@ -70,21 +293,36 @@ Apps restart after crashes with increasing delays and a retry limit. A separate
70
293
 
71
294
  Settings, control credentials and logs live in `~/.prnext/processes` (`PRNEXT_PM_HOME` overrides it). Logs rotate at 10 MiB plus three older segments. Apps load project `.env` files; caller-shell secrets are not saved. `prnext pstartup` opts into restoration on the next user login through Windows Task Scheduler, a macOS LaunchAgent or a Linux systemd user service; `--remove` removes that startup configuration. Windows/macOS startup is per-user, not a pre-login system service. Linux boot-time operation requires administrator-configured user lingering. Re-run `pstartup` after moving or upgrading Node or PRNext. See the [persistent command guide](https://github.com/Lorkydey/PRNext#keep-production-apps-running).
72
295
 
73
- ## Platforms
296
+ Before upgrading a supervised application, follow the [persistent app upgrade guide](https://github.com/Lorkydey/PRNext#upgrade-persistent-apps).
297
+
298
+ </details>
299
+
300
+ <details>
301
+ <summary><strong>Platforms</strong></summary>
74
302
 
75
- Native package targets cover Windows MSVC arm64/x64, macOS arm64/x64 and Linux glibc arm64/x64. Windows runs directly from PowerShell or Command Prompt, without WSL. Alpine/musl is not a prebuilt target. Linux binaries are built on Ubuntu 22.04 with glibc 2.35. Windows archives must be published alongside the matching framework version before registry consumers can install them.
303
+ ### Platforms
304
+
305
+ Native package targets cover Windows MSVC arm64/x64, macOS arm64/x64 and Linux glibc arm64/x64. Windows runs directly from PowerShell or Command Prompt, without WSL. Alpine/musl is not a prebuilt target. The native Linux CI builds use Ubuntu 22.04 with glibc 2.35. Framework and native components use matching release versions.
76
306
 
77
307
  Keep npm optional dependencies enabled: PRNext automatically installs the binary for your platform. Rust is not required to use the published package.
78
308
 
309
+ </details>
310
+
79
311
  ## Links and feedback
80
312
 
81
313
  - Website and benchmarks: [prnext.dev](https://prnext.dev)
82
314
  - Source and contributions: [Lorkydey/PRNext](https://github.com/Lorkydey/PRNext)
315
+ - Project updates on X: [@PRNextdev](https://x.com/PRNextdev)
83
316
  - Bug reports: [GitHub Issues](https://github.com/Lorkydey/PRNext/issues)
84
317
  - Questions and suggestions: [contact@heythomas.dev](mailto:contact@heythomas.dev)
85
318
 
86
- ## License and credits
319
+ <details>
320
+ <summary><strong>License & credits</strong></summary>
321
+
322
+ ### License and credits
87
323
 
88
324
  PRNext is licensed under the [MIT License](LICENSE), copyright © 2026 Thomas (Lorkydey). Commercial use, modification, and redistribution are permitted; keep the copyright and license notice when distributing copies or substantial portions of the software. Third-party components retain their own licenses.
89
325
 
90
326
  If PRNext helps your project, a credit such as **“PRNext by Thomas (Lorkydey)”** with a link to [prnext.dev](https://prnext.dev) would be appreciated. A public credit is optional, not an additional license condition.
327
+
328
+ </details>
@@ -2,6 +2,7 @@ import { build } from 'esbuild';
2
2
  import { createRequire } from 'node:module';
3
3
  import path from 'node:path';
4
4
  import {frameworkImportName} from './framework-imports.mjs';
5
+ import { projectTsconfig } from './typescript.mjs';
5
6
 
6
7
  export function validateCacheHandler(input) {
7
8
  if (input === undefined) return undefined;
@@ -11,7 +12,7 @@ export function validateCacheHandler(input) {
11
12
 
12
13
  export async function prepareCacheHandler(options) {
13
14
  if (!options.config.cacheHandler) return undefined;
14
- const handlers = await prepareCacheHandlers({ ...options, config: { cacheHandlers: { __legacy: options.config.cacheHandler } } });
15
+ const handlers = await prepareCacheHandlers({ ...options, config: { ...options.config, cacheHandlers: { __legacy: options.config.cacheHandler } } });
15
16
  return handlers.__legacy;
16
17
  }
17
18
 
@@ -33,6 +34,7 @@ export function validateCacheHandlers(input) {
33
34
  export async function prepareCacheHandlers({ config, projectRoot, stage, environment = {} }) {
34
35
  const result = {};
35
36
  const compiled = new Map();
37
+ const tsconfig = config.typescript ? await projectTsconfig(projectRoot, config) : undefined;
36
38
  const require = createRequire(path.join(projectRoot, 'package.json'));
37
39
  for (const [name, source] of Object.entries(config.cacheHandlers || {})) {
38
40
  let entry;
@@ -41,6 +43,7 @@ export async function prepareCacheHandlers({ config, projectRoot, stage, environ
41
43
  if (compiled.has(entry)) { result[name] = compiled.get(entry); continue; }
42
44
  const output = `server/cache-handler-${name}.mjs`;
43
45
  await build({ absWorkingDir: projectRoot, entryPoints: [entry], outfile: path.join(stage, output), bundle: true,
46
+ ...(tsconfig ? { tsconfig } : {}),
44
47
  platform: 'node', target: 'node22', format: 'esm', packages: 'external', logLevel: 'silent', define: environment,
45
48
  banner: { js: "import {createRequire as __prnextCreateRequire} from 'node:module';const require=__prnextCreateRequire(import.meta.url);" },
46
49
  plugins: [{ name: 'cache-handler-compatibility', setup(builder) {
@@ -134,6 +134,7 @@ export async function build(options) {
134
134
  }}))};
135
135
  // Runtime vendor bundles are framework-owned, not project webpack targets.
136
136
  const projectGraph = (options.plugins || []).some(plugin => framework.test(plugin.name));
137
+ if (projectGraph && state?.tsconfig) options = { ...options, tsconfig: state.tsconfig };
137
138
  const compile = input => state?.incremental && state.dev && (projectGraph || !input.plugins?.length) ? incrementalBuild(input,{root:state.root,stage:state.stage,group:projectGraph?'project':'auxiliary',run:callback=>compilation.run(state,callback)}) : esbuild(input);
138
139
  if (!(state?.config?.webpack || Object.keys(state?.config?.turbopack?.rules || {}).length) || !projectGraph) return compile(options);
139
140
  const target = options.define?.['process.env.NEXT_RUNTIME'] === '"edge"' ? 'edge' : options.platform === 'browser' ? 'browser' : 'node';
package/build/config.mjs CHANGED
@@ -15,17 +15,21 @@ import { validateStandaloneConfig } from './standalone-config.mjs';
15
15
  import { validateTurbopack } from './module-resolution.mjs';
16
16
  import { validateI18n } from './i18n.mjs';
17
17
  import { frameworkPackages } from './framework-imports.mjs';
18
+ import { validateTypeScript } from './typescript.mjs';
19
+ import { validateWorkerRetirement } from './worker-retirement.mjs';
18
20
 
19
21
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
22
  const configName = /^(?:next|prnext)\.config\.(?:js|mjs|cjs|ts)$/;
21
23
  const supported = new Set(['env', 'redirects', 'headers', 'rewrites', 'generateBuildId', 'compress', 'poweredByHeader', 'productionBrowserSourceMaps', 'basePath', 'assetPrefix', 'experimental', 'sassOptions', 'transpilePackages', 'serverExternalPackages', 'cacheComponents', 'cacheLife', 'images', 'reactStrictMode', 'pageExtensions', 'trailingSlash', 'skipTrailingSlashRedirect', 'skipMiddlewareUrlNormalize', 'skipProxyUrlNormalize', 'cacheHandlers', 'distDir']);
22
24
  const defaults = { distDir: '.prnext', env: {}, compress: true, poweredByHeader: true, productionBrowserSourceMaps: false, basePath: '', assetPrefix: '', trailingSlash: false, skipTrailingSlashRedirect: false, skipMiddlewareUrlNormalize: false, experimental: { nextScriptWorkers: false } };
23
25
  supported.add('cacheHandler');
26
+ supported.add('workerRetirement');
24
27
  supported.add('cacheMaxMemorySize');
25
28
  supported.add('turbopack');
26
29
  supported.add('i18n');
27
30
  supported.add('webpack');
28
31
  supported.add('onDemandEntries');
32
+ for (const key of ['reactCompiler', 'typedRoutes', 'typescript']) supported.add(key);
29
33
  for (const key of ['output', 'outputFileTracingRoot', 'outputFileTracingIncludes', 'outputFileTracingExcludes']) supported.add(key);
30
34
 
31
35
  function validPrefixPath(value, where, { root = false } = {}) {
@@ -78,6 +82,13 @@ export function validateProjectConfig(input, filename = 'project config') {
78
82
  // Optional properties explicitly set to undefined still use their defaults,
79
83
  // as in common process.env-based Next configuration wrappers.
80
84
  const config = { ...defaults, ...Object.fromEntries(Object.entries(input).filter(([,value]) => value !== undefined)) };
85
+ for (const key of ['reactCompiler', 'typedRoutes']) {
86
+ if (config[key] === undefined || config[key] === false) continue;
87
+ if (key === 'reactCompiler') throw new Error('reactCompiler currently supports only false. PRNext does not run the React Compiler transform; keep Next.js for builds that require it.');
88
+ throw new Error('typedRoutes currently supports only false. PRNext does not generate or validate typed route declarations; keep Next.js for builds that require them.');
89
+ }
90
+ config.typescript = validateTypeScript(config.typescript);
91
+ config.workerRetirement = validateWorkerRetirement(config.workerRetirement);
81
92
  if (config.onDemandEntries !== undefined) {
82
93
  const entries = config.onDemandEntries;
83
94
  if (!entries || typeof entries !== 'object' || Array.isArray(entries)) throw new TypeError('onDemandEntries must be an object');
package/build/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { rename } from '../runtime/fs.mjs';
2
+ import { writeRuntimeContract } from './runtime-contract.mjs';
2
3
  import {frameworkImportName, frameworkRuntimeImportPattern} from './framework-imports.mjs';
3
4
  import { expandLocales, localizedStaticPaths } from './i18n.mjs';
4
5
  import { build as bundle, withCompilation, configureCompiler, compilerSource } from './compiler.mjs';
@@ -29,6 +30,7 @@ import { npmPackagesPlugin } from './npm-packages.mjs';
29
30
  import { createRefreshTransform, devSingletonsPlugin, devClientFile } from './dev.mjs';
30
31
  import { snapshotEnvConfig } from '../runtime/env.mjs';
31
32
  import { validateStaticPaths } from '../runtime/pages-paths.mjs';
33
+ import { projectTsconfig, checkTypeScript } from './typescript.mjs';
32
34
  export { staticPath } from '../runtime/pages-paths.mjs';
33
35
 
34
36
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -197,6 +199,7 @@ export function build(projectRoot, options = {}) {
197
199
  async function buildProject(projectRoot, { dev = false, envMode, incremental = false, devInputs } = {}) {
198
200
  const started = performance.now();
199
201
  const config = await loadProjectConfig(projectRoot, { dev, envMode });
202
+ const tsconfig = config.typescript ? await projectTsconfig(projectRoot, config) : undefined;
200
203
  const environment = defineEnvironment(config);
201
204
  const publicPaths = { trailingSlash: config.trailingSlash, skipTrailingSlashRedirect: config.skipTrailingSlashRedirect, basePath: config.basePath, assetPrefix: config.assetPrefix, assetBase: publicAssetBase(config) };
202
205
  const customRoutes = await compileCustomRoutes(config);
@@ -219,7 +222,7 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
219
222
  const refresh = dev ? createRefreshTransform(project.root) : undefined;
220
223
  const fonts = createFonts({ projectRoot: project.root, stage, assetBase: publicPaths.assetBase });
221
224
  const cssModules = createCssModules({ projectRoot: project.root, stage, assetBase: publicPaths.assetBase, sassOptions: config.sassOptions });
222
- configureCompiler({config,root:project.root,stage,buildId,dev,incremental,devInputs,assetBase:publicPaths.assetBase});
225
+ configureCompiler({config,root:project.root,stage,buildId,dev,incremental,devInputs,tsconfig,assetBase:publicPaths.assetBase});
223
226
  try {
224
227
  await mkdir(path.join(stage, 'runtime'), { recursive: true });
225
228
  await mkdir(path.join(stage, 'compat'), { recursive: true });
@@ -235,6 +238,7 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
235
238
  }
236
239
  await prepareImageResponse(stage);
237
240
  manifest.config.serverActions = config.experimental.serverActions;
241
+ if (config.workerRetirement) manifest.config.workerRetirement = config.workerRetirement;
238
242
  const cacheHandlers = await prepareCacheHandlers({ config, projectRoot: project.root, stage, environment });
239
243
  if (Object.keys(cacheHandlers).length) manifest.config.cacheHandlers = cacheHandlers;
240
244
  const cacheHandler = await prepareCacheHandler({ config, projectRoot: project.root, stage, environment });
@@ -292,6 +296,7 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
292
296
  await compileMiddleware({ project, stage, manifest, dev, moduleResolution: config.turbopack, defineEnvironment: environment });
293
297
  await prepareMetadataRoutes(project, stage);
294
298
  await compileApp({ project, stage, manifest, moduleResolution: config.turbopack, cssModules, fonts, images, dev, defineEnvironment: environment, productionBrowserSourceMaps: config.productionBrowserSourceMaps, serverExternalPackages: config.serverExternalPackages });
299
+ await checkTypeScript(project.root, config, { dev, tsconfig });
295
300
  fonts.attach(manifest);
296
301
  expandLocales(manifest);
297
302
  await prerender(stage, manifest, dev);
@@ -323,6 +328,7 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
323
328
  ...(appRoute ? { app: { client: appRoute.client, css: appRoute.css } } : {}),
324
329
  }));
325
330
  }
331
+ await writeRuntimeContract(stage);
326
332
  await writeFile(path.join(stage, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
327
333
  if (config.compress) await precompressBuild(stage, manifest, {cacheDirectory:path.join(project.root,'.prnext-cache/precompress-v1')});
328
334
  if (config.output === 'standalone' && !dev) await (await import('./standalone-runner.mjs')).prepareStandalone({ projectRoot: project.root, stage, manifest, config });
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { readFile } from 'node:fs/promises';
3
- import { builtinModules } from 'node:module';
3
+ import { builtinModules, createRequire } from 'node:module';
4
4
  import { parse } from '@babel/parser';
5
5
  import { VISITOR_KEYS } from '@babel/types';
6
6
  import { createNativeDiscovery } from './native-discovery.mjs';
@@ -15,6 +15,7 @@ const bare = name => !name.startsWith('.') && !name.startsWith('#') && !path.isA
15
15
  /** Inspect reached imports once per compiler graph; never execute npm modules. */
16
16
  export function npmPackagesPlugin({ pages = false, projectRoot, transpilePackages = [], serverExternalPackages = [] }) {
17
17
  const transpile = new Set(transpilePackages), external = new Set(serverExternalPackages);
18
+ const nodeResolve = createRequire(path.join(projectRoot, 'package.json')).resolve;
18
19
  const sources = new Map(), decisions = new Map();
19
20
  let sourceBytes = 0;
20
21
  const native = createNativeDiscovery({ projectRoot, isClientSource: source => /['"]use (?:client|server)['"]/.test(source) });
@@ -91,6 +92,16 @@ export function npmPackagesPlugin({ pages = false, projectRoot, transpilePackage
91
92
  if (!pages && !external.has(name)) return;
92
93
  const resolved = await resolve(args.path, args.importer || path.join(projectRoot, 'package.json'), args.kind);
93
94
  if (resolved.errors.length || resolved.external || resolved.namespace !== 'file') return resolved;
95
+ // A tsconfig paths/baseUrl import can look like an npm specifier while
96
+ // resolving to application source. Node cannot resolve that alias from
97
+ // the emitted server bundle; keep the resolved source in the graph.
98
+ if (!resolved.path.split(path.sep).includes('node_modules')) {
99
+ let nodePath;
100
+ try { nodePath = nodeResolve(args.path, { paths: [path.dirname(args.importer || path.join(projectRoot, 'package.json'))] }); } catch {}
101
+ // Preserve externalization of real workspace packages that Node can
102
+ // resolve through a node_modules link, even after esbuild realpaths it.
103
+ if (nodePath !== resolved.path) return resolved;
104
+ }
94
105
  const decision = await classify(resolved.path);
95
106
  const fromRoot = await resolve(args.path, path.join(projectRoot, 'package.json'), args.kind);
96
107
  const nestedResolution = fromRoot.errors.length || fromRoot.path !== resolved.path;
@@ -0,0 +1,16 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile, readdir, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ // Protocol compatibility does not imply safe retirement. These modules jointly
6
+ // implement activity tracking, cancellation and post-response draining.
7
+ export const lifecycleModules = ['worker.mjs', 'transport.mjs', 'request-work.mjs', 'app-render.mjs', 'app-stream-html.mjs', 'rsc-worker.mjs'];
8
+ export async function writeRuntimeContract(stage) {
9
+ const files = (await Promise.all(['runtime', 'compat'].map(async directory =>
10
+ (await readdir(path.join(stage,directory))).filter(name => /\.(?:mjs|cjs|json)$/.test(name)).map(name => `${directory}/${name}`)))).flat().sort();
11
+ const modules = Object.fromEntries(await Promise.all(files.map(async name =>
12
+ [name, createHash('sha256').update(await readFile(path.join(stage, name))).digest('hex')])));
13
+ const contract = { schema: 1, protocol: 2, lifecycle: 1,
14
+ capabilities: ['activity', 'tracked-finalization', 'graceful-drain', 'lane-cancellation'], modules };
15
+ await writeFile(path.join(stage, 'runtime-contract.json'), JSON.stringify(contract, null, 2) + '\n');
16
+ }
@@ -0,0 +1,20 @@
1
+ import { createRequire } from 'node:module';
2
+
3
+ const [, , compiler, filename] = process.argv;
4
+ const ts = createRequire(import.meta.url)(compiler);
5
+ const host = {
6
+ ...ts.sys,
7
+ onUnRecoverableConfigFileDiagnostic(diagnostic) {
8
+ process.stderr.write(ts.formatDiagnostics([diagnostic], formatHost));
9
+ process.exit(1);
10
+ },
11
+ };
12
+ const formatHost = { getCanonicalFileName: name => name, getCurrentDirectory: ts.sys.getCurrentDirectory, getNewLine: () => ts.sys.newLine };
13
+ const parsed = ts.getParsedCommandLineOfConfigFile(filename, { noEmit: true }, host);
14
+ const diagnostics = parsed.errors.length ? parsed.errors : ts.getPreEmitDiagnostics(ts.createProgram({
15
+ rootNames: parsed.fileNames, options: parsed.options, projectReferences: parsed.projectReferences,
16
+ }));
17
+ if (diagnostics.length) {
18
+ process.stderr.write(ts.formatDiagnostics(diagnostics, formatHost));
19
+ process.exitCode = 1;
20
+ }
@@ -0,0 +1,49 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { createRequire } from 'node:module';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { fileURLToPath } from 'node:url';
6
+ import path from 'node:path';
7
+
8
+ const execute = promisify(execFile);
9
+
10
+ export function validateTypeScript(input) {
11
+ if (input === undefined) return undefined;
12
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('typescript must be an object');
13
+ for (const key of Object.keys(input)) if (!['ignoreBuildErrors', 'tsconfigPath'].includes(key)) throw new TypeError(`typescript.${key} is not supported`);
14
+ const { ignoreBuildErrors = false, tsconfigPath = 'tsconfig.json' } = input;
15
+ if (typeof ignoreBuildErrors !== 'boolean') throw new TypeError('typescript.ignoreBuildErrors must be a boolean');
16
+ if (typeof tsconfigPath !== 'string' || !tsconfigPath || tsconfigPath.includes('\0') || Buffer.byteLength(tsconfigPath) > 4096) throw new TypeError('typescript.tsconfigPath must be a nonempty filesystem path of at most 4096 bytes');
17
+ return { ignoreBuildErrors, tsconfigPath };
18
+ }
19
+
20
+ export async function projectTsconfig(root, config) {
21
+ const name = config.typescript?.tsconfigPath ?? 'tsconfig.json';
22
+ const filename = path.resolve(root, name);
23
+ try {
24
+ if (!(await stat(filename)).isFile()) throw new Error(`TypeScript configuration is not a file: ${filename}`);
25
+ return filename;
26
+ } catch (error) {
27
+ if (error.code !== 'ENOENT') throw error;
28
+ if (name !== 'tsconfig.json') throw new Error(`typescript.tsconfigPath does not exist: ${filename}`, { cause: error });
29
+ return undefined;
30
+ }
31
+ }
32
+
33
+ /** Run the checker outside the build/dev process so its graph is released on exit.
34
+ * Never emit files, rewrite the user's config, or install packages implicitly. */
35
+ export async function checkTypeScript(root, config, { dev = false, tsconfig } = {}) {
36
+ if (dev || !config.typescript || config.typescript.ignoreBuildErrors) return;
37
+ tsconfig ??= await projectTsconfig(root, config);
38
+ if (!tsconfig) throw new Error('TypeScript checking was explicitly enabled, but tsconfig.json does not exist. Create a TypeScript configuration, set typescript.tsconfigPath to an existing configuration, or explicitly set typescript.ignoreBuildErrors: true if types are checked separately.');
39
+ let compiler;
40
+ try { compiler = createRequire(path.join(root, 'package.json')).resolve('typescript'); }
41
+ catch (error) { throw new Error('TypeScript checking requires typescript in the project. Install it as a dev dependency, or explicitly set typescript.ignoreBuildErrors: true if a separate process checks types.', { cause: error }); }
42
+ try {
43
+ await execute(process.execPath, [fileURLToPath(new URL('./typescript-check.mjs', import.meta.url)), compiler, tsconfig], {
44
+ cwd: root, windowsHide: true, maxBuffer: 4 * 1024 * 1024,
45
+ });
46
+ } catch (error) {
47
+ throw new Error(`TypeScript check failed (${path.relative(root, tsconfig)}):\n${error.stdout || error.stderr || error.message}`, { cause: error });
48
+ }
49
+ }
@@ -0,0 +1,14 @@
1
+ export function validateWorkerRetirement(value) {
2
+ if (value === undefined) return undefined;
3
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('workerRetirement must be an object');
4
+ for (const [key, setting] of Object.entries(value)) {
5
+ if (!['idleSeconds', 'laneIdleSeconds', 'keepWarm'].includes(key)) throw new TypeError(`workerRetirement.${key} is not supported`);
6
+ if (setting === undefined) continue;
7
+ if (key === 'keepWarm') {
8
+ if (typeof setting !== 'boolean') throw new TypeError('workerRetirement.keepWarm must be a boolean');
9
+ } else if (!Number.isSafeInteger(setting) || setting < 0 || setting > 604800) {
10
+ throw new TypeError(`workerRetirement.${key} must be an integer between 0 and 604800 seconds (0 disables retirement)`);
11
+ }
12
+ }
13
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
14
+ }
package/cli.mjs CHANGED
@@ -41,6 +41,7 @@ Simple next dev/build/start scripts become prnext commands; custom shell scripts
41
41
 
42
42
  Server options: --port 3000 --hostname 127.0.0.1 --workers 1 --inspect
43
43
  Production profiles: --profile balanced|speed|memory|classic
44
+ Worker retirement: --worker-idle-seconds 300 --lane-idle-seconds 30 --keep-warm [true|false]
44
45
  Memory favors lower RAM under concurrency, accepting more waiting and CPU per response.
45
46
  Without an option: PRNEXT_PROFILE, legacy PRNEXT_MEMORY_PROFILE=compact, then balanced.
46
47
  Classic preserves the former standard settings; standard remains an alias. Legacy compact remains accepted.