@thomas.f/prnext 0.1.17-alpha → 0.1.19-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.
Files changed (202) hide show
  1. package/README.md +27 -868
  2. package/build/actions.mjs +27 -15
  3. package/build/analyze.mjs +180 -24
  4. package/build/app-config.mjs +13 -0
  5. package/build/app-static.mjs +34 -12
  6. package/build/app-styles.mjs +79 -0
  7. package/build/app.mjs +122 -46
  8. package/build/bundle-runtime.mjs +22 -9
  9. package/build/cache-components.mjs +1 -1
  10. package/build/cache-handlers.mjs +1 -1
  11. package/build/compact-css.mjs +53 -0
  12. package/build/compilation-cache.mjs +269 -0
  13. package/build/compilation-store.mjs +142 -0
  14. package/build/compiler.mjs +34 -10
  15. package/build/config.mjs +34 -3
  16. package/build/create.mjs +239 -0
  17. package/build/css-modules.mjs +56 -12
  18. package/build/deployment-assets.mjs +89 -0
  19. package/build/deployment-config.mjs +18 -0
  20. package/build/dev-css-assets.mjs +173 -0
  21. package/build/dev-css-update.mjs +18 -14
  22. package/build/dev-dependencies.mjs +1 -1
  23. package/build/dev-discovery.mjs +3 -3
  24. package/build/dev-lazy-build.mjs +77 -0
  25. package/build/dev.mjs +15 -8
  26. package/build/dynamic.mjs +14 -7
  27. package/build/edge.mjs +3 -3
  28. package/build/fonts.mjs +1 -1
  29. package/build/framework-imports.mjs +15 -5
  30. package/build/html-only-config.mjs +53 -0
  31. package/build/html-only-graph.mjs +67 -0
  32. package/build/incremental.mjs +112 -18
  33. package/build/index.mjs +90 -28
  34. package/build/instrumentation.mjs +30 -0
  35. package/build/lazy-dynamic-imports.mjs +137 -0
  36. package/build/metadata-routes.mjs +1 -1
  37. package/build/migrate.mjs +4 -4
  38. package/build/migration-audit.mjs +19 -31
  39. package/build/migration-imports.mjs +245 -0
  40. package/build/migration-scenarios-runner.mjs +73 -0
  41. package/build/migration-scenarios.mjs +72 -0
  42. package/build/native-discovery.mjs +65 -5
  43. package/build/native-packages.mjs +2 -0
  44. package/build/native-type-config.mjs +37 -0
  45. package/build/npm-packages.mjs +1 -0
  46. package/build/persistent-cache.mjs +18 -3
  47. package/build/precompress.mjs +1 -1
  48. package/build/prerender-cache.mjs +70 -0
  49. package/build/prerender-config.mjs +29 -0
  50. package/build/prerender-pages.mjs +19 -9
  51. package/build/prerender-pool.mjs +165 -0
  52. package/build/prerender-process-worker.mjs +58 -0
  53. package/build/prerender-worker.mjs +5 -5
  54. package/build/production-builder.mjs +59 -10
  55. package/build/react-compiler-report.mjs +225 -0
  56. package/build/react-compiler.mjs +139 -0
  57. package/build/runtime-contract.mjs +2 -2
  58. package/build/standalone.mjs +21 -3
  59. package/build/static-export.mjs +4 -4
  60. package/build/styles.mjs +6 -4
  61. package/build/transform.mjs +1 -1
  62. package/build/typed-routes.mjs +4 -0
  63. package/build/typescript-dev.mjs +35 -8
  64. package/build/typescript-native-worker.mjs +55 -0
  65. package/build/typescript-native.mjs +18 -0
  66. package/build/typescript-toolchain.mjs +19 -0
  67. package/build/typescript.mjs +6 -4
  68. package/build/webpack-graph.mjs +19 -6
  69. package/build/worker-memory.mjs +9 -0
  70. package/cli.mjs +70 -5
  71. package/compat/activity-cache.cjs +26 -0
  72. package/compat/after.cjs +6 -4
  73. package/compat/app-dynamic.cjs +12 -1
  74. package/compat/app-layout-context.cjs +52 -2
  75. package/compat/base64.cjs +10 -0
  76. package/compat/cache-handlers.cjs +15 -3
  77. package/compat/cache-wire.cjs +28 -0
  78. package/compat/cache.cjs +2 -1
  79. package/compat/cache.d.cts +4 -0
  80. package/compat/cookies.cjs +21 -5
  81. package/compat/data-cache.cjs +12 -8
  82. package/compat/flight-delta.cjs +152 -0
  83. package/compat/headers.cjs +4 -2
  84. package/compat/incremental-cache.cjs +20 -7
  85. package/compat/index.d.ts +69 -4
  86. package/compat/link.cjs +13 -10
  87. package/compat/navigation-authorization.cjs +9 -0
  88. package/compat/navigation-stages.cjs +19 -0
  89. package/compat/offline-store.cjs +11 -0
  90. package/compat/offline.cjs +5 -0
  91. package/compat/offline.d.cts +2 -0
  92. package/compat/redis-cache.cjs +201 -0
  93. package/compat/redis-cache.d.cts +37 -0
  94. package/compat/server-primitives.cjs +31 -11
  95. package/compat/server.d.cts +15 -2
  96. package/compat/static-generation.cjs +7 -0
  97. package/compat/use-cache.cjs +3 -0
  98. package/docs/ai/compatibility.md +248 -2
  99. package/docs/ai/data.md +165 -5
  100. package/docs/ai/deployment.md +158 -0
  101. package/docs/ai/quickstart.md +29 -4
  102. package/docs/ai/routing.md +93 -0
  103. package/docs/ai/troubleshooting.md +116 -2
  104. package/package.json +40 -9
  105. package/runtime/action-client.mjs +10 -4
  106. package/runtime/action-server.mjs +2 -0
  107. package/runtime/app-client.mjs +316 -92
  108. package/runtime/app-content.mjs +4 -3
  109. package/runtime/app-partial-client.mjs +3 -0
  110. package/runtime/app-partial.mjs +1 -1
  111. package/runtime/app-prefetch-constants.mjs +4 -0
  112. package/runtime/app-prefetch.mjs +2 -3
  113. package/runtime/app-render-key.mjs +5 -0
  114. package/runtime/app-render.mjs +88 -28
  115. package/runtime/app-routing.mjs +22 -9
  116. package/runtime/app-static.mjs +18 -8
  117. package/runtime/app-stream-html.mjs +10 -53
  118. package/runtime/app-style-selection.mjs +34 -0
  119. package/runtime/app-styles.mjs +154 -0
  120. package/runtime/atomic-file.mjs +27 -0
  121. package/runtime/cache-components-rsc.mjs +7 -2
  122. package/runtime/cache-request.mjs +2 -1
  123. package/runtime/deployment-client.mjs +19 -0
  124. package/runtime/dev-client.mjs +13 -3
  125. package/runtime/dev-compiler-broker.mjs +42 -0
  126. package/runtime/dev-file-changes.mjs +123 -8
  127. package/runtime/dev-indicator.mjs +8 -3
  128. package/runtime/dev-lazy-imports.mjs +121 -0
  129. package/runtime/dev-lazy-staging.mjs +14 -0
  130. package/runtime/dev-lazy-tool.mjs +16 -0
  131. package/runtime/dev-style-retention.mjs +15 -0
  132. package/runtime/development.mjs +83 -25
  133. package/runtime/document.mjs +1 -1
  134. package/runtime/edge.mjs +6 -5
  135. package/runtime/fetch-cache.mjs +10 -8
  136. package/runtime/flight-delta.mjs +74 -0
  137. package/runtime/flight-tap.mjs +28 -0
  138. package/runtime/fs.mjs +15 -8
  139. package/runtime/html-only.mjs +27 -0
  140. package/runtime/http.mjs +2 -0
  141. package/runtime/incremental-cache.mjs +25 -4
  142. package/runtime/instrumentation.mjs +49 -0
  143. package/runtime/lazy-import-client.mjs +38 -0
  144. package/runtime/navigation-recovery.mjs +36 -0
  145. package/runtime/navigation.mjs +7 -0
  146. package/runtime/node-html-stream.mjs +47 -0
  147. package/runtime/pages-client.mjs +14 -7
  148. package/runtime/pages-navigation.mjs +48 -63
  149. package/runtime/prefetch-scheduler.mjs +110 -0
  150. package/runtime/profile.mjs +14 -0
  151. package/runtime/profiles.json +2 -2
  152. package/runtime/render.mjs +3 -0
  153. package/runtime/route-pattern.mjs +44 -0
  154. package/runtime/rsc-worker.mjs +34 -12
  155. package/runtime/trace.mjs +78 -0
  156. package/runtime/watch-project.mjs +19 -15
  157. package/runtime/worker.mjs +70 -26
  158. package/vendor/nft/LICENSE +7 -0
  159. package/vendor/nft/README.md +16 -0
  160. package/vendor/nft/out/analyze.d.ts +8 -0
  161. package/vendor/nft/out/analyze.js +1216 -0
  162. package/vendor/nft/out/analyze.js.map +1 -0
  163. package/vendor/nft/out/cli.d.ts +2 -0
  164. package/vendor/nft/out/cli.js +94 -0
  165. package/vendor/nft/out/cli.js.map +1 -0
  166. package/vendor/nft/out/fs.d.ts +22 -0
  167. package/vendor/nft/out/fs.js +106 -0
  168. package/vendor/nft/out/fs.js.map +1 -0
  169. package/vendor/nft/out/index.d.ts +4 -0
  170. package/vendor/nft/out/index.js +25 -0
  171. package/vendor/nft/out/index.js.map +1 -0
  172. package/vendor/nft/out/node-file-trace.d.ts +41 -0
  173. package/vendor/nft/out/node-file-trace.js +380 -0
  174. package/vendor/nft/out/node-file-trace.js.map +1 -0
  175. package/vendor/nft/out/resolve-dependency.d.ts +6 -0
  176. package/vendor/nft/out/resolve-dependency.js +427 -0
  177. package/vendor/nft/out/resolve-dependency.js.map +1 -0
  178. package/vendor/nft/out/types.d.ts +66 -0
  179. package/vendor/nft/out/types.js +2 -0
  180. package/vendor/nft/out/types.js.map +1 -0
  181. package/vendor/nft/out/utils/ast-helpers.js +49 -0
  182. package/vendor/nft/out/utils/ast-helpers.js.map +1 -0
  183. package/vendor/nft/out/utils/binary-locators.js +88 -0
  184. package/vendor/nft/out/utils/binary-locators.js.map +1 -0
  185. package/vendor/nft/out/utils/get-package-base.js +30 -0
  186. package/vendor/nft/out/utils/get-package-base.js.map +1 -0
  187. package/vendor/nft/out/utils/interop-require.js +24 -0
  188. package/vendor/nft/out/utils/interop-require.js.map +1 -0
  189. package/vendor/nft/out/utils/node-version.js +10 -0
  190. package/vendor/nft/out/utils/node-version.js.map +1 -0
  191. package/vendor/nft/out/utils/sharedlib-emit.js +33 -0
  192. package/vendor/nft/out/utils/sharedlib-emit.js.map +1 -0
  193. package/vendor/nft/out/utils/special-cases.js +347 -0
  194. package/vendor/nft/out/utils/special-cases.js.map +1 -0
  195. package/vendor/nft/out/utils/static-eval.js +579 -0
  196. package/vendor/nft/out/utils/static-eval.js.map +1 -0
  197. package/vendor/nft/out/utils/types.js +2 -0
  198. package/vendor/nft/out/utils/types.js.map +1 -0
  199. package/vendor/nft/out/utils/wrappers.js +659 -0
  200. package/vendor/nft/out/utils/wrappers.js.map +1 -0
  201. package/vendor/nft/package.json +4 -0
  202. package/vendor/nft/upstream.json +51 -0
@@ -0,0 +1,72 @@
1
+ import { execFile, fork } from 'node:child_process';
2
+ import { readFile, realpath, stat } from 'node:fs/promises';
3
+ import { fileURLToPath } from 'node:url';
4
+ import path from 'node:path';
5
+
6
+ const runner = fileURLToPath(new URL('./migration-scenarios-runner.mjs', import.meta.url));
7
+ const scope = 'Application-provided browser scenarios run independently against each server in isolated contexts. Passing both servers does not establish full application equivalence. Application code is explicitly executed and may submit forms or change test data. Browser messages, request data and raw errors are excluded from this report.';
8
+ function origin(value) {
9
+ const url = new URL(value);
10
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('Scenario servers must be HTTP(S) origins without credentials, paths, or queries.');
11
+ return url.origin;
12
+ }
13
+
14
+ /** The explicit scenario option is the only path that loads application test code. */
15
+ export async function runMigrationScenarios({ directory, against, candidate, scenarios, timeout = 10000 }) {
16
+ const root = await realpath(path.resolve(directory));
17
+ const reference = origin(against), current = origin(candidate);
18
+ if (typeof scenarios !== 'string' || !scenarios || !/\.[cm]?js$/.test(scenarios)) throw new Error('Provide an application browser scenario module ending in .mjs, .cjs or .js.');
19
+ const filename = await realpath(path.resolve(root, scenarios));
20
+ const relative = path.relative(root, filename);
21
+ if (!relative || relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative)) throw new Error('The browser scenario module must be inside the application directory.');
22
+ if (!(await stat(filename)).isFile()) throw new Error('The browser scenario module must be a regular file.');
23
+ if (!Number.isInteger(timeout) || timeout < 100 || timeout > 60000) throw new Error('Scenario timeout must be 100–60000 milliseconds.');
24
+ // Require an application manifest, without loading it as executable code.
25
+ JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
26
+ return new Promise(resolve => {
27
+ const child = fork(runner, [], { cwd: root, execArgv: [], stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true });
28
+ let settled = false, report, timer, killTimer, browserPid, termination = Promise.resolve();
29
+ const incomplete = reason => ({ ok: false, state: 'unknown', reason, results: [], scope });
30
+ const finish = value => {
31
+ if (settled) return;
32
+ settled = true; clearTimeout(timer); clearTimeout(killTimer);
33
+ child.removeAllListeners('message');
34
+ resolve({ ...value, scope });
35
+ };
36
+ // The worker has 20 scenarios maximum, two targets each, plus a bounded
37
+ // startup/teardown budget. A hung module import cannot hold the CLI open.
38
+ const terminate = () => {
39
+ if (child.exitCode !== null || child.signalCode !== null || !child.pid) return;
40
+ // Kill only this worker's descendants on Windows. A synchronous user test
41
+ // cannot process SIGTERM or close its browser itself.
42
+ if (process.platform === 'win32') termination = new Promise(done => execFile('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true }, () => { if (child.exitCode === null) child.kill(); done(); }));
43
+ else {
44
+ // BrowserServer owns this process, and Chromium closes its subprocesses
45
+ // when that browser process exits. No shared browser is ever connected.
46
+ if (browserPid) { try { process.kill(browserPid, 'SIGKILL'); } catch {} }
47
+ child.kill('SIGTERM'); killTimer = setTimeout(() => child.kill('SIGKILL'), 1000);
48
+ }
49
+ };
50
+ const budget = Math.min(300000, 20000 + timeout * 40);
51
+ timer = setTimeout(() => { report = incomplete('worker-timeout'); terminate(); }, budget);
52
+ child.on('message', message => {
53
+ if (message?.type === 'browser-started' && Number.isInteger(message.pid) && message.pid > 0 && message.pid !== process.pid && message.pid !== child.pid) { browserPid = message.pid; return; }
54
+ if (message?.type === 'browser-closed') { browserPid = undefined; return; }
55
+ if (message?.type !== 'result' || !message.report || report) return;
56
+ // Never forward arbitrary fields from application code or a compromised
57
+ // IPC message: the public report has a fixed, deliberately small schema.
58
+ const input = message.report;
59
+ if (!Array.isArray(input.results) || input.results.length > 40 || input.results.some(item => !item || !Number.isInteger(item.scenario) || item.scenario < 1 || item.scenario > 20 || !['reference', 'candidate'].includes(item.target) || !['passed', 'failed'].includes(item.status))) { report = incomplete('invalid-result'); terminate(); return; }
60
+ const results = input.results.map(item => ({ scenario: Number.isInteger(item.scenario) ? item.scenario : 0, target: item.target === 'reference' ? 'reference' : 'candidate', status: item.status === 'passed' ? 'passed' : 'failed', ...(item.reason === 'timeout' ? { reason: 'timeout' } : item.status !== 'passed' ? { reason: 'assertion-or-runtime-failure' } : {}) }));
61
+ const allowed = new Set(['playwright-unavailable', 'invalid-scenarios', 'scenario-module-failed', 'scenario-module-timeout', 'browser-unavailable', 'worker-failed']);
62
+ report = { ok: input.ok === true && results.length > 0 && results.every(item => item.status === 'passed'), state: input.state === 'checked' ? 'checked' : 'unknown', ...(allowed.has(input.reason) ? { reason: input.reason } : {}), results };
63
+ child.send({ type: 'close' }, () => {});
64
+ // A module can leave its own timers running. Once the owned browser has
65
+ // closed, exit the worker rather than waiting for application handles.
66
+ clearTimeout(timer); timer = setTimeout(terminate, 5000);
67
+ });
68
+ child.once('error', () => finish(incomplete('worker-failed')));
69
+ child.once('close', () => { void termination.then(() => finish(report || incomplete('worker-failed'))); });
70
+ child.send({ type: 'run', root, filename, reference, candidate: current, timeout }, error => { if (error) { report = incomplete('worker-failed'); terminate(); } });
71
+ });
72
+ }
@@ -1,6 +1,9 @@
1
1
  import { readdir, readFile, realpath } from 'node:fs/promises';
2
- import { createRequire } from 'node:module';
2
+ import { builtinModules, createRequire } from 'node:module';
3
3
  import path from 'node:path';
4
+ import { analyzeSource } from './source-analysis.mjs';
5
+
6
+ const builtins = new Set([...builtinModules, ...builtinModules.map(name => 'node:' + name)]);
4
7
 
5
8
  /** Inspect only packages reached through imports and declared runtime dependencies. */
6
9
  export function createNativeDiscovery({ projectRoot, isClientSource }) {
@@ -9,6 +12,27 @@ export function createNativeDiscovery({ projectRoot, isClientSource }) {
9
12
  const packageFiles = new Map();
10
13
  const dependencyRoots = new Map();
11
14
  const clientBoundaries = new Map();
15
+ const sourceImports = new Map(), entryNative = new Map(), declaredNative = new Map();
16
+ const resolvers = new Map();
17
+ let sourceBytes = 0;
18
+ const isApplication = directory => {
19
+ const relative = path.relative(directory, projectRoot);
20
+ return relative === '' || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative));
21
+ };
22
+
23
+ function importsFor(file) {
24
+ if (!sourceImports.has(file)) sourceImports.set(file, (async () => {
25
+ if (!/\.(?:[cm]?[jt]s|jsx|tsx)$/.test(file) || /\.d\.[cm]?ts$/.test(file)) return { imports: [], computedRequire: false };
26
+ const source = await readFile(file, 'utf8');
27
+ const size = Buffer.byteLength(source);
28
+ if (size > 8 * 1024 ** 2 || (sourceBytes += size) > 64 * 1024 ** 2) throw new Error('Native npm import analysis exceeds its 8 MiB file / 64 MiB source budget. Separate the native loader into a server-only package.');
29
+ // The compiler accepts JSX in JavaScript npm entries, including .js.
30
+ const { imports, computedRequire } = analyzeSource(source, /\.[cm]?js$/.test(file) ? file + '.jsx' : file);
31
+ // Keep edges, not the AST, so the shared parser can bound its retained cache.
32
+ return { imports, computedRequire };
33
+ })());
34
+ return sourceImports.get(file);
35
+ }
12
36
 
13
37
  function readMetadata(directory) {
14
38
  if (!metadata.has(directory)) metadata.set(directory, readFile(path.join(directory, 'package.json'), 'utf8').then(JSON.parse).catch(error => {
@@ -53,7 +77,7 @@ export function createNativeDiscovery({ projectRoot, isClientSource }) {
53
77
  else if (entry.isFile() || entry.isSymbolicLink()) {
54
78
  if (entry.name.endsWith('.node')) result.native = true;
55
79
  if (/\.(?:css|scss|sass)$/i.test(entry.name)) result.css = true;
56
- if (/\.(?:[cm]?js|jsx|tsx?)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) result.sources.push(file);
80
+ if (/\.(?:[cm]?[jt]s|jsx|tsx)$/.test(entry.name) && !/\.d\.[cm]?ts$/.test(entry.name)) result.sources.push(file);
57
81
  }
58
82
  }
59
83
  }
@@ -99,6 +123,38 @@ export function createNativeDiscovery({ projectRoot, isClientSource }) {
99
123
  return false;
100
124
  }
101
125
 
126
+ function declaredNativeFor(owner) {
127
+ if (!declaredNative.has(owner.directory)) declaredNative.set(owner.directory, nativeReachable(owner));
128
+ return declaredNative.get(owner.directory);
129
+ }
130
+
131
+ function nativeFromEntry(file, mode) {
132
+ const key = mode + '\0' + file;
133
+ if (!entryNative.has(key)) entryNative.set(key, (async () => {
134
+ const pending = [file], visited = new Set();
135
+ while (pending.length) {
136
+ const current = pending.pop();
137
+ if (visited.has(current)) continue;
138
+ visited.add(current);
139
+ if (visited.size > 4096) throw new Error('Native npm import analysis exceeds 4096 reached modules. Separate the native loader into a server-only package.');
140
+ const owner = await packageFor(current);
141
+ if (!owner || isApplication(owner.directory)) continue;
142
+ if ((await filesFor(owner.directory)).native) return true;
143
+ const source = await importsFor(current);
144
+ // A runtime loader may compute a declared package name. Preserve its
145
+ // original npm boundary when that unknown name can reach an addon.
146
+ if (source.computedRequire && await declaredNativeFor(owner)) return true;
147
+ for (const dependency of source.imports) {
148
+ if (builtins.has(dependency.path)) continue;
149
+ const resolved = await resolvers.get(mode)(dependency.path, current, dependency.kind);
150
+ if (!resolved.errors?.length && !resolved.external && resolved.namespace === 'file') pending.push(resolved.path);
151
+ }
152
+ }
153
+ return false;
154
+ })());
155
+ return entryNative.get(key);
156
+ }
157
+
102
158
  function hasClientBoundary(directory, sources) {
103
159
  if (!clientBoundaries.has(directory)) clientBoundaries.set(directory, (async () => {
104
160
  for (const file of sources) {
@@ -116,13 +172,17 @@ export function createNativeDiscovery({ projectRoot, isClientSource }) {
116
172
  }
117
173
 
118
174
  return {
175
+ setResolver(mode, resolve) { resolvers.set(mode, resolve); },
119
176
  canExternalizeDirectory,
120
177
  async externalOwner(file, mode) {
121
178
  const owner = await packageFor(file);
122
179
  if (!owner) return;
123
- const application = path.relative(owner.directory, projectRoot);
124
- if (application === '' || (!application.startsWith('..' + path.sep) && application !== '..' && !path.isAbsolute(application))) return;
125
- if (!await nativeReachable(owner)) return;
180
+ if (isApplication(owner.directory)) return;
181
+ if (!await declaredNativeFor(owner)) return;
182
+ // A package can declare build tools with native dependencies that its
183
+ // runtime entry never imports (for example an i18n extractor). Follow
184
+ // that entry's real compiler resolution before externalizing its parent.
185
+ if (resolvers.has(mode) && !await nativeFromEntry(file, mode)) return;
126
186
  // Keep CSS extraction and Server Component client-reference transforms in
127
187
  // the compiler. A native dependency imported by this package is examined
128
188
  // separately when its own entry is resolved.
@@ -66,6 +66,8 @@ export function createNativePackages(projectRoot, { isClientSource }) {
66
66
  return {
67
67
  name: `prnext-native-packages-${mode}`,
68
68
  setup(esbuild) {
69
+ discovery.setResolver(mode, (name, importer, kind) => esbuild.resolve(name, { importer, resolveDir: path.dirname(importer), kind,
70
+ pluginData: { prnextNativeResolving: true, prnextNpmResolving: true } }));
69
71
  esbuild.onResolve({ filter: /\.node$/ }, async args => {
70
72
  if (args.pluginData?.prnextNativeResolving) return;
71
73
  if (mode === 'browser') return { errors: [{ text: `Native Node addon ${args.path} cannot run in a Client Component (${args.importer}). Keep the native dependency in a Server Component or route handler.` }] };
@@ -0,0 +1,37 @@
1
+ import {readFile} from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {fileURLToPath} from 'node:url';
4
+ import {parseTsconfig,createPathsMatcher} from 'get-tsconfig';
5
+ import {parse,modify,applyEdits} from 'jsonc-parser';
6
+
7
+ // TypeScript 7 has no stable legacy compiler API. Parse configuration only with
8
+ // the dedicated JSONC/config resolvers; semantic checking stays in native tsc.
9
+ export async function nativeTypeConfig(filename,helper){
10
+ let source=await readFile(filename,'utf8');const errors=[];
11
+ const own=parse(source,errors,{allowTrailingComma:true});
12
+ if(errors.length||!own||typeof own!=='object'||Array.isArray(own))throw new Error('Cannot configure typed routes: invalid tsconfig.');
13
+ const config=parseTsconfig(filename),directory=path.dirname(filename);
14
+ const format={insertSpaces:true,tabSize:2,eol:source.includes('\r\n')?'\r\n':'\n'};
15
+ const update=(location,value)=>{source=applyEdits(source,modify(source,location,value,{formattingOptions:format}));};
16
+ const files=own.files??config.files??[];
17
+ if(!Array.isArray(files)||files.some(file=>typeof file!=='string'))throw new Error('tsconfig.files must be an array.');
18
+ if(!files.some(file=>path.resolve(directory,file)===helper))update(['files'],[...files,path.relative(directory,helper).replaceAll(path.sep,'/')]);
19
+ const options=config.compilerOptions||{},paths=options.paths||{};
20
+ const missing=['next','next/*'].filter(name=>!Object.hasOwn(paths,name));
21
+ if(!missing.length)return source;
22
+ const base=options.baseUrl?path.resolve(directory,options.baseUrl):directory;
23
+ const relative=filename=>{const value=path.relative(base,filename).replaceAll(path.sep,'/');return value.startsWith('.')?value:'./'+value;};
24
+ const resolved=own.compilerOptions?.paths?{...paths}:{};
25
+ if(!own.compilerOptions?.paths){
26
+ const matcher=createPathsMatcher({path:filename,config});
27
+ for(const pattern of Object.keys(paths)){
28
+ const probe='__prnext_type_path__',values=matcher?.(pattern.replace('*',probe));
29
+ if(!values?.length)throw new Error('Cannot resolve inherited TypeScript path: '+pattern);
30
+ resolved[pattern]=values.map(value=>relative(value).replaceAll(probe,'*'));
31
+ }
32
+ }
33
+ const compat=fileURLToPath(new URL('../compat/',import.meta.url));
34
+ for(const name of missing)resolved[name]=[relative(path.join(compat,name==='next'?'index.d.ts':'*.d.cts'))];
35
+ update(['compilerOptions','paths'],resolved);
36
+ return source;
37
+ }
@@ -33,6 +33,7 @@ export function npmPackagesPlugin({ pages = false, projectRoot, transpilePackage
33
33
  return { name: 'prnext-npm-packages', setup(build) {
34
34
  const resolving = { prnextNpmResolving: true, prnextNativeResolving: true };
35
35
  const resolve = (name, importer, kind) => build.resolve(name, { importer, resolveDir: path.dirname(importer), kind, pluginData: resolving });
36
+ native.setResolver('ssr', resolve);
36
37
  function classify(file) {
37
38
  if (!decisions.has(file)) decisions.set(file, (async () => {
38
39
  const queue = [file], visited = new Set();
@@ -17,7 +17,21 @@ async function frameworkHash(){
17
17
  const hash=createHash('sha256');
18
18
  for(const f of files)hash.update(f).update(await readFile(path.join(packageRoot,'build',f)));
19
19
  hash.update(await readFile(path.join(packageRoot,'package.json')));
20
- for(const name of ['@babel/core','@babel/parser','@babel/traverse','@babel/generator','@babel/types','esbuild'])hash.update(name+':'+require(name+'/package.json').version);
20
+ for(const name of ['@babel/core','@babel/parser','@babel/traverse','@babel/generator','@babel/types','babel-plugin-react-compiler','esbuild'])hash.update(name+':'+require(name+'/package.json').version);
21
+ // CSS graph outputs also depend on the installed style engine and browser
22
+ // database, including nested/non-hoisted installations with different pins.
23
+ for(const name of ['lightningcss','browserslist','caniuse-lite','electron-to-chromium','postcss','postcss-value-parser','postcss-flexbugs-fixes','postcss-preset-env']){
24
+ let filename;
25
+ try{filename=require.resolve(name+'/package.json');}
26
+ catch{
27
+ let directory=path.dirname(require.resolve(name));
28
+ while(true){
29
+ try{const candidate=path.join(directory,'package.json'),value=JSON.parse(await readFile(candidate,'utf8'));if(value.name===name){filename=candidate;break;}}catch{}
30
+ const parent=path.dirname(directory);if(parent===directory)throw new Error(`Cannot fingerprint ${name}`);directory=parent;
31
+ }
32
+ }
33
+ hash.update(name).update(await readFile(filename));
34
+ }
21
35
  return hash.digest('hex');
22
36
  })();
23
37
  return frameworkIdentity;
@@ -78,8 +92,9 @@ export async function readCacheRecord(directory,key,limit=16*1024*1024){
78
92
  return JSON.parse(record.payload);
79
93
  }catch{return null;}finally{await file?.close().catch(()=>{});}
80
94
  }
81
- export async function writeCacheRecord(directory,key,value,limit=16*1024*1024){
95
+ export async function writeCacheRecord(directory,key,value,limit=16*1024*1024,{maxFiles=64}={}){
82
96
  if(!directory||!/^[a-f0-9]{64}$/.test(key))return false;
97
+ if(!Number.isInteger(maxFiles)||maxFiles<1||maxFiles>4096)return false;
83
98
  const filename=path.join(directory,key+'.json'),temporary=filename+'.'+randomUUID()+'.tmp';
84
99
  try{
85
100
  const payload=JSON.stringify(value),body=JSON.stringify({version:1,key,checksum:cacheHash(payload),payload});
@@ -91,7 +106,7 @@ export async function writeCacheRecord(directory,key,value,limit=16*1024*1024){
91
106
  const stat=await lstat(path.join(directory,name)).catch(()=>null);if(stat?.isFile()&&!stat.isSymbolicLink())records.push({name,size:stat.size,time:stat.mtimeMs});
92
107
  }
93
108
  records.sort((a,b)=>b.time-a.time);let total=0;
94
- for(let i=0;i<records.length;i++){total+=records[i].size;if(i>=64||total>MAX_DISK)await unlink(path.join(directory,records[i].name)).catch(()=>{});}
109
+ for(let i=0;i<records.length;i++){total+=records[i].size;if(i>=maxFiles||total>MAX_DISK)await unlink(path.join(directory,records[i].name)).catch(()=>{});}
95
110
  return true;
96
111
  }catch{return false;}finally{await unlink(temporary).catch(()=>{});}
97
112
  }
@@ -20,7 +20,7 @@ async function digest(stream, limit = Infinity) {
20
20
  // this bounded index and serialize publication/eviction, including overwrites.
21
21
  // Cached bytes are still decoded and hashed before reuse below.
22
22
  async function compressionCache(directory) {
23
- if (!directory) return;
23
+ if (!directory || process.env.PRNEXT_BUILD_CACHE === '0') return;
24
24
  try {
25
25
  await mkdir(directory,{recursive:true});
26
26
  const info=await lstat(directory);
@@ -0,0 +1,70 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
3
+
4
+ const MAX_VALUE = 2 * 1024 * 1024;
5
+ const MAX_BYTES = 32 * 1024 * 1024;
6
+ const MAX_ENTRIES = 256;
7
+ const LEASE_MS = 25_000;
8
+ const keyPattern = /^[a-f\d]{64}$/;
9
+
10
+ // A build-only, loopback capability. It shares only values which the existing
11
+ // runtime explicitly elects to cache. It never stores route output or fetches
12
+ // marked no-store. The entire store dies with this prerender session.
13
+ export async function createPrerenderCache() {
14
+ const token = randomBytes(32).toString('hex');
15
+ const values = new Map(), leases = new Map();
16
+ let bytes = 0, active = 0;
17
+ const generation = 0;
18
+ function remove(key) { const value = values.get(key); if (value) { bytes -= value.cost; values.delete(key); } }
19
+ function operation(input) {
20
+ if (input.op === 'generation') return { generation };
21
+ if (input.op === 'invalidate') throw new Error('Build cache invalidation requires a running production server');
22
+ if (!keyPattern.test(input.key)) throw new Error('Invalid build cache key');
23
+ const now = Date.now();
24
+ for(const [key,lease] of leases)if(lease.until<=now)leases.delete(key);
25
+ const lease = leases.get(input.key);
26
+ if (input.op === 'leaseStatus') return { valid: Boolean(lease && lease.id === input.lease) };
27
+ if (input.op === 'release') { if (lease?.id === input.lease) leases.delete(input.key); return {}; }
28
+ if (input.op === 'commit') {
29
+ if (!lease || lease.id !== input.lease || typeof input.value !== 'string' || input.value.length > Math.ceil(MAX_VALUE / 3) * 4) return { stored: false };
30
+ const size = Buffer.byteLength(input.value, 'base64');
31
+ if (size > MAX_VALUE || (input.revalidate !== null && (!Number.isFinite(input.revalidate) || input.revalidate < 0))) return { stored: false };
32
+ remove(input.key);
33
+ const cost = Buffer.byteLength(input.value) + 512;
34
+ while (values.size && (bytes + cost > MAX_BYTES || values.size >= MAX_ENTRIES)) remove(values.keys().next().value);
35
+ values.set(input.key, { value: input.value, cost, timestamp: now, revalidate: input.revalidate, expire: input.expire }); bytes += cost;
36
+ leases.delete(input.key);
37
+ return { stored: true };
38
+ }
39
+ if (input.op !== 'read') throw new Error('Unknown build cache operation');
40
+ const record = values.get(input.key);
41
+ const ttl = Math.min(input.revalidate ?? Infinity, record?.revalidate ?? Infinity, record?.expire ?? Infinity);
42
+ if (record && now - record.timestamp < ttl * 1000) {
43
+ values.delete(input.key); values.set(input.key, record);
44
+ return { state: 'fresh', value: record.value, key: input.key, generation };
45
+ }
46
+ if (record) remove(input.key);
47
+ if (lease) return { state: 'pending', retryAfterMs: 20, key: input.key, generation };
48
+ if (leases.size >= MAX_ENTRIES) throw new Error('Build cache lease capacity exceeded');
49
+ const id = randomBytes(16).toString('hex'); leases.set(input.key, { id, until: now + LEASE_MS });
50
+ return { state: 'miss', lease: id, key: input.key, generation };
51
+ }
52
+ const server = createServer(async (request, response) => {
53
+ if(active>=8){response.writeHead(503).end();return;}
54
+ active++;
55
+ try {
56
+ const auth = Buffer.from(request.headers.authorization || '');
57
+ const expected = Buffer.from(`Bearer ${token}`);
58
+ if (request.method !== 'POST' || request.url !== '/' || auth.length !== expected.length || !timingSafeEqual(auth, expected)) { response.writeHead(403).end(); return; }
59
+ let size = 0; const chunks = [];
60
+ for await (const chunk of request) { size += chunk.length; if (size > 3 * 1024 * 1024) throw new Error('Build cache request too large'); chunks.push(chunk); }
61
+ const result = operation(JSON.parse(Buffer.concat(chunks).toString()));
62
+ response.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(result));
63
+ } catch { if (!response.headersSent) response.writeHead(400); response.end(); }
64
+ finally{active--;}
65
+ });
66
+ server.requestTimeout = 5000; server.headersTimeout = 5000;
67
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
68
+ const env = { PRNEXT_CACHE_URL: `http://127.0.0.1:${server.address().port}/`, PRNEXT_CACHE_TOKEN: token };
69
+ return { env, async close() { values.clear(); leases.clear(); bytes = 0; await new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }); } };
70
+ }
@@ -0,0 +1,29 @@
1
+ import { availableParallelism, freemem } from 'node:os';
2
+
3
+ const MIB = 1024 * 1024;
4
+
5
+ export function validatePrerenderConfig(value) {
6
+ if (value === undefined) return {};
7
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('prerender must be an object.');
8
+ for (const key of Object.keys(value)) if (!['workers', 'maxMemoryMb'].includes(key)) throw new TypeError(`prerender.${key} is not supported.`);
9
+ const result = {};
10
+ if (value.workers !== undefined) {
11
+ if (!Number.isInteger(value.workers) || value.workers < 1 || value.workers > 8) throw new TypeError('prerender.workers must be an integer from 1 to 8.');
12
+ result.workers = value.workers;
13
+ }
14
+ if (value.maxMemoryMb !== undefined) {
15
+ if (!Number.isInteger(value.maxMemoryMb) || value.maxMemoryMb < 256 || value.maxMemoryMb > 16384) throw new TypeError('prerender.maxMemoryMb must be an integer from 256 to 16384.');
16
+ result.maxMemoryMb = value.maxMemoryMb;
17
+ }
18
+ return result;
19
+ }
20
+
21
+ // This is an admission budget, not an OS memory sandbox. Reserve memory for the
22
+ // coordinator/compiler and derive the process count from currently free RAM.
23
+ export function prerenderResources(value, { cpus = availableParallelism(), free = Math.min(freemem(), process.availableMemory?.() ?? Infinity) } = {}) {
24
+ const config = validatePrerenderConfig(value);
25
+ const availableMb = Math.max(0, Math.floor(free / MIB) - 512);
26
+ const maxMemoryMb = Math.min(config.maxMemoryMb ?? 2048, Math.max(256, Math.floor(availableMb / 2)));
27
+ const workers = Math.max(1, Math.min(config.workers ?? 4, Math.max(1, cpus - 1), Math.floor(maxMemoryMb / 384)));
28
+ return { workers, maxMemoryMb, heapMb: Math.max(64, Math.floor(maxMemoryMb / workers / 3)), explicit: config.workers !== undefined };
29
+ }
@@ -4,6 +4,7 @@ import {writeFile} from 'node:fs/promises';
4
4
  import {createHash} from 'node:crypto';
5
5
  import {localizedStaticPaths} from './i18n.mjs';
6
6
  import {mapConcurrent} from './concurrent.mjs';
7
+ import {mapPrerender,prerenderPath} from './prerender-pool.mjs';
7
8
  const isDynamic=pattern=>pattern.includes('[');
8
9
 
9
10
  export async function prerenderPages(stage, manifest, dev) {
@@ -11,12 +12,12 @@ export async function prerenderPages(stage, manifest, dev) {
11
12
  // getStaticProps/getStaticPaths for every route on every edit.
12
13
  const renderer = dev ? null : await import(pathToFileURL(path.join(stage, 'runtime/render.mjs')).href);
13
14
  const paths = new Set();
14
- const seedsByRoute = new Map();
15
15
  const validatedDocuments = new Set();
16
16
  const staticListings = new Map();
17
+ const jobs=[],seedPaths=new Map(),seeds=new Map();
17
18
  // Error page data is available when another static page returns notFound.
18
19
  const ordered = [...manifest.routes].sort((left, right) => Number(!!right.errorStatus) - Number(!!left.errorStatus));
19
- async function renderRoute(route) {
20
+ async function renderRoute(route,immediate=false) {
20
21
  if (route.kind !== 'page' || route.router === 'app') return;
21
22
  const modulePath = path.join(stage, route.module);
22
23
  const page = await import(pathToFileURL(modulePath).href);
@@ -59,10 +60,13 @@ export async function prerenderPages(stage, manifest, dev) {
59
60
  route.fallback = result.fallback;
60
61
  entries = result.paths;
61
62
  }
63
+ seedPaths.set(route.id,entries.map(entry=>entry.path));
64
+ const routeJobs=[];
62
65
  for (const entry of entries) {
63
66
  if (paths.has(entry.path)) throw new Error(`Multiple pages prerender the same path: ${entry.path}.`);
64
67
  paths.add(entry.path);
65
- const rendered = await renderer.prerenderRoute({ ...manifest.config, manifest, distDir: stage, route, modulePath, path: entry.path, params: entry.params, client: route.client, css: route.css, pattern: route.pattern, errorStatus: route.errorStatus, buildId: manifest.buildId, production: !dev });
68
+ routeJobs.push(async()=>{
69
+ const rendered = await prerenderPath('pages',{ ...manifest.config, manifest, distDir: stage, route, modulePath, path: entry.path, params: entry.params, client: route.client, css: route.css, pattern: route.pattern, errorStatus: route.errorStatus, buildId: manifest.buildId, production: !dev },renderer.prerenderRoute);
66
70
  const file = `static/${createHash('sha256').update(entry.path).digest('hex').slice(0, 20)}.html`;
67
71
  await writeFile(path.join(stage, file), rendered.body);
68
72
  const seed = { routeId: route.id, path: entry.path, file, status: route.errorStatus || rendered.status || 200, headers: rendered.headers || {} };
@@ -73,18 +77,24 @@ export async function prerenderPages(stage, manifest, dev) {
73
77
  await writeFile(path.join(stage, seed.dataFile), rendered.dataJSON);
74
78
  }
75
79
  manifest.prerendered.push(seed);
76
- if (!seedsByRoute.has(route.id)) seedsByRoute.set(route.id, []);
77
- seedsByRoute.get(route.id).push(seed);
80
+ seeds.set(entry.path,seed);
81
+ });
78
82
  }
79
83
  if (route.fallback === true) {
80
- const rendered = await renderer.renderFallback({ ...manifest.config, modulePath, path: route.pattern, params: {}, client: route.client, css: route.css, pattern: route.pattern, buildId: manifest.buildId, production: !dev });
84
+ routeJobs.push(async()=>{
85
+ const rendered = await prerenderPath('fallback',{ ...manifest.config, manifest, distDir: stage, route, modulePath, path: route.pattern, params: {}, client: route.client, css: route.css, pattern: route.pattern, buildId: manifest.buildId, production: !dev },renderer.renderFallback);
81
86
  route.fallbackFile = `static/fallback-${route.id}.html`;
82
87
  await writeFile(path.join(stage, route.fallbackFile), rendered.body);
88
+ });
83
89
  }
90
+ if(immediate)for(const run of routeJobs)await run();
91
+ else jobs.push({route,run:routeJobs});
84
92
  }
85
93
  // Error seeds must exist before ordinary pages can render notFound/failures.
86
- for(const route of ordered.filter(route=>route.errorStatus))await renderRoute(route);
87
- await mapConcurrent(ordered.filter(route=>!route.errorStatus),renderRoute);
94
+ for(const route of ordered.filter(route=>route.errorStatus))await renderRoute(route,true);
95
+ await mapConcurrent(ordered.filter(route=>!route.errorStatus),route=>renderRoute(route));
96
+ jobs.sort((a,b)=>manifest.routes.indexOf(a.route)-manifest.routes.indexOf(b.route));
97
+ await mapPrerender(jobs.flatMap(job=>job.run),run=>run());
88
98
  // Rendering errors first must not change the published route/seed ordering.
89
- manifest.prerendered = manifest.routes.flatMap(route => seedsByRoute.get(route.id) || []);
99
+ manifest.prerendered = manifest.routes.flatMap(route => (seedPaths.get(route.id)||[]).flatMap(path=>seeds.has(path)?[seeds.get(path)]:[]));
90
100
  }
@@ -0,0 +1,165 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { fork } from 'node:child_process';
3
+ import { prerenderResources } from './prerender-config.mjs';
4
+ import { createPrerenderCache } from './prerender-cache.mjs';
5
+ import { mapConcurrent } from './concurrent.mjs';
6
+
7
+ const storage = new AsyncLocalStorage();
8
+ const MIB = 1024 * 1024;
9
+
10
+ export async function withPrerenderSession(root, stage, manifest, config, callback, {signal}={}) {
11
+ if (storage.getStore()) return callback();
12
+ const session = new PrerenderPool(root, stage, manifest, config);
13
+ const abort=()=>void session.abort(signal.reason instanceof Error?signal.reason:new Error('Prerender cancelled'));
14
+ signal?.addEventListener('abort',abort,{once:true});
15
+ try { signal?.throwIfAborted();const result=await storage.run(session,callback);signal?.throwIfAborted();return result; }
16
+ finally { signal?.removeEventListener('abort',abort);await session.close(); }
17
+ }
18
+
19
+ export async function mapPrerender(items, run) {
20
+ const values = Array.from(items), session = storage.getStore();
21
+ if (!session) return mapConcurrent(values, run);
22
+ await session.prepare(values.length);
23
+ try { return await mapConcurrent(values, run, session.parallelism); }
24
+ catch (error) { await session.abort(error); throw error; }
25
+ }
26
+
27
+ export function prerenderPath(kind, options, local) {
28
+ const session = storage.getStore();
29
+ return session?.enabled ? session.run(kind, options) : local(options);
30
+ }
31
+
32
+ export class PrerenderPool {
33
+ constructor(root, stage, manifest, config, resources, dependencies = {}) {
34
+ this.root = root; this.stage = stage; this.manifest = manifest;
35
+ this.resources = prerenderResources(config, resources);
36
+ this.children = []; this.enabled = false; this.parallelism = this.resources.workers;
37
+ this.queue = []; this.revision = 0;
38
+ this.sequence = 0; this.failure = undefined; this.closing = false;
39
+ this.fork = dependencies.fork || fork;
40
+ this.killGraceMs = dependencies.killGraceMs ?? 5000;
41
+ this.updateTimeoutMs = dependencies.updateTimeoutMs ?? 30000;
42
+ }
43
+ async prepare(count) {
44
+ if (this.failure) throw this.failure;
45
+ if(this.preparing){await this.preparing;return;}
46
+ // Starting a second Node/React runtime costs more than a tiny local render.
47
+ if(this.enabled){await this.updateManifest();return;}
48
+ if (this.parallelism < 2 || count < (this.resources.explicit ? 2 : 4)) return;
49
+ this.preparing=(async()=>{
50
+ this.parallelism = Math.min(this.parallelism, count);
51
+ this.cache = await createPrerenderCache();
52
+ try {
53
+ if(this.failure)throw this.failure;
54
+ await Promise.all(Array.from({ length: this.parallelism }, () => this.start()));
55
+ this.enabled = true;
56
+ } catch (error) { await this.abort(error); throw error; }
57
+ })();
58
+ try{await this.preparing;}finally{this.preparing=undefined;}
59
+ }
60
+ start() {
61
+ return new Promise((resolve, reject) => {
62
+ const child = this.fork(new URL('./prerender-process-worker.mjs', import.meta.url), [], {
63
+ cwd: this.root, execArgv: [`--max-old-space-size=${this.resources.heapMb}`], serialization: 'advanced',
64
+ env: { ...process.env, ...this.cache.env }, stdio: ['ignore', 'inherit', 'inherit', 'ipc'], windowsHide: true,
65
+ });
66
+ const worker = { child, rss: 0, busy: false, ready: false, updates: new Map() };
67
+ this.children.push(worker);
68
+ // A failed spawn emits error+close without exit. close is also the final
69
+ // stdio/IPC terminal event for a successfully spawned process.
70
+ worker.exited = new Promise(done => child.once('close', done));
71
+ const timeout = setTimeout(() => void this.abort(new Error('Prerender worker startup exceeded 30 seconds')), 30_000);
72
+ child.once('close',()=>clearTimeout(timeout));
73
+ worker.startReject = reject;
74
+ child.on('error', error => void this.abort(error));
75
+ child.on('exit', (code, signal) => {
76
+ clearTimeout(timeout);
77
+ if (!this.closing) void this.abort(new Error(`Prerender worker exited (${signal || code}); its path was not retried`));
78
+ });
79
+ child.on('message', message => {
80
+ if (Number.isSafeInteger(message.rss)) worker.rss = message.rss;
81
+ if (this.children.reduce((sum, value) => sum + value.rss, 0) > this.resources.maxMemoryMb * MIB) {
82
+ void this.abort(new Error(`Prerender workers exceeded the ${this.resources.maxMemoryMb} MiB RSS budget`)); return;
83
+ }
84
+ if (message.type === 'ready') { clearTimeout(timeout); worker.ready = true; worker.startReject = undefined; resolve(); }
85
+ else if(message.type==='updated'){const update=worker.updates.get(message.revision);worker.updates.delete(message.revision);clearTimeout(update?.timer);update?.resolve();}
86
+ else if (message.type === 'failure') { void this.abort(Object.assign(new Error(message.error), { code: message.code })); }
87
+ else if (message.type === 'result') {
88
+ if (!worker.current || worker.current.id !== message.id) { void this.abort(new Error('Invalid prerender worker response')); return; }
89
+ clearTimeout(worker.current.timer);
90
+ const current = worker.current; worker.current = undefined; worker.busy = false; current.resolve(message.rendered);this.dispatch();
91
+ }
92
+ });
93
+ child.send({ type: 'init', stage: this.stage, manifest: this.manifest }, error => { if (error) void this.abort(error); });
94
+ });
95
+ }
96
+ run(kind, options) {
97
+ if (this.failure) return Promise.reject(this.failure);
98
+ if(this.closing||this.closed||!this.enabled)return Promise.reject(new Error('Prerender pool is not open'));
99
+ if(this.queue.length>=32)return Promise.reject(new Error('Prerender scheduler exceeded its bounded waiting queue'));
100
+ return new Promise((resolve, reject) => {
101
+ this.queue.push({kind,options,resolve,reject});this.dispatch();
102
+ });
103
+ }
104
+ dispatch(){
105
+ if(this.failure||this.closing)return;
106
+ for(const worker of this.children){
107
+ if(!worker.ready||worker.busy||!this.queue.length)continue;
108
+ const {kind,options,resolve,reject}=this.queue.shift();
109
+ worker.busy=true;const id=++this.sequence;
110
+ const timeoutMs = Math.min(300_000, (options.route?.cacheConfig?.maxDuration || 60) * 1000);
111
+ const timer = setTimeout(() => void this.abort(new Error(`Prerender path ${options.path} exceeded ${timeoutMs} ms; it was not retried`)), timeoutMs);
112
+ worker.current = { id, resolve, reject, timer };
113
+ const { manifest, ...serialized } = options;
114
+ worker.child.send({ type: 'job', id, kind, options: serialized }, error => { if (error) void this.abort(error); });
115
+ }
116
+ }
117
+ async updateManifest(){
118
+ const revision=++this.revision;
119
+ await Promise.all(this.children.map(worker=>new Promise((resolve,reject)=>{
120
+ const timer=setTimeout(()=>void this.abort(new Error('Prerender manifest acknowledgement timed out')),this.updateTimeoutMs);
121
+ worker.updates.set(revision,{resolve,reject,timer});
122
+ worker.child.send({type:'update',revision,manifest:this.manifest},error=>{if(error)void this.abort(error);});
123
+ })));
124
+ }
125
+ async abort(error) {
126
+ this.failure ||= error;
127
+ this.closing = true;
128
+ for(const job of this.queue.splice(0))job.reject(this.failure);
129
+ for (const worker of this.children) {
130
+ worker.startReject?.(this.failure); worker.startReject = undefined;
131
+ for(const update of worker.updates.values()){clearTimeout(update.timer);update.reject(this.failure);}worker.updates.clear();
132
+ if (worker.current) { clearTimeout(worker.current.timer); worker.current.reject(this.failure); worker.current = undefined; }
133
+ }
134
+ await this.stopChildren(true);
135
+ }
136
+ stopChildren(force) {
137
+ if(this.stopping)return this.stopping;
138
+ this.stopping=(async()=>{
139
+ for(const {child}of this.children){
140
+ if(!child.pid||child.exitCode!==null||child.signalCode!==null)continue;
141
+ if(force)child.kill('SIGTERM');
142
+ else child.send({type:'close'},()=>{});
143
+ }
144
+ // User code may install a SIGTERM handler. Arm escalation before waiting
145
+ // for any terminal event, including on the error/cancellation path.
146
+ const killer=setTimeout(()=>{
147
+ for(const {child}of this.children)if(child.pid&&child.exitCode===null&&child.signalCode===null)child.kill('SIGKILL');
148
+ },this.killGraceMs);
149
+ try{await Promise.all(this.children.map(worker=>worker.exited));}
150
+ finally{clearTimeout(killer);}
151
+ })();
152
+ return this.stopping;
153
+ }
154
+ async close() {
155
+ if (this.closed) return this.closed;
156
+ this.closed = (async () => {
157
+ await this.preparing?.catch(()=>{});
158
+ if(this.queue.length||this.children.some(worker=>worker.current))await this.abort(new Error('Prerender session closed with unfinished paths; they were not retried'));
159
+ this.closing = true;
160
+ try { await this.stopChildren(Boolean(this.failure)); }
161
+ finally { await this.cache?.close(); }
162
+ })();
163
+ return this.closed;
164
+ }
165
+ }