@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,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
|
+
}
|
package/runtime/dev-client.mjs
CHANGED
|
@@ -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)
|
|
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();
|
|
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
|
|
2
|
-
import {randomBytes
|
|
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=
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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.
|
|
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);
|
|
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(
|
|
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
|
|
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'));
|
package/runtime/dev-gateway.mjs
CHANGED
|
@@ -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=
|
|
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
|
-
|
|
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
|
+
}
|
package/runtime/development.mjs
CHANGED
|
@@ -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
|
-
|
|
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=>
|
|
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&¤t&&warm&&active.has(warm.pattern)&¤t.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
|
|
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
|
+
}
|
package/runtime/flight-tap.mjs
CHANGED
|
@@ -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;
|
|
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
|
|
21
|
-
|
|
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
|
|
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);
|