@thomas.f/prnext 0.1.16-alpha → 0.1.17-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/AI.md +63 -0
- package/README.md +384 -9
- package/build/ADAPTERS.md +27 -2
- package/build/actions.mjs +5 -0
- package/build/analyze.mjs +184 -0
- package/build/app-config.mjs +7 -8
- package/build/app-static.mjs +12 -7
- package/build/app.mjs +23 -10
- package/build/build-lock.mjs +6 -6
- package/build/bundle-runtime.mjs +20 -5
- package/build/check.mjs +56 -11
- package/build/compiler.mjs +25 -1
- package/build/concurrent.mjs +12 -0
- package/build/config.mjs +21 -13
- package/build/css-modules.mjs +15 -4
- package/build/dev-compiler-health.mjs +11 -0
- package/build/dev-css-update.mjs +83 -0
- package/build/dev-dependencies.mjs +44 -0
- package/build/dev-discovery.mjs +28 -0
- package/build/dev-worker.mjs +14 -5
- package/build/dev.mjs +55 -4
- package/build/edge.mjs +3 -2
- package/build/framework-imports.mjs +40 -0
- package/build/incremental.mjs +8 -4
- package/build/index.mjs +96 -101
- package/build/inspect.mjs +3 -0
- package/build/inspection-summary.mjs +37 -4
- package/build/loader-cache.mjs +53 -26
- package/build/metadata-routes.mjs +2 -3
- package/build/migrate.mjs +60 -57
- package/build/npm-packages.mjs +3 -18
- package/build/package-manager.mjs +308 -0
- package/build/persistent-cache.mjs +97 -0
- package/build/precompress.mjs +62 -30
- package/build/prerender-pages.mjs +90 -0
- package/build/prerender-worker.mjs +10 -0
- package/build/production-builder.mjs +80 -0
- package/build/scan.mjs +4 -4
- package/build/source-analysis.mjs +42 -0
- package/build/static-adapter.mjs +13 -5
- package/build/static-input.mjs +48 -0
- package/build/styles.mjs +15 -3
- package/build/transform-cache.mjs +37 -4
- package/build/type-command.mjs +41 -0
- package/build/typed-routes.mjs +91 -0
- package/build/typescript-dev-worker.mjs +18 -0
- package/build/typescript-dev.mjs +22 -0
- package/build/typescript-watch.mjs +22 -0
- package/build/typescript.mjs +5 -4
- package/build/webpack-graph.mjs +164 -27
- package/build/worker-preload.mjs +9 -0
- package/cli.mjs +105 -121
- package/compat/index.d.ts +21 -7
- package/compat/link.d.cts +4 -3
- package/compat/navigation.d.cts +4 -3
- package/compat/router.d.cts +4 -3
- package/compat/typed-route.d.cts +17 -0
- package/docs/ai/compatibility.md +106 -0
- package/docs/ai/data.md +128 -0
- package/docs/ai/deployment.md +111 -0
- package/docs/ai/manifest.json +11 -0
- package/docs/ai/quickstart.md +146 -0
- package/docs/ai/routing.md +132 -0
- package/docs/ai/troubleshooting.md +120 -0
- package/index.mjs +2 -0
- package/native/diagnostics.mjs +34 -0
- package/native/resolve.mjs +7 -2
- package/package.json +25 -7
- package/release/ai-docs.mjs +48 -0
- package/release/verify.mjs +2 -0
- package/runtime/ai-docs.mjs +202 -0
- package/runtime/app-client.mjs +17 -6
- package/runtime/app-render.mjs +12 -0
- package/runtime/cloud/archive.mjs +5 -6
- package/runtime/compile-cache.mjs +8 -0
- package/runtime/dev-ai-brands.mjs +26 -0
- package/runtime/dev-ai-style.mjs +99 -0
- package/runtime/dev-build-reasons.mjs +25 -0
- package/runtime/dev-cache-control.mjs +53 -0
- package/runtime/dev-client.mjs +239 -45
- package/runtime/dev-console-copy.mjs +19 -0
- package/runtime/dev-console.mjs +106 -0
- package/runtime/dev-dependency-graph.mjs +40 -0
- package/runtime/dev-element-picker-style.mjs +27 -0
- package/runtime/dev-element-picker.mjs +213 -0
- package/runtime/dev-error-overlay-style.mjs +41 -0
- package/runtime/dev-error-overlay.mjs +167 -0
- package/runtime/dev-error-report.mjs +121 -0
- package/runtime/dev-file-changes.mjs +1 -1
- package/runtime/dev-gateway.mjs +44 -0
- package/runtime/dev-host-relay.mjs +66 -0
- package/runtime/dev-indicator.mjs +186 -0
- package/runtime/dev-issue-motion.mjs +214 -0
- package/runtime/dev-lazy-tool.mjs +18 -0
- package/runtime/dev-logo.mjs +2 -0
- package/runtime/dev-notification.mjs +81 -0
- package/runtime/dev-popout.mjs +34 -0
- package/runtime/dev-position.mjs +107 -0
- package/runtime/dev-preview-touch.mjs +122 -0
- package/runtime/dev-runtime-pool.mjs +45 -0
- package/runtime/dev-runtime-spare.mjs +57 -0
- package/runtime/dev-tools-client.mjs +155 -0
- package/runtime/dev-tools-mcp.mjs +126 -0
- package/runtime/dev-tools-metrics.mjs +31 -0
- package/runtime/dev-tools-model.mjs +42 -0
- package/runtime/dev-tools-panel.mjs +341 -0
- package/runtime/dev-tools-result.mjs +37 -0
- package/runtime/dev-tools-server.mjs +256 -0
- package/runtime/dev-tools-source.mjs +104 -0
- package/runtime/dev-tooltip.mjs +94 -0
- package/runtime/dev-ui-icons.mjs +8 -0
- package/runtime/dev-workbench-style.mjs +106 -0
- package/runtime/dev-workbench.mjs +428 -0
- package/runtime/dev-worker-logs.mjs +31 -0
- package/runtime/development.mjs +307 -0
- package/runtime/env.mjs +2 -1
- package/runtime/pages-client.mjs +10 -1
- package/runtime/pages-navigation.mjs +6 -1
- package/runtime/persistent/client.mjs +6 -5
- package/runtime/persistent/daemon.mjs +66 -56
- package/runtime/persistent/native-proxy.mjs +63 -0
- package/runtime/preload.mjs +12 -0
- package/runtime/rsc-worker.mjs +7 -0
- package/runtime/typed-route.mjs +19 -0
- package/runtime/watch-project.mjs +12 -5
- package/runtime/worker.mjs +36 -2
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { request } from 'node:http';
|
|
3
3
|
import { cp, mkdir, readFile, rm, realpath } from 'node:fs/promises';
|
|
4
4
|
import { randomUUID, createHash } from 'node:crypto';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { readBuildDirectory } from '../build-directory.mjs';
|
|
7
7
|
import { atomicJSON, delay, environment, validName, validHealthPath } from './common.mjs';
|
|
8
8
|
import { RotatingLog } from './logger.mjs';
|
|
9
|
+
import {nativeGateway,stopNativeProxy} from './native-proxy.mjs';
|
|
9
10
|
|
|
10
11
|
const home = process.argv[2];
|
|
11
12
|
const registry = path.join(home, 'apps.json');
|
|
@@ -16,6 +17,7 @@ const send = message => { if (process.connected) process.send(message); };
|
|
|
16
17
|
|
|
17
18
|
function validate(config) {
|
|
18
19
|
validName(config.name); validHealthPath(config.healthPath);
|
|
20
|
+
if(config.rollbackWindow!==undefined&&(!Number.isInteger(config.rollbackWindow)||config.rollbackWindow<0||config.rollbackWindow>120000))throw new Error('rollbackWindow must be between 0 and 120000 milliseconds');
|
|
19
21
|
if (!path.isAbsolute(config.root) || !path.isAbsolute(config.binary) || !path.isAbsolute(config.node)) throw new Error('Application, Node and native executable paths must be absolute');
|
|
20
22
|
if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) throw new Error('--port must be between 1 and 65535');
|
|
21
23
|
if (typeof config.hostname !== 'string' || !config.hostname || /[\s\x00-\x1f]/.test(config.hostname)) throw new Error('Choose a valid --hostname');
|
|
@@ -27,7 +29,8 @@ async function persist() {
|
|
|
27
29
|
await atomicJSON(registry, { version: 1, apps: [...apps.values()].map(app => ({ config: app.config, enabled: app.enabled, snapshots: [...app.snapshots] })) });
|
|
28
30
|
}
|
|
29
31
|
function newApp(config, enabled = false) {
|
|
30
|
-
|
|
32
|
+
config={rollbackWindow:15000,...config};
|
|
33
|
+
return { config, enabled, snapshots: new Set(), active: null, generations: new Set(), gateway: null, state: 'stopped', error: null, starts: 0, failures: [], retry: null, probation:null,
|
|
31
34
|
log: new RotatingLog(path.join(home, 'logs', `app-${config.name}.log`)) };
|
|
32
35
|
}
|
|
33
36
|
function summary(app) {
|
|
@@ -36,44 +39,13 @@ function summary(app) {
|
|
|
36
39
|
activeRequests: [...app.generations].reduce((sum, generation) => sum + generation.active, 0),
|
|
37
40
|
draining: [...app.generations].filter(generation => generation !== app.active).length,
|
|
38
41
|
buildId: app.active?.buildId || null, startedAt: app.active?.startedAt || null, error: app.error,
|
|
42
|
+
proxyPid:app.gateway?.pid||null,
|
|
43
|
+
rollback:app.probation?{previousBuildId:app.probation.previous.buildId,until:app.probation.deadline}:null,
|
|
39
44
|
log: path.join(home, 'logs', `app-${app.config.name}.log`) };
|
|
40
45
|
}
|
|
41
|
-
function hopHeaders(headers) {
|
|
42
|
-
const result = { ...headers };
|
|
43
|
-
const nominated = String(headers.connection || '').split(',').map(value => value.trim().toLowerCase());
|
|
44
|
-
for (const key of ['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', ...nominated]) delete result[key];
|
|
45
|
-
return result;
|
|
46
|
-
}
|
|
47
46
|
async function listen(app) {
|
|
48
47
|
if (app.gateway) return;
|
|
49
|
-
|
|
50
|
-
const generation = app.active;
|
|
51
|
-
if (!incoming.url.startsWith('/')) { outgoing.writeHead(400).end('Use a local request path.'); return; }
|
|
52
|
-
if (!generation || generation.dead) { outgoing.writeHead(503, { 'retry-after': '1' }).end('Application is restarting.'); incoming.resume(); return; }
|
|
53
|
-
if (generation.active >= 512) { outgoing.writeHead(503, { 'retry-after': '1' }).end('Application is busy.'); incoming.resume(); return; }
|
|
54
|
-
generation.active++;
|
|
55
|
-
const headers = hopHeaders(incoming.headers);
|
|
56
|
-
// Preserve original Host and trusted reverse-proxy headers. Never invent
|
|
57
|
-
// retry/replay behavior for POSTs or requests that have already streamed.
|
|
58
|
-
const upstream = request({ host: '127.0.0.1', port: generation.port, method: incoming.method, path: incoming.url, headers, agent: generation.agent }, response => {
|
|
59
|
-
outgoing.writeHead(response.statusCode, hopHeaders(response.headers));
|
|
60
|
-
response.on('error', () => outgoing.destroy()); response.pipe(outgoing);
|
|
61
|
-
});
|
|
62
|
-
generation.requests.add(upstream);
|
|
63
|
-
let released = false;
|
|
64
|
-
const release = () => { if (released) return; released = true; generation.active--; generation.requests.delete(upstream); upstream.destroy(); };
|
|
65
|
-
outgoing.once('finish', release); outgoing.once('close', release);
|
|
66
|
-
incoming.once('aborted', () => outgoing.destroy());
|
|
67
|
-
incoming.once('error', () => outgoing.destroy());
|
|
68
|
-
upstream.once('error', () => { if (!outgoing.headersSent) outgoing.writeHead(502).end('Application connection closed.'); else outgoing.destroy(); });
|
|
69
|
-
incoming.pipe(upstream);
|
|
70
|
-
});
|
|
71
|
-
server.on('upgrade', (_request, socket) => socket.end('HTTP/1.1 501 Not Implemented\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'));
|
|
72
|
-
server.on('connection', socket => { app.sockets.add(socket); socket.once('close', () => app.sockets.delete(socket)); });
|
|
73
|
-
server.maxConnections = 1024;
|
|
74
|
-
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(app.config.port, app.config.hostname, resolve); });
|
|
75
|
-
server.on('error', error => { app.error = error.message; app.log.message(error.message); });
|
|
76
|
-
app.gateway = server;
|
|
48
|
+
app.gateway=await nativeGateway(app.config,{home,notify:send,onFatal:error=>{console.error(error.message);if(!closing)process.exit(1);}});
|
|
77
49
|
}
|
|
78
50
|
async function removeSnapshot(app, relative) {
|
|
79
51
|
// Only delete a directory generated by this manager inside this exact root.
|
|
@@ -120,11 +92,9 @@ async function stopGeneration(app, generation, drain = true) {
|
|
|
120
92
|
generation.expected = true;
|
|
121
93
|
generation.stopping = (async () => {
|
|
122
94
|
const deadline = Date.now() + (drain ? app.config.drainTimeout : 0);
|
|
123
|
-
|
|
124
|
-
for (const req of generation.requests) req.destroy();
|
|
125
|
-
generation.agent.destroy();
|
|
95
|
+
await app.gateway?.retire(generation,drain?Math.max(0,deadline-Date.now()):0).catch(error=>app.log.message(`Proxy retirement: ${error.message}`));
|
|
126
96
|
generation.child.stdin?.end();
|
|
127
|
-
await Promise.race([generation.closed, delay(3000)]);
|
|
97
|
+
await Promise.race([generation.closed, delay(drain?Math.max(1000,deadline-Date.now()):3000)]);
|
|
128
98
|
if (!generation.dead) generation.child.kill('SIGKILL');
|
|
129
99
|
if (process.platform !== 'win32' && generation.child.pid) { try { process.kill(-generation.child.pid, 'SIGKILL'); } catch {} }
|
|
130
100
|
await generation.closed;
|
|
@@ -156,7 +126,7 @@ async function launch(app) {
|
|
|
156
126
|
env: { ...environment(), NODE_ENV: 'production', ...(config.inspect ? { PRNEXT_INSPECT_DIR: path.join(config.root, '.prnext-cache/inspect') } : {}) },
|
|
157
127
|
});
|
|
158
128
|
child.stdin.on('error', () => {});
|
|
159
|
-
const generation = { child, ...build, active: 0,
|
|
129
|
+
const generation = { child, ...build, active: 0, dead: false, expected: false, port: null, startedAt: Date.now() };
|
|
160
130
|
app.generations.add(generation);
|
|
161
131
|
if (child.pid) send({ type: 'child', pid: child.pid, running: true });
|
|
162
132
|
let output = '', failure;
|
|
@@ -173,12 +143,12 @@ async function launch(app) {
|
|
|
173
143
|
generation.closed = new Promise(resolve => child.once('close', (code, signal) => {
|
|
174
144
|
generation.dead = true;
|
|
175
145
|
if (child.pid) send({ type: 'child', pid: child.pid, running: false });
|
|
176
|
-
if (app.active === generation) app.active = null;
|
|
146
|
+
if (app.active === generation) {app.active = null;void app.gateway?.pause(generation).catch(error=>app.log.message(error.message));}
|
|
177
147
|
if (!generation.expected) {
|
|
178
148
|
app.log.message(`Process ${child.pid || 'unknown'} exited (${signal || code}).`);
|
|
179
149
|
if (generation.ready) {
|
|
180
150
|
// Serialize recovery and cleanup with explicit lifecycle commands.
|
|
181
|
-
sequence = sequence.then(async () => { await stopGeneration(app, generation, false); await persist(); retry(app, new Error(`Application exited (${signal || code})`)); }).catch(error => app.log.message(error.stack));
|
|
151
|
+
sequence = sequence.then(async () => { if(app.probation?.next===generation&&await rollback(app,new Error(`Replacement exited (${signal||code})`)))return;await stopGeneration(app, generation, false); await persist(); if(!app.active)retry(app, new Error(`Application exited (${signal || code})`)); }).catch(error => app.log.message(error.stack));
|
|
182
152
|
}
|
|
183
153
|
}
|
|
184
154
|
resolve();
|
|
@@ -197,7 +167,42 @@ async function launch(app) {
|
|
|
197
167
|
} catch (error) { await stopGeneration(app, generation, false); throw error; }
|
|
198
168
|
finally { child.stdout.off('data', started); }
|
|
199
169
|
}
|
|
170
|
+
async function finishProbation(app){
|
|
171
|
+
const probation=app.probation;if(!probation)return;
|
|
172
|
+
clearTimeout(probation.timer);app.probation=null;
|
|
173
|
+
await stopGeneration(app,probation.previous).catch(error=>app.log.message(`Previous build cleanup: ${error.message}`));
|
|
174
|
+
await persist();
|
|
175
|
+
}
|
|
176
|
+
async function rollback(app,error){
|
|
177
|
+
const probation=app.probation;if(!probation)return false;
|
|
178
|
+
clearTimeout(probation.timer);app.probation=null;
|
|
179
|
+
if(!app.enabled||closing||probation.previous.dead||probation.previous.expected){
|
|
180
|
+
await stopGeneration(app,probation.previous,false);return false;
|
|
181
|
+
}
|
|
182
|
+
// A live previous process still has to pass its health check. Never roll
|
|
183
|
+
// traffic back to an instance whose dependencies have also become unhealthy.
|
|
184
|
+
try{await health(probation.previous,app.config,Math.min(2000,app.config.healthTimeout));await app.gateway.activate(probation.previous);}
|
|
185
|
+
catch(restore){app.log.message(`Rollback unavailable: ${restore.message}`);await stopGeneration(app,probation.previous,false);return false;}
|
|
186
|
+
app.active=probation.previous;app.state='online';app.error=`Rolled back: ${error.message}`;
|
|
187
|
+
app.log.message(`Rolled back to build ${app.active.buildId}: ${error.message}`);
|
|
188
|
+
await stopGeneration(app,probation.next);await persist();return true;
|
|
189
|
+
}
|
|
190
|
+
function beginProbation(app,next,previous){
|
|
191
|
+
const probation=app.probation={next,previous,deadline:Date.now()+app.config.rollbackWindow,failures:0};
|
|
192
|
+
const schedule=()=>{probation.timer=setTimeout(()=>{
|
|
193
|
+
sequence=sequence.then(async()=>{
|
|
194
|
+
if(app.probation!==probation||!app.enabled||closing)return;
|
|
195
|
+
if(next.dead){await rollback(app,new Error('Replacement exited during monitoring'));return;}
|
|
196
|
+
if(Date.now()>=probation.deadline){await finishProbation(app);return;}
|
|
197
|
+
try{await health(next,app.config,Math.min(2000,app.config.healthTimeout));probation.failures=0;}
|
|
198
|
+
catch(error){if(++probation.failures>=3){if(!await rollback(app,error)){await stopGeneration(app,next,false);retry(app,error);}return;}}
|
|
199
|
+
if(app.probation===probation)schedule();
|
|
200
|
+
}).catch(error=>app.log.message(`Replacement monitoring: ${error.stack||error}`));
|
|
201
|
+
},Math.min(1000,Math.max(1,probation.deadline-Date.now())));};
|
|
202
|
+
schedule();
|
|
203
|
+
}
|
|
200
204
|
async function activate(app) {
|
|
205
|
+
await finishProbation(app);
|
|
201
206
|
await listen(app);
|
|
202
207
|
const previous = app.active;
|
|
203
208
|
const wasEnabled = app.enabled;
|
|
@@ -208,9 +213,13 @@ async function activate(app) {
|
|
|
208
213
|
app.enabled = true;
|
|
209
214
|
await persist();
|
|
210
215
|
if (next.dead) throw new Error('Application exited before traffic could switch');
|
|
216
|
+
await app.gateway.activate(next);
|
|
217
|
+
if (next.dead) throw new Error('Application exited while traffic switched');
|
|
211
218
|
app.active = next; app.state = 'online'; app.error = null;
|
|
212
219
|
app.log.message(`Ready: pid ${next.child.pid}, build ${next.buildId || 'unknown'}, ${app.config.hostname}:${app.config.port}`);
|
|
213
220
|
} catch (error) {
|
|
221
|
+
if (previous && !previous.dead) await app.gateway.activate(previous).catch(restore=>app.log.message(restore.message));
|
|
222
|
+
else await app.gateway?.pause().catch(restore=>app.log.message(restore.message));
|
|
214
223
|
if (next) await stopGeneration(app, next, false).catch(cleanup => app.log.message(cleanup.message));
|
|
215
224
|
app.enabled = wasEnabled;
|
|
216
225
|
app.state = previous && !previous.dead ? 'online' : 'errored'; app.error = error.message;
|
|
@@ -218,21 +227,17 @@ async function activate(app) {
|
|
|
218
227
|
app.log.message(`Start failed; ${previous && !previous.dead ? 'previous generation remains active' : 'no healthy generation'}: ${error.message}`);
|
|
219
228
|
throw error;
|
|
220
229
|
}
|
|
221
|
-
if (previous)
|
|
222
|
-
|
|
223
|
-
await persist();
|
|
224
|
-
}
|
|
230
|
+
if(previous && app.config.rollbackWindow>0)beginProbation(app,next,previous);
|
|
231
|
+
else if(previous){await stopGeneration(app,previous);await persist();}
|
|
225
232
|
}
|
|
226
233
|
async function stopApp(app) {
|
|
234
|
+
if(app.probation)clearTimeout(app.probation.timer);app.probation=null;
|
|
227
235
|
app.enabled = false; clearTimeout(app.retry); app.retry = null;
|
|
228
236
|
await persist();
|
|
229
237
|
app.state = 'stopping'; app.active = null;
|
|
230
|
-
|
|
231
|
-
const closed = gateway ? new Promise(resolve => gateway.close(resolve)) : Promise.resolve();
|
|
232
|
-
gateway?.closeIdleConnections();
|
|
238
|
+
await app.gateway?.pause();
|
|
233
239
|
await Promise.all([...app.generations].map(generation => stopGeneration(app, generation)));
|
|
234
|
-
|
|
235
|
-
await closed; app.state = 'stopped'; app.error = null; await persist();
|
|
240
|
+
await app.gateway?.close();app.gateway=null;app.state = 'stopped'; app.error = null; await persist();
|
|
236
241
|
app.log.message('Stopped.');
|
|
237
242
|
}
|
|
238
243
|
function select(message) {
|
|
@@ -242,8 +247,12 @@ function select(message) {
|
|
|
242
247
|
return selected;
|
|
243
248
|
}
|
|
244
249
|
async function command(message) {
|
|
245
|
-
if (message.command === 'status')
|
|
250
|
+
if (message.command === 'status') {
|
|
251
|
+
for(const app of apps.values())if(app.gateway){const counts=await app.gateway.counts();for(const g of app.generations)g.active=counts[g.relative]||0;}
|
|
252
|
+
return { daemonPid: process.pid, apps: [...apps.values()].map(summary) };
|
|
253
|
+
}
|
|
246
254
|
if (message.command === 'start') {
|
|
255
|
+
message.config={rollbackWindow:15000,...message.config};
|
|
247
256
|
validate(message.config);
|
|
248
257
|
message.config.root = await realpath(message.config.root);
|
|
249
258
|
let app = apps.get(message.config.name);
|
|
@@ -274,12 +283,13 @@ async function shutdown() {
|
|
|
274
283
|
await sequence;
|
|
275
284
|
// A supervisor shutdown preserves each enabled flag for the next launch.
|
|
276
285
|
await Promise.all([...apps.values()].map(async app => {
|
|
277
|
-
clearTimeout(app.
|
|
286
|
+
if(app.probation)clearTimeout(app.probation.timer);app.probation=null;
|
|
287
|
+
clearTimeout(app.retry); app.active = null;await app.gateway?.pause();
|
|
278
288
|
await Promise.all([...app.generations].map(generation => stopGeneration(app, generation)));
|
|
279
|
-
|
|
289
|
+
await app.gateway?.close();app.gateway=null;
|
|
280
290
|
app.log.end();
|
|
281
291
|
}));
|
|
282
|
-
await persist(); process.exit(0);
|
|
292
|
+
await stopNativeProxy();await persist(); process.exit(0);
|
|
283
293
|
}
|
|
284
294
|
process.on('disconnect', () => void shutdown());
|
|
285
295
|
process.on('SIGTERM', () => void shutdown());
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Lifecycle RPC only: no public HTTP bytes, headers or response bodies enter JS.
|
|
2
|
+
import {spawn} from 'node:child_process';
|
|
3
|
+
import {environment,delay} from './common.mjs';
|
|
4
|
+
let manager;
|
|
5
|
+
class NativeProxy {
|
|
6
|
+
constructor(binary,{home,notify,onFatal}){
|
|
7
|
+
this.pending=new Map();this.next=0;this.stopping=false;
|
|
8
|
+
this.child=spawn(binary,['persistent-proxy'],{cwd:home,windowsHide:true,detached:process.platform!=='win32',stdio:['pipe','pipe','inherit'],env:{...environment(),TOKIO_WORKER_THREADS:'1'}});
|
|
9
|
+
this.pid=this.child.pid;if(this.pid)notify({type:'child',pid:this.pid,running:true});
|
|
10
|
+
this.closed=new Promise(resolve=>this.child.once('close',()=>{if(this.pid)notify({type:'child',pid:this.pid,running:false});resolve();}));
|
|
11
|
+
this.ready=new Promise((resolve,reject)=>{
|
|
12
|
+
const timer=setTimeout(()=>{reject(new Error('Native persistent proxy did not initialize'));this.child.kill();},10000);timer.unref();
|
|
13
|
+
let buffer='';
|
|
14
|
+
this.child.stdout.setEncoding('utf8');
|
|
15
|
+
this.child.stdout.on('data',chunk=>{
|
|
16
|
+
buffer+=chunk;
|
|
17
|
+
if(buffer.length>1024*1024){this.child.kill();return;}
|
|
18
|
+
for(let end;(end=buffer.indexOf('\n'))>=0;){
|
|
19
|
+
const line=buffer.slice(0,end);buffer=buffer.slice(end+1);let message;
|
|
20
|
+
try{message=JSON.parse(line);}catch{this.child.kill();return;}
|
|
21
|
+
if(message.type==='ready'&&message.protocol===1){clearTimeout(timer);resolve();continue;}
|
|
22
|
+
const request=this.pending.get(message.id);if(!request)continue;
|
|
23
|
+
this.pending.delete(message.id);clearTimeout(request.timer);message.ok?request.resolve(message.value):request.reject(new Error(message.error||'Native proxy request failed'));
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
const failed=error=>{
|
|
27
|
+
clearTimeout(timer);reject(error);
|
|
28
|
+
for(const request of this.pending.values()){clearTimeout(request.timer);request.reject(error);}this.pending.clear();
|
|
29
|
+
if(!this.stopping)onFatal(error);
|
|
30
|
+
};
|
|
31
|
+
this.child.once('error',failed);this.child.once('exit',(code,signal)=>failed(new Error(`Native persistent proxy exited (${signal||code})`)));
|
|
32
|
+
});
|
|
33
|
+
this.child.stdin.on('error',()=>{});
|
|
34
|
+
}
|
|
35
|
+
async request(command,values={},timeout=10000){
|
|
36
|
+
await this.ready;
|
|
37
|
+
if(this.stopping||this.child.exitCode!==null||this.pending.size>=32)throw new Error('Native persistent proxy unavailable');
|
|
38
|
+
const id=++this.next;
|
|
39
|
+
return new Promise((resolve,reject)=>{
|
|
40
|
+
const timer=setTimeout(()=>{
|
|
41
|
+
this.pending.delete(id);reject(new Error('Native proxy control request timed out'));
|
|
42
|
+
// A timed-out switch/listen might still mutate native state later.
|
|
43
|
+
// End that uncertain control plane; the watchdog restores saved apps.
|
|
44
|
+
this.child.kill('SIGKILL');
|
|
45
|
+
},timeout);timer.unref();
|
|
46
|
+
this.pending.set(id,{resolve,reject,timer});
|
|
47
|
+
this.child.stdin.write(JSON.stringify({id,command,...values})+'\n',error=>{if(error){const request=this.pending.get(id);if(request){clearTimeout(request.timer);this.pending.delete(id);request.reject(error);}}});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
async stop(){this.stopping=true;this.child.stdin.end();await Promise.race([this.closed,delay(3000)]);if(this.child.exitCode===null)this.child.kill('SIGKILL');await this.closed;}
|
|
51
|
+
}
|
|
52
|
+
export async function nativeGateway(config,options){
|
|
53
|
+
manager??=new NativeProxy(config.binary,options);await manager.ready;
|
|
54
|
+
const app=config.name;await manager.request('listen',{app,hostname:config.hostname,port:config.port});
|
|
55
|
+
return {pid:manager.pid,
|
|
56
|
+
activate:g=>manager.request('activate',{app,generation:g.relative,port:g.port}),
|
|
57
|
+
pause:g=>manager.request('pause',{app,...(g?{generation:g.relative}:{})}),
|
|
58
|
+
retire:(g,timeout)=>manager.request('retire',{app,generation:g.relative,timeout},timeout+5000),
|
|
59
|
+
counts:async()=> (await manager.request('status',{app})).generations,
|
|
60
|
+
close:()=>manager.request('close',{app}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export async function stopNativeProxy(){if(manager){const active=manager;manager=undefined;await active.stop();}}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {pathToFileURL} from 'node:url';
|
|
3
|
+
// This is an import budget, not a total RSS limit. An individual module and its
|
|
4
|
+
// transitive imports can exceed it; imported JavaScript cannot be unloaded.
|
|
5
|
+
export async function preloadModules(files,{maxRssMb,timeoutMs},load=file=>import(pathToFileURL(file).href)){
|
|
6
|
+
const deadline=performance.now()+timeoutMs,loaded=[];
|
|
7
|
+
for(const file of files){
|
|
8
|
+
if(performance.now()>=deadline||process.memoryUsage().rss>=maxRssMb*1024*1024)break;
|
|
9
|
+
await load(path.resolve(file));loaded.push(file);
|
|
10
|
+
}
|
|
11
|
+
return loaded.length;
|
|
12
|
+
}
|
package/runtime/rsc-worker.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
import {scheduleCompileCacheFlush} from './compile-cache.mjs';
|
|
3
|
+
import {preloadModules} from './preload.mjs';
|
|
2
4
|
import { flightBundler } from './worker-metadata.mjs';
|
|
3
5
|
import { removeBasePath, normalizeTrailingSlash } from '../compat/paths.cjs';
|
|
4
6
|
import { renderToReadableStream } from 'react-server-dom-webpack/server.node';
|
|
@@ -40,6 +42,7 @@ async function routeEntry(request) {
|
|
|
40
42
|
return entry;
|
|
41
43
|
}
|
|
42
44
|
const entry = await import(pathToFileURL(key).href);
|
|
45
|
+
scheduleCompileCacheFlush();
|
|
43
46
|
if (request.production) {
|
|
44
47
|
if (routeEntries.size >= 64) routeEntries.delete(routeEntries.keys().next().value);
|
|
45
48
|
routeEntries.set(key, entry);
|
|
@@ -387,6 +390,10 @@ const responseCompletions = new Map();
|
|
|
387
390
|
const manifests = new Map();
|
|
388
391
|
const artifacts = new Map();
|
|
389
392
|
parentPort.on('message', async ({ id, request, type, value, token }) => {
|
|
393
|
+
if(type==='preload'){
|
|
394
|
+
try{await preloadModules(value.files,value.options,file=>routeEntry({modulePath:file,production:true}));parentPort.postMessage({type:'preloaded',id});}
|
|
395
|
+
catch(error){parentPort.postMessage({type:'preloaded',id,error:error.message});}return;
|
|
396
|
+
}
|
|
390
397
|
if (type === 'after-response') { responseCompletions.get(id)?.(); return; }
|
|
391
398
|
// Only the host arms these probes, while completed responses still have
|
|
392
399
|
// tracked cache work. There is no timer or heartbeat in an idle RSC isolate.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Materialize a discovered route pattern without interpolating path separators. */
|
|
2
|
+
export function route(pattern,params={}) {
|
|
3
|
+
if(typeof pattern!=='string'||!pattern.startsWith('/')||pattern.startsWith('//')||pattern.length>4096||!params||typeof params!=='object'||Array.isArray(params))throw TypeError('Invalid route pattern or parameters');
|
|
4
|
+
const used=new Set(),parts=[];
|
|
5
|
+
const segment=value=>{
|
|
6
|
+
if(typeof value!=='string'||!value||value==='.'||value==='..'||/[/\\\0]/.test(value))throw TypeError('Route parameters must be nonempty path segments');
|
|
7
|
+
return encodeURIComponent(value);
|
|
8
|
+
};
|
|
9
|
+
for(const part of pattern.split('/').slice(1)){
|
|
10
|
+
const match=/^(?:\[\[\.\.\.([A-Za-z_$][\w$]*)\]\]|\[\.\.\.([A-Za-z_$][\w$]*)\]|\[([A-Za-z_$][\w$]*)\])$/.exec(part);
|
|
11
|
+
if(!match){if(/[\[\]?#\\\0]/.test(part)||part==='.'||part==='..'||!part&&pattern!=='/')throw TypeError('Invalid route segment');parts.push(part);continue;}
|
|
12
|
+
const name=match[1]||match[2]||match[3];used.add(name);const value=params[name];
|
|
13
|
+
if(match[1]&&value===undefined)continue;
|
|
14
|
+
if(match[1]||match[2]){if(!Array.isArray(value)||match[2]&&!value.length)throw TypeError(`Route parameter ${name} must be an array of segments`);parts.push(...value.map(segment));}
|
|
15
|
+
else parts.push(segment(value));
|
|
16
|
+
}
|
|
17
|
+
for(const name of Object.keys(params))if(!used.has(name))throw TypeError(`Unknown route parameter ${name}`);
|
|
18
|
+
const result='/'+parts.join('/');if(result.length>8192)throw TypeError('Route exceeds 8 KiB');return result;
|
|
19
|
+
}
|
|
@@ -9,9 +9,10 @@ const missing = error => error.code === 'ENOENT' || error.code === 'ENOTDIR';
|
|
|
9
9
|
// including ignored build trees. Atomic build swaps can delete a descendant
|
|
10
10
|
// during that scan. Watch only source directories, and handle removal races
|
|
11
11
|
// in our own traversal. No file contents or node_modules watches are retained.
|
|
12
|
-
export async function watchProject(root, { onChange, onError, ignore = () => false, watch = watchDirectory, maxDirectories = 8192, nativeRecursive = process.platform === 'darwin'
|
|
13
|
-
//
|
|
14
|
-
// subscriptions
|
|
12
|
+
export async function watchProject(root, { onChange, onError, ignore = () => false, watch = watchDirectory, maxDirectories = 8192, nativeRecursive = process.platform === 'darwin' }) {
|
|
13
|
+
// Windows' recursive notification buffer can overflow while V8/webpack flush
|
|
14
|
+
// caches. Source-only subscriptions avoid losing edits in that disk traffic.
|
|
15
|
+
// macOS uses its native recursive backend for coalesced directory renames.
|
|
15
16
|
if (nativeRecursive) {
|
|
16
17
|
const watcher = watch(root, { recursive: true }, (event, filename) => {
|
|
17
18
|
const relative = filename && String(filename).replaceAll(path.sep, '/');
|
|
@@ -65,7 +66,8 @@ export async function watchProject(root, { onChange, onError, ignore = () => fal
|
|
|
65
66
|
async function scan(relative, recursive = false) {
|
|
66
67
|
if (closed) return;
|
|
67
68
|
if (!allowedDirectory(relative)) { remove(relative); return; }
|
|
68
|
-
|
|
69
|
+
const fresh = !directories.has(relative);
|
|
70
|
+
if (fresh) {
|
|
69
71
|
if (directories.size >= maxDirectories) throw new Error(`Development watcher exceeds ${maxDirectories} source directories. Narrow the application directory.`);
|
|
70
72
|
let watcher;
|
|
71
73
|
try {
|
|
@@ -78,7 +80,11 @@ export async function watchProject(root, { onChange, onError, ignore = () => fal
|
|
|
78
80
|
return;
|
|
79
81
|
}
|
|
80
82
|
const changed = filename ? path.posix.join(relative, String(filename).replaceAll(path.sep, '/')) : relative;
|
|
81
|
-
|
|
83
|
+
let metadataOnly = false;
|
|
84
|
+
if (process.platform === 'win32' && event === 'change') {
|
|
85
|
+
try { metadataOnly = statSync(path.join(root, changed)).isDirectory(); } catch {}
|
|
86
|
+
}
|
|
87
|
+
if (allowedFile(changed) && !metadataOnly) onChange(changed);
|
|
82
88
|
// The .contentlayer container itself is ignored as a source, but
|
|
83
89
|
// its generated directory may be created after the watcher starts.
|
|
84
90
|
// Platforms can coalesce a directory rename into a change event.
|
|
@@ -103,6 +109,7 @@ export async function watchProject(root, { onChange, onError, ignore = () => fal
|
|
|
103
109
|
const children = new Set();
|
|
104
110
|
for (const entry of entries) {
|
|
105
111
|
const child = path.posix.join(relative, entry.name);
|
|
112
|
+
if (fresh && ready && entry.isFile() && allowedFile(child)) onChange(child);
|
|
106
113
|
if (!entry.isDirectory() || !allowedDirectory(child)) continue;
|
|
107
114
|
children.add(child);
|
|
108
115
|
if (recursive || !directories.has(child)) await scan(child, recursive);
|
package/runtime/worker.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// prnext-transport:socket-v2
|
|
2
2
|
// prnext-concurrency:512
|
|
3
3
|
import { requestFrames } from './request-transport.mjs';
|
|
4
|
+
import {scheduleCompileCacheFlush} from './compile-cache.mjs';
|
|
5
|
+
import {preloadModules} from './preload.mjs';
|
|
4
6
|
import { requestWork } from './request-work.mjs';
|
|
5
7
|
import { freezeClientModules } from './worker-metadata.mjs';
|
|
6
8
|
import { AsyncResource } from 'node:async_hooks';
|
|
@@ -36,12 +38,20 @@ loadEnvConfig(projectRoot, { dev: !!manifest.dev });
|
|
|
36
38
|
// browser graph and the RSC worker's mode, including dev launched from CI.
|
|
37
39
|
process.env.NODE_ENV = manifest.dev ? 'development' : 'production';
|
|
38
40
|
const production = !manifest.dev;
|
|
41
|
+
let dispatchRequest=handleRequest;
|
|
42
|
+
if(!production){
|
|
43
|
+
const {createDevWorkerConsole}=await import('./dev-worker-logs.mjs'),logs=createDevWorkerConsole();globalThis.console=logs.console;
|
|
44
|
+
dispatchRequest=(input,...args)=>logs.run({requestId:input.id,route:routes.get(input.routeId)?.pattern},()=>handleRequest(input,...args));
|
|
45
|
+
const {publishDevCacheCapability}=await import('./dev-cache-control.mjs');
|
|
46
|
+
await publishDevCacheCapability({root:projectRoot,distDir,manifest}).catch(()=>{console.warn('[prnext] Local cache controls are unavailable for this development generation.');});
|
|
47
|
+
}
|
|
39
48
|
if (production) freezeClientModules(manifest.app?.clientModules);
|
|
40
49
|
// Load each renderer beside the application only when that request needs it.
|
|
41
50
|
// Middleware and API workers do not pay for React or the Pages/App renderers.
|
|
42
51
|
const runtimeModule = async name => {
|
|
43
52
|
const started = diagnostics.enabled ? performance.now() : 0;
|
|
44
53
|
const module = await import(pathToFileURL(path.join(distDir, `runtime/${name}.mjs`)).href);
|
|
54
|
+
scheduleCompileCacheFlush();
|
|
45
55
|
if (diagnostics.enabled) diagnostics.record('wake', { phase: 'module', module: name, durationMs: performance.now() - started });
|
|
46
56
|
return module;
|
|
47
57
|
};
|
|
@@ -62,6 +72,25 @@ async function handleRequest(input, output, signalController, responseComplete)
|
|
|
62
72
|
let responseStarted = false;
|
|
63
73
|
const finalizers = new Set();
|
|
64
74
|
try {
|
|
75
|
+
if(input.routeId==='__prnext_preload'&&input.renderMode==='preload'){
|
|
76
|
+
const options=manifest.config?.workerPreload;
|
|
77
|
+
if(!production||!options){await output(input.id,{status:204,body:''},input);return;}
|
|
78
|
+
const selected=options.routes.map(pattern=>manifest.routes.find(route=>route.pattern===pattern&&!route.internal)).filter(Boolean);
|
|
79
|
+
const deadline=performance.now()+options.timeoutMs;
|
|
80
|
+
for(const route of selected){
|
|
81
|
+
if(performance.now()>=deadline||process.memoryUsage().rss>=options.maxRssMb*1024*1024)break;
|
|
82
|
+
const remaining={...options,timeoutMs:Math.max(1,deadline-performance.now())};
|
|
83
|
+
if(route.router==='app'&&route.kind==='page'){
|
|
84
|
+
appRuntime??=await runtimeModule('app-render');
|
|
85
|
+
await appRuntime.preloadAppRoutes([path.resolve(distDir,route.module)],remaining);
|
|
86
|
+
}else{
|
|
87
|
+
if(route.kind==='page')pagesRuntime??=await runtimeModule('render');else apiRuntime??=await runtimeModule('api');
|
|
88
|
+
await preloadModules([path.resolve(distDir,route.module)],remaining);
|
|
89
|
+
}
|
|
90
|
+
if(process.memoryUsage().rss>=options.maxRssMb*1024*1024)break;
|
|
91
|
+
}
|
|
92
|
+
scheduleCompileCacheFlush();await output(input.id,{status:204,body:''},input);return;
|
|
93
|
+
}
|
|
65
94
|
if (input.renderMode === 'incremental-cache' && input.routeId === '__prnext_incremental_cache') {
|
|
66
95
|
incrementalRuntime ??= await runtimeModule('incremental-cache');
|
|
67
96
|
const result = await incrementalRuntime.runIncrementalCache({ ...input, manifest, distDir, production, signal: signalController.signal });
|
|
@@ -223,7 +252,7 @@ if (token && laneLimit > 1) {
|
|
|
223
252
|
workTimer.unref();
|
|
224
253
|
};
|
|
225
254
|
active.signal.addEventListener('abort', retireIfAbandoned, { once: true });
|
|
226
|
-
try { await
|
|
255
|
+
try { await dispatchRequest(input, output, active, () => { controller = undefined; }); }
|
|
227
256
|
finally {
|
|
228
257
|
finished = true;
|
|
229
258
|
if (!pendingWork) { invocations.delete(active); clearTimeout(workTimer); }
|
|
@@ -262,6 +291,10 @@ if (token && laneLimit > 1) {
|
|
|
262
291
|
else throw error;
|
|
263
292
|
});
|
|
264
293
|
server.listen(0, '127.0.0.1', () => {
|
|
294
|
+
if(!production&&typeof globalThis.__PRNEXT_DEV_WORKER_READY__==='function'){
|
|
295
|
+
globalThis.__PRNEXT_DEV_WORKER_READY__({port:server.address().port,binary:true,gracefulShutdown:true});
|
|
296
|
+
delete globalThis.__PRNEXT_DEV_WORKER_READY__;
|
|
297
|
+
}
|
|
265
298
|
writeProtocol(JSON.stringify({ port: server.address().port, binary: true, heartbeat: true, activity: reportActivity, gracefulShutdown: true }) + '\n');
|
|
266
299
|
heartbeat = setInterval(() => {
|
|
267
300
|
// Coalesce the idle transition with the existing heartbeat. Normal
|
|
@@ -269,6 +302,7 @@ if (token && laneLimit > 1) {
|
|
|
269
302
|
// cannot add two messages for every response.
|
|
270
303
|
reportedBusy = reportActivity && invocations.size > 0;
|
|
271
304
|
writeProtocol(reportedBusy ? 'busy\n' : 'alive\n');
|
|
305
|
+
if(!production)globalThis.__PRNEXT_DEV_WORKER_PULSE__?.(reportedBusy?'busy\n':'alive\n');
|
|
272
306
|
}, 1000).unref();
|
|
273
307
|
});
|
|
274
308
|
} else {
|
|
@@ -276,7 +310,7 @@ if (token && laneLimit > 1) {
|
|
|
276
310
|
for await (let line of readline.createInterface({ input: process.stdin, crlfDelay: Infinity })) {
|
|
277
311
|
if (!line.trim()) continue;
|
|
278
312
|
const input = JSON.parse(line); line = '';
|
|
279
|
-
try { await
|
|
313
|
+
try { await dispatchRequest(input, output, new AbortController()); }
|
|
280
314
|
catch (error) { console.error('[prnext] Worker transport failed:', error?.stack || error); process.exit(1); }
|
|
281
315
|
}
|
|
282
316
|
// The maintenance/ISR transport is serial and awaits its own finalizers.
|