@thomas.f/prnext 0.1.0-alpha.1 → 0.1.11-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/LICENSE +21 -0
- package/README.md +158 -21
- package/build/app-static.mjs +2 -0
- package/build/compiler.mjs +2 -1
- package/build/dev.mjs +1 -0
- package/build/index.mjs +2 -1
- package/build/inspect.mjs +68 -0
- package/build/loader-cache.mjs +2 -1
- package/build/migrate.mjs +19 -8
- package/build/migration-audit.mjs +126 -0
- package/build/precompress.mjs +2 -1
- package/build/standalone-runner.mjs +1 -1
- package/build/standalone.mjs +80 -9
- package/build/static-export.mjs +2 -1
- package/cli.mjs +100 -14
- package/compat/cache-handlers.cjs +3 -1
- package/compat/data-cache.cjs +10 -1
- package/compat/diagnostics.cjs +40 -0
- package/compat/static-generation.cjs +2 -0
- package/native/npm.mjs +29 -0
- package/native/platforms.json +3 -1
- package/native/resolve.mjs +4 -3
- package/package.json +8 -5
- package/release/verify.mjs +2 -2
- package/runtime/fetch-cache.mjs +12 -1
- package/runtime/fs.mjs +15 -0
- package/runtime/persistent/client.mjs +164 -0
- package/runtime/persistent/common.mjs +67 -0
- package/runtime/persistent/daemon.mjs +312 -0
- package/runtime/persistent/logger.mjs +52 -0
- package/runtime/persistent/startup.mjs +71 -0
- package/runtime/persistent/watchdog.mjs +109 -0
- package/runtime/watch-project.mjs +13 -6
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thomas (Lorkydey)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -2,53 +2,190 @@
|
|
|
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) · [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
|
|
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
|
-
##
|
|
11
|
+
## Latest update
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
<details>
|
|
14
|
+
<summary><strong>0.1.1-alpha · October 8, 2026 · Features & fixes</strong></summary>
|
|
15
|
+
|
|
16
|
+
**README update:** `0.1.11-alpha` updates the README only; runtime features and fixes remain those of `0.1.1-alpha`.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **Windows x64 & ARM64:** native packages alongside macOS and Linux glibc support.
|
|
21
|
+
- **Persistent apps:** background processes, crash recovery, rotating logs, health-checked replacements and optional restoration at login.
|
|
22
|
+
- **Multi-app hosting:** hostname routing, on-demand startup, idle sleep and sampled memory budgets.
|
|
23
|
+
- **Migration checks:** source audits and optional GET response comparisons with `migrate --check`.
|
|
24
|
+
- **Inspector:** opt-in route, cache and request timing diagnostics.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Standalone dependency tracing for Windows drives, scoped packages and workspace aliases.
|
|
29
|
+
- Development process shutdown and file replacement on Windows.
|
|
30
|
+
- Cold-worker connection bursts on macOS.
|
|
31
|
+
- Windows package publishing and portable `prnext` migration scripts.
|
|
32
|
+
|
|
33
|
+
</details>
|
|
34
|
+
|
|
35
|
+
[Full changelog](https://github.com/Lorkydey/PRNext/blob/main/CHANGELOG.md)
|
|
36
|
+
|
|
37
|
+
## Install & upgrade
|
|
38
|
+
|
|
39
|
+
Node.js **22+** · Windows, macOS and Linux glibc · x64 and ARM64
|
|
40
|
+
|
|
41
|
+
<details>
|
|
42
|
+
<summary><strong>Install · migrate an existing Next.js app</strong></summary>
|
|
43
|
+
|
|
44
|
+
Run these commands from your application directory. The current alpha requires matching React, React DOM and React Server Components versions:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
npm install @thomas.f/prnext@alpha react@19.3.0 react-dom@19.3.0 react-server-dom-webpack@19.3.0
|
|
48
|
+
npx --no-install prnext migrate --dry-run
|
|
49
|
+
npx --no-install prnext migrate
|
|
50
|
+
npx --no-install prnext check
|
|
51
|
+
npx --no-install prnext dev
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
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.
|
|
55
|
+
|
|
56
|
+
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.
|
|
57
|
+
|
|
58
|
+
</details>
|
|
59
|
+
|
|
60
|
+
<details>
|
|
61
|
+
<summary><strong>Upgrade · update an existing PRNext project</strong></summary>
|
|
62
|
+
|
|
63
|
+
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.
|
|
16
64
|
|
|
17
65
|
```sh
|
|
18
66
|
npm install @thomas.f/prnext@alpha react@19.3.0 react-dom@19.3.0 react-server-dom-webpack@19.3.0
|
|
19
|
-
npx --no-install
|
|
20
|
-
npx --no-install
|
|
21
|
-
npx --no-install
|
|
22
|
-
npx --no-install prn dev
|
|
67
|
+
npx --no-install prnext --version
|
|
68
|
+
npx --no-install prnext check
|
|
69
|
+
npx --no-install prnext build
|
|
23
70
|
```
|
|
24
71
|
|
|
25
|
-
|
|
72
|
+
Then restart your app with its usual command; for a foreground production server:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
npx --no-install prnext start
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
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.
|
|
79
|
+
|
|
80
|
+
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.
|
|
81
|
+
|
|
82
|
+
</details>
|
|
83
|
+
|
|
84
|
+
<details>
|
|
85
|
+
<summary><strong>Build & run · development and production</strong></summary>
|
|
86
|
+
|
|
87
|
+
For development:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
npx --no-install prnext dev
|
|
91
|
+
```
|
|
26
92
|
|
|
27
|
-
|
|
93
|
+
For production:
|
|
28
94
|
|
|
29
95
|
```sh
|
|
30
|
-
npx --no-install
|
|
31
|
-
npx --no-install
|
|
96
|
+
npx --no-install prnext build
|
|
97
|
+
npx --no-install prnext start
|
|
32
98
|
```
|
|
33
99
|
|
|
34
|
-
|
|
100
|
+
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).
|
|
101
|
+
|
|
102
|
+
</details>
|
|
103
|
+
|
|
104
|
+
<details>
|
|
105
|
+
<summary><strong>Runtime profiles · balanced, speed, memory and classic</strong></summary>
|
|
106
|
+
|
|
107
|
+
Balanced is the default. Set a profile when starting the production server:
|
|
108
|
+
|
|
109
|
+
| Profile | Command | Priority |
|
|
110
|
+
| --- | --- | --- |
|
|
111
|
+
| Balanced | `npx --no-install prnext start --profile balanced` | Memory, CPU and response times |
|
|
112
|
+
| Speed | `npx --no-install prnext start --profile speed` | Throughput and responsiveness |
|
|
113
|
+
| Memory | `npx --no-install prnext start --profile memory` | Lower memory usage |
|
|
114
|
+
| Classic | `npx --no-install prnext start --profile classic` | Original runtime settings |
|
|
115
|
+
|
|
116
|
+
Results depend on your workload; see the [benchmark and its methodology](https://github.com/Lorkydey/PRNext#early-benchmarks-against-nextjs).
|
|
117
|
+
|
|
118
|
+
</details>
|
|
119
|
+
|
|
120
|
+
## Guides
|
|
121
|
+
|
|
122
|
+
<details>
|
|
123
|
+
<summary><strong>Migration audit, inspector and multi-app hosting</strong></summary>
|
|
124
|
+
|
|
125
|
+
### Migration audit, inspector and multi-app hosting
|
|
35
126
|
|
|
36
127
|
```sh
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
128
|
+
prnext migrate ./my-app --check --json
|
|
129
|
+
prnext migrate ./my-app --check --against http://localhost:3000 --candidate http://localhost:3001 --routes paths.json
|
|
130
|
+
prnext start ./my-app --inspect
|
|
131
|
+
prnext inspect ./my-app --json
|
|
132
|
+
prnext host prnext.host.json --check
|
|
133
|
+
prnext host prnext.host.json
|
|
134
|
+
prnext host prnext.host.json --status --json
|
|
41
135
|
```
|
|
42
136
|
|
|
43
|
-
|
|
137
|
+
The migration audit reports file/line diagnostics without rewriting the application. Response comparison checks GET status, redirects and normalized bodies between servers you start separately. HTML comparison excludes scripts/styles and does not replace browser tests.
|
|
138
|
+
|
|
139
|
+
The inspector explains build decisions and summarizes optional local HTTP, fetch, cache and invalidation samples. Durations end at response headers. Runtime logs omit request bodies, credentials and query strings, and hash tag/path identifiers. Measurements are bounded and may drop samples under load.
|
|
140
|
+
|
|
141
|
+
The host uses separate native server/Node process trees, starts apps on demand and stops idle apps. A minimal configuration is `{"memoryMb":512,"apps":[{"name":"site","root":"./site","hosts":["site.localhost"],"memoryMb":256,"idleSeconds":300}]}`. Roots are relative to this file; apps must already have production builds. Additional app options are `workers`, `profile`, `env` and `inspect`. The listener defaults to `127.0.0.1:8080`; set `hostname` and `port` explicitly when needed.
|
|
142
|
+
|
|
143
|
+
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.
|
|
144
|
+
|
|
145
|
+
See the [repository guide and example configuration](https://github.com/Lorkydey/PRNext#host-several-applications) for the full workflow.
|
|
146
|
+
|
|
147
|
+
</details>
|
|
44
148
|
|
|
45
|
-
|
|
149
|
+
<details>
|
|
150
|
+
<summary><strong>Persistent production commands</strong></summary>
|
|
151
|
+
|
|
152
|
+
### Persistent production commands
|
|
153
|
+
|
|
154
|
+
`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`.
|
|
155
|
+
|
|
156
|
+
Apps restart after crashes with increasing delays and a retry limit. A separate watchdog supervises the manager. Replacements get a private build copy and must return HTTP 2xx/3xx from `--health-path /` within `--health-timeout 15000` ms before traffic switches; a failed candidate leaves the old instance serving. Existing requests can finish for `--drain-timeout 30000` ms. Dependencies, public files and disk caches remain shared. This adds a Node HTTP proxy; WebSocket upgrades are not supported and crashes can interrupt requests.
|
|
157
|
+
|
|
158
|
+
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).
|
|
159
|
+
|
|
160
|
+
Before upgrading a supervised application, follow the [persistent app upgrade guide](https://github.com/Lorkydey/PRNext#upgrade-persistent-apps).
|
|
161
|
+
|
|
162
|
+
</details>
|
|
163
|
+
|
|
164
|
+
<details>
|
|
165
|
+
<summary><strong>Platforms</strong></summary>
|
|
166
|
+
|
|
167
|
+
### Platforms
|
|
168
|
+
|
|
169
|
+
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. Framework and native components use matching release versions.
|
|
46
170
|
|
|
47
171
|
Keep npm optional dependencies enabled: PRNext automatically installs the binary for your platform. Rust is not required to use the published package.
|
|
48
172
|
|
|
173
|
+
</details>
|
|
174
|
+
|
|
49
175
|
## Links and feedback
|
|
50
176
|
|
|
51
177
|
- Website and benchmarks: [prnext.dev](https://prnext.dev)
|
|
52
178
|
- Source and contributions: [Lorkydey/PRNext](https://github.com/Lorkydey/PRNext)
|
|
53
179
|
- Bug reports: [GitHub Issues](https://github.com/Lorkydey/PRNext/issues)
|
|
54
180
|
- Questions and suggestions: [contact@heythomas.dev](mailto:contact@heythomas.dev)
|
|
181
|
+
|
|
182
|
+
<details>
|
|
183
|
+
<summary><strong>License & credits</strong></summary>
|
|
184
|
+
|
|
185
|
+
### License and credits
|
|
186
|
+
|
|
187
|
+
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.
|
|
188
|
+
|
|
189
|
+
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.
|
|
190
|
+
|
|
191
|
+
</details>
|
package/build/app-static.mjs
CHANGED
|
@@ -86,6 +86,7 @@ export async function prerenderApp(stage, manifest, dev) {
|
|
|
86
86
|
// continuation itself is never patched.
|
|
87
87
|
const rendered = await pages.prerenderAppRoute({ ...options, path: fallback.path, params, partialParams: unknown });
|
|
88
88
|
if (rendered.partial) {
|
|
89
|
+
route.dynamicReasons = [...new Set([...(route.dynamicReasons || []), ...(rendered.metadata?.dynamicReasons || [])])];
|
|
89
90
|
const file = `static/generic-${createHash('sha256').update(route.id + JSON.stringify(supplied)).digest('hex').slice(0, 20)}.json`;
|
|
90
91
|
await writeFile(path.join(stage, file), JSON.stringify({ ...rendered.partial, ...rendered.metadata }));
|
|
91
92
|
(route.pprGeneric ||= []).push({ file, params: supplied, unknown });
|
|
@@ -119,6 +120,7 @@ export async function prerenderApp(stage, manifest, dev) {
|
|
|
119
120
|
continue;
|
|
120
121
|
}
|
|
121
122
|
if (rendered.dynamic) {
|
|
123
|
+
route.dynamicReasons = [...new Set([...(route.dynamicReasons || []), rendered.reason || 'request-time data'])];
|
|
122
124
|
if (!handler && config.dynamic === 'error') throw new Error(`App route ${entry.path} with dynamic='error' used dynamic data: ${rendered.reason || 'dynamic rendering required'}.`);
|
|
123
125
|
if (!dynamic) { delete route.ssg; delete route.fallback; delete route.pprFallback; }
|
|
124
126
|
else {
|
package/build/compiler.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { runLoaders } from 'loader-runner';
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import { mkdir, writeFile, readFile } from 'node:fs/promises';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import { createRequire } from 'node:module';
|
|
8
9
|
import { moduleResolutionPlugin, validateWebpackResolution } from './module-resolution.mjs';
|
|
9
10
|
import { cachedLoader } from './loader-cache.mjs';
|
|
@@ -61,7 +62,7 @@ async function targetConfig(state, target) {
|
|
|
61
62
|
const webpack = state.config.webpack ? (await import('webpack')).default : undefined;
|
|
62
63
|
const base = { mode:state.dev?'development':'production', context:state.root, resolve:{alias:{},extensions:['.tsx','.ts','.jsx','.js','.mjs','.json']},module:{rules:[]},plugins:[] };
|
|
63
64
|
const configured = state.config.webpack ? await state.config.webpack(base,{buildId:state.buildId,dev:state.dev,isServer:target!=='browser',nextRuntime:target==='browser'?undefined:target==='edge'?'edge':'nodejs',webpack,
|
|
64
|
-
defaultLoaders:{babel:{loader:new URL('./passthrough-loader.cjs',import.meta.url)
|
|
65
|
+
defaultLoaders:{babel:{loader:fileURLToPath(new URL('./passthrough-loader.cjs',import.meta.url))}}}) : base;
|
|
65
66
|
if (!configured || typeof configured !== 'object') throw new Error('webpack hook must return its configuration');
|
|
66
67
|
for (const key of Object.keys(configured)) if (!['mode','context','resolve','module','plugins','watchOptions'].includes(key)) throw new Error(`webpack.${key} cannot be translated to the PRNext compiler`);
|
|
67
68
|
const watchOptions = configured.watchOptions;
|
package/build/dev.mjs
CHANGED
|
@@ -50,6 +50,7 @@ export function createRefreshTransform(projectRoot) {
|
|
|
50
50
|
projectRoot = path.resolve(projectRoot);
|
|
51
51
|
const canonicalRoot = realpath(projectRoot).catch(() => projectRoot);
|
|
52
52
|
return async (source, filename) => {
|
|
53
|
+
filename = path.resolve(filename);
|
|
53
54
|
if (filename.includes(`${path.sep}node_modules${path.sep}`) || filename.includes(`${path.sep}.prnext-build-`)) return source;
|
|
54
55
|
// Bundlers resolve symlinks (including /var -> /private/var on macOS).
|
|
55
56
|
// Compare against both spellings so temporary/linked apps still refresh.
|
package/build/index.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { rename } from '../runtime/fs.mjs';
|
|
1
2
|
import {frameworkImportName, frameworkRuntimeImportPattern} from './framework-imports.mjs';
|
|
2
3
|
import { expandLocales, localizedStaticPaths } from './i18n.mjs';
|
|
3
4
|
import { build as bundle, withCompilation, configureCompiler, compilerSource } from './compiler.mjs';
|
|
4
|
-
import { mkdir, readFile, writeFile,
|
|
5
|
+
import { mkdir, readFile, writeFile, rm, cp, stat, readdir } from 'node:fs/promises';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
8
|
import { createRequire } from 'node:module';
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readBuildDirectory } from '../runtime/build-directory.mjs';
|
|
4
|
+
|
|
5
|
+
export function explainRoute(route, manifest) {
|
|
6
|
+
const seeds = manifest.prerendered.filter(seed => seed.routeId === route.id || (!seed.routeId && seed.path === route.pattern));
|
|
7
|
+
const config = route.cacheConfig || {};
|
|
8
|
+
const reasons = [...(route.dynamicReasons || [])];
|
|
9
|
+
if (route.ssp) reasons.push('getServerSideProps runs for each request');
|
|
10
|
+
if (route.gip) reasons.push('page getInitialProps runs for each request');
|
|
11
|
+
if (route.appGip && !route.ssg) reasons.push('custom App getInitialProps disables automatic static rendering');
|
|
12
|
+
if (config.dynamic === 'force-dynamic') reasons.push('dynamic is force-dynamic');
|
|
13
|
+
if (config.revalidate === 0) reasons.push('revalidate is 0');
|
|
14
|
+
if (config.forceNoStore || config.fetchCache === 'force-no-store') reasons.push('route data cache is disabled');
|
|
15
|
+
if (config.runtime === 'edge') reasons.push('Edge runtime');
|
|
16
|
+
if (!reasons.length && !seeds.length && !route.ssg) reasons.push(route.kind === 'api' ? 'request handler' : route.router === 'app' ? 'request-time rendering or paths not generated at build time' : 'getServerSideProps, getInitialProps, or another request-time dependency');
|
|
17
|
+
const partial = Boolean(route.ppr || route.pprGeneric || route.fallback === 'ppr');
|
|
18
|
+
const mode = partial ? 'partial' : seeds.length || route.ssg ? 'static / ISR' : 'dynamic';
|
|
19
|
+
return { pattern: route.pattern, router: route.router || 'pages', kind: route.kind, mode,
|
|
20
|
+
reasons: [...new Set(reasons)], prerenderedPaths: seeds.length,
|
|
21
|
+
revalidate: [...new Set(seeds.map(seed => seed.revalidate ?? false))],
|
|
22
|
+
invalidation: { tags: [...new Set(seeds.flatMap(seed => seed.tags || []))], paths: [...new Set(seeds.flatMap(seed => seed.paths || []))] } };
|
|
23
|
+
}
|
|
24
|
+
export async function inspectProject(directory) {
|
|
25
|
+
const root = path.resolve(directory), output = await readBuildDirectory(root);
|
|
26
|
+
let manifest;
|
|
27
|
+
try { manifest = JSON.parse(await readFile(path.join(root, output, 'manifest.json'), 'utf8')); }
|
|
28
|
+
catch (error) { throw new Error(`Cannot read a build in ${root}. Run prn build first.`, { cause: error }); }
|
|
29
|
+
const folder = path.join(root, '.prnext-cache/inspect');
|
|
30
|
+
let names = [];
|
|
31
|
+
try { names = (await readdir(folder)).filter(name => /^(?:worker-\d+-\d+|native-\d+)\.jsonl(?:\.1)?$/.test(name)); }
|
|
32
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
33
|
+
const files = await Promise.all(names.map(async name => ({ name, info: await stat(path.join(folder, name)).catch(() => null) })));
|
|
34
|
+
const events = [];
|
|
35
|
+
for (const { name, info } of files.filter(value => value.info?.isFile() && value.info.size <= 2 * 1024 ** 2).sort((a, b) => b.info.mtimeMs - a.info.mtimeMs).slice(0, 64)) {
|
|
36
|
+
const source = await readFile(path.join(folder, name), 'utf8').catch(() => '');
|
|
37
|
+
for (const line of source.split('\n')) { try { const value = JSON.parse(line); if (typeof value.time === 'number' && typeof value.type === 'string') events.push(value); } catch {} }
|
|
38
|
+
}
|
|
39
|
+
events.sort((a, b) => a.time - b.time);
|
|
40
|
+
const recent = events.slice(-10000), groups = new Map(), entries = new Map();
|
|
41
|
+
for (const event of recent) {
|
|
42
|
+
if (event.type === 'cache' && typeof event.key === 'string') entries.set(event.key, { key: event.key, route: event.route, state: event.state, tags: event.tags || [], paths: event.paths || [], time: event.time });
|
|
43
|
+
if (!['http', 'fetch', 'cache'].includes(event.type)) continue;
|
|
44
|
+
const key = JSON.stringify([event.type, event.route, event.destination || null, event.state || null, event.reason || null]);
|
|
45
|
+
if (!groups.has(key)) groups.set(key, { type: event.type, route: event.route, destination: event.destination, state: event.state, reason: event.reason, count: 0, durations: [], errors: 0 });
|
|
46
|
+
const group = groups.get(key); group.count++; if (event.status >= 500 || event.error) group.errors++;
|
|
47
|
+
if (Number.isFinite(event.durationMs)) group.durations.push(event.durationMs);
|
|
48
|
+
}
|
|
49
|
+
const measurements = [...groups.values()].map(({ durations, ...group }) => {
|
|
50
|
+
durations.sort((a, b) => a - b);
|
|
51
|
+
return { ...group, ...(durations.length ? { meanMs: Math.round(durations.reduce((a, b) => a + b, 0) / durations.length * 100) / 100, p95Ms: durations[Math.ceil(durations.length * .95) - 1], maxMs: durations.at(-1) } : {}) };
|
|
52
|
+
}).sort((a, b) => (b.p95Ms || 0) - (a.p95Ms || 0));
|
|
53
|
+
return { version: 1, root, buildId: manifest.buildId, routes: manifest.routes.filter(route => !route.internal).map(route => explainRoute(route, manifest)),
|
|
54
|
+
measurements, cacheEntries: [...entries.values()].slice(-100), invalidations: recent.filter(event => event.type === 'invalidation').slice(-50), dynamicUsage: recent.filter(event => event.type === 'dynamic').slice(-50),
|
|
55
|
+
sample: { events: recent.length, from: recent[0]?.time, to: recent.at(-1)?.time },
|
|
56
|
+
notes: ['Enable measurements with prn start --inspect (or prn dev --inspect), visit routes, then run prn inspect again.',
|
|
57
|
+
'HTTP and fetch durations end when response headers are ready. Samples are bounded and can span server sessions; they are not full-response benchmarks.',
|
|
58
|
+
'Fetch destinations omit queries and credentials. Runtime cache tags/paths are hashed; matching identifiers link reads to invalidations. No diagnostic HTTP endpoint is exposed.'] };
|
|
59
|
+
}
|
|
60
|
+
export function printInspection(report) {
|
|
61
|
+
console.log(`PRNext inspector: ${report.root}`);
|
|
62
|
+
for (const route of report.routes) console.log(`${route.mode.padEnd(13)} ${route.pattern}\n ${route.reasons.join('; ') || `${route.prerenderedPaths} prerendered path(s); revalidate: ${route.revalidate.join(', ') || 'on demand'}`}`);
|
|
63
|
+
for (const item of report.measurements) console.log(`${item.type.toUpperCase()} ${item.route}${item.destination ? ' -> ' + item.destination : ''}: ${item.count} sample(s)${item.state ? ', ' + item.state : ''}${item.reason ? ' (' + item.reason + ')' : ''}${item.p95Ms !== undefined ? ', p95 ' + item.p95Ms.toFixed(2) + ' ms' : ''}, ${item.errors} error(s)`);
|
|
64
|
+
for (const event of report.invalidations) console.log(`INVALIDATION ${event.route}: ${event.mode}; tags ${(event.tags || []).join(', ') || 'none'}; paths ${(event.paths || []).join(', ') || 'none'}`);
|
|
65
|
+
for (const event of report.cacheEntries) console.log(`CACHE ENTRY ${event.key} ${event.route}: ${event.state}; tags ${event.tags.join(', ') || 'none'}; paths ${event.paths.join(', ') || 'none'}`);
|
|
66
|
+
for (const message of new Set(report.dynamicUsage.map(event => `DYNAMIC ${event.route}: ${event.reason}`))) console.log(message);
|
|
67
|
+
for (const note of report.notes) console.log(note);
|
|
68
|
+
}
|
package/build/loader-cache.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { rename } from '../runtime/fs.mjs';
|
|
1
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
3
|
import { createRequire } from 'node:module';
|
|
3
|
-
import { readFile, writeFile, mkdir,
|
|
4
|
+
import { readFile, writeFile, mkdir, rm, readdir, stat } from 'node:fs/promises';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
const require = createRequire(import.meta.url);
|
|
6
7
|
const hash = value => createHash('sha256').update(value).digest('hex');
|
package/build/migrate.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { rename } from '../runtime/fs.mjs';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { fileURLToPath } from 'node:url';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
4
5
|
import { spawn } from 'node:child_process';
|
|
5
6
|
import { constants } from 'node:fs';
|
|
6
|
-
import { readFile, writeFile,
|
|
7
|
+
import { readFile, writeFile, rm, lstat, realpath, access } from 'node:fs/promises';
|
|
7
8
|
import { checkProject } from './check.mjs';
|
|
8
9
|
import { sourceCheckout } from '../native/resolve.mjs';
|
|
9
10
|
|
|
@@ -21,14 +22,22 @@ async function optionalFile(file) {
|
|
|
21
22
|
export function parseMigrationArgs(args) {
|
|
22
23
|
const options = { dryRun: false, install: true, json: false };
|
|
23
24
|
let directory;
|
|
24
|
-
for (
|
|
25
|
+
for (let index = 0; index < args.length; index++) {
|
|
26
|
+
const arg = args[index];
|
|
25
27
|
if (arg === '--dry-run') options.dryRun = true;
|
|
28
|
+
else if (arg === '--check') options.check = true;
|
|
29
|
+
else if (['--against', '--candidate', '--routes', '--timeout'].includes(arg)) {
|
|
30
|
+
const value = args[++index];
|
|
31
|
+
if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value.`);
|
|
32
|
+
options[arg.slice(2)] = arg === '--timeout' ? Number(value) : value;
|
|
33
|
+
}
|
|
26
34
|
else if (arg === '--no-install') options.install = false;
|
|
27
35
|
else if (arg === '--json') options.json = true;
|
|
28
|
-
else if (arg.startsWith('-')) throw new Error(`Unknown migrate option: ${arg}. Use --dry-run, --no-install or --json.`);
|
|
36
|
+
else if (arg.startsWith('-')) throw new Error(`Unknown migrate option: ${arg}. Use --check, --dry-run, --no-install or --json.`);
|
|
29
37
|
else if (directory === undefined) directory = arg;
|
|
30
38
|
else throw new Error('migrate accepts a single project directory.');
|
|
31
39
|
}
|
|
40
|
+
if ((options.against || options.candidate || options.routes || options.timeout !== undefined) && (!options.check || !options.against || !options.candidate)) throw new Error('Server comparison requires --check, --against and --candidate.');
|
|
32
41
|
return { directory: path.resolve(directory || '.'), options };
|
|
33
42
|
}
|
|
34
43
|
|
|
@@ -52,13 +61,15 @@ function words(script) {
|
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
export function migrateScript(script, command) {
|
|
55
|
-
|
|
64
|
+
// PRN is a reserved device name in older Windows shells. Generated scripts
|
|
65
|
+
// must remain portable even when migration runs on another operating system.
|
|
66
|
+
if (script === undefined) return { value: `prnext ${command}`, removed: [] };
|
|
56
67
|
if (typeof script !== 'string') throw new Error(`scripts.${command} must be a string.`);
|
|
57
68
|
// Existing PRNext scripts, including customized wrappers, are not rewritten.
|
|
58
69
|
if (/^(?:prn|prnext)\s+/.test(script.trim())) return { value: script, removed: [] };
|
|
59
70
|
let tokens;
|
|
60
71
|
try { tokens = words(script); }
|
|
61
|
-
catch (error) { throw new Error(`scripts.${command} ${error.message}. Adapt it to
|
|
72
|
+
catch (error) { throw new Error(`scripts.${command} ${error.message}. Adapt it to prnext ${command} manually before migrating; no scripts were overwritten.`); }
|
|
62
73
|
const prefix = [];
|
|
63
74
|
let environmentPort;
|
|
64
75
|
if (tokens[0]?.value === 'cross-env') prefix.push(tokens.shift().raw);
|
|
@@ -73,11 +84,11 @@ export function migrateScript(script, command) {
|
|
|
73
84
|
}
|
|
74
85
|
const executable = tokens.shift()?.value;
|
|
75
86
|
if (!['next', 'rx'].includes(executable) || tokens.shift()?.value !== command) {
|
|
76
|
-
throw new Error(`scripts.${command} is customized. Adapt it to
|
|
87
|
+
throw new Error(`scripts.${command} is customized. Adapt it to prnext ${command} manually before migrating; no scripts were overwritten.`);
|
|
77
88
|
}
|
|
78
89
|
// Upgrade the former shortcut without interpreting its native PRNext options
|
|
79
90
|
// as Next.js bundler flags. Complex shell programs still require a manual edit.
|
|
80
|
-
if (executable === 'rx') return { value: [...prefix, '
|
|
91
|
+
if (executable === 'rx') return { value: [...prefix, 'prnext', command, ...tokens.map(token => token.raw)].join(' '), removed: [] };
|
|
81
92
|
const kept = [], removed = [];
|
|
82
93
|
let directory = false, explicitPort = false;
|
|
83
94
|
for (let i = 0; i < tokens.length; i++) {
|
|
@@ -105,7 +116,7 @@ export function migrateScript(script, command) {
|
|
|
105
116
|
if (!/^\d+$/.test(environmentPort) || Number(environmentPort) > 65535) throw new Error(`scripts.${command}: PORT must be a literal port number. Adapt the script before migrating.`);
|
|
106
117
|
kept.push('--port', environmentPort);
|
|
107
118
|
}
|
|
108
|
-
return { value: [...prefix, '
|
|
119
|
+
return { value: [...prefix, 'prnext', command, ...kept].join(' '), removed };
|
|
109
120
|
}
|
|
110
121
|
|
|
111
122
|
export function migrationPackage(input, framework, spec) {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { parse } from '@babel/parser';
|
|
2
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { checkProject } from './check.mjs';
|
|
6
|
+
import { frameworkImportName } from './framework-imports.mjs';
|
|
7
|
+
import { packageManifest } from '../native/resolve.mjs';
|
|
8
|
+
|
|
9
|
+
const supported = new Set([...Object.keys(packageManifest.exports).map(name => name.slice(2)), 'font/google', 'font/local', 'next-router-context', 'next-app-router-context']);
|
|
10
|
+
const ignored = new Set(['node_modules', '.git', '.next', '.prnext', '.prnext-cache', 'dist', 'build', 'out', 'coverage', 'test-results', '__tests__']);
|
|
11
|
+
const code = /\.(?:[cm]?[jt]s|[jt]sx)$/;
|
|
12
|
+
function walkAst(node, visit) {
|
|
13
|
+
if (!node || typeof node !== 'object') return;
|
|
14
|
+
if (typeof node.type === 'string') visit(node);
|
|
15
|
+
for (const [key, value] of Object.entries(node)) {
|
|
16
|
+
if (['loc', 'comments', 'tokens', 'extra'].includes(key)) continue;
|
|
17
|
+
if (Array.isArray(value)) for (const child of value) walkAst(child, visit);
|
|
18
|
+
else if (value && typeof value === 'object') walkAst(value, visit);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A bounded source audit. It never runs a build, installs packages, or edits the project. */
|
|
23
|
+
export async function auditMigration(directory) {
|
|
24
|
+
const root = path.resolve(directory);
|
|
25
|
+
const preflight = await checkProject(root, { validateDependencies: false });
|
|
26
|
+
const report = { version: 1, root, ok: false, status: 'checked', files: 0, routes: preflight.routes,
|
|
27
|
+
versions: preflight.versions, findings: [], imports: [], notes: [
|
|
28
|
+
'This source audit does not execute application routes or inspect every installed dependency. Run a production build and browser tests before switching.',
|
|
29
|
+
'Configuration files are evaluated by preflight. Application sources and package files are not modified.',
|
|
30
|
+
] };
|
|
31
|
+
const finding = (severity, id, file, line, message, action) => report.findings.push({ severity, id, file, line, message, action });
|
|
32
|
+
for (const error of preflight.errors) finding('error', 'preflight', null, null, error, 'Fix the configuration or route convention, then run the check again.');
|
|
33
|
+
let bytes = 0;
|
|
34
|
+
async function scan(dir) {
|
|
35
|
+
for (const item of (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
36
|
+
if (ignored.has(item.name) || item.name.startsWith('.prnext-')) continue;
|
|
37
|
+
const filename = path.join(dir, item.name), file = path.relative(root, filename).split(path.sep).join('/');
|
|
38
|
+
if (item.isSymbolicLink()) { finding('warning', 'linked-source', file, null, 'Linked source was not scanned.', 'Check the linked package during the production build.'); continue; }
|
|
39
|
+
if (item.isDirectory()) { await scan(filename); continue; }
|
|
40
|
+
if (!item.isFile() || !code.test(item.name) || /(?:\.d|\.test|\.spec)\.[cm]?[jt]sx?$/.test(item.name)) continue;
|
|
41
|
+
if (++report.files > 5000 || (bytes += (await stat(filename)).size) > 32 * 1024 ** 2) throw new Error('Source audit exceeds 5000 files or 32 MiB. Choose the application directory.');
|
|
42
|
+
const source = await readFile(filename, 'utf8');
|
|
43
|
+
let ast;
|
|
44
|
+
try { ast = parse(source, { sourceType: 'unambiguous', plugins: ['jsx', ...( /\.[cm]?tsx?$/.test(item.name) ? ['typescript'] : []), 'decorators-legacy'] }); }
|
|
45
|
+
catch (error) { finding('warning', 'source-syntax', file, error.loc?.line || 1, 'The audit could not parse this source file.', 'Validate it with the production compiler; custom loaders may be required.'); continue; }
|
|
46
|
+
const client = ast.program.directives.some(directive => directive.value.value === 'use client');
|
|
47
|
+
walkAst(ast.program, node => {
|
|
48
|
+
const specifier = ['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration'].includes(node.type) && node.importKind !== 'type' && node.exportKind !== 'type' ? node.source?.value
|
|
49
|
+
: node.type === 'CallExpression' && (node.callee.type === 'Import' || node.callee.name === 'require') ? node.arguments[0]?.value : undefined;
|
|
50
|
+
if (typeof specifier !== 'string' || !(specifier === 'next' || specifier.startsWith('next/'))) return;
|
|
51
|
+
if (node.type === 'ImportDeclaration' && node.specifiers.length && node.specifiers.every(value => value.importKind === 'type')) return;
|
|
52
|
+
const name = frameworkImportName(specifier), available = supported.has(name);
|
|
53
|
+
report.imports.push({ file, line: node.loc.start.line, specifier, supported: available });
|
|
54
|
+
if (!available) finding('error', 'unsupported-import', file, node.loc.start.line, `No PRNext runtime adapter for ${specifier}.`, 'Replace this import with a supported public API or keep this route on Next.js.');
|
|
55
|
+
else if (client && ['headers', 'cache', 'server', 'og'].includes(name)) finding('error', 'server-import-in-client', file, node.loc.start.line, `${specifier} is imported by a client module.`, 'Move the server operation into a Server Component or Server Action.');
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
try { await scan(root); }
|
|
60
|
+
catch (error) { finding('error', 'scan-incomplete', null, null, error.message, 'Resolve the file access or size limit and run the check again.'); }
|
|
61
|
+
let pkg = {};
|
|
62
|
+
try { pkg = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); }
|
|
63
|
+
catch (error) { finding('error', 'package-json', 'package.json', null, error.message, 'Provide a valid package.json in the application directory.'); }
|
|
64
|
+
const { migrateScript } = await import('./migrate.mjs');
|
|
65
|
+
for (const command of ['dev', 'build', 'start']) {
|
|
66
|
+
try { migrateScript(pkg.scripts?.[command], command); }
|
|
67
|
+
catch (error) { finding('warning', 'custom-script', 'package.json', null, error.message, `Keep a backup of the command and adapt scripts.${command} manually.`); }
|
|
68
|
+
}
|
|
69
|
+
const expected = packageManifest.peerDependencies;
|
|
70
|
+
for (const name of ['react', 'react-dom', ...(report.routes.some(route => route.router === 'app') ? ['react-server-dom-webpack'] : [])]) {
|
|
71
|
+
if (report.versions[name] !== expected[name]) finding('warning', 'react-version', 'package.json', null, `${name}: ${report.versions[name] || 'not installed'}; PRNext expects ${expected[name]}.`, 'Review the dependency changes with prn migrate --dry-run.');
|
|
72
|
+
}
|
|
73
|
+
report.ok = !report.findings.some(item => item.severity === 'error');
|
|
74
|
+
return report;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function origin(value) {
|
|
78
|
+
const url = new URL(value);
|
|
79
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('Comparison servers must be HTTP(S) origins without credentials, paths, or queries.');
|
|
80
|
+
return url.origin;
|
|
81
|
+
}
|
|
82
|
+
const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object'
|
|
83
|
+
? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
|
|
84
|
+
export function comparableBody(body, type) {
|
|
85
|
+
if (type.includes('json')) return JSON.stringify(canonical(JSON.parse(body)));
|
|
86
|
+
if (type.includes('html')) return body.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '').replace(/<!--[\s\S]*?-->/g, '')
|
|
87
|
+
.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
88
|
+
return body;
|
|
89
|
+
}
|
|
90
|
+
async function probe(base, route, timeout) {
|
|
91
|
+
const started = performance.now();
|
|
92
|
+
const response = await fetch(base + route, { redirect: 'manual', signal: AbortSignal.timeout(timeout) });
|
|
93
|
+
const headersMs = performance.now() - started;
|
|
94
|
+
let size = 0;
|
|
95
|
+
const chunks = [];
|
|
96
|
+
if (response.body) for await (const chunk of response.body) {
|
|
97
|
+
if ((size += chunk.length) > 2 * 1024 ** 2) throw new Error('Comparison response exceeds 2 MiB.');
|
|
98
|
+
chunks.push(chunk);
|
|
99
|
+
}
|
|
100
|
+
const type = (response.headers.get('content-type') || '').split(';')[0].toLowerCase();
|
|
101
|
+
const location = response.headers.get('location');
|
|
102
|
+
const target = location ? new URL(location, base + route) : null;
|
|
103
|
+
const bytes = Buffer.concat(chunks);
|
|
104
|
+
const body = type.includes('json') || type.includes('html') || type.startsWith('text/') ? comparableBody(bytes.toString('utf8'), type) : bytes;
|
|
105
|
+
return { status: response.status, type, redirect: target ? (target.origin === base ? '' : target.origin) + target.pathname + target.search + target.hash : null,
|
|
106
|
+
bodySha256: createHash('sha256').update(body).digest('hex'), bytes: size, headersMs: Math.round(headersMs * 100) / 100,
|
|
107
|
+
totalMs: Math.round((performance.now() - started) * 100) / 100, cacheControl: response.headers.get('cache-control') };
|
|
108
|
+
}
|
|
109
|
+
export async function compareServers({ against, candidate, routes, timeout = 10000 }) {
|
|
110
|
+
const reference = origin(against), current = origin(candidate);
|
|
111
|
+
if (!Array.isArray(routes) || !routes.length || routes.length > 100 || routes.some(route => typeof route !== 'string' || !route.startsWith('/') || route.startsWith('//') || /[\\\r\n#]/.test(route) || new URL(route, reference).origin !== reference)) throw new Error('Provide 1–100 local URL paths, including concrete values for dynamic routes.');
|
|
112
|
+
if (!Number.isInteger(timeout) || timeout < 100 || timeout > 60000) throw new Error('Comparison timeout must be 100–60000 milliseconds.');
|
|
113
|
+
const results = [];
|
|
114
|
+
for (const route of [...new Set(routes)]) {
|
|
115
|
+
const values = await Promise.allSettled([probe(reference, route, timeout), probe(current, route, timeout)]);
|
|
116
|
+
if (values.some(value => value.status === 'rejected')) {
|
|
117
|
+
results.push({ route, equal: false, error: values.map((value, index) => value.status === 'rejected' ? `${index ? 'candidate' : 'reference'}: ${value.reason.message}` : null).filter(Boolean).join('; ') });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const [before, after] = values.map(value => value.value);
|
|
121
|
+
const differences = ['status', 'type', 'redirect', 'bodySha256'].filter(key => before[key] !== after[key]);
|
|
122
|
+
results.push({ route, equal: differences.length === 0, differences, reference: before, candidate: after });
|
|
123
|
+
}
|
|
124
|
+
return { ok: results.every(result => result.equal), reference, candidate: current, results,
|
|
125
|
+
scope: 'GET status, content type, redirect target and body comparison. HTML compares text without scripts/styles; JSON ignores object key order. Headers timings are observations, not a benchmark. Browser behavior and authenticated requests require separate tests.' };
|
|
126
|
+
}
|
package/build/precompress.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { rename } from '../runtime/fs.mjs';
|
|
1
2
|
import { createReadStream, createWriteStream } from 'node:fs';
|
|
2
|
-
import { readdir, rm, stat, utimes, mkdir, copyFile
|
|
3
|
+
import { readdir, rm, stat, utimes, mkdir, copyFile } from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { pipeline } from 'node:stream/promises';
|
|
5
6
|
import { createGzip, createGunzip } from 'node:zlib';
|
|
@@ -6,7 +6,7 @@ export async function prepareStandalone(options) {
|
|
|
6
6
|
const { output, distDir, outputFileTracingRoot, outputFileTracingIncludes, outputFileTracingExcludes } = options.config;
|
|
7
7
|
const worker = new Worker(new URL('./standalone-worker.mjs', import.meta.url), {
|
|
8
8
|
workerData: { ...options, config: { output, distDir, outputFileTracingRoot, outputFileTracingIncludes, outputFileTracingExcludes } },
|
|
9
|
-
execArgv:
|
|
9
|
+
execArgv: [],
|
|
10
10
|
});
|
|
11
11
|
try {
|
|
12
12
|
await new Promise((resolve, reject) => {
|