@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.
Files changed (47) hide show
  1. package/build/app.mjs +4 -4
  2. package/build/build-timings.mjs +38 -0
  3. package/build/bundle-runtime.mjs +8 -8
  4. package/build/compiler.mjs +2 -1
  5. package/build/flight-summary.mjs +64 -0
  6. package/build/index.mjs +32 -28
  7. package/build/inspect.mjs +3 -0
  8. package/build/inspection-summary.mjs +7 -2
  9. package/build/native-discovery.mjs +18 -6
  10. package/build/npm-packages.mjs +2 -2
  11. package/build/source-analysis.mjs +54 -1
  12. package/build/standalone-copy.mjs +48 -0
  13. package/build/standalone.mjs +2 -22
  14. package/build/transform-cache.mjs +17 -3
  15. package/cli.mjs +13 -0
  16. package/compat/diagnostics.cjs +5 -4
  17. package/compat/runtime-sample.cjs +56 -0
  18. package/compat/use-cache.cjs +5 -2
  19. package/docs/ai/compatibility.md +3 -3
  20. package/docs/ai/deployment.md +8 -5
  21. package/docs/ai/troubleshooting.md +22 -0
  22. package/package.json +20 -20
  23. package/runtime/app-client.mjs +106 -38
  24. package/runtime/app-render.mjs +31 -6
  25. package/runtime/app-stream-channel.mjs +17 -4
  26. package/runtime/app-stream-html.mjs +30 -11
  27. package/runtime/cache-components-rsc.mjs +26 -6
  28. package/runtime/cache-result.mjs +58 -0
  29. package/runtime/dev-client.mjs +26 -5
  30. package/runtime/dev-compiler-broker.mjs +20 -23
  31. package/runtime/dev-gateway.mjs +22 -6
  32. package/runtime/development-flight.mjs +59 -0
  33. package/runtime/development-queue.mjs +33 -0
  34. package/runtime/development.mjs +11 -9
  35. package/runtime/flight-diagnostics.mjs +48 -0
  36. package/runtime/flight-tap.mjs +10 -5
  37. package/runtime/html-envelope.mjs +34 -0
  38. package/runtime/loopback-rpc.mjs +92 -0
  39. package/runtime/native-ppr.mjs +57 -4
  40. package/runtime/persistent/client.mjs +1 -1
  41. package/runtime/persistent/common.mjs +7 -18
  42. package/runtime/persistent/daemon.mjs +1 -1
  43. package/runtime/persistent/native-proxy.mjs +1 -1
  44. package/runtime/persistent/watchdog.mjs +12 -27
  45. package/runtime/prewarm.mjs +35 -0
  46. package/runtime/rsc-worker.mjs +58 -8
  47. 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
+ }
@@ -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 = JSON.parse(bytes);
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 !== 1) throw new Error('Supervisor protocol mismatch. Run prn pdown before upgrading.'); if (result.ready) return; }
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 new Promise((resolve, reject) => {
51
- const socket = net.connect({ host: '127.0.0.1', port: connection.port });
52
- socket.setEncoding('utf8');
53
- let buffer = '', finished = false;
54
- const finish = (error, value) => { if (finished) return; finished = true; socket.destroy(); error ? reject(error) : resolve(value); };
55
- socket.setTimeout(timeout, () => finish(new Error('Supervisor request timed out. Check prn plogs --supervisor.')));
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.setTimeout(125000, () => socket.destroy());
18
- let buffer = '', received = false;
19
- socket.on('error', () => {});
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
- pending.set(id, reply);
40
- socket.once('close', () => pending.delete(id));
41
- daemon.send({ type: 'request', id, request: message }, error => { if (error) { pending.delete(id); reply({ ok: false, error: error.message }); } });
42
- } catch (error) { reply({ ok: false, error: error.message }); }
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({ ok: true, value: 'Supervisor stopped; enabled apps will return on the next launch.' });
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
+ }
@@ -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
- async function renderFlight(request, fallback = null, channel = null) {
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 live = !model.routing && canResumePartialDirectly(stored) ? model : await createFromReadableStream(boundedPartialFlight(renderToReadableStream(model, bundlerConfig, {
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) channel.start({ 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,
299
- rscError: Boolean(ordinaryRenderError(renderError)), boundaryIndex: fallback?.index, headers: request.responseHeaders });
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 renderFlight({ ...request, cacheState: cacheContext.cacheState, staticState: cacheContext.staticState }, { kind: 'not-found', index: boundary }, channel);
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
- try{await preloadModules(value.files,value.options,file=>routeEntry({modulePath:file,production:true}));parentPort.postMessage({type:'preloaded',id});}
416
- catch(error){parentPort.postMessage({type:'preloaded',id,error:error.message});}return;
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
@@ -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
- const renderer=async name=>{
95
- if(selected.modules.has(name))return selected.modules.get(name);
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
- if(manifest.routes.some(route=>route.router==='app'&&route.kind==='page'))await(await renderer('app-render')).prewarmAppRenderer(options);
112
- if(manifest.routes.some(route=>route.kind==='page'&&route.router!=='app'))await renderer('render');
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