@thomas.f/prnext 0.1.13-alpha → 0.1.15-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
@@ -1,51 +1,21 @@
1
- <a href="https://prnext.dev"><img src="https://prnext.dev/favicon.svg" alt="PRNext logo" width="80" height="80"></a>
1
+ <p align="center">
2
+ <a href="https://prnext.dev">
3
+ <img src="https://raw.githubusercontent.com/Lorkydey/PRNext/main/.github/assets/prnext-logo.svg" alt="PRNext logo" width="112" height="112">
4
+ </a>
5
+ </p>
2
6
 
3
- # PRNext
7
+ <h1 align="center">PRNext</h1>
4
8
 
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)
9
+ <p align="center">
10
+ <a href="https://www.npmjs.com/package/@thomas.f/prnext"><img src="https://img.shields.io/npm/v/%40thomas.f%2Fprnext/alpha?style=for-the-badge&amp;label=npm&amp;labelColor=161b22&amp;color=007ec6" alt="Current npm alpha version" height="28" align="absmiddle"></a> &nbsp;
11
+ <a href="https://github.com/Lorkydey/PRNext/blob/main/LICENSE"><img src="https://img.shields.io/github/license/Lorkydey/PRNext?style=for-the-badge&amp;label=license&amp;labelColor=161b22&amp;color=98d6ac" alt="MIT License" height="28" align="absmiddle"></a> &nbsp;
12
+ <a href="https://x.com/PRNextdev"><img src="https://img.shields.io/badge/-%40PRNextdev-171717?style=for-the-badge&amp;logo=x&amp;logoColor=white" alt="Follow @PRNextdev on X" height="28" align="absmiddle"></a>
13
+ </p>
6
14
 
7
- A Rust-powered runtime for Next.js projects, with React and npm modules running on Node.js.
15
+ A Rust-powered web runtime with React rendering on Node.js and a static adapter for completed browser builds.
8
16
 
9
17
  > **Very early alpha.** Built for experimentation and feedback. Next.js compatibility is partial; expect bugs, missing features and breaking changes.
10
18
 
11
- ## Latest update
12
-
13
- **0.1.13-alpha — worker lifecycle, streaming and routing.** Lower Linux production-launcher overhead, parent-death containment for Linux workers, and earlier release of completed RSC response resources while background cache work remains supervised. Streaming API and middleware responses reuse their inactivity timer, preserving timeout and cancellation behavior. Ready response producers yield after bounded batches, including empty chunks, so network events and cancellation timers can run. Profile admission, V8 and idle policies remain distinct; see the runtime profile notes below.
14
-
15
- The source candidate reuses immutable production App Router routing plans, decodes the destination pathname once per request and reduces temporary segment arrays. Component exports and configuration remain live; dynamic output and request parameters are not cached. Rebuild to enable these plans; older/mutable routing trees and development retain the compatible live traversal. These changes do not establish a general HTTP performance advantage over Next.js.
16
-
17
- 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 remain part of the release path. Rebuild dynamic applications with `prnext build` after upgrading, then restart them.
18
-
19
- Shutdown now drains tracked worker and ISR work before closing private cache services. Standalone service shutdown shares a 30-second deadline between HTTP responses and background work. Ordinary Host sleep and capacity eviction allow up to 300 seconds of tracked work, covering the supported `maxDuration` range, followed by process cleanup. Global Host shutdown remains bounded to 35 seconds; memory-budget violations retain their shorter enforced stop. Monitoring continues while applications drain. This protects supported `after()` callbacks, not arbitrary timers or durable jobs.
20
-
21
- The October 15 comparison of a frozen local candidate with Next.js 16.4.1 measured `balanced` SSR at 365 vs 281 req/s on Windows and 218 vs 158 on Linux, with cumulative server RSS of 182 vs 362 MiB and 149 vs 247 MiB respectively. Production, one server CPU, 64 connections, five 30-second runs after 10-second warmups, the same 100-row application, no dynamic page cache. These measurements precede the final shutdown fix and are not final-package performance certification. TanStack Start was faster on the App Router-equivalent SSR, streaming and JSON workloads; PRNext does not claim a universal performance lead. See the [release methodology and limits](https://github.com/Lorkydey/PRNext/blob/main/RELEASE.md#performance-evidence).
22
-
23
- 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.
24
-
25
- <details>
26
- <summary><strong>0.1.1-alpha · October 8, 2026 · Features & fixes</strong></summary>
27
-
28
- **README update:** `0.1.11-alpha` updates the README only; runtime features and fixes remain those of `0.1.1-alpha`.
29
-
30
- ### Added
31
-
32
- - **Windows x64 & ARM64:** native packages alongside macOS and Linux glibc support.
33
- - **Persistent apps:** background processes, crash recovery, rotating logs, health-checked replacements and optional restoration at login.
34
- - **Multi-app hosting:** hostname routing, on-demand startup, idle sleep and sampled memory budgets.
35
- - **Migration checks:** source audits and optional GET response comparisons with `migrate --check`.
36
- - **Inspector:** opt-in route, cache and request timing diagnostics.
37
-
38
- ### Fixed
39
-
40
- - Standalone dependency tracing for Windows drives, scoped packages and workspace aliases.
41
- - Development process shutdown and file replacement on Windows.
42
- - Cold-worker connection bursts on macOS.
43
- - Windows package publishing and portable `prnext` migration scripts.
44
-
45
- </details>
46
-
47
- [Full changelog](https://github.com/Lorkydey/PRNext/blob/main/CHANGELOG.md)
48
-
49
19
  ## Install & upgrade
50
20
 
51
21
  Node.js **22+** · Windows, macOS and Linux glibc · x64 and ARM64
@@ -331,6 +301,27 @@ Under concurrent streaming, `memory` used 179 MiB versus 207 MiB for `balanced`,
331
301
 
332
302
  ## Guides
333
303
 
304
+ ### Build adapters
305
+
306
+ PRNext keeps its React/Next-convention build as the default. The source candidate
307
+ also imports static browser builds produced by Vite or another framework:
308
+
309
+ ```sh
310
+ npm run build
311
+ npx --no-install prnext build --adapter static --from dist --spa
312
+ npx --no-install prnext start
313
+ ```
314
+
315
+ Use `--spa` only for client-side routing. Omit it for a multi-page site. PRNext
316
+ imports the completed public files; it does not compile Vue/Svelte source or run
317
+ another framework's SSR server. Static requests require no Node render worker.
318
+ This adapter needs the updated native binary built from the same source candidate;
319
+ it is not part of the previously published alpha package.
320
+
321
+ Every new build writes a versioned `build-output.json`. Inspect it with
322
+ `prnext output --json`. See the [adapter contract](packages/prnext/build/ADAPTERS.md)
323
+ and [compatibility scope](COMPATIBILITY.md) for supported inputs and boundaries.
324
+
334
325
  <details>
335
326
  <summary><strong>Deploy to PRNext Cloud (local development)</strong></summary>
336
327
 
@@ -423,10 +414,24 @@ prnext start ./my-app --inspect
423
414
  # In another terminal, after visiting the application:
424
415
  prnext inspect ./my-app
425
416
  prnext inspect ./my-app --json
417
+ prnext inspect ./my-app --otlp > metrics.json
426
418
  ```
427
419
 
428
420
  The inspector explains static, dynamic and partially prerendered routes using build metadata, including observed App prerendering bailouts and Pages data hooks. Opt-in measurements show native HTTP timings, fetch destinations and timings, native data cache hits/misses/stale reads, bypass reasons and successful tag/path invalidations. A custom cache handler is identified as a delegation; its internal hit rate is not inferred. Native page-file cache decisions and client router caching are not counted as data-cache hits.
429
421
 
422
+ The summary adds sampled HTTP p50/p95, server errors, worker startup p95, current
423
+ worker queue/activity and recent Node worker RSS. Missing or stale resource samples
424
+ are shown as unavailable, never as zero. RSS excludes the native server; worker
425
+ threads sharing a PID are counted once. `start --inspect` also enables bounded native
426
+ counter snapshots. These opt-in diagnostics do not change normal serving defaults.
427
+
428
+ `inspect --otlp` writes an OTLP/JSON Metrics payload for a bounded local sample.
429
+ The custom `prnext.http.response.headers.duration` histogram uses seconds and stops
430
+ at response headers, excluding streaming body time. It is deliberately not named
431
+ `http.server.request.duration`. Samples can span sessions and overlap repeated
432
+ exports; this is a diagnostic snapshot, not a continuous traffic or billing counter.
433
+ No collector connection or public diagnostics endpoint is enabled automatically.
434
+
430
435
  Timings end when headers are ready, so streaming body time is excluded. Samples are bounded, can span process sessions, and are written under the app's `.prnext-cache/inspect` directory. Request bodies, headers, cookies, URL queries and credentials are omitted; runtime tag/path identifiers are hashed consistently. Logs retain at most 1 MiB per segment plus a previous segment, with old process sessions pruned. Under load, diagnostics may drop samples. `--inspect` also works with `prnext dev`; standalone/native launches can set `PRNEXT_INSPECT_DIR` explicitly. No public diagnostic endpoint is added.
431
436
 
432
437
  </details>
@@ -545,6 +550,12 @@ For source builds, `cargo build --release --locked --features gzip-zlib-rs` enab
545
550
 
546
551
  </details>
547
552
 
553
+ ## Contributing
554
+
555
+ Read the [contribution guide](https://github.com/Lorkydey/PRNext/blob/main/CONTRIBUTING.md),
556
+ [Code of Conduct](https://github.com/Lorkydey/PRNext/blob/main/CODE_OF_CONDUCT.md)
557
+ and [security policy](https://github.com/Lorkydey/PRNext/blob/main/SECURITY.md).
558
+
548
559
  <details>
549
560
  <summary><strong>License & credits</strong></summary>
550
561
 
@@ -0,0 +1,121 @@
1
+ # Build output contract v1
2
+
3
+ This contract describes the adapter and entrypoints of a completed PRNext build.
4
+ It is distinct from `runtime-contract.json`, which fingerprints the React worker
5
+ implementation and enforces lifecycle guarantees. It is not a signature or an
6
+ integrity check for arbitrary files, and build artifacts must remain trusted.
7
+
8
+ ## Output selection
9
+
10
+ The project-level `.prnext-output.json` selects a relative build directory, usually
11
+ `.prnext`. Paths in `build-output.json` are relative to that directory. The runtime
12
+ does not evaluate project configuration to start a completed build.
13
+
14
+ Every new React and static build emits all seven fields below. Unknown fields,
15
+ versions, adapters, entrypoints and inconsistent build identities are rejected by
16
+ the native server before listening. Existing React builds without this metadata
17
+ retain their original runtime-contract checks. Static builds require it.
18
+
19
+ ```json
20
+ {
21
+ "version": 1,
22
+ "adapter": "react",
23
+ "buildId": "a-build-identifier",
24
+ "manifest": "manifest.json",
25
+ "assets": "assets",
26
+ "server": {
27
+ "entry": "runtime/worker.mjs",
28
+ "runtimeContract": "runtime-contract.json"
29
+ },
30
+ "fallback": null
31
+ }
32
+ ```
33
+
34
+ `buildId` must match the manifest and contain 1–128 ASCII letters, digits,
35
+ underscores or hyphens. Metadata is limited to 64 KiB. Entry paths are fixed in
36
+ v1; they cannot select an arbitrary executable or escape the build directory.
37
+ The React manifest continues to describe routing, caching and rendering details;
38
+ its implementation-specific fields are not made stable by this public envelope.
39
+
40
+ ## Static adapter
41
+
42
+ Build the application with its own tool, then import **only** its public browser
43
+ output. Vite's default output is `dist`; see its [deployment guide](https://vite.dev/guide/static-deploy.html).
44
+
45
+ ```sh
46
+ npm run build
47
+ prnext build --adapter static --from dist
48
+ prnext output --json
49
+ prnext start --hostname 127.0.0.1 --port 3000
50
+ ```
51
+
52
+ Programmatic equivalent:
53
+
54
+ ```js
55
+ import { build, buildStatic, readBuildOutput } from '@thomas.f/prnext';
56
+ await build(projectRoot, { adapter: 'static', from: 'dist', spa: true });
57
+ // Or: await buildStatic(projectRoot, { from: 'dist', spa: true });
58
+ const output = await readBuildOutput(buildDirectory);
59
+ ```
60
+
61
+ The static envelope has `adapter: "static"`, `assets: "static"`, `server: null`
62
+ and `fallback: null`. With `--spa`, fallback is `"index.html"`. Its manifest has:
63
+
64
+ ```json
65
+ {
66
+ "version": 1,
67
+ "buildId": "a-build-identifier",
68
+ "config": { "staticOnly": true, "staticFallback": false, "compress": true, "poweredByHeader": true },
69
+ "routes": [],
70
+ "prerendered": [
71
+ { "path": "/", "file": "static/index.html", "revalidate": false }
72
+ ]
73
+ }
74
+ ```
75
+
76
+ Every imported file has an exact, URL-encoded path in `prerendered`; `file` stays
77
+ relative to the build. `/index.html` also maps to `/`, `/docs/index.html` to `/docs`,
78
+ and `/about.html` to `/about`. Conflicting aliases fail the import. Normal native
79
+ trailing-slash redirects apply. No server routes, middleware, ISR or cache handler
80
+ can be declared. The project's separate `public/` directory is not served.
81
+
82
+ With SPA fallback enabled, unmatched GET/HEAD requests that explicitly accept
83
+ `text/html` can receive the root index. Existing files and pages take precedence.
84
+ Missing file extensions, `/api/*`, hidden paths and framework transport paths do
85
+ not fall back. JSON requests and POSTs never receive an SPA fallback. Apps using
86
+ dotted client route names need explicit HTML output for those URLs. Configure the
87
+ source builder's asset base for its deployed URL; the adapter does not rewrite JS
88
+ or HTML assets or infer subpath hosting configuration.
89
+
90
+ The source directory must be inside the project, separate from the destination,
91
+ with real directories and files. Symlinks, dotfiles other than a root `.well-known`
92
+ tree, `node_modules`, reserved `_next`/`_prnext` paths and a root `server` directory
93
+ are rejected. Limits are 20,000 files, 512 MiB total and 64 directory levels.
94
+ Environment files and server outputs must never be placed in public input.
95
+
96
+ Imports use a staging directory and publish the manifest, files and output pointer
97
+ together. A validation/copy failure leaves the previous build intact. Source files
98
+ and configuration are untouched. A project lock excludes concurrent React builds
99
+ and static imports, including separate processes and canonical path aliases.
100
+ Another active build fails clearly; a dead local owner is recovered. A lock from
101
+ another host, an unverifiable owner or an interrupted lock recovery fails closed;
102
+ check `.prnext-build.lock` manually only after confirming no build is active.
103
+ Finish the source framework build before importing its files.
104
+
105
+ Compressible files are precompressed with the same bounded gzip pipeline as React
106
+ builds. Source files are unchanged. Existing gzip sidecars must decode exactly to
107
+ their source; a mismatched sidecar fails the import and preserves the last build.
108
+
109
+ Serve with the updated native binary from this source candidate. Node can run the
110
+ CLI to build/start, but no Node worker or React installation is required to serve
111
+ static requests. `prnext host` can validate and host these completed artifacts.
112
+ Cloud source-build auto-detection is a separate integration; this change does not
113
+ change existing PRNext Host projects or enable generic SSR hosting.
114
+
115
+ ## Extending adapters
116
+
117
+ The current adapter names are `react` and `static`. Unknown adapters fail rather
118
+ than being silently interpreted as React. Add a new adapter only with a documented
119
+ runtime model and integration tests. A future generic Node SSR adapter needs its
120
+ own request/response, streaming, cancellation, crash and shutdown contract; a
121
+ `server` path alone does not implement those guarantees.
package/build/app.mjs CHANGED
@@ -391,6 +391,9 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
391
391
  define:{...common.define,'process.env.NEXT_RUNTIME':'"nodejs"'},
392
392
  chunkNames: 'chunk-[hash]', banner: { js: "import {createRequire as __prnextCreateRequire} from 'node:module';const require=__prnextCreateRequire(import.meta.url);" },
393
393
  target: 'node22', conditions: mode === 'rsc' ? ['react-server', 'node'] : ['node'], sourcemap: dev,
394
+ // Dual-format packages can retain use-client boundaries only in their
395
+ // ESM distribution (a bundled CommonJS entry can erase those directives).
396
+ mainFields: mode === 'rsc' ? ['module', 'main'] : ['main', 'module'],
394
397
  plugins: [moduleResolutionPlugin(moduleResolution, project.root), images.plugin(), fonts.plugin(), graphPlugin(mode, { styleTarget }), npmPackagesPlugin({ projectRoot: project.root, serverExternalPackages }), nativePackages.plugin(mode), cssModules.plugin(false)],
395
398
  });
396
399
  if (nativePackages.version !== nativeVersion) {
@@ -0,0 +1,71 @@
1
+ import { mkdir, writeFile, rename, rm, realpath, lstat, open } from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { hostname } from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ const LOCK = '.prnext-build.lock';
7
+ const busy = message => Object.assign(new Error(message), { code: 'PRNEXT_BUILD_LOCKED' });
8
+ function alive(pid) {
9
+ try { process.kill(pid, 0); return true; }
10
+ catch (error) { return error.code !== 'ESRCH'; }
11
+ }
12
+ async function owner(directory) {
13
+ const info = await lstat(directory);
14
+ if (!info.isDirectory() || info.isSymbolicLink()) throw busy('The build lock is not a real directory. Check .prnext-build.lock before retrying.');
15
+ const file = await open(path.join(directory, 'owner.json'), 'r');
16
+ try {
17
+ if ((await file.stat()).size > 4096) throw new Error('oversized owner');
18
+ const value = JSON.parse(await file.readFile('utf8'));
19
+ if (!Number.isSafeInteger(value.pid) || value.pid < 1 || typeof value.host !== 'string' || !/^[a-f0-9-]{36}$/.test(value.token)) throw new Error('invalid owner');
20
+ return value;
21
+ } finally { await file.close(); }
22
+ }
23
+
24
+ /** One publisher per canonical project, covering React and direct static imports. */
25
+ export async function withBuildLock(directory, action) {
26
+ const root = await realpath(directory), lock = path.join(root, LOCK);
27
+ const identity = { pid: process.pid, host: hostname(), token: randomUUID(), createdAt: new Date().toISOString() };
28
+ const prepared = path.join(root, `.prnext-lock-${identity.token}`);
29
+ await mkdir(prepared, { mode: 0o700 });
30
+ try {
31
+ await writeFile(path.join(prepared, 'owner.json'), JSON.stringify(identity), { flag: 'wx', mode: 0o600 });
32
+ for (let attempt = 0; ; attempt++) {
33
+ try { await rename(prepared, lock); break; }
34
+ catch (error) {
35
+ if (!['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(error.code)) throw error;
36
+ let previous;
37
+ try { previous = await owner(lock); }
38
+ catch (cause) {
39
+ if (cause.code === 'ENOENT' && attempt < 8) continue;
40
+ throw busy('Cannot verify the build lock. Check .prnext-build.lock before retrying.');
41
+ }
42
+ if (previous.host !== identity.host || alive(previous.pid)) throw busy(`A build is already running for this project (PID ${previous.pid}). Wait for it to finish.`);
43
+ // Exactly one contender can recover this generation. Never delete a lock
44
+ // merely because it is old: long builds and suspended processes are valid.
45
+ let claim;
46
+ try { claim = await open(path.join(lock, 'recovery'), 'wx', 0o600); }
47
+ catch (cause) {
48
+ if (cause.code === 'ENOENT' && attempt < 8) continue;
49
+ throw busy('Build lock recovery is in progress. Retry shortly; if recovery was interrupted, check .prnext-build.lock manually.');
50
+ }
51
+ const retired = path.join(root, `.prnext-lock-retired-${identity.token}`);
52
+ try {
53
+ await claim.writeFile(JSON.stringify(identity)); await claim.close(); claim = null;
54
+ const current = await owner(lock);
55
+ if (current.token !== previous.token) {
56
+ // Do not unlink through this path: the new owner can release and
57
+ // another generation can appear between our read and the unlink.
58
+ throw busy('The build lock changed during recovery. Retry the build.');
59
+ }
60
+ await rename(lock, retired);
61
+ await rm(retired, { recursive: true });
62
+ } finally { await claim?.close(); }
63
+ }
64
+ }
65
+ try { return await action(root); }
66
+ finally {
67
+ // Ownership must still match before releasing a lock.
68
+ if ((await owner(lock)).token === identity.token) await rm(lock, { recursive: true });
69
+ }
70
+ } finally { await rm(prepared, { recursive: true, force: true }); }
71
+ }
package/build/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { rename } from '../runtime/fs.mjs';
2
2
  import { writeRuntimeContract } from './runtime-contract.mjs';
3
+ import { writeBuildOutput } from './output-contract.mjs';
3
4
  import { bundleRuntime } from './bundle-runtime.mjs';
4
5
  import {frameworkImportName, frameworkRuntimeImportPattern} from './framework-imports.mjs';
5
6
  import { expandLocales, localizedStaticPaths } from './i18n.mjs';
@@ -20,6 +21,7 @@ import { createImages } from './images.mjs';
20
21
  import { compileApp } from './app.mjs';
21
22
  import { prerenderApp } from './app-static.mjs';
22
23
  import { precompressBuild } from './precompress.mjs';
24
+ import { withBuildLock } from './build-lock.mjs';
23
25
  import { loadProjectConfig, defineEnvironment, generateBuildId, publicAssetBase } from './config.mjs';
24
26
  import { compileCustomRoutes } from './custom-routes.mjs';
25
27
  import { compileMiddleware } from './middleware.mjs';
@@ -186,10 +188,13 @@ let buildQueue = Promise.resolve();
186
188
  /** Isolate process.env between programmatic builds, including simultaneous calls. */
187
189
  export function build(projectRoot, options = {}) {
188
190
  const task = buildQueue.then(async () => {
191
+ if (options.adapter === 'static') return (await import('./static-adapter.mjs')).buildStatic(projectRoot, options);
192
+ if (options.adapter !== undefined && options.adapter !== 'react') throw new Error(`Unknown PRNext adapter: ${options.adapter}`);
193
+ if (options.from !== undefined || options.spa !== undefined) throw new Error('from and spa are only supported by the static adapter.');
189
194
  const restore = snapshotEnvConfig();
190
195
  const envMode = options.envMode || (process.env.NODE_ENV === 'test' ? 'test' : options.dev ? 'development' : 'production');
191
196
  process.env.NODE_ENV = options.dev ? 'development' : 'production';
192
- try { return await withCompilation(() => buildProject(projectRoot, { ...options, envMode })); }
197
+ try { return await withBuildLock(projectRoot, root => withCompilation(() => buildProject(root, { ...options, envMode }))); }
193
198
  finally { restore(); }
194
199
  });
195
200
  buildQueue = task.catch(() => {});
@@ -335,6 +340,7 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
335
340
  }
336
341
  await writeRuntimeContract(stage);
337
342
  await writeFile(path.join(stage, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
343
+ await writeBuildOutput(stage, manifest);
338
344
  if (config.compress) await precompressBuild(stage, manifest, {cacheDirectory:path.join(project.root,'.prnext-cache/precompress-v1')});
339
345
  if (config.output === 'standalone' && !dev) await (await import('./standalone-runner.mjs')).prepareStandalone({ projectRoot: project.root, stage, manifest, config });
340
346
  const exporting = config.output === 'export' && !dev ? await import('./static-export.mjs') : null;
package/build/inspect.mjs CHANGED
@@ -1,6 +1,8 @@
1
- import { readFile, readdir, stat } from 'node:fs/promises';
1
+ import { readFile, readdir, stat, open } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { readBuildDirectory } from '../runtime/build-directory.mjs';
4
+ import { summarizeDiagnostics, inspectionMetrics } from './inspection-summary.mjs';
5
+ export { inspectionMetrics };
4
6
 
5
7
  export function explainRoute(route, manifest) {
6
8
  const seeds = manifest.prerendered.filter(seed => seed.routeId === route.id || (!seed.routeId && seed.path === route.pattern));
@@ -27,6 +29,18 @@ export async function inspectProject(directory) {
27
29
  try { manifest = JSON.parse(await readFile(path.join(root, output, 'manifest.json'), 'utf8')); }
28
30
  catch (error) { throw new Error(`Cannot read a build in ${root}. Run prn build first.`, { cause: error }); }
29
31
  const folder = path.join(root, '.prnext-cache/inspect');
32
+ const runtime = [];
33
+ for (const name of (await readdir(folder).catch(error => {if(error.code==='ENOENT')return [];throw error;})).filter(name => /^metrics-\d+\.jsonl$/.test(name)).slice(-8)) {
34
+ let file;
35
+ try {
36
+ file = await open(path.join(folder,name),'r');
37
+ const info = await file.stat(), bytes = Buffer.alloc(Math.min(info.size,65536));
38
+ await file.read(bytes,0,bytes.length,Math.max(0,info.size-bytes.length));
39
+ for (const line of bytes.toString().split('\n').reverse()) {
40
+ try { const value=JSON.parse(line);if(Number.isFinite(value.unixMs)&&value.workers){runtime.push(value);break;} } catch {}
41
+ }
42
+ } catch {} finally {await file?.close();}
43
+ }
30
44
  let names = [];
31
45
  try { names = (await readdir(folder)).filter(name => /^(?:worker-\d+-\d+|native-\d+)\.jsonl(?:\.1)?$/.test(name)); }
32
46
  catch (error) { if (error.code !== 'ENOENT') throw error; }
@@ -50,7 +64,7 @@ export async function inspectProject(directory) {
50
64
  durations.sort((a, b) => a - b);
51
65
  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
66
  }).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)),
67
+ return { version: 1, root, buildId: manifest.buildId, ...summarizeDiagnostics(recent,runtime), routes: manifest.routes.filter(route => !route.internal).map(route => explainRoute(route, manifest)),
54
68
  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
69
  sample: { events: recent.length, from: recent[0]?.time, to: recent.at(-1)?.time },
56
70
  notes: ['Enable measurements with prn start --inspect (or prn dev --inspect), visit routes, then run prn inspect again.',
@@ -59,6 +73,10 @@ export async function inspectProject(directory) {
59
73
  }
60
74
  export function printInspection(report) {
61
75
  console.log(`PRNext inspector: ${report.root}`);
76
+ console.log(`Recorded sample: ${report.summary.requests} HTTP requests; p50 ${report.summary.p50Ms ?? 'n/a'} ms; p95 ${report.summary.p95Ms ?? 'n/a'} ms (response headers); ${report.summary.errors} server errors.`);
77
+ if(report.summary.wakeP95Ms !== null) console.log(`Worker startup p95: ${report.summary.wakeP95Ms} ms.`);
78
+ if(report.summary.workerRssBytes !== null) console.log(`Latest recorded worker RSS: ${(report.summary.workerRssBytes/1048576).toFixed(1)} MiB. Native server memory is not included.`);
79
+ for(const finding of report.findings)console.log(`${finding.level.toUpperCase()}: ${finding.message} ${finding.action}`);
62
80
  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
81
  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
82
  for (const event of report.invalidations) console.log(`INVALIDATION ${event.route}: ${event.mode}; tags ${(event.tags || []).join(', ') || 'none'}; paths ${(event.paths || []).join(', ') || 'none'}`);
@@ -0,0 +1,37 @@
1
+ const quantile = (values, q) => values.length ? Math.round([...values].sort((a,b)=>a-b)[Math.ceil(values.length*q)-1]*100)/100 : null;
2
+ const durations = events => events.map(e=>e.durationMs).filter(v=>Number.isFinite(v)&&v>=0);
3
+ export function summarizeDiagnostics(events, snapshots = []) {
4
+ const http=events.filter(e=>e.type==='http'), times=durations(http);
5
+ const wake=durations(events.filter(e=>e.type==='wake'&&e.phase==='node-ready'));
6
+ const processes=new Map();
7
+ for(const event of events)if(event.type==='process'&&Number.isSafeInteger(event.pid)&&Number.isFinite(event.rssBytes)&&event.rssBytes>=0)processes.set(event.pid,event);
8
+ const last=snapshots.sort((a,b)=>b.unixMs-a.unixMs)[0];
9
+ const current=last&&Date.now()-last.unixMs<30000&&Date.now()>=last.unixMs;
10
+ const rss=[...processes.values()].filter(e=>Date.now()-e.time<30000&&Date.now()>=e.time);
11
+ const summary={requests:http.length,errors:http.filter(e=>e.status>=500).length,p50Ms:quantile(times,.5),p95Ms:quantile(times,.95),wakeP95Ms:quantile(wake,.95),workerRssBytes:rss.length?rss.reduce((n,e)=>n+e.rssBytes,0):null,
12
+ queuedJobs:current?last.workers.queuedJobs??null:null,activeJobs:current?last.workers.activeJobs??null:null, runtimeSampleAt:last?.unixMs??null};
13
+ const findings=[];
14
+ if(!http.length)findings.push({level:'info',code:'NO_SAMPLE',message:'No HTTP sample recorded.',action:'Start with --inspect, visit the site and run inspect again.'});
15
+ if(summary.queuedJobs>0)findings.push({level:'warning',code:'WORKER_QUEUE',message:`${summary.queuedJobs} jobs are waiting for a worker.`,action:'Check slow routes and available CPU/RAM before increasing concurrency.'});
16
+ if(summary.p95Ms>=1000)findings.push({level:'warning',code:'SLOW_HEADERS',message:'The slowest 5% of recorded responses take at least one second to prepare headers.',action:'Inspect route and fetch timings below; this includes queueing and startup.'});
17
+ if(summary.errors)findings.push({level:'warning',code:'SERVER_ERRORS',message:`${summary.errors} recorded requests returned a server error.`,action:'Check application logs for the same sample period.'});
18
+ if(summary.wakeP95Ms>=1000)findings.push({level:'info',code:'SLOW_WAKE',message:'Recorded worker startup p95 exceeds one second.',action:'Review startup imports or use keep-warm if the extra memory is acceptable.'});
19
+ return {summary,findings,metricSample:http.filter(e=>Number.isFinite(e.durationMs)&&e.durationMs>=0&&e.durationMs<=86400000&&Number.isSafeInteger(e.time)&&e.time>=0&&e.time<=18446744073709).map(e=>({route:typeof e.route==='string'?e.route.slice(0,256):'<unmatched>',method:/^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|CONNECT|TRACE)$/.test(e.method)?e.method:'_OTHER',durationMs:e.durationMs,time:e.time}))};
20
+ }
21
+
22
+ /** OTLP/JSON Metrics export of a bounded local sample, never a complete traffic counter. */
23
+ export function inspectionMetrics(report) {
24
+ const bounds=[.001,.005,.01,.025,.05,.1,.25,.5,1,2.5,5,10], groups=new Map();
25
+ for(const event of report.metricSample||[]) {
26
+ const key=JSON.stringify([event.route,event.method]);
27
+ if(!groups.has(key))groups.set(key,{route:event.route,method:event.method,values:[],start:event.time,end:event.time});
28
+ const group=groups.get(key);group.values.push(event.durationMs/1000);group.start=Math.min(group.start,event.time);group.end=Math.max(group.end,event.time);
29
+ }
30
+ const dataPoints=[...groups.values()].map(group=>{
31
+ const buckets=Array(bounds.length+1).fill(0);
32
+ for(const value of group.values){const index=bounds.findIndex(bound=>value<=bound);buckets[index<0?bounds.length:index]++;}
33
+ return {attributes:[{key:'http.route',value:{stringValue:group.route}},{key:'http.request.method',value:{stringValue:group.method}}],
34
+ startTimeUnixNano:String(BigInt(Math.trunc(group.start))*1000000n),timeUnixNano:String(BigInt(Math.trunc(group.end))*1000000n),count:String(group.values.length),sum:group.values.reduce((a,b)=>a+b,0),min:Math.min(...group.values),max:Math.max(...group.values),explicitBounds:bounds,bucketCounts:buckets.map(String)};
35
+ });
36
+ return {resourceMetrics:[{resource:{attributes:[{key:'service.name',value:{stringValue:'prnext'}},{key:'prnext.inspect.sampled',value:{boolValue:true}}]},scopeMetrics:[{scope:{name:'prnext.inspect',version:'1'},metrics:[{name:'prnext.http.response.headers.duration',description:'Bounded diagnostic sample of time until response headers; excludes response body streaming.',unit:'s',histogram:{aggregationTemporality:1,dataPoints}}]}]}]};
37
+ }
package/build/og.mjs CHANGED
@@ -1,14 +1,8 @@
1
- import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
2
- import { createRequire } from 'node:module';
1
+ import { cp, mkdir } from 'node:fs/promises';
2
+ import { fileURLToPath } from 'node:url';
3
3
  import path from 'node:path';
4
4
  export async function prepareImageResponse(stage) {
5
- const directory = path.dirname(createRequire(import.meta.url).resolve('@vercel/og'));
6
5
  const target = path.join(stage, 'runtime/og');
7
6
  await mkdir(target, { recursive: true });
8
- await writeFile(path.join(target, 'index.node.mjs'), `import {createRequire as __prnextRequire} from 'node:module';import {fileURLToPath as __prnextFile} from 'node:url';import {dirname as __prnextDir} from 'node:path';const require=__prnextRequire(import.meta.url),__filename=__prnextFile(import.meta.url),__dirname=__prnextDir(__filename);\n` + await readFile(path.join(directory, 'index.node.js'), 'utf8'));
9
- for (const name of ['Geist-Regular.ttf', 'resvg.wasm']) await cp(path.join(directory, name), path.join(target, name));
10
- await cp(path.join(directory, '../LICENSE'), path.join(target, 'LICENSE'));
11
- await writeFile(path.join(target, 'NOTICE.txt'), 'Image renderer: @vercel/og 1.0.3 (https://github.com/vercel/og). PRNext adds Node CommonJS globals to its bundled ESM entry and includes the adjacent HarfBuzz WASM needed by that entry. Upstream bundled notices are preserved in index.node.mjs.\n');
12
- const satori = createRequire(path.join(directory, 'index.node.js')).resolve('satori');
13
- await cp(createRequire(satori).resolve('harfbuzzjs/hb.wasm'), path.join(target, 'hb.wasm'));
7
+ await cp(fileURLToPath(new URL('../runtime/og/', import.meta.url)), target, { recursive: true });
14
8
  }
@@ -0,0 +1,41 @@
1
+ import { open, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export const OUTPUT_CONTRACT = 'build-output.json';
5
+ const fields = ['version', 'adapter', 'buildId', 'manifest', 'assets', 'server', 'fallback'];
6
+
7
+ /** The public adapter contract is separate from internal rendering metadata. */
8
+ export function validateBuildOutput(value) {
9
+ const invalid = reason => { throw new Error(`Invalid PRNext build output: ${reason}`); };
10
+ if (!value || typeof value !== 'object' || Array.isArray(value)) invalid('expected an object');
11
+ if (value.version !== 1) invalid(`unsupported version ${value.version}; expected 1`);
12
+ if (Object.keys(value).some(key => !fields.includes(key)) || fields.some(key => !(key in value))) invalid('unexpected or missing fields');
13
+ if (!['react', 'static'].includes(value.adapter)) invalid('unsupported adapter');
14
+ if (typeof value.buildId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(value.buildId)) invalid('invalid buildId');
15
+ if (value.manifest !== 'manifest.json') invalid('manifest must be manifest.json');
16
+ if (value.adapter === 'static') {
17
+ if (value.assets !== 'static' || value.server !== null || ![null, 'index.html'].includes(value.fallback)) invalid('invalid static entrypoints');
18
+ } else if (value.assets !== 'assets' || value.fallback !== null || !value.server ||
19
+ Object.keys(value.server).sort().join(',') !== 'entry,runtimeContract' ||
20
+ value.server.entry !== 'runtime/worker.mjs' || value.server.runtimeContract !== 'runtime-contract.json') invalid('invalid React entrypoints');
21
+ return value;
22
+ }
23
+
24
+ export async function writeBuildOutput(directory, manifest, { adapter = 'react', spa = false } = {}) {
25
+ const value = validateBuildOutput({ version: 1, adapter, buildId: manifest.buildId,
26
+ manifest: 'manifest.json', assets: adapter === 'static' ? 'static' : 'assets',
27
+ server: adapter === 'static' ? null : { entry: 'runtime/worker.mjs', runtimeContract: 'runtime-contract.json' },
28
+ fallback: spa ? 'index.html' : null });
29
+ await writeFile(path.join(directory, OUTPUT_CONTRACT), JSON.stringify(value, null, 2) + '\n');
30
+ return value;
31
+ }
32
+
33
+ export async function readBuildOutput(directory) {
34
+ const file = await open(path.join(directory, OUTPUT_CONTRACT), 'r');
35
+ try {
36
+ const bytes = Buffer.alloc(65537);
37
+ const { bytesRead } = await file.read(bytes, 0, bytes.length, 0);
38
+ if (bytesRead > 65536) throw new Error('Invalid PRNext build output: exceeds 64 KiB');
39
+ return validateBuildOutput(JSON.parse(bytes.subarray(0, bytesRead).toString('utf8')));
40
+ } finally { await file.close(); }
41
+ }
@@ -49,6 +49,19 @@ async function compress(filename, cacheDirectory) {
49
49
  const original = await stat(filename);
50
50
  if (!original.size) return 'skipped';
51
51
  const sidecar = filename + '.gz';
52
+ // Static builders may already supply gzip files. Validate them rather than
53
+ // overwriting public input or publishing a mismatched encoded response.
54
+ const existing = await stat(sidecar).catch(error => { if (error.code !== 'ENOENT') throw error; });
55
+ if (existing) {
56
+ const input = createReadStream(sidecar), unzip = createGunzip();
57
+ const verified = digest(unzip, original.size);
58
+ const pumping = pipeline(input, unzip);
59
+ const [actual, expected] = await Promise.all([verified, digest(createReadStream(filename)), pumping]);
60
+ if (actual !== expected) throw new Error(`Precompressed asset does not match its source: ${path.basename(sidecar)}`);
61
+ await utimes(filename, original.atimeMs / 1000, original.mtimeMs / 1000);
62
+ await utimes(sidecar, original.atimeMs / 1000, original.mtimeMs / 1000);
63
+ return 'reused';
64
+ }
52
65
  const key = cacheDirectory && original.size <= 64*1024*1024 ? await digest(createReadStream(filename)) : null;
53
66
  const cached = key && path.join(cacheDirectory,key+'.gz');
54
67
  let reused = false;
package/build/scan.mjs CHANGED
@@ -61,7 +61,7 @@ export async function scanProject(projectRoot, { basePath = '', pageExtensions,
61
61
  const appRoots = [];
62
62
  for (const name of ['app', 'src/app']) if (await exists(path.join(root, name))) appRoots.push(path.join(root, name));
63
63
  if (appRoots.length > 1) throw new Error('Both app/ and src/app/ exist. Keep exactly one App Router directory.');
64
- if (!roots.length && !appRoots.length) throw new Error('No pages/, src/pages/, app/, or src/app/ directory found. Create app/page.tsx or pages/index.tsx to start.');
64
+ if (!roots.length && !appRoots.length) throw new Error('The React adapter needs a pages/, src/pages/, app/, or src/app/ directory. Create app/page.tsx or pages/index.tsx to start. For a Vite or other static site, run its own build, then prnext build --adapter static --from dist.');
65
65
  const pageRoot = roots[0];
66
66
  const files = (pageRoot ? await walk(pageRoot) : []).filter(file => sourceExtension.test(file) && !file.endsWith('.d.ts'));
67
67
  const routes = [];