@thomas.f/prnext 0.1.20-alpha → 0.1.21-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/build/app.mjs +4 -4
- package/build/build-timings.mjs +38 -0
- package/build/bundle-runtime.mjs +8 -8
- package/build/compiler.mjs +2 -1
- package/build/flight-summary.mjs +64 -0
- package/build/index.mjs +32 -28
- package/build/inspect.mjs +3 -0
- package/build/inspection-summary.mjs +7 -2
- package/build/native-discovery.mjs +18 -6
- package/build/npm-packages.mjs +2 -2
- package/build/source-analysis.mjs +54 -1
- package/build/standalone-copy.mjs +48 -0
- package/build/standalone.mjs +2 -22
- package/build/transform-cache.mjs +17 -3
- package/cli.mjs +13 -0
- package/compat/diagnostics.cjs +5 -4
- package/compat/runtime-sample.cjs +56 -0
- package/compat/use-cache.cjs +5 -2
- package/docs/ai/compatibility.md +3 -3
- package/docs/ai/deployment.md +8 -5
- package/docs/ai/troubleshooting.md +22 -0
- package/package.json +20 -20
- package/runtime/app-client.mjs +106 -38
- package/runtime/app-render.mjs +31 -6
- package/runtime/app-stream-channel.mjs +17 -4
- package/runtime/app-stream-html.mjs +30 -11
- package/runtime/cache-components-rsc.mjs +26 -6
- package/runtime/cache-result.mjs +58 -0
- package/runtime/dev-client.mjs +26 -5
- package/runtime/dev-compiler-broker.mjs +20 -23
- package/runtime/dev-gateway.mjs +22 -6
- package/runtime/development-flight.mjs +59 -0
- package/runtime/development-queue.mjs +33 -0
- package/runtime/development.mjs +11 -9
- package/runtime/flight-diagnostics.mjs +48 -0
- package/runtime/flight-tap.mjs +10 -5
- package/runtime/html-envelope.mjs +34 -0
- package/runtime/loopback-rpc.mjs +92 -0
- package/runtime/native-ppr.mjs +57 -4
- package/runtime/persistent/client.mjs +1 -1
- package/runtime/persistent/common.mjs +7 -18
- package/runtime/persistent/daemon.mjs +1 -1
- package/runtime/persistent/native-proxy.mjs +1 -1
- package/runtime/persistent/watchdog.mjs +12 -27
- package/runtime/prewarm.mjs +35 -0
- package/runtime/rsc-worker.mjs +58 -8
- package/runtime/worker.mjs +29 -14
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import {createHmac,randomBytes,timingSafeEqual} from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
const protocol='prnext-loopback-v2',noncePattern=/^[a-f0-9]{64}$/;
|
|
5
|
+
const nonce=()=>randomBytes(32).toString('hex');
|
|
6
|
+
const authenticationError=()=>Object.assign(Error('Loopback peer authentication failed'),{code:'ERR_PRNEXT_PEER_AUTH'});
|
|
7
|
+
const proof=(token,context,role,client,server,...body)=>createHmac('sha256',token).update(JSON.stringify([protocol,context,role,client,server,...body])).digest('hex');
|
|
8
|
+
function matches(supplied,expected){return typeof supplied==='string'&&noncePattern.test(supplied)&&timingSafeEqual(Buffer.from(supplied,'hex'),Buffer.from(expected,'hex'));}
|
|
9
|
+
function encode(value,limit){const body=JSON.stringify(value);if(typeof body!=='string'||Buffer.byteLength(body)>limit)throw Error('Loopback control body is too large');return body;}
|
|
10
|
+
function send(socket,value){socket.write(JSON.stringify({protocol,...value})+'\n');}
|
|
11
|
+
|
|
12
|
+
function frames(socket,limit,receive,fail){
|
|
13
|
+
let buffer=Buffer.alloc(0);
|
|
14
|
+
socket.on('data',chunk=>{
|
|
15
|
+
try{
|
|
16
|
+
buffer=Buffer.concat([buffer,chunk]);
|
|
17
|
+
for(;;){
|
|
18
|
+
const end=buffer.indexOf(10),maximum=limit();
|
|
19
|
+
if(end<0){if(buffer.length>maximum)throw Error('Loopback control frame is too large');return;}
|
|
20
|
+
if(end>maximum)throw Error('Loopback control frame is too large');
|
|
21
|
+
const message=JSON.parse(buffer.subarray(0,end).toString('utf8'));buffer=buffer.subarray(end+1);
|
|
22
|
+
if(!message||message.protocol!==protocol)throw authenticationError();
|
|
23
|
+
receive(message);if(socket.destroyed)return;
|
|
24
|
+
}
|
|
25
|
+
}catch(error){fail(error);}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// One fresh transcript authorizes exactly one payload. Role and context labels
|
|
30
|
+
// prevent reflection and cross-service reuse; both nonces prevent replay across
|
|
31
|
+
// connections and watchdog restarts. The first client frame contains only a
|
|
32
|
+
// nonce; the command follows a verified server proof. The key is never sent.
|
|
33
|
+
export function requestLoopback({port,token},request,{context,timeout=120000,signal,maxRequestBytes=65536,maxResponseBytes=1024*1024}={}) {
|
|
34
|
+
if(signal?.aborted)return Promise.reject(signal.reason||Error('Loopback request aborted'));
|
|
35
|
+
let body;try{body=encode(request,maxRequestBytes);}catch(error){return Promise.reject(error);}
|
|
36
|
+
return new Promise((resolve,reject)=>{
|
|
37
|
+
const socket=net.connect({host:'127.0.0.1',port});
|
|
38
|
+
const client=nonce();let server,state='challenge',finished=false;
|
|
39
|
+
const finish=(error,value)=>{if(finished)return;finished=true;clearTimeout(handshake);clearTimeout(total);signal?.removeEventListener('abort',abort);socket.destroy();error?reject(error):resolve(value);};
|
|
40
|
+
const abort=()=>finish(signal.reason||Error('Loopback request aborted'));
|
|
41
|
+
const total=setTimeout(()=>finish(Error('Loopback request timed out')),timeout);
|
|
42
|
+
const handshake=setTimeout(()=>finish(authenticationError()),Math.min(5000,timeout));
|
|
43
|
+
signal?.addEventListener('abort',abort,{once:true});
|
|
44
|
+
socket.once('connect',()=>send(socket,{type:'hello',client}));
|
|
45
|
+
socket.on('error',error=>finish(error));
|
|
46
|
+
socket.on('end',()=>finish(state==='challenge'?authenticationError():Error('Loopback peer disconnected')));
|
|
47
|
+
socket.on('close',()=>{if(!finished)finish(state==='challenge'?authenticationError():Error('Loopback peer disconnected'));});
|
|
48
|
+
frames(socket,()=>state==='challenge'?1024:2*maxResponseBytes+1024,message=>{
|
|
49
|
+
if(state==='challenge'){
|
|
50
|
+
if(message.type!=='challenge'||typeof message.server!=='string'||!noncePattern.test(message.server)||!matches(message.proof,proof(token,context,'server',client,message.server)))throw authenticationError();
|
|
51
|
+
server=message.server;state='response';clearTimeout(handshake);
|
|
52
|
+
send(socket,{type:'request',body,proof:proof(token,context,'request',client,server,body)});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if(state!=='response'||message.type!=='response'||typeof message.body!=='string'||Buffer.byteLength(message.body)>maxResponseBytes||!matches(message.proof,proof(token,context,'response',client,server,body,message.body)))throw authenticationError();
|
|
56
|
+
const result=JSON.parse(message.body);state='done';
|
|
57
|
+
if(!result||typeof result.ok!=='boolean')throw Error('Invalid loopback response');
|
|
58
|
+
finish(result.ok?null:Error(typeof result.error==='string'?result.error:'Loopback request failed'),result.value);
|
|
59
|
+
},error=>finish(error));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function serveLoopback(socket,{token,context,handle,timeout=125000,handshakeTimeout=5000,maxRequestBytes=65536,maxResponseBytes=1024*1024}) {
|
|
64
|
+
const controller=new AbortController();let client,server,requestBody,state='hello';
|
|
65
|
+
const deadlineMs=Date.now()+timeout;
|
|
66
|
+
const close=()=>{state='closed';controller.abort();socket.destroy();};
|
|
67
|
+
const total=setTimeout(close,timeout),handshake=setTimeout(close,Math.min(handshakeTimeout,timeout));
|
|
68
|
+
socket.on('error',()=>{});
|
|
69
|
+
socket.once('close',()=>{state='closed';clearTimeout(total);clearTimeout(handshake);controller.abort();});
|
|
70
|
+
socket.on('end',close);
|
|
71
|
+
frames(socket,()=>state==='hello'?1024:2*maxRequestBytes+1024,message=>{
|
|
72
|
+
if(state==='hello'){
|
|
73
|
+
if(message.type!=='hello'||typeof message.client!=='string'||!noncePattern.test(message.client))throw authenticationError();
|
|
74
|
+
client=message.client;server=nonce();state='request';
|
|
75
|
+
send(socket,{type:'challenge',server,proof:proof(token,context,'server',client,server)});return;
|
|
76
|
+
}
|
|
77
|
+
if(state!=='request'||message.type!=='request'||typeof message.body!=='string'||Buffer.byteLength(message.body)>maxRequestBytes||!matches(message.proof,proof(token,context,'request',client,server,message.body)))throw authenticationError();
|
|
78
|
+
requestBody=message.body;const request=JSON.parse(requestBody);
|
|
79
|
+
if(!request||typeof request!=='object'||Array.isArray(request))throw Error('Invalid loopback request');
|
|
80
|
+
clearTimeout(handshake);state='busy';
|
|
81
|
+
const reply=result=>{
|
|
82
|
+
if(state!=='busy')return;
|
|
83
|
+
let body;
|
|
84
|
+
try{body=encode(result,maxResponseBytes);}catch{body=encode({ok:false,error:'Loopback response is too large'},maxResponseBytes);}
|
|
85
|
+
state='done';socket.end(JSON.stringify({protocol,type:'response',body,proof:proof(token,context,'response',client,server,requestBody,body)})+'\n');
|
|
86
|
+
};
|
|
87
|
+
Promise.resolve().then(()=>{
|
|
88
|
+
if(controller.signal.aborted)throw Error('Loopback client disconnected');
|
|
89
|
+
return handle(request,{signal:controller.signal,deadlineMs});
|
|
90
|
+
}).then(value=>reply({ok:true,value}),error=>reply({ok:false,error:String(error.message).slice(0,2048)})).catch(close);
|
|
91
|
+
},close);
|
|
92
|
+
}
|
package/runtime/native-ppr.mjs
CHANGED
|
@@ -7,6 +7,61 @@ export const NATIVE_PPR_ARTIFACT_BYTES = 2 * 1024 * 1024;
|
|
|
7
7
|
export const NATIVE_PPR_PREFIX_BYTES = 128 * 1024;
|
|
8
8
|
export const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
9
9
|
|
|
10
|
+
// Only immutable build JSON is retained, after the caller verified the current
|
|
11
|
+
// file bytes. No live model, decoder, request context or continuation is cached.
|
|
12
|
+
export class NativePprArtifactCache {
|
|
13
|
+
constructor({maxEntries=8,maxBytes=4*1024*1024,maxEntryBytes=1024*1024,maxNodes=8192}={}) {
|
|
14
|
+
for(const value of [maxEntries,maxBytes,maxEntryBytes,maxNodes])if(!Number.isSafeInteger(value)||value<0)throw new TypeError('Invalid native PPR parsing cache budget');
|
|
15
|
+
this.maxEntries=maxEntries;this.maxBytes=maxBytes;this.maxEntryBytes=maxEntryBytes;this.maxNodes=maxNodes;
|
|
16
|
+
this.entries=new Map();this.bytes=0;this.hits=0;this.parses=0;
|
|
17
|
+
}
|
|
18
|
+
parse(filename,bytes,route,descriptor) {
|
|
19
|
+
const key=JSON.stringify([filename,descriptor.artifactHash,route.client||null]);
|
|
20
|
+
let entry=this.entries.get(key);
|
|
21
|
+
if(entry){this.hits++;this.entries.delete(key);this.entries.set(key,entry);}
|
|
22
|
+
else{
|
|
23
|
+
this.parses++;
|
|
24
|
+
const artifact=JSON.parse(bytes);
|
|
25
|
+
const prefix=Buffer.from(partialShell(artifact,route).replace(/<\/body><\/html>$/i,''));
|
|
26
|
+
entry={artifact,prefixHash:sha256(prefix),identity:sha256(artifact.flight)};
|
|
27
|
+
if(artifact.version!==1||!artifact.postponed||artifact.complete||artifact.generic||entry.prefixHash!==descriptor.prefixHash||entry.identity!==descriptor.identity)throw new Error('Invalid native PPR snapshot');
|
|
28
|
+
// Reserve substantial expansion for JSON slots/strings before walking.
|
|
29
|
+
// This is a conservative admission estimate, not an exact V8 heap size.
|
|
30
|
+
let size=bytes.length*16+key.length*2+256;
|
|
31
|
+
if(this.maxEntries&&size<=Math.min(this.maxBytes,this.maxEntryBytes)){
|
|
32
|
+
const pending=[artifact],objects=[];let nodes=1,eligible=this.maxNodes>0;
|
|
33
|
+
while(eligible&&pending.length){
|
|
34
|
+
const value=pending.pop();objects.push(value);size+=128;
|
|
35
|
+
// JSON arrays are dense: reject wide arrays before enumerating keys
|
|
36
|
+
// or allocating any list of their primitive/object children.
|
|
37
|
+
if(Array.isArray(value)&&value.length>this.maxNodes-nodes){eligible=false;break;}
|
|
38
|
+
for(const name in value){
|
|
39
|
+
if(!Object.hasOwn(value,name))continue;
|
|
40
|
+
if(++nodes>this.maxNodes){eligible=false;break;}
|
|
41
|
+
const child=value[name];size+=16+name.length*2+(typeof child==='string'?child.length*2:0);
|
|
42
|
+
if(size>Math.min(this.maxBytes,this.maxEntryBytes)){eligible=false;break;}
|
|
43
|
+
if(child&&typeof child==='object'){
|
|
44
|
+
if(Array.isArray(child)&&child.length>this.maxNodes-nodes){eligible=false;break;}
|
|
45
|
+
pending.push(child);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if(eligible&&!pending.length&&size<=Math.min(this.maxBytes,this.maxEntryBytes)){
|
|
50
|
+
for(const value of objects)Object.freeze(value);
|
|
51
|
+
while(this.entries.size&&(this.entries.size>=this.maxEntries||this.bytes+size>this.maxBytes)){
|
|
52
|
+
const [oldKey,old]=this.entries.entries().next().value;this.entries.delete(oldKey);this.bytes-=old.size;
|
|
53
|
+
}
|
|
54
|
+
entry.size=size;this.entries.set(key,entry);this.bytes+=size;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Descriptor bindings remain authoritative even when the public JSON hit.
|
|
59
|
+
if(entry.prefixHash!==descriptor.prefixHash||entry.identity!==descriptor.identity)throw new Error('Invalid native PPR snapshot');
|
|
60
|
+
return entry.artifact;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const parsedArtifacts=new NativePprArtifactCache();
|
|
64
|
+
|
|
10
65
|
export function partialShell(artifact, route) {
|
|
11
66
|
const bootstrap = '<script>self.__PRNEXT_FLIGHT_STREAM__=self.__PRNEXT_FLIGHT_STREAM__||[];</script>' +
|
|
12
67
|
(route.client ? `<script type="module" async src="${escapeHtml(route.client)}"></script>` : '');
|
|
@@ -18,7 +73,7 @@ export function partialShell(artifact, route) {
|
|
|
18
73
|
// This is a private transport field, never an HTTP header. Rust has already
|
|
19
74
|
// checked cache generation and committed these exact build bytes. Pinning the
|
|
20
75
|
// same artifact gives the request one snapshot even if invalidation races Node.
|
|
21
|
-
export async function nativePprArtifact({renderMode, manifest, route, url, distDir}) {
|
|
76
|
+
export async function nativePprArtifact({renderMode, manifest, route, url, distDir},cache=parsedArtifacts) {
|
|
22
77
|
if (!renderMode?.startsWith('native-ppr:')) return null;
|
|
23
78
|
if (!manifest.config?.nativePprShell || manifest.dev || !/^[a-f\d]{64}$/.test(renderMode.slice(11))) throw new Error('Invalid native PPR continuation');
|
|
24
79
|
const pathname = new URL(url).pathname;
|
|
@@ -28,8 +83,6 @@ export async function nativePprArtifact({renderMode, manifest, route, url, distD
|
|
|
28
83
|
if (!descriptor.artifact.startsWith('static/') || !filename.startsWith(path.resolve(distDir) + path.sep) || (await stat(filename)).size > NATIVE_PPR_ARTIFACT_BYTES) throw new Error('Invalid native PPR artifact path or size');
|
|
29
84
|
const bytes = await readFile(filename);
|
|
30
85
|
if (bytes.length > NATIVE_PPR_ARTIFACT_BYTES || sha256(bytes) !== descriptor.artifactHash) throw new Error('Native PPR artifact changed after publication');
|
|
31
|
-
const artifact =
|
|
32
|
-
const prefix = Buffer.from(partialShell(artifact, route).replace(/<\/body><\/html>$/i, ''));
|
|
33
|
-
if (artifact.version !== 1 || !artifact.postponed || artifact.complete || artifact.generic || sha256(prefix) !== descriptor.prefixHash || sha256(artifact.flight) !== descriptor.identity) throw new Error('Invalid native PPR snapshot');
|
|
86
|
+
const artifact = cache.parse(filename,bytes,route,descriptor);
|
|
34
87
|
return {artifact, descriptor};
|
|
35
88
|
}
|
|
@@ -64,7 +64,7 @@ async function ensure(connection) {
|
|
|
64
64
|
child.on('error', () => {}); child.unref();
|
|
65
65
|
}
|
|
66
66
|
for (let i = 0; i < 200; i++) {
|
|
67
|
-
try { const result = await rpc(connection, { command: 'ping' }, 1500); if (result.version !==
|
|
67
|
+
try { const result = await rpc(connection, { command: 'ping' }, 1500); if (result.version !== 2) throw new Error('Supervisor protocol mismatch. Stop the supervisor with its current PRNext version before upgrading.'); if (result.ready) return; }
|
|
68
68
|
catch (error) { if (!['ECONNREFUSED', 'ECONNRESET'].includes(error.code)) throw error; }
|
|
69
69
|
await delay(100);
|
|
70
70
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile, realpath, rm } from 'node:fs/promises';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
4
|
-
import net from 'node:net';
|
|
5
4
|
import path from 'node:path';
|
|
6
5
|
import { rename } from '../fs.mjs';
|
|
6
|
+
import { requestLoopback } from '../loopback-rpc.mjs';
|
|
7
7
|
|
|
8
8
|
export const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
9
9
|
export const homeDirectory = () => path.resolve(process.env.PRNEXT_PM_HOME || path.join(homedir(), '.prnext', 'processes'));
|
|
@@ -47,21 +47,10 @@ export function validHealthPath(value) {
|
|
|
47
47
|
return value;
|
|
48
48
|
}
|
|
49
49
|
export async function rpc(connection, request, timeout = 120000) {
|
|
50
|
-
return
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
socket.on('error', error => finish(error));
|
|
57
|
-
socket.once('connect', () => socket.write(JSON.stringify({ ...request, token: connection.token }) + '\n'));
|
|
58
|
-
socket.on('data', chunk => {
|
|
59
|
-
buffer += chunk.toString();
|
|
60
|
-
if (buffer.length > 1024 * 1024) return finish(new Error('Supervisor response is too large'));
|
|
61
|
-
if (!buffer.includes('\n')) return;
|
|
62
|
-
try { const result = JSON.parse(buffer.slice(0, buffer.indexOf('\n'))); finish(result.ok ? null : new Error(result.error || 'Supervisor request failed'), result.value); }
|
|
63
|
-
catch (error) { finish(error); }
|
|
64
|
-
});
|
|
65
|
-
socket.on('end', () => { if (!finished) finish(new Error('Supervisor disconnected')); });
|
|
66
|
-
});
|
|
50
|
+
try { return await requestLoopback(connection, request, { context: 'persistent-supervisor', timeout }); }
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code === 'ERR_PRNEXT_PEER_AUTH') error.message = 'Cannot authenticate the supervisor control listener. If an older PRNext supervisor is running, stop it with that version\'s prn pdown before upgrading; saved apps are preserved.';
|
|
53
|
+
else if (error.message === 'Loopback request timed out') error.message = 'Supervisor request timed out. Check prn plogs --supervisor.';
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
67
56
|
}
|
|
@@ -76,7 +76,7 @@ async function snapshot(app) {
|
|
|
76
76
|
if (path.relative(root, container).startsWith('..') || path.isAbsolute(path.relative(root, container))) throw new Error('Managed builds must remain inside the project');
|
|
77
77
|
await cp(source, destination, { recursive: true, verbatimSymlinks: true, filter: file => file !== path.join(source, 'standalone') });
|
|
78
78
|
if (await readFile(path.join(source, 'manifest.json'), 'utf8') !== before || await readFile(path.join(destination, 'manifest.json'), 'utf8') !== before) throw new Error('Build changed while copying. Finish prn build, then retry prn prestart.');
|
|
79
|
-
return { relative, buildId: manifest.buildId || null };
|
|
79
|
+
return { relative, buildId: manifest.buildId || null, server: manifest.config?.server || {} };
|
|
80
80
|
} catch (error) { await removeSnapshot(app, relative); throw error; }
|
|
81
81
|
}
|
|
82
82
|
async function stopGeneration(app, generation, drain = true) {
|
|
@@ -53,7 +53,7 @@ export async function nativeGateway(config,options){
|
|
|
53
53
|
manager??=new NativeProxy(config.binary,options);await manager.ready;
|
|
54
54
|
const app=config.name;await manager.request('listen',{app,hostname:config.hostname,port:config.port});
|
|
55
55
|
return {pid:manager.pid,
|
|
56
|
-
activate:g=>manager.request('activate',{app,generation:g.relative,port:g.port}),
|
|
56
|
+
activate:g=>manager.request('activate',{app,generation:g.relative,port:g.port,server:g.server}),
|
|
57
57
|
pause:g=>manager.request('pause',{app,...(g?{generation:g.relative}:{})}),
|
|
58
58
|
retire:(g,timeout)=>manager.request('retire',{app,generation:g.relative,timeout},timeout+5000),
|
|
59
59
|
counts:async()=> (await manager.request('status',{app})).generations,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { fork } from 'node:child_process';
|
|
2
2
|
import { createServer } from 'node:net';
|
|
3
|
-
import { timingSafeEqual } from 'node:crypto';
|
|
4
3
|
import { fileURLToPath } from 'node:url';
|
|
5
4
|
import path from 'node:path';
|
|
6
5
|
import { initializeHome, environment, delay } from './common.mjs';
|
|
7
6
|
import { RotatingLog } from './logger.mjs';
|
|
7
|
+
import { serveLoopback } from '../loopback-rpc.mjs';
|
|
8
8
|
|
|
9
9
|
const connection = await initializeHome(process.argv[2]);
|
|
10
10
|
const log = new RotatingLog(path.join(connection.home, 'logs', 'supervisor.log'));
|
|
@@ -12,35 +12,20 @@ const pending = new Map(), children = new Set(), sockets = new Set();
|
|
|
12
12
|
const shutdownReplies = [];
|
|
13
13
|
let daemon, ready = false, closing = false, counter = 0, heartbeat = Date.now(), failures = 0, restartTimer;
|
|
14
14
|
const server = createServer(socket => {
|
|
15
|
-
socket.setEncoding('utf8');
|
|
16
15
|
sockets.add(socket); socket.once('close', () => sockets.delete(socket));
|
|
17
|
-
socket.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
socket.on('data', chunk => {
|
|
21
|
-
if (received) return;
|
|
22
|
-
buffer += chunk.toString();
|
|
23
|
-
if (buffer.length > 65536) { socket.destroy(); return; }
|
|
24
|
-
if (!buffer.includes('\n')) return;
|
|
25
|
-
received = true;
|
|
26
|
-
let message;
|
|
27
|
-
const reply = result => socket.end(JSON.stringify(result) + '\n');
|
|
28
|
-
try {
|
|
29
|
-
message = JSON.parse(buffer.slice(0, buffer.indexOf('\n')));
|
|
30
|
-
const supplied = Buffer.from(typeof message.token === 'string' ? message.token : '');
|
|
31
|
-
const expected = Buffer.from(connection.token);
|
|
32
|
-
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) throw new Error('Invalid supervisor credentials');
|
|
33
|
-
delete message.token;
|
|
34
|
-
if (message.command === 'ping') { reply({ ok: true, value: { version: 1, pid: process.pid, daemonPid: daemon?.pid || null, ready } }); return; }
|
|
35
|
-
if (message.command === 'shutdown') { shutdownReplies.push(reply); void shutdown(); return; }
|
|
16
|
+
serveLoopback(socket, { token: connection.token, context: 'persistent-supervisor', handle: async message => {
|
|
17
|
+
if (message.command === 'ping') return { version: 2, pid: process.pid, daemonPid: daemon?.pid || null, ready };
|
|
18
|
+
if (message.command === 'shutdown') return new Promise(resolve => { shutdownReplies.push(resolve); void shutdown(); });
|
|
36
19
|
if (!ready || !daemon?.connected) throw new Error('Supervisor is recovering. Retry shortly.');
|
|
37
20
|
if (pending.size >= 32) throw new Error('Supervisor is busy. Retry shortly.');
|
|
38
21
|
const id = ++counter;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const reply = result => result.ok ? resolve(result.value) : reject(Error(result.error || 'Supervisor request failed'));
|
|
24
|
+
pending.set(id, reply);
|
|
25
|
+
socket.once('close', () => { pending.delete(id); reject(Error('Supervisor client disconnected')); });
|
|
26
|
+
daemon.send({ type: 'request', id, request: message }, error => { if (error) { pending.delete(id); reject(error); } });
|
|
27
|
+
});
|
|
28
|
+
} });
|
|
44
29
|
});
|
|
45
30
|
server.maxConnections = 64;
|
|
46
31
|
try { await new Promise((resolve, reject) => { server.once('error', reject); server.listen(connection.port, '127.0.0.1', resolve); }); }
|
|
@@ -100,7 +85,7 @@ async function shutdown() {
|
|
|
100
85
|
log.message('Supervisor stopped.');
|
|
101
86
|
await new Promise(resolve => log.end(resolve));
|
|
102
87
|
server.close();
|
|
103
|
-
for (const reply of shutdownReplies) reply(
|
|
88
|
+
for (const reply of shutdownReplies) reply('Supervisor stopped; enabled apps will return on the next launch.');
|
|
104
89
|
await delay(50);
|
|
105
90
|
for (const socket of sockets) socket.destroy();
|
|
106
91
|
process.exit(0);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Framework preparation only: never import a route, render an app component or call
|
|
2
|
+
// application code. Imports cannot be unloaded or interrupted inside V8; the
|
|
3
|
+
// native parent bounds bootstrap and owns cancellation of the entire child.
|
|
4
|
+
export async function prewarmFramework({ routes, options, load, signal }, {
|
|
5
|
+
now = () => performance.now(), rss = () => process.memoryUsage().rss,
|
|
6
|
+
} = {}) {
|
|
7
|
+
if (!Number.isFinite(options?.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > 30000
|
|
8
|
+
|| !Number.isFinite(options?.maxRssMb) || options.maxRssMb <= 0 || options.maxRssMb > 2048) {
|
|
9
|
+
throw new TypeError('Invalid framework prewarm budget');
|
|
10
|
+
}
|
|
11
|
+
const started = now(), deadline = started + options.timeoutMs, loaded = [];
|
|
12
|
+
const available = () => signal?.aborted ? 'cancelled'
|
|
13
|
+
: now() >= deadline ? 'timeout'
|
|
14
|
+
: rss() >= options.maxRssMb * 1024 * 1024 ? 'rss' : undefined;
|
|
15
|
+
const result = reason => ({ ready: !reason, reason, loaded, durationMs: Math.max(0, now() - started) });
|
|
16
|
+
const nodeRoutes = routes.filter(route => (route.cacheConfig?.runtime ?? route.handlerConfig?.runtime) !== 'edge');
|
|
17
|
+
const modules = [];
|
|
18
|
+
if (nodeRoutes.some(route => route.router === 'app' && route.kind === 'page')) modules.push('app-render');
|
|
19
|
+
if (nodeRoutes.some(route => route.router !== 'app' && route.kind === 'page')) modules.push('render');
|
|
20
|
+
if (nodeRoutes.some(route => route.kind === 'api')) modules.push('api');
|
|
21
|
+
for (const name of modules) {
|
|
22
|
+
let reason = available();
|
|
23
|
+
if (reason) return result(reason);
|
|
24
|
+
const module = await load(name);
|
|
25
|
+
reason = available();
|
|
26
|
+
if (reason) return result(reason);
|
|
27
|
+
if (name === 'app-render') {
|
|
28
|
+
await module.prewarmAppRenderer({ ...options, timeoutMs: Math.max(1, deadline - now()), signal });
|
|
29
|
+
reason = available();
|
|
30
|
+
if (reason) return result(reason);
|
|
31
|
+
}
|
|
32
|
+
loaded.push(name);
|
|
33
|
+
}
|
|
34
|
+
return result(available());
|
|
35
|
+
}
|
package/runtime/rsc-worker.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import { PartialTemplates, canResumePartialDirectly, partialTemplateReuse } from
|
|
|
32
32
|
import { createAfterScope } from '../compat/after.cjs';
|
|
33
33
|
import {renderKeyFor} from './app-render-key.mjs';
|
|
34
34
|
import { selectAppStyles } from './app-style-selection.mjs';
|
|
35
|
+
import {createFlightMeasurement} from './flight-diagnostics.mjs';
|
|
35
36
|
|
|
36
37
|
installFetchCache();
|
|
37
38
|
if (process.env.PRNEXT_INSPECT_DIR) parentPort.postMessage({type:'rsc-ready'});
|
|
@@ -173,7 +174,24 @@ function errorBoundary(entry, error, production, before = Infinity) {
|
|
|
173
174
|
return index < 0 ? null : { kind: 'error', index, error: publicError(error, production) };
|
|
174
175
|
}
|
|
175
176
|
|
|
176
|
-
|
|
177
|
+
function renderFlight(request, fallback = null, channel = null) {
|
|
178
|
+
const measurement=createFlightMeasurement(request,'rsc');
|
|
179
|
+
if(!measurement)return renderFlightModel(request,fallback,channel);
|
|
180
|
+
return observeFlightModel(request,fallback,channel,measurement);
|
|
181
|
+
}
|
|
182
|
+
async function observeFlightModel(request,fallback,channel,measurement){
|
|
183
|
+
let status;
|
|
184
|
+
try{
|
|
185
|
+
const result=await renderFlightModel(request,fallback,channel,measurement,measurement?value=>{status=value;}:undefined);
|
|
186
|
+
measurement?.finish('completed',result?.status??status);
|
|
187
|
+
return result;
|
|
188
|
+
}catch(error){
|
|
189
|
+
const reason=channel?.signal?.reason;
|
|
190
|
+
measurement?.finish(error?.statusCode===504||reason?.statusCode===504?'timedOut':channel?.signal?.aborted?'cancelled':'failed',status??error?.statusCode);
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async function renderFlightModel(request, fallback, channel,measurement,onStatus) {
|
|
177
195
|
return runRequestContext({ ...request, phase: 'render' }, async () => {
|
|
178
196
|
const cacheContext = currentRequest();
|
|
179
197
|
try {
|
|
@@ -220,7 +238,9 @@ async function renderFlight(request, fallback = null, channel = null) {
|
|
|
220
238
|
const stored = request.production
|
|
221
239
|
? await partialTemplates.get(request.partialFlight, consumer.serverConsumerManifest, decode, renderSignal)
|
|
222
240
|
: await decode();
|
|
223
|
-
const
|
|
241
|
+
const direct=!model.routing&&canResumePartialDirectly(stored);
|
|
242
|
+
measurement?.partial(direct?'direct':'normalized');
|
|
243
|
+
const live = direct ? model : await createFromReadableStream(boundedPartialFlight(renderToReadableStream(model, bundlerConfig, {
|
|
224
244
|
signal: renderSignal,
|
|
225
245
|
onError(error) { return publicError(error, request.production).digest; },
|
|
226
246
|
})), consumer);
|
|
@@ -295,9 +315,14 @@ async function renderFlight(request, fallback = null, channel = null) {
|
|
|
295
315
|
let offset = 0;
|
|
296
316
|
for (const item of batch) { value.set(item, offset); offset += item.byteLength; }
|
|
297
317
|
}
|
|
298
|
-
if (!channel.started)
|
|
299
|
-
|
|
318
|
+
if (!channel.started) {
|
|
319
|
+
const status=request.partialFlight?200:fallback?.kind==='not-found'||(entry.routing&&renderError?.digest==='NEXT_HTTP_ERROR_FALLBACK;404')?404:renderError||fallback?.kind==='error'?500:request.responseStatus||200;
|
|
320
|
+
onStatus?.(status);
|
|
321
|
+
channel.start({status,rscError:Boolean(ordinaryRenderError(renderError)),boundaryIndex:fallback?.index,headers:request.responseHeaders});
|
|
322
|
+
}
|
|
323
|
+
const emittedBytes=measurement?value.byteLength:0;
|
|
300
324
|
await channel.write(value, abort.signal, Boolean(next.result?.done));
|
|
325
|
+
measurement?.chunk(emittedBytes);
|
|
301
326
|
continue;
|
|
302
327
|
}
|
|
303
328
|
// The byte stream hands ownership of each Uint8Array to its reader.
|
|
@@ -315,7 +340,7 @@ async function renderFlight(request, fallback = null, channel = null) {
|
|
|
315
340
|
if (channel?.started && renderError.statusCode !== 504) { channel.end(); return; }
|
|
316
341
|
if (renderError.digest === 'NEXT_HTTP_ERROR_FALLBACK;404' && !entry.routing) {
|
|
317
342
|
const boundary = (entry.segments || []).findLastIndex((segment, index) => index < (fallback?.index ?? Infinity) && segment.notFound);
|
|
318
|
-
if (boundary >= 0) return
|
|
343
|
+
if (boundary >= 0) return renderFlightModel({ ...request, cacheState: cacheContext.cacheState, staticState: cacheContext.staticState }, { kind: 'not-found', index: boundary }, channel,measurement,onStatus);
|
|
319
344
|
} else if (ordinaryRenderError(renderError)) {
|
|
320
345
|
if (cacheContext.staticState) throw renderError;
|
|
321
346
|
// Preserve the original errored Flight tree. React's browser
|
|
@@ -329,7 +354,7 @@ async function renderFlight(request, fallback = null, channel = null) {
|
|
|
329
354
|
}
|
|
330
355
|
}
|
|
331
356
|
if (channel) {
|
|
332
|
-
if (!channel.started) channel.start({ status: request.responseStatus || 200, headers: request.responseHeaders });
|
|
357
|
+
if (!channel.started) {onStatus?.(request.responseStatus||200);channel.start({ status: request.responseStatus || 200, headers: request.responseHeaders });}
|
|
333
358
|
channel.end();
|
|
334
359
|
return;
|
|
335
360
|
}
|
|
@@ -340,6 +365,7 @@ async function renderFlight(request, fallback = null, channel = null) {
|
|
|
340
365
|
for (const chunk of chunks) {
|
|
341
366
|
body.set(chunk, offset);
|
|
342
367
|
offset += chunk.byteLength;
|
|
368
|
+
measurement?.chunk(chunk.byteLength);
|
|
343
369
|
}
|
|
344
370
|
return { body: body.buffer, status: staticNavigation?.status || (fallback?.kind === 'not-found' || (entry.routing && renderError?.digest === 'NEXT_HTTP_ERROR_FALLBACK;404') ? 404 : renderError || fallback?.kind === 'error' ? 500 : request.responseStatus || 200),
|
|
345
371
|
rscError: Boolean(ordinaryRenderError(renderError)), boundaryIndex: fallback?.index, headers: { ...request.responseHeaders, ...staticNavigation?.headers },
|
|
@@ -409,11 +435,35 @@ const activeRequests = new Set();
|
|
|
409
435
|
const responseCompletions = new Map();
|
|
410
436
|
const manifests = new Map();
|
|
411
437
|
const artifacts = new Map();
|
|
438
|
+
const preloading = new Map();
|
|
412
439
|
parentPort.on('message', async ({ id, request, type, value, token }) => {
|
|
413
440
|
if(type==='telemetry-flush'){await drainTraces();parentPort.postMessage({type:'telemetry-flushed',token});return;}
|
|
441
|
+
if(type==='preload-cancel'){preloading.get(id)?.abort(new Error('Framework prewarm was cancelled'));return;}
|
|
414
442
|
if(type==='preload'){
|
|
415
|
-
|
|
416
|
-
|
|
443
|
+
const abort=new AbortController();preloading.set(id,abort);let timer,reader,reply,transfer;
|
|
444
|
+
try{
|
|
445
|
+
if(value.options.frameworkWarmup){
|
|
446
|
+
if(value.files.length||!Number.isFinite(value.options.timeoutMs)||value.options.timeoutMs<=0||value.options.timeoutMs>30000||!Number.isFinite(value.options.maxRssMb)||value.options.maxRssMb<=0||value.options.maxRssMb>2048)throw new TypeError('Invalid synthetic renderer prewarm');
|
|
447
|
+
const deadline=performance.now()+value.options.timeoutMs;
|
|
448
|
+
const available=()=>{abort.signal.throwIfAborted();if(performance.now()>=deadline||process.memoryUsage().rss>=value.options.maxRssMb*1024*1024)throw new Error('Synthetic renderer prewarm budget exhausted');};
|
|
449
|
+
timer=setTimeout(()=>abort.abort(new Error('Synthetic renderer prewarm timed out')),value.options.timeoutMs);
|
|
450
|
+
available();
|
|
451
|
+
// Host elements and literals only: no routeEntry, application imports,
|
|
452
|
+
// fetches, caches, contexts or handlers participate in this warm-up.
|
|
453
|
+
const model={tree:React.createElement('html',null,React.createElement('head'),React.createElement('body',null,React.createElement('main',null,React.createElement('ul',null,Array.from({length:8},(_,index)=>React.createElement('li',{key:index},'PRNext framework '+index)))))),router:{pathname:'/_prnext-prewarm',query:{}}};
|
|
454
|
+
reader=renderToReadableStream(model,{}, {signal:abort.signal,onError:()=> 'PRNEXT_FRAMEWORK_PREWARM'}).getReader();
|
|
455
|
+
const chunks=[];let length=0;
|
|
456
|
+
for(;;){available();const next=await reader.read();if(next.done)break;length+=next.value.byteLength;if(length>65536)throw new Error('Synthetic renderer prewarm Flight exceeds its byte budget');chunks.push(new Uint8Array(next.value));}
|
|
457
|
+
available();const bytes=new Uint8Array(length);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.length;}
|
|
458
|
+
await reader.cancel();reader=undefined;
|
|
459
|
+
reply={type:'preloaded',id,frameworkFlight:bytes.buffer};transfer=[bytes.buffer];
|
|
460
|
+
}else{
|
|
461
|
+
abort.signal.throwIfAborted();await preloadModules(value.files,value.options,file=>routeEntry({modulePath:file,production:true}));abort.signal.throwIfAborted();reply={type:'preloaded',id};
|
|
462
|
+
}
|
|
463
|
+
}catch(error){reply={type:'preloaded',id,error:error.message};}
|
|
464
|
+
finally{clearTimeout(timer);await reader?.cancel().catch(()=>{});preloading.delete(id);}
|
|
465
|
+
parentPort.postMessage(reply,transfer);
|
|
466
|
+
return;
|
|
417
467
|
}
|
|
418
468
|
if (type === 'after-response') { responseCompletions.get(id)?.(); return; }
|
|
419
469
|
// Only the host arms these probes, while completed responses still have
|
package/runtime/worker.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import {deferredCompiler} from './dev-compiler-broker.mjs';
|
|
|
7
7
|
import { requestFrames } from './request-transport.mjs';
|
|
8
8
|
import {scheduleCompileCacheFlush} from './compile-cache.mjs';
|
|
9
9
|
import {preloadModules} from './preload.mjs';
|
|
10
|
+
import {prewarmFramework} from './prewarm.mjs';
|
|
10
11
|
import { requestWork } from './request-work.mjs';
|
|
11
12
|
import { freezeClientModules } from './worker-metadata.mjs';
|
|
12
13
|
import { AsyncResource } from 'node:async_hooks';
|
|
@@ -70,6 +71,29 @@ const routes = new Map(manifest.routes.map(route => [route.id, route]));
|
|
|
70
71
|
const prerendered = new Set((manifest.prerendered || []).map(item => item.path));
|
|
71
72
|
const deployment={distDir,manifest,routes,prerendered,modules:new Map()};
|
|
72
73
|
const deployments=new Map([[distDir,deployment]]);
|
|
74
|
+
const prewarmRequested = process.env.PRNEXT_WORKER_PREWARM === '1';
|
|
75
|
+
delete process.env.PRNEXT_WORKER_PREWARM;
|
|
76
|
+
async function rendererFor(selected,name){
|
|
77
|
+
if(selected.modules.has(name))return selected.modules.get(name);
|
|
78
|
+
const pending=workScope.runInAsyncScope(()=>runtimeModule(name,selected.distDir));
|
|
79
|
+
selected.modules.set(name,pending);
|
|
80
|
+
try{const module=await pending;selected.modules.set(name,module);return module;}
|
|
81
|
+
catch(error){if(selected.modules.get(name)===pending)selected.modules.delete(name);throw error;}
|
|
82
|
+
}
|
|
83
|
+
async function prepareFramework(signal) {
|
|
84
|
+
const configured = manifest.config?.workerPreload;
|
|
85
|
+
const options = { maxRssMb: configured?.maxRssMb ?? 256, timeoutMs: Math.min(15000, configured?.timeoutMs ?? 15000) };
|
|
86
|
+
const result = await prewarmFramework({ routes: manifest.routes, options, signal,
|
|
87
|
+
load: name => rendererFor(deployment, name) });
|
|
88
|
+
if (diagnostics.enabled) diagnostics.record('wake', { phase: 'framework-prewarm', ...result });
|
|
89
|
+
if (!result.ready) console.warn(`[prnext] Framework prewarm incomplete (${result.reason}); normal requests remain available.`);
|
|
90
|
+
return result.ready;
|
|
91
|
+
}
|
|
92
|
+
let frameworkPrewarmed = false;
|
|
93
|
+
if (production && prewarmRequested) {
|
|
94
|
+
try { frameworkPrewarmed = await prepareFramework(); }
|
|
95
|
+
catch { console.warn('[prnext] Framework prewarm failed; normal requests remain available.'); }
|
|
96
|
+
}
|
|
73
97
|
const compileDeferred=!production&&compilerDescriptor?deferredCompiler(compilerDescriptor,{root:projectRoot,home:path.dirname(distDir)}):undefined;
|
|
74
98
|
async function deferredDeployment(route,signal,sources=[]){
|
|
75
99
|
if(!compileDeferred)throw Error('Deferred development compiler is unavailable');
|
|
@@ -91,15 +115,8 @@ async function drainRuntime(){
|
|
|
91
115
|
|
|
92
116
|
async function handleRequest(input, output, signalController, responseComplete, selected=deployment) {
|
|
93
117
|
const {manifest,distDir,routes,prerendered}=selected;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
// Imports share one pending job, then retain only the namespace. Starting
|
|
97
|
-
// outside the visitor's ALS scope prevents the module cache retaining it.
|
|
98
|
-
const pending=workScope.runInAsyncScope(()=>runtimeModule(name,distDir));
|
|
99
|
-
selected.modules.set(name,pending);
|
|
100
|
-
try{const module=await pending;selected.modules.set(name,module);return module;}
|
|
101
|
-
catch(error){if(selected.modules.get(name)===pending)selected.modules.delete(name);throw error;}
|
|
102
|
-
};
|
|
118
|
+
// Bootstrap and requests share pending imports outside the visitor's ALS.
|
|
119
|
+
const renderer=name=>rendererFor(selected,name);
|
|
103
120
|
let responseStarted = false;
|
|
104
121
|
const finalizers = new Set();
|
|
105
122
|
try {
|
|
@@ -108,10 +125,8 @@ async function handleRequest(input, output, signalController, responseComplete,
|
|
|
108
125
|
const options=manifest.config?.workerPreload||(frameworkOnly?{routes:[],maxRssMb:256,timeoutMs:15000}:undefined);
|
|
109
126
|
if(!production||!options){await output(input.id,{status:204,body:''},input);return;}
|
|
110
127
|
if(frameworkOnly){
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if(manifest.routes.some(route=>route.kind==='api'))await renderer('api');
|
|
114
|
-
scheduleCompileCacheFlush();await output(input.id,{status:204,body:''},input);return;
|
|
128
|
+
frameworkPrewarmed = await prepareFramework(signalController.signal);
|
|
129
|
+
scheduleCompileCacheFlush();await output(input.id,{status:frameworkPrewarmed?204:503,body:''},input);return;
|
|
115
130
|
}
|
|
116
131
|
const selected=options.routes.map(pattern=>manifest.routes.find(route=>route.pattern===pattern&&!route.internal)).filter(Boolean);
|
|
117
132
|
const deadline=performance.now()+options.timeoutMs;
|
|
@@ -347,7 +362,7 @@ if (token && laneLimit > 1) {
|
|
|
347
362
|
globalThis.__PRNEXT_DEV_WORKER_READY__({port:server.address().port,binary:true,gracefulShutdown:true,requestBodyStream:1});
|
|
348
363
|
delete globalThis.__PRNEXT_DEV_WORKER_READY__;
|
|
349
364
|
}
|
|
350
|
-
writeProtocol(JSON.stringify({ port: server.address().port, binary: true, heartbeat: true, activity: reportActivity, gracefulShutdown: true, requestBodyStream: 1 }) + '\n');
|
|
365
|
+
writeProtocol(JSON.stringify({ port: server.address().port, binary: true, heartbeat: true, activity: reportActivity, gracefulShutdown: true, requestBodyStream: 1, frameworkPrewarmed }) + '\n');
|
|
351
366
|
heartbeat = setInterval(() => {
|
|
352
367
|
// Coalesce the idle transition with the existing heartbeat. Normal
|
|
353
368
|
// request completion adds no stdout messages, and short cache bursts
|