@thomas.f/prnext 0.1.17-alpha → 0.1.18-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 (137) hide show
  1. package/README.md +26 -869
  2. package/build/actions.mjs +1 -1
  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 +81 -26
  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 +31 -8
  15. package/build/config.mjs +30 -1
  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-update.mjs +4 -2
  21. package/build/dev-dependencies.mjs +1 -1
  22. package/build/dev-discovery.mjs +3 -3
  23. package/build/dev.mjs +15 -8
  24. package/build/dynamic.mjs +1 -1
  25. package/build/edge.mjs +3 -3
  26. package/build/fonts.mjs +1 -1
  27. package/build/framework-imports.mjs +15 -5
  28. package/build/html-only-config.mjs +53 -0
  29. package/build/html-only-graph.mjs +67 -0
  30. package/build/index.mjs +59 -26
  31. package/build/instrumentation.mjs +30 -0
  32. package/build/metadata-routes.mjs +1 -1
  33. package/build/migration-audit.mjs +1 -1
  34. package/build/native-type-config.mjs +37 -0
  35. package/build/persistent-cache.mjs +18 -3
  36. package/build/precompress.mjs +1 -1
  37. package/build/prerender-cache.mjs +70 -0
  38. package/build/prerender-config.mjs +29 -0
  39. package/build/prerender-pages.mjs +19 -9
  40. package/build/prerender-pool.mjs +165 -0
  41. package/build/prerender-process-worker.mjs +58 -0
  42. package/build/prerender-worker.mjs +5 -5
  43. package/build/production-builder.mjs +58 -9
  44. package/build/react-compiler-report.mjs +225 -0
  45. package/build/react-compiler.mjs +139 -0
  46. package/build/runtime-contract.mjs +2 -2
  47. package/build/static-export.mjs +4 -4
  48. package/build/styles.mjs +6 -4
  49. package/build/transform.mjs +1 -1
  50. package/build/typed-routes.mjs +4 -0
  51. package/build/typescript-dev.mjs +35 -8
  52. package/build/typescript-native-worker.mjs +55 -0
  53. package/build/typescript-native.mjs +18 -0
  54. package/build/typescript-toolchain.mjs +19 -0
  55. package/build/typescript.mjs +6 -4
  56. package/build/webpack-graph.mjs +6 -3
  57. package/build/worker-memory.mjs +9 -0
  58. package/cli.mjs +60 -4
  59. package/compat/activity-cache.cjs +26 -0
  60. package/compat/after.cjs +6 -4
  61. package/compat/app-layout-context.cjs +52 -2
  62. package/compat/base64.cjs +10 -0
  63. package/compat/cache-handlers.cjs +12 -2
  64. package/compat/cache-wire.cjs +28 -0
  65. package/compat/cache.cjs +2 -1
  66. package/compat/cache.d.cts +4 -0
  67. package/compat/cookies.cjs +21 -5
  68. package/compat/data-cache.cjs +12 -8
  69. package/compat/flight-delta.cjs +150 -0
  70. package/compat/headers.cjs +4 -2
  71. package/compat/incremental-cache.cjs +20 -7
  72. package/compat/index.d.ts +68 -3
  73. package/compat/link.cjs +12 -9
  74. package/compat/navigation-authorization.cjs +9 -0
  75. package/compat/navigation-stages.cjs +19 -0
  76. package/compat/offline-store.cjs +11 -0
  77. package/compat/offline.cjs +5 -0
  78. package/compat/offline.d.cts +2 -0
  79. package/compat/redis-cache.cjs +201 -0
  80. package/compat/redis-cache.d.cts +37 -0
  81. package/compat/server-primitives.cjs +31 -11
  82. package/compat/server.d.cts +15 -2
  83. package/compat/static-generation.cjs +7 -0
  84. package/compat/use-cache.cjs +3 -0
  85. package/docs/ai/compatibility.md +171 -2
  86. package/docs/ai/data.md +165 -5
  87. package/docs/ai/deployment.md +158 -0
  88. package/docs/ai/quickstart.md +29 -4
  89. package/docs/ai/routing.md +82 -0
  90. package/docs/ai/troubleshooting.md +37 -2
  91. package/package.json +26 -7
  92. package/runtime/action-client.mjs +10 -4
  93. package/runtime/action-server.mjs +2 -0
  94. package/runtime/app-client.mjs +212 -65
  95. package/runtime/app-content.mjs +4 -3
  96. package/runtime/app-partial-client.mjs +3 -0
  97. package/runtime/app-partial.mjs +1 -1
  98. package/runtime/app-prefetch-constants.mjs +4 -0
  99. package/runtime/app-prefetch.mjs +2 -3
  100. package/runtime/app-render-key.mjs +5 -0
  101. package/runtime/app-render.mjs +88 -28
  102. package/runtime/app-routing.mjs +22 -9
  103. package/runtime/app-static.mjs +18 -8
  104. package/runtime/app-stream-html.mjs +10 -53
  105. package/runtime/app-style-selection.mjs +34 -0
  106. package/runtime/app-styles.mjs +154 -0
  107. package/runtime/cache-components-rsc.mjs +4 -1
  108. package/runtime/cache-request.mjs +2 -1
  109. package/runtime/deployment-client.mjs +19 -0
  110. package/runtime/dev-client.mjs +7 -2
  111. package/runtime/dev-compiler-broker.mjs +42 -0
  112. package/runtime/dev-indicator.mjs +8 -3
  113. package/runtime/dev-lazy-tool.mjs +16 -0
  114. package/runtime/development.mjs +29 -12
  115. package/runtime/document.mjs +1 -1
  116. package/runtime/edge.mjs +6 -5
  117. package/runtime/fetch-cache.mjs +10 -8
  118. package/runtime/flight-delta.mjs +50 -0
  119. package/runtime/flight-tap.mjs +28 -0
  120. package/runtime/html-only.mjs +27 -0
  121. package/runtime/http.mjs +2 -0
  122. package/runtime/incremental-cache.mjs +25 -4
  123. package/runtime/instrumentation.mjs +49 -0
  124. package/runtime/navigation-recovery.mjs +36 -0
  125. package/runtime/navigation.mjs +7 -0
  126. package/runtime/node-html-stream.mjs +47 -0
  127. package/runtime/pages-client.mjs +14 -7
  128. package/runtime/pages-navigation.mjs +48 -63
  129. package/runtime/prefetch-scheduler.mjs +110 -0
  130. package/runtime/profile.mjs +14 -0
  131. package/runtime/profiles.json +2 -2
  132. package/runtime/render.mjs +3 -0
  133. package/runtime/route-pattern.mjs +44 -0
  134. package/runtime/rsc-worker.mjs +34 -12
  135. package/runtime/trace.mjs +78 -0
  136. package/runtime/watch-project.mjs +19 -15
  137. package/runtime/worker.mjs +70 -26
@@ -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
+ }
@@ -0,0 +1,58 @@
1
+ import path from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+
4
+ let stage, manifest, busy = false;
5
+ let closeRuntime;
6
+ const send = value => new Promise((resolve, reject) => process.send(value, error => error ? reject(error) : resolve()));
7
+ const memory = () => ({ rss: process.memoryUsage().rss, pid: process.pid });
8
+ const pulse = setInterval(() => { if (process.connected) process.send({ type: 'memory', ...memory() }); }, 100);
9
+ pulse.unref();
10
+
11
+ process.on('message', async message => {
12
+ try {
13
+ if (message.type === 'init') {
14
+ if (stage) throw new Error('Prerender worker was initialized twice');
15
+ ({ stage, manifest } = message);
16
+ await send({ type: 'ready', ...memory() });
17
+ return;
18
+ }
19
+ if (message.type === 'update') {
20
+ manifest = message.manifest;
21
+ await send({ type: 'updated', revision: message.revision });
22
+ return;
23
+ }
24
+ if (message.type === 'close') {
25
+ if (busy) throw new Error('Cannot close an active prerender worker');
26
+ if (closeRuntime) await closeRuntime();
27
+ clearInterval(pulse);
28
+ process.disconnect();
29
+ return;
30
+ }
31
+ if (message.type !== 'job' || !stage || busy) throw new Error('Invalid prerender worker operation');
32
+ busy = true;
33
+ const options = { ...message.options, manifest };
34
+ let rendered;
35
+ if (message.kind === 'pages' || message.kind === 'fallback') {
36
+ const runtime = await import(pathToFileURL(path.join(stage, 'runtime/render.mjs')).href);
37
+ rendered = await runtime[message.kind === 'pages' ? 'prerenderRoute' : 'renderFallback'](options);
38
+ } else if (message.kind === 'app' || message.kind === 'handler') {
39
+ const runtime = await import(pathToFileURL(path.join(stage, `runtime/${message.kind === 'app' ? 'app-static' : 'route-static'}.mjs`)).href);
40
+ if (message.kind === 'app') closeRuntime ||= (await import(pathToFileURL(path.join(stage, 'runtime/app-render.mjs')).href)).closeAppRuntime;
41
+ rendered = await runtime[message.kind === 'app' ? 'prerenderAppRoute' : 'prerenderAppRouteHandler'](options);
42
+ } else throw new Error('Unknown prerender job kind');
43
+ busy = false;
44
+ // Runtime prerender entrypoints already flush their request/cache work.
45
+ // Do not transfer retained finalizers, contexts or incidental worker data.
46
+ const result = {};
47
+ for (const field of ['body','dataJSON','status','headers','revalidate','generatedAt','tags','paths','partial','metadata','dynamic','reason','flight']) {
48
+ if (rendered[field] !== undefined) result[field] = rendered[field];
49
+ }
50
+ await send({ type: 'result', id: message.id, rendered: result, ...memory() });
51
+ } catch (error) {
52
+ busy = false;
53
+ await send({ type: 'failure', id: message.id, error: error.stack || error.message, code: error.code, ...memory() }).catch(() => {});
54
+ }
55
+ });
56
+
57
+ // A dead coordinator must not leave application work or an RSC thread behind.
58
+ process.once('disconnect', () => process.exit(0));
@@ -1,9 +1,9 @@
1
- import {prerenderPages} from './prerender-pages.mjs';
2
- import {prerenderApp} from './app-static.mjs';
3
- process.once('message',async({stage,manifest})=>{
1
+ import {prerenderProduction} from './production-builder.mjs';
2
+ const controller=new AbortController();
3
+ process.on('message',message=>{if(message.type==='abort')controller.abort(new Error('Production prerender cancelled by supervisor'));});
4
+ process.once('message',async({root,stage,manifest,options})=>{
4
5
  try{
5
- await prerenderPages(stage,manifest,false);
6
- await prerenderApp(stage,manifest,false);
6
+ await prerenderProduction(root,stage,manifest,{...options,signal:controller.signal});
7
7
  process.send({ok:true,manifest},()=>process.exit(0));
8
8
  }catch(error){process.send({ok:false,error:error.stack||error.message},()=>process.exit(1));}
9
9
  });
@@ -4,6 +4,9 @@ import path from 'node:path';
4
4
  import {watchProject} from '../runtime/watch-project.mjs';
5
5
  import {createDevChangeFilter} from '../runtime/dev-file-changes.mjs';
6
6
  import {incrementalCompilerStats} from './incremental.mjs';
7
+ import {withPrerenderSession} from './prerender-pool.mjs';
8
+ import {prerenderPages} from './prerender-pages.mjs';
9
+ import {prerenderApp} from './app-static.mjs';
7
10
 
8
11
  /** Explicit reusable build session for integrations; always dispose it. */
9
12
  export async function createBuilder(projectRoot,options={}){
@@ -21,15 +24,61 @@ export async function createBuilder(projectRoot,options={}){
21
24
 
22
25
  // Application imports, SSG and RSC isolates belong to one build only. Keeping
23
26
  // esbuild's graph must not retain application globals or execute stale modules.
24
- export function isolatedPrerender(root,stage,manifest){
25
- return new Promise((resolve,reject)=>{
26
- const child=fork(new URL('./prerender-worker.mjs',import.meta.url),[],{cwd:root,execArgv:[],stdio:['ignore','inherit','inherit','ipc'],windowsHide:true});
27
- let result;
28
- const timeout=setTimeout(()=>{child.kill();reject(new Error('Production prerender exceeded ten minutes'));},600000);
29
- child.once('error',error=>{clearTimeout(timeout);reject(error);});
30
- child.on('message',value=>{result=value;});
31
- child.once('exit',(code,signal)=>{clearTimeout(timeout);result?.ok&&code===0?resolve(result.manifest):reject(new Error(result?.error||`Prerender exited ${signal||code}`));});
32
- child.send({stage,manifest});
27
+ export async function prerenderProduction(root,stage,manifest,{prerender,signal}={}){
28
+ await withPrerenderSession(root,stage,manifest,prerender,async()=>{
29
+ await prerenderPages(stage,manifest,false);
30
+ await prerenderApp(stage,manifest,false);
31
+ },{signal});
32
+ return manifest;
33
+ }
34
+
35
+ export function isolatedPrerender(root, stage, manifest, options = {}, dependencies = {}) {
36
+ const spawn = dependencies.fork || fork;
37
+ const { timeoutMs = 600000, abortGraceMs = 30000, killGraceMs = 5000 } = dependencies;
38
+ return new Promise((resolve, reject) => {
39
+ const child = spawn(new URL('./prerender-worker.mjs', import.meta.url), [], {
40
+ cwd: root, execArgv: [], stdio: ['ignore', 'inherit', 'inherit', 'ipc'], windowsHide: true,
41
+ });
42
+ let result, failure, terminateTimer, killTimer, closed = false, terminating = false;
43
+ const running = () => !closed && child.pid && child.exitCode === null && child.signalCode === null;
44
+ const terminate = () => {
45
+ if (!running() || terminating) return;
46
+ terminating = true;
47
+ child.kill('SIGTERM');
48
+ // SIGTERM is an application-observable signal on POSIX. Never wait on
49
+ // its handler before arming the unconditional final termination.
50
+ killTimer ||= setTimeout(() => { if (running()) child.kill('SIGKILL'); }, killGraceMs);
51
+ };
52
+ const fail = error => {
53
+ failure ||= error;
54
+ clearTimeout(timeout);
55
+ clearTimeout(terminateTimer);
56
+ terminate();
57
+ };
58
+ const send = message => {
59
+ if (!child.connected) { fail(new Error('Prerender coordinator IPC disconnected')); return; }
60
+ try { child.send(message, error => { if (error && !closed) fail(error); }); }
61
+ catch (error) { fail(error); }
62
+ };
63
+ const timeout = setTimeout(() => {
64
+ failure ||= new Error('Production prerender exceeded ten minutes');
65
+ // Cooperative cancellation drains the coordinator's owned path workers.
66
+ // A blocked application generator may never process this IPC message.
67
+ send({ type: 'abort' });
68
+ if (!closed) terminateTimer = setTimeout(terminate, abortGraceMs);
69
+ }, timeoutMs);
70
+ child.on('error', fail);
71
+ child.once('disconnect', () => { if (!closed && !result) fail(new Error('Prerender coordinator IPC disconnected before completion')); });
72
+ child.on('message', value => { if (typeof value?.ok === 'boolean') result = value; });
73
+ // Failed spawns emit error+close without exit. Publish/reject only after
74
+ // the final terminal event, so stage cleanup cannot race this process.
75
+ child.once('close', (code, signal) => {
76
+ closed = true;
77
+ clearTimeout(timeout); clearTimeout(terminateTimer); clearTimeout(killTimer);
78
+ if (!failure && result?.ok && code === 0) resolve(result.manifest);
79
+ else reject(failure || new Error(result?.error || `Prerender exited ${signal || code}`));
80
+ });
81
+ send({ root, stage, manifest, options });
33
82
  });
34
83
  }
35
84