@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,58 @@
1
+ // Private, versioned cache bytes. These packets never become browser Flight.
2
+ import {isUtf8} from 'node:buffer';
3
+ const MAGIC=Buffer.from([0x50,0x52,0x4e,0x43,2]);
4
+ const HEADER=MAGIC.length+1;
5
+ export const CACHE_RESULT_LIMIT=16*1024*1024;
6
+ export const CACHE_RESULT_KEY_VERSION='flight-primitives-v2';
7
+ const SCALAR_LIMIT=CACHE_RESULT_LIMIT-64*1024;
8
+ function packet(type,payload=Buffer.alloc(0)){
9
+ if(payload.length>CACHE_RESULT_LIMIT)throw new Error('Cached React result exceeds the 16 MiB rendering limit');
10
+ const bytes=Buffer.allocUnsafe(HEADER+payload.length);MAGIC.copy(bytes);bytes[MAGIC.length]=type;payload.copy(bytes,HEADER);return bytes;
11
+ }
12
+ function textPacket(type,value,length){
13
+ const bytes=Buffer.allocUnsafe(HEADER+length);MAGIC.copy(bytes);bytes[MAGIC.length]=type;bytes.write(value,HEADER,length,'utf8');return bytes;
14
+ }
15
+ export function primitiveCacheResult(value){
16
+ if(value===null)return packet(1);
17
+ switch(typeof value){
18
+ case 'undefined':return packet(2);
19
+ case 'boolean':return packet(value?3:4);
20
+ case 'number':{const bytes=Buffer.allocUnsafe(8);bytes.writeDoubleLE(value);return packet(5,bytes);}
21
+ case 'bigint':{const text=String(value);return text.length>SCALAR_LIMIT?undefined:textPacket(6,text,text.length);}
22
+ case 'string':{
23
+ // Leave framing-sensitive boundary values to the official Flight limit.
24
+ if(value.length>SCALAR_LIMIT)return undefined;
25
+ // Flight's small JSON strings and large UTF8 text rows do not normalize
26
+ // lone surrogates or BOMs alike. Keep any such string on the fallback,
27
+ // independently of React's text-row threshold or chunk boundaries.
28
+ if(!value.isWellFormed()||value.includes('\ufeff'))return undefined;
29
+ const length=Buffer.byteLength(value);
30
+ if(length>SCALAR_LIMIT)return undefined;
31
+ return textPacket(7,value,length);
32
+ }
33
+ // In particular, do not enumerate objects, inspect then, or read a proxy or
34
+ // getter. React owns every nonprimitive result and its temporary references.
35
+ default:return undefined;
36
+ }
37
+ }
38
+ export function flightCacheResult(chunks,size){
39
+ if(size>CACHE_RESULT_LIMIT)throw new Error('Cached React result exceeds the 16 MiB rendering limit');
40
+ // Ordinary Flight keeps its original bytes and sole aggregate copy. Reserve
41
+ // the whole first-byte family for this private protocol, without assuming
42
+ // anything about React's current or future row syntax. Inspect the aggregate
43
+ // so an empty leading chunk cannot hide a reserved first byte.
44
+ const bytes=Buffer.concat(chunks,size);
45
+ return isCacheResultPacket(bytes)?packet(0,bytes):bytes;
46
+ }
47
+ export function isCacheResultPacket(bytes){return bytes[0]===MAGIC[0];}
48
+ export function readCacheResult(bytes){
49
+ if(!Buffer.isBuffer(bytes)||bytes.length<HEADER||bytes.length>HEADER+CACHE_RESULT_LIMIT||!bytes.subarray(0,MAGIC.length).equals(MAGIC))throw new Error('Invalid RSC cache result envelope');
50
+ const type=bytes[MAGIC.length],payload=bytes.subarray(HEADER);
51
+ // Unwrap an escaped raw payload once; never interpret it as another packet.
52
+ if(type===0&&isCacheResultPacket(payload))return {flight:payload};
53
+ if(type>=1&&type<=4&&payload.length===0)return {value:type===1?null:type===2?undefined:type===3};
54
+ if(type===5&&payload.length===8)return {value:payload.readDoubleLE()};
55
+ if(type===6&&payload.length&&/^(?:0|-?[1-9]\d*)$/.test(payload.toString()))return {value:BigInt(payload.toString())};
56
+ if(type===7&&isUtf8(payload))return {value:payload.toString()};
57
+ throw new Error('Invalid RSC cache result envelope');
58
+ }
@@ -158,6 +158,10 @@ async function applyUpdate(message, {navigation=false}={}) {
158
158
  if(message.sequence&&message.sequence<=(state.sequence||0)&&!(navigation&&state.skippedUpdate?.buildId===message.buildId))return;
159
159
  if (state.applying) {
160
160
  if(!state.queued||(message.sequence||0)>=(state.queued.sequence||0))state.queued={...message,navigation};
161
+ const active = state.refreshUpdate;
162
+ // Duplicate readiness messages must not cancel their own generation. The
163
+ // owner exists before any await, including before devCommit creates a GET.
164
+ if (active && message.buildId !== active.buildId && (message.sequence || 0) > active.sequence) active.abort.abort(new DOMException('Newer development generation', 'AbortError'));
161
165
  const pending=state.applied;await pending;
162
166
  if((message.sequence||0)>(state.sequence||0)&&state.applying)await state.applied;
163
167
  return;
@@ -185,28 +189,35 @@ async function applyUpdate(message, {navigation=false}={}) {
185
189
  }
186
190
  state.applying = true; state.confirmed=false; state.unsafe = false; state.refreshSourceChanged = false; state.pending = null; state.indicator?.update();
187
191
  let applied;state.applied=new Promise(resolve=>{applied=resolve;});
192
+ const update = { buildId: message.buildId, sequence: message.sequence || 0, abort: new AbortController() };
193
+ state.refreshUpdate = update;
188
194
  state.dismissedErrors = new WeakSet();
189
195
  state.dismissedDigests = new Set();
190
196
  try {
191
- const response = await fetch(message.clientManifest, { cache: 'no-store', credentials: 'same-origin' });
197
+ const response = await fetch(message.clientManifest, { cache: 'no-store', credentials: 'same-origin', signal: update.abort.signal });
192
198
  if (!response.ok) throw new Error(`Development manifest failed (${response.status})`);
193
199
  const manifest = await response.json();
200
+ update.abort.signal.throwIfAborted();
194
201
  if (manifest.buildId !== message.buildId) throw new Error('Development build changed during refresh');
195
202
  const kind = state.currentKind;
196
203
  const application = await state.applications.get(kind);
204
+ update.abort.signal.throwIfAborted();
197
205
  const route = kind === 'pages' ? manifest.pages.find(route => route.pattern === application.router.snapshot().pathname) : null;
198
206
  const entry = kind === 'pages' ? route?.client : manifest.app?.client;
199
207
  if (!entry) { reload('The current route changed. Page reloaded.'); return; }
200
208
  if(!message.cssOnly){
201
209
  await import(/* webpackIgnore: true */ entry);
210
+ update.abort.signal.throwIfAborted();
202
211
  // Activated deferred modules must register their updated component
203
212
  // families before Refresh runs, even when their loader is already cached.
204
213
  await Promise.all((manifest.lazy||[]).map(module=>import(/* webpackIgnore: true */ module.url)));
214
+ update.abort.signal.throwIfAborted();
205
215
  }
206
216
  const pending = state.pending;
207
217
  if (pending) await application.devPrepare?.(pending.options);
218
+ update.abort.signal.throwIfAborted();
208
219
  if (state.unsafe) { reload('An edited module exports values outside React components. Page reloaded.'); return; }
209
- if (kind === 'app' && application.devPrepareStyles) await application.devPrepareStyles(manifest.app.routes);
220
+ if (kind === 'app' && application.devPrepareStyles) await application.devPrepareStyles(manifest.app.routes, update.abort.signal);
210
221
  else await updateStyles([...(kind === 'pages' ? route.css : application.devStyles?.(manifest.app.routes) || manifest.app.css),...(manifest.lazy||[]).flatMap(module=>module.css)]);
211
222
  if (kind === 'app') application.devStylesApplied?.(manifest.app.routes, { commit: !!message.cssOnly || !pending });
212
223
  // An unrelated type-only edit may publish a new generation without changing
@@ -218,20 +229,30 @@ async function applyUpdate(message, {navigation=false}={}) {
218
229
  }else if(hasCriticalIssue()&&(state.issueKind==='build'||errorKey(state.issue))){
219
230
  clearError();
220
231
  }
232
+ update.abort.signal.throwIfAborted();
233
+ if (!message.cssOnly && application.devBeforeRefresh?.({ navigation }) === false) return;
221
234
  if(!message.cssOnly)Refresh.performReactRefresh();
222
- if (pending) await (navigation ? application.devSync?.(pending.options) : application.devCommit?.(pending.options));
235
+ if (pending) {
236
+ const committed = await (navigation ? application.devSync?.(pending.options) : application.devCommit?.(pending.options, { signal: update.abort.signal }));
237
+ // A private 'superseded' result replaces only the UI; its compiler
238
+ // generation is still applied. False remains a failed/aborted refresh.
239
+ if (committed === false || update.abort.signal.aborted) return;
240
+ }
223
241
  state.buildId = message.buildId;
224
242
  if(message.sequence)state.sequence=message.sequence;
225
243
  state.manifest = manifest;
226
244
  state.skippedUpdate=undefined;
227
245
  document.documentElement.dataset.prnextDevBuild = message.buildId;
228
246
  state.lastRefresh={buildId:message.buildId,durationMs:Math.round(performance.now()-refreshStarted),changed};
229
- await painted();state.confirmed=true;
247
+ await painted();
248
+ if (update.abort.signal.aborted) return;
249
+ state.confirmed=true;
230
250
  globalThis.dispatchEvent(new CustomEvent('prnext:refresh', { detail: state.lastRefresh }));
231
251
  globalThis.dispatchEvent(new CustomEvent('prnext:devtools:ready'));
232
252
  if(state.typeErrors)typeDiagnostics(state.typeErrors);
233
- } catch (error) { showError(error); }
253
+ } catch (error) { if (!update.abort.signal.aborted) showError(error); }
234
254
  finally {
255
+ if (state.refreshUpdate === update) state.refreshUpdate = undefined;
235
256
  state.applying = false; state.pending = null; state.indicator?.update();
236
257
  applied();
237
258
  if (state.queued) { const queued = state.queued; state.queued = null; if (queued.buildId !== state.buildId) void applyUpdate(queued,{navigation:queued.navigation}); }
@@ -1,38 +1,35 @@
1
- import http from 'node:http';
2
- import {randomBytes,timingSafeEqual} from 'node:crypto';
1
+ import {createServer} from 'node:net';
2
+ import {randomBytes} from 'node:crypto';
3
3
  import {readFile,realpath} from 'node:fs/promises';
4
4
  import path from 'node:path';
5
+ import {requestLoopback,serveLoopback} from './loopback-rpc.mjs';
5
6
 
6
7
  // Private control plane. Request headers and bodies never cross this endpoint.
7
8
  export async function createDevCompilerBroker({compile,maxPending=64}) {
8
- const token=randomBytes(32).toString('base64url');let pending=0;
9
- const server=http.createServer(async(req,res)=>{
10
- const supplied=Buffer.from(req.headers.authorization||''),expected=Buffer.from('Bearer '+token);
11
- if(req.method!=='POST'||req.url!=='/compile'||supplied.length!==expected.length||!timingSafeEqual(supplied,expected)){res.writeHead(403).end();return;}
12
- if(pending>=maxPending){res.writeHead(503).end();return;}
13
- pending++;
14
- try{
15
- const chunks=[];let bytes=0;
16
- for await(const chunk of req){bytes+=chunk.length;if(bytes>65536)throw Error('Compiler request too large');chunks.push(chunk);}
17
- // Decode once: an HTTP chunk boundary may split a UTF-8 route name.
18
- const input=JSON.parse(Buffer.concat(chunks,bytes).toString('utf8'));if(typeof input.pattern!=='string'||!input.pattern.startsWith('/')||input.pattern.length>4096)throw Error('Invalid route pattern');
19
- if(input.sources!==undefined&&(!Array.isArray(input.sources)||input.sources.length>32||!input.sources.every(p=>typeof p==='string'&&p.startsWith('/')&&p.length<=4096)))throw Error('Invalid retained route patterns');
20
- const directory=await compile(input.pattern,input.sources||[]);
21
- res.writeHead(200,{'content-type':'application/json','cache-control':'no-store'}).end(JSON.stringify({directory}));
22
- }catch(error){res.writeHead(500,{'content-type':'application/json'}).end(JSON.stringify({error:String(error.message).slice(0,2048)}));}
23
- finally{pending--;}
9
+ const token=randomBytes(32).toString('base64url'),sockets=new Set();let pending=0;
10
+ const server=createServer(socket=>{
11
+ sockets.add(socket);socket.once('close',()=>sockets.delete(socket));
12
+ serveLoopback(socket,{token,context:'development-compiler',timeout:120000,maxResponseBytes:65536,handle:async(input,admission)=>{
13
+ if(pending>=maxPending)throw Error('Development compiler is busy. Retry shortly.');
14
+ pending++;
15
+ try{
16
+ if(typeof input.pattern!=='string'||!input.pattern.startsWith('/')||input.pattern.length>4096)throw Error('Invalid route pattern');
17
+ if(input.sources!==undefined&&(!Array.isArray(input.sources)||input.sources.length>32||!input.sources.every(p=>typeof p==='string'&&p.startsWith('/')&&p.length<=4096)))throw Error('Invalid retained route patterns');
18
+ const directory=await compile(input.pattern,input.sources||[],admission);
19
+ return {directory};
20
+ }finally{pending--;}
21
+ }});
24
22
  });
25
- server.requestTimeout=125000;server.headersTimeout=5000;
23
+ server.maxConnections=64;
26
24
  await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});
27
- return {descriptor:JSON.stringify({port:server.address().port,token}),close:()=>new Promise(resolve=>{server.close(resolve);server.closeIdleConnections();})};
25
+ return {descriptor:JSON.stringify({version:2,port:server.address().port,token}),close:()=>new Promise(resolve=>{server.close(resolve);for(const socket of sockets)socket.destroy();})};
28
26
  }
29
27
 
30
28
  export function deferredCompiler(descriptor,{root,home}) {
31
29
  const value=JSON.parse(descriptor);
32
- if(!Number.isInteger(value.port)||value.port<1||value.port>65535||!/^[\w-]{43}$/.test(value.token))throw Error('Invalid private compiler endpoint');
30
+ if(value.version!==2||!Number.isInteger(value.port)||value.port<1||value.port>65535||!/^[\w-]{43}$/.test(value.token))throw Error('Invalid private compiler endpoint');
33
31
  return async(pattern,signal,sources=[])=>{
34
- const response=await fetch(`http://127.0.0.1:${value.port}/compile`,{method:'POST',headers:{authorization:'Bearer '+value.token,'content-type':'application/json'},body:JSON.stringify({pattern,sources}),signal:AbortSignal.any([...(signal?[signal]:[]),AbortSignal.timeout(120000)])});
35
- const result=await response.json();if(!response.ok)throw Error(result.error||'Deferred route compilation failed');
32
+ const result=await requestLoopback(value,{pattern,sources},{context:'development-compiler',signal,timeout:120000,maxResponseBytes:65536});
36
33
  const directory=await realpath(path.resolve(root,result.directory)),parent=await realpath(home),relative=path.relative(parent,directory);
37
34
  if(!relative||relative.startsWith('..')||path.isAbsolute(relative))throw Error('Compiler generation escaped its development session');
38
35
  const manifest=JSON.parse(await readFile(path.join(directory,'manifest.json'),'utf8'));
@@ -1,10 +1,10 @@
1
1
  import {spawn} from 'node:child_process';
2
2
 
3
3
  export class DevGateway {
4
- constructor(binary,root,{hostname,port,onEnsure,onOpen,onClose,onFatal,onBeforeChild=()=>{},onChild=()=>{}}) {
5
- this.next=0;this.pending=new Map();this.stopping=false;
4
+ constructor(binary,root,{hostname,port,onEnsure,onOpen,onClose,onFatal,onBeforeChild=()=>{},onChild=()=>{}},{spawnChild=spawn}={}) {
5
+ this.next=0;this.pending=new Map();this.ensures=new Map();this.stopping=false;
6
6
  const ticket=onBeforeChild();
7
- this.child=spawn(binary,['dev-gateway',root,'--hostname',hostname,'--port',String(port)],{stdio:['pipe','pipe','inherit'],windowsHide:true});onChild(this.child,ticket);
7
+ this.child=spawnChild(binary,['dev-gateway',root,'--hostname',hostname,'--port',String(port)],{stdio:['pipe','pipe','inherit'],windowsHide:true});onChild(this.child,ticket);
8
8
  this.closed=new Promise(resolve=>this.child.once('close',resolve));
9
9
  this.ready=new Promise((resolve,reject)=>{
10
10
  const timer=setTimeout(()=>{reject(Error('Development listener did not start'));this.child.kill();},15000);
@@ -17,18 +17,34 @@ export class DevGateway {
17
17
  if(message.type==='ready'){clearTimeout(timer);resolve(message);continue;}
18
18
  if(message.type==='open'){onOpen(message);continue;}
19
19
  if(message.type==='close'){onClose(message);continue;}
20
+ if(message.type==='ensure-cancel'){this.cancelEnsure(message.id);continue;}
20
21
  if(message.type==='ensure'){
21
- Promise.resolve().then(()=>onEnsure(message.path,message.client)).then(value=>this.request('result',{request:message.id,value}),error=>this.request('result',{request:message.id,value:{error:String(error.message).slice(0,8000)}})).catch(error=>{if(!this.stopping)onFatal(error);});continue;
22
+ const now=Date.now();
23
+ if(!Number.isSafeInteger(message.id)||!Number.isSafeInteger(message.deadlineMs)||this.ensures.has(message.id)||this.ensures.size>=64){this.child.kill();continue;}
24
+ if(message.deadlineMs<=now)continue;
25
+ const deadlineMs=Math.min(message.deadlineMs,now+120000);
26
+ const controller=new AbortController(),entry={controller,timer:setTimeout(()=>this.cancelEnsure(message.id),deadlineMs-now)};
27
+ this.ensures.set(message.id,entry);
28
+ const reply=value=>{
29
+ if(this.ensures.get(message.id)!==entry)return;
30
+ clearTimeout(entry.timer);this.ensures.delete(message.id);
31
+ return this.request('result',{request:message.id,value});
32
+ };
33
+ Promise.resolve().then(()=>{
34
+ if(controller.signal.aborted)return;
35
+ return onEnsure(message.path,message.client,{signal:controller.signal,deadlineMs});
36
+ }).then(reply,error=>reply({error:String(error.message).slice(0,8000)})).catch(error=>{if(!this.stopping)onFatal(error);});continue;
22
37
  }
23
38
  const pending=this.pending.get(message.id);if(!pending)continue;
24
39
  this.pending.delete(message.id);clearTimeout(pending.timer);message.ok?pending.resolve(message.value):pending.reject(Error(message.error));
25
40
  }
26
41
  });
27
- const failed=error=>{clearTimeout(timer);reject(error);for(const pending of this.pending.values()){clearTimeout(pending.timer);pending.reject(error);}this.pending.clear();if(!this.stopping)onFatal(error);};
42
+ const failed=error=>{clearTimeout(timer);reject(error);for(const id of this.ensures.keys())this.cancelEnsure(id);for(const pending of this.pending.values()){clearTimeout(pending.timer);pending.reject(error);}this.pending.clear();if(!this.stopping)onFatal(error);};
28
43
  this.child.once('error',failed);this.child.once('exit',(code,signal)=>failed(Error(`Development listener exited ${signal||code}`)));
29
44
  });
30
45
  this.child.stdin.on('error',()=>{});
31
46
  }
47
+ cancelEnsure(id){const entry=this.ensures.get(id);if(!entry)return;this.ensures.delete(id);clearTimeout(entry.timer);entry.controller.abort();}
32
48
  async request(command,values={}) {
33
49
  await this.ready;
34
50
  if(this.stopping||this.child.exitCode!==null)throw Error('Development listener is closed');
@@ -41,5 +57,5 @@ export class DevGateway {
41
57
  this.child.stdin.write(line);
42
58
  });
43
59
  }
44
- async close(){this.stopping=true;this.child.stdin.end();const timer=setTimeout(()=>this.child.kill('SIGKILL'),6000);try{await this.closed;}finally{clearTimeout(timer);}}
60
+ async close(){this.stopping=true;for(const id of this.ensures.keys())this.cancelEnsure(id);this.child.stdin.end();const timer=setTimeout(()=>this.child.kill('SIGKILL'),6000);try{await this.closed;}finally{clearTimeout(timer);}}
45
61
  }
@@ -0,0 +1,59 @@
1
+ // Only owned development navigation GETs opt into a partial Flight decoder. An intentional
2
+ // supersession closes that decoder while cancelling its sole upstream reader;
3
+ // genuine transport errors still reject. This stream never claims genuine EOF
4
+ // on behalf of the transport or retains response bytes as a reusable delta base.
5
+ export function supersedableDevelopmentFlight(source, signal) {
6
+ const reader = source.getReader();
7
+ let controller, closed = false;
8
+ const clean = () => {
9
+ signal.removeEventListener('abort', abort);
10
+ try { reader.releaseLock(); } catch {}
11
+ };
12
+ const cancel = reason => Promise.resolve(reader.cancel(reason)).catch(() => {}).finally(clean);
13
+ const abort = () => {
14
+ if (closed) return;
15
+ closed = true;
16
+ controller.close();
17
+ void cancel(signal.reason);
18
+ };
19
+ return new ReadableStream({
20
+ start(value) {
21
+ controller = value;
22
+ signal.addEventListener('abort', abort, { once: true });
23
+ if (signal.aborted) abort();
24
+ },
25
+ async pull(value) {
26
+ if (closed) return;
27
+ try {
28
+ const next = await reader.read();
29
+ if (closed) return;
30
+ // React's ordinary EOF rejects missing rows. With partial decoding
31
+ // enabled, reserve a clean close exclusively for owned supersession.
32
+ if (next.done) { closed = true; clean(); value.error(new Error('Connection closed.')); }
33
+ else value.enqueue(next.value);
34
+ } catch (error) {
35
+ if (closed) return;
36
+ closed = true;
37
+ value.error(error);
38
+ await cancel(error);
39
+ }
40
+ },
41
+ async cancel(reason) {
42
+ if (closed) return;
43
+ closed = true;
44
+ await cancel(reason);
45
+ },
46
+ }, { highWaterMark: 0 });
47
+ }
48
+
49
+ // A partial decoder also halts an unresolved root on supersession. Race its
50
+ // root promise explicitly so cancellation cannot leave navigation waiting on it.
51
+ export function developmentFlightRoot(root, signal) {
52
+ return new Promise((resolve, reject) => {
53
+ const clean = () => signal.removeEventListener('abort', abort);
54
+ const abort = () => { clean(); reject(signal.reason || new DOMException('Development refresh cancelled', 'AbortError')); };
55
+ signal.addEventListener('abort', abort, { once: true });
56
+ Promise.resolve(root).then(value => { clean(); resolve(value); }, error => { clean(); reject(error); });
57
+ if (signal.aborted) abort();
58
+ });
59
+ }
@@ -0,0 +1,33 @@
1
+ // Cancellation owns only a queued request. An executing build can also serve
2
+ // another navigation or a watcher update, and must finish normally.
3
+ export function createDevelopmentQueue({isStopping=()=>false,now=Date.now}={}) {
4
+ const waiting=[];let running=false;const idle=[];
5
+ const expired=()=>Error('Development request expired before compilation started');
6
+ function drain(){
7
+ if(running)return;
8
+ const entry=waiting.shift();
9
+ if(!entry){for(const resolve of idle.splice(0))resolve();return;}
10
+ entry.clean();running=true;
11
+ Promise.resolve().then(()=>{
12
+ if(isStopping())throw Error('Development is stopping');
13
+ if(entry.signal?.aborted||entry.deadlineMs!==undefined&&now()>=entry.deadlineMs)throw expired();
14
+ return entry.run();
15
+ }).then(entry.resolve,entry.reject).finally(()=>{running=false;drain();});
16
+ }
17
+ return {
18
+ enqueue(run,{signal,deadlineMs}={}) {
19
+ if(signal?.aborted||deadlineMs!==undefined&&(!Number.isSafeInteger(deadlineMs)||now()>=deadlineMs))return Promise.reject(expired());
20
+ return new Promise((resolve,reject)=>{
21
+ let timer;
22
+ const cancel=()=>{const index=waiting.indexOf(entry);if(index<0)return;waiting.splice(index,1);entry.clean();reject(expired());if(!running)drain();};
23
+ const entry={run,resolve,reject,signal,deadlineMs,clean(){clearTimeout(timer);signal?.removeEventListener('abort',cancel);}};
24
+ waiting.push(entry);signal?.addEventListener('abort',cancel,{once:true});
25
+ if(deadlineMs!==undefined)timer=setTimeout(cancel,Math.max(0,deadlineMs-now()));
26
+ // Defer the first start so same-turn cancellation cannot start work.
27
+ queueMicrotask(drain);
28
+ });
29
+ },
30
+ idle(){return running||waiting.length?new Promise(resolve=>idle.push(resolve)):Promise.resolve();},
31
+ get pending(){return waiting.length;},
32
+ };
33
+ }
@@ -25,6 +25,7 @@ import {prepareDevNotification} from './dev-notification.mjs';
25
25
  import {writeAtomicFile} from './atomic-file.mjs';
26
26
  import {lazyImportIdentifier,materializeDevImport} from './dev-lazy-imports.mjs';
27
27
  import {discardLazyBuild} from './dev-lazy-staging.mjs';
28
+ import {createDevelopmentQueue} from './development-queue.mjs';
28
29
  import {pruneDevelopmentStyles} from './dev-style-retention.mjs';
29
30
  import {createDevelopmentSessionOwner,pruneDevelopmentSessions,createRetiredGenerationCleanup} from './dev-session-retention.mjs';
30
31
 
@@ -107,12 +108,13 @@ async function runDevelopment(projectRoot,args,binary) {
107
108
  let compiler,gateway,watcher,timer,tools,hostRelay,hostRelayTask,stopping=false,current,discovery,typeChecker,readyState={state:'building'},typeErrors=[],sequence=0;
108
109
  let dependencyIndexValid=false;
109
110
  const lifecycle=new AbortController();
110
- let queue=Promise.resolve(),stateQueue=Promise.resolve(),output='.prnext',changes=new Set(),active=new Map(),failed=new Set();
111
+ const compilationQueue=createDevelopmentQueue({isStopping:()=>stopping});
112
+ let stateQueue=Promise.resolve(),output='.prnext',changes=new Set(),active=new Map(),failed=new Set();
111
113
  const failedPaths=new Set();
112
114
  const requestedImports=new Set();
113
115
  const generations=[],styleGenerations=[],retiring=new Set(),pins=new Map(),routeCache=new Map(),filter=createDevChangeFilter(root,{directoryEventsAreRecursive:process.platform==='darwin',ignore:relative=>relative===output||relative.startsWith(output+'/')});
114
116
  const retiredCleanup=createRetiredGenerationCleanup({root,home,gateway:{request:(...args)=>gateway.request(...args)},stop});
115
- const compilerBroker=await createDevCompilerBroker({compile:(pattern,sources)=>serialized(async()=>{
117
+ const compilerBroker=await createDevCompilerBroker({compile:(pattern,sources,admission)=>serialized(async()=>{
116
118
  if(stopping)throw Error('Development is stopping');
117
119
  if(!discovery)await discover();
118
120
  const patterns=[...new Set([pattern,...sources])];
@@ -121,7 +123,7 @@ async function runDevelopment(projectRoot,args,binary) {
121
123
  for(const value of patterns)active.set(value,Date.now());
122
124
  if(!current||patterns.some(p=>!current.patterns.has(p)))await rebuild([...active.keys()]);
123
125
  return current.directory;
124
- })});
126
+ },admission)});
125
127
  const runtimePool=createDevRuntimePool({binary,root,home,environment:{PRNEXT_DEV_COMPILER:compilerBroker.descriptor,PRNEXT_INSPECT_DIR:diagnosticsDirectory,...(options.profile?{PRNEXT_PROFILE:options.profile}:{})},onBeforeChild:()=>sessionOwner.beforeSpawn(),onReady:(child,info,ticket)=>sessionOwner.spawned(ticket,child,[info.pid]),onChild:child=>captureOutput({stderr:child.stderr,once:child.once.bind(child)},'runtime',{forwardStdout:false})});
126
128
  const toolSnapshot=()=>({...readyState,safeMode:options.safe,sequence:readyState.sequence??sequence,session,typeErrors,
127
129
  routes:(discovery?.project.routes||[]).filter(route=>!route.internal).map(route=>({path:(discovery.config.basePath||'')+(route.pattern==='/'?'/':route.pattern),kind:route.kind,source:route.file?path.relative(root,route.file).replaceAll(path.sep,'/'):undefined,compiled:current?.patterns.has(route.pattern)||false,pinned:[...pins.values()].some(pin=>pin.pattern===route.pattern)})),
@@ -159,7 +161,7 @@ async function runDevelopment(projectRoot,args,binary) {
159
161
  });
160
162
  stateQueue=task.catch(()=>{});return task;
161
163
  };
162
- const serialized=run=>{const task=queue.then(()=>{if(stopping)throw Error('Development is stopping');return run();});queue=task.catch(()=>{});return task;};
164
+ const serialized=(run,admission)=>compilationQueue.enqueue(run,admission);
163
165
  async function compilerRequest(type,values={}){
164
166
  if(!compiler||compiler.exitCode!==null||compiler.signalCode){
165
167
  const cache=await privateCacheDirectory(root,'dev-compiler-v8');
@@ -277,7 +279,7 @@ async function runDevelopment(projectRoot,args,binary) {
277
279
  }catch(error){if(!stopping){tools?.recordBuild({sequence,reason:reason.summary,reasonCode:reason.code,time:Date.now(),startedAt,duration:performance.now()-started,phases,state:'error'});tools?.addServerLog('error',error.message,{process:'compiler'});console.error('Build failed: '+error.message);await writeState({state:'error',reason,error:error.message});}throw error;}
278
280
  }
279
281
  const updateInfo=()=>({buildId:current.manifest.buildId,clientManifest:current.manifest.devClient,pagesManifest:current.manifest.pagesManifest,sequence:current.manifest.devSequence,session,changed:current.changed,cssOnly:current.cssOnly,affectedRoutes:current.affectedRoutes});
280
- async function ensure(raw,client,exact=false){
282
+ async function ensure(raw,client,exact=false,admission={}){
281
283
  if(new URL(raw,'http://local').pathname.startsWith('/_prnext/lazy/'))return serialized(async()=>{
282
284
  const id=lazyImportIdentifier(raw);
283
285
  if(!id||!current)throw Error('Unknown deferred import identifier');
@@ -289,7 +291,7 @@ async function runDevelopment(projectRoot,args,binary) {
289
291
  finally{try{await discardLazyBuild(root,built);}catch(error){if(failure){failure.cleanupError=error;failure.message+='; deferred compiler staging cleanup also failed';}else throw error;}}
290
292
  requestedImports.add(id);
291
293
  return {lazy};
292
- });
294
+ },admission);
293
295
  const resolve=()=>exact?discovery.project.routes.find(route=>route.pattern===raw):findRoute(raw);
294
296
  const warm=discovery&&resolve();
295
297
  if(!stopping&&current&&warm&&active.has(warm.pattern)&&current.patterns.has(warm.pattern)){
@@ -315,7 +317,7 @@ async function runDevelopment(projectRoot,args,binary) {
315
317
  try{await rebuild([...active.keys()]);}catch(error){if(route){failed.add(route.pattern);active.delete(route.pattern);}throw error;}
316
318
  }
317
319
  return updateInfo();
318
- });}
320
+ },admission);}
319
321
  async function changed(){
320
322
  try{
321
323
  const pending=[...changes];changes.clear();
@@ -349,7 +351,7 @@ async function runDevelopment(projectRoot,args,binary) {
349
351
  // Node process has drained after() work and cache fills on stdin EOF.
350
352
  await runtimePool.close();
351
353
  await Promise.all([tools?.close(),...generations.map(g=>stop(g.child)),...retiring,retiredCleanup.close()]);
352
- await queue.catch(()=>{});await compilerBroker.close();await stateQueue;await rm(home,{recursive:true,force:true,maxRetries:10,retryDelay:100});await rm(stateFile,{force:true});
354
+ await compilationQueue.idle();await compilerBroker.close();await stateQueue;await rm(home,{recursive:true,force:true,maxRetries:10,retryDelay:100});await rm(stateFile,{force:true});
353
355
  })();return closing;
354
356
  };
355
357
  const fatal=error=>{if(!stopping){console.error(error.message);process.exitCode=1;void shutdown();}};
@@ -363,7 +365,7 @@ async function runDevelopment(projectRoot,args,binary) {
363
365
  return {...updateInfo(),...result};
364
366
  })});
365
367
  await writeState({state:'building',session});
366
- gateway=new DevGateway(binary,root,{...options,onEnsure:ensure,onFatal:fatal,onBeforeChild:()=>sessionOwner.beforeSpawn(),onChild:(child,ticket)=>sessionOwner.spawned(ticket,child),
368
+ gateway=new DevGateway(binary,root,{...options,onEnsure:(raw,client,admission)=>ensure(raw,client,false,admission),onFatal:fatal,onBeforeChild:()=>sessionOwner.beforeSpawn(),onChild:(child,ticket)=>sessionOwner.spawned(ticket,child),
367
369
  onOpen:message=>{const route=discovery&&findRoute(message.path);if(route)pins.set(message.client,{pattern:route.pattern,connection:message.connection});},
368
370
  onClose:message=>{if(pins.get(message.client)?.connection===message.connection)pins.delete(message.client);}});
369
371
  const listener=await gateway.ready;
@@ -0,0 +1,48 @@
1
+ import diagnostics from '../compat/diagnostics.cjs';
2
+
3
+ const phases = new Set(['ssr', 'rsc']);
4
+ const outcomes = new Set(['completed', 'failed', 'cancelled', 'timedOut']);
5
+ const count = value => Number.isSafeInteger(value) && value >= 0 ? Math.min(value, 32 * 1024 * 1024) : 0;
6
+
7
+ // Disabled in the ordinary request path. No body, URL, parameters, nonce, model,
8
+ // client references or request context is retained, even when enabled. Timings
9
+ // include asynchronous rendering/waiting; they are NOT isolated encoder CPU.
10
+ export function createFlightMeasurement(request, phase, options) {
11
+ if (!(options?.enabled ?? diagnostics.enabled) || !phases.has(phase)) return;
12
+ const now = options?.now ?? (() => performance.now());
13
+ const record = options?.record ?? diagnostics.record;
14
+ const started = now();
15
+ const route = typeof request?.routePattern === 'string' ? request.routePattern.slice(0, 256) : '<unattributed>';
16
+ const method = /^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)$/.test(request?.method) ? request.method : '_OTHER';
17
+ let ended = false, firstChunkMs, modelReadyMs, shellReadyMs, ppr;
18
+ const elapsed = () => Math.max(0, Math.min(86400000, now() - started));
19
+ const metrics = {
20
+ flightBytes: 0, flightChunks: 0, responseBytes: 0, peakQueuedBytes: 0,
21
+ chunk(bytes) {
22
+ if (ended) return;
23
+ firstChunkMs ??= elapsed();
24
+ metrics.flightBytes += count(bytes);
25
+ metrics.flightChunks++;
26
+ },
27
+ mark(name) {
28
+ if (ended) return;
29
+ if (name === 'modelReady') modelReadyMs ??= elapsed();
30
+ else if (name === 'shellReady') shellReadyMs ??= elapsed();
31
+ },
32
+ partial(mode) { if (!ended && ['direct', 'normalized'].includes(mode)) ppr = mode; },
33
+ finish(outcome, status) {
34
+ if (ended || !outcomes.has(outcome)) return;
35
+ ended = true;
36
+ const event = { route, method, phase, outcome, durationMs: elapsed(),
37
+ flightBytes: count(metrics.flightBytes), flightChunks: count(metrics.flightChunks),
38
+ responseBytes: count(metrics.responseBytes), peakQueuedBytes: count(metrics.peakQueuedBytes),
39
+ ...(Number.isInteger(status) && status >= 100 && status <= 599 ? {status} : {}),
40
+ ...(firstChunkMs !== undefined ? {firstChunkMs} : {}),
41
+ ...(modelReadyMs !== undefined ? {modelReadyMs} : {}),
42
+ ...(shellReadyMs !== undefined ? {shellReadyMs} : {}), ...(ppr ? {ppr} : {}) };
43
+ // Diagnostics must never replace a response/error with an export failure.
44
+ try { record('flight', event); } catch {}
45
+ },
46
+ };
47
+ return metrics;
48
+ }
@@ -1,7 +1,7 @@
1
1
  // Share Flight allocations with the SSR decoder, then group only already
2
2
  // available bootstrap bytes. Never wait for a future Suspense chunk to fill a
3
3
  // block. The queue's pre-shell ceiling remains the overall response budget.
4
- export function flightTap(source) {
4
+ export function flightTap(source, metrics) {
5
5
  const reader=source.getReader(),queue=[];
6
6
  let head=0,bytes=0,done=false,failure,outputStarted=false,wakeData,wakeSpace,cancelled;
7
7
  const finish=error=>{done=true;failure=error;wakeData?.();wakeData=undefined;wakeSpace?.();wakeSpace=undefined;};
@@ -11,17 +11,22 @@ export function flightTap(source) {
11
11
  while(!done&&outputStarted&&bytes>=128*1024)await new Promise(resolve=>{wakeSpace=resolve;});
12
12
  if(done){if(failure)controller.error(failure);else controller.close();return;}
13
13
  const next=await reader.read();if(next.done){finish();controller.close();return;}
14
- queue.push(next.value);bytes+=next.value.byteLength;wakeData?.();wakeData=undefined;controller.enqueue(next.value);
14
+ queue.push(next.value);bytes+=next.value.byteLength;
15
+ if(metrics){metrics.chunk(next.value.byteLength);metrics.peakQueuedBytes=Math.max(metrics.peakQueuedBytes,bytes);}
16
+ wakeData?.();wakeData=undefined;controller.enqueue(next.value);
15
17
  }catch(error){finish(error);controller.error(error);}},cancel,
16
18
  },{highWaterMark:0});
17
19
  return {body,cancel,startOutput(){outputStarted=true;},async next(){
18
20
  while(head===queue.length&&!done)await new Promise(resolve=>{wakeData=resolve;});
19
21
  if(head<queue.length){
20
- const parts=[];let size=0;
21
- do{const value=queue[head];queue[head++]=undefined;parts.push(value);size+=value.byteLength;}while(head<queue.length&&size+queue[head].byteLength<=32*1024);
22
+ const first=queue[head];queue[head++]=undefined;
23
+ let size=first.byteLength,parts;
24
+ while(head<queue.length&&size+queue[head].byteLength<=32*1024){
25
+ parts??=[first];const value=queue[head];queue[head++]=undefined;parts.push(value);size+=value.byteLength;
26
+ }
22
27
  bytes-=size;if(head===queue.length){queue.length=0;head=0;}else if(head>=64){queue.splice(0,head);head=0;}
23
28
  wakeSpace?.();wakeSpace=undefined;
24
- return {value:parts.length===1?parts[0]:Buffer.concat(parts,size),done:false};
29
+ return {value:parts?Buffer.concat(parts,size):first,done:false};
25
30
  }
26
31
  if(failure)throw failure;return {done:true};
27
32
  }};
@@ -1,9 +1,39 @@
1
1
  import { MAX_RESPONSE_BYTES } from './http.mjs';
2
2
 
3
+ const flightScriptEndText = '");document.currentScript.remove()</script>';
4
+ const flightScriptEnd = Buffer.from(flightScriptEndText);
5
+
6
+ // nonceAttribute is already escaped by the response's HTML renderer. Keep its
7
+ // tiny prefix per response, not a full base64 script string for each chunk.
8
+ // The returned Buffer is owned by the caller and can go straight to transport.
9
+ export function flightBootstrap(nonceAttribute = '') {
10
+ const start = `<script${nonceAttribute}>(self.__PRNEXT_FLIGHT_STREAM__||=[]).push("`;
11
+ let prefix, first = true;
12
+ return value => {
13
+ if (!(value instanceof Uint8Array) || value.byteLength > MAX_RESPONSE_BYTES) throw new TypeError('Invalid Flight bootstrap bytes');
14
+ const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value.buffer, value.byteOffset, value.byteLength);
15
+ const encoded = bytes.toString('base64');
16
+ // Most tiny responses finish with one frame. Keep their short original
17
+ // encoding path instead of paying for a reusable prefix plus three writes.
18
+ if (first && bytes.byteLength <= 2048) { first = false; return Buffer.from(start + encoded + flightScriptEndText); }
19
+ first = false;
20
+ prefix ??= Buffer.from(start);
21
+ const result = Buffer.allocUnsafe(prefix.length + encoded.length + flightScriptEnd.length);
22
+ prefix.copy(result);
23
+ result.write(encoded, prefix.length, encoded.length, 'ascii');
24
+ flightScriptEnd.copy(result, prefix.length + encoded.length);
25
+ return result;
26
+ };
27
+ }
28
+
3
29
  // A raw element cannot be interrupted with a Flight script. Keep its chunks
4
30
  // until its closing tag arrives, but only scan newly received characters.
5
31
  // Joining and rescanning a growing inline script on every chunk is quadratic.
6
32
  const whitespace = /\s/;
33
+ // Complete, unquoted ordinary markup needs no per-tag JS scan. Reject every
34
+ // raw/document/comment candidate (including split prefixes) before this path.
35
+ // Quoted attributes and incomplete tags keep the general streaming parser.
36
+ const specialMarkup = /<(?:!|\?|\/(?:body|html)|script|style|textarea|title|svg|math)/i;
7
37
  function holdRaw(state, text) {
8
38
  if (!text) return;
9
39
  // Compact small chunks in tiers: even single-character input cannot retain
@@ -60,6 +90,10 @@ export function htmlEnvelope({ resumed = false } = {}) {
60
90
  append(text) {
61
91
  received += Buffer.byteLength(text);
62
92
  if (received > MAX_RESPONSE_BYTES) throw new Error('HTML response exceeds the 16 MiB PRNext limit');
93
+ if (!pending && !rawPending) {
94
+ if (!text.includes('<') || !text.includes('"') && !text.includes("'") &&
95
+ !specialMarkup.test(text) && text.lastIndexOf('>') > text.lastIndexOf('<')) return text;
96
+ }
63
97
  let ready = '';
64
98
  if (rawPending) {
65
99
  const end = rawEnd(text, rawPending);