@thomas.f/prnext 0.1.19-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 +46 -78
- package/build/build-lock.mjs +2 -71
- package/build/build-retention.mjs +119 -0
- package/build/build-timings.mjs +38 -0
- package/build/bundle-runtime.mjs +17 -16
- package/build/cache-command.mjs +31 -0
- package/build/check.mjs +41 -3
- package/build/compilation-cache.mjs +32 -12
- package/build/compilation-storage.mjs +190 -0
- package/build/compiler.mjs +5 -4
- package/build/config.mjs +23 -2
- package/build/create.mjs +1 -1
- package/build/css-modules.mjs +4 -0
- package/build/dev-action-graph.mjs +21 -0
- package/build/flight-summary.mjs +64 -0
- package/build/font-cache.mjs +9 -6
- package/build/font-files.mjs +1 -1
- package/build/index.mjs +57 -36
- package/build/inspect.mjs +3 -0
- package/build/inspection-summary.mjs +7 -2
- package/build/native-discovery.mjs +18 -6
- package/build/native-ppr.mjs +32 -0
- package/build/npm-packages.mjs +2 -2
- package/build/og.mjs +121 -1
- package/build/parallel-plan.mjs +55 -0
- package/build/persistent-cache.mjs +29 -24
- package/build/production-check.mjs +34 -0
- package/build/react-compiler-report.mjs +31 -8
- package/build/react-compiler.mjs +101 -10
- package/build/server-config.mjs +35 -0
- package/build/source-analysis.mjs +108 -5
- package/build/standalone-copy.mjs +48 -0
- package/build/standalone.mjs +2 -22
- package/build/static-adapter.mjs +7 -3
- package/build/transform-cache.mjs +17 -3
- package/build/transform.mjs +38 -6
- package/build/typescript-dev.mjs +3 -1
- package/cli.mjs +27 -5
- package/compat/app-layout-context.cjs +7 -3
- package/compat/diagnostics.cjs +5 -4
- package/compat/flight-delta.cjs +47 -12
- package/compat/index.d.ts +24 -1
- package/compat/navigation-authorization.cjs +8 -1
- package/compat/runtime-sample.cjs +56 -0
- package/compat/use-cache.cjs +5 -2
- package/docs/ai/compatibility.md +113 -10
- package/docs/ai/deployment.md +144 -0
- package/docs/ai/quickstart.md +5 -0
- package/docs/ai/troubleshooting.md +22 -0
- package/package.json +25 -22
- package/release/fontkit.mjs +31 -0
- package/release/verify.mjs +3 -0
- package/runtime/api.mjs +5 -2
- package/runtime/app-client.mjs +246 -54
- package/runtime/app-partial.mjs +2 -10
- package/runtime/app-prefetch.mjs +2 -2
- package/runtime/app-render.mjs +42 -13
- package/runtime/app-stream-channel.mjs +20 -6
- package/runtime/app-stream-html.mjs +30 -11
- package/runtime/app-styles.mjs +22 -4
- package/runtime/cache-components-rsc.mjs +26 -6
- package/runtime/cache-result.mjs +58 -0
- package/runtime/compile-cache-retention.mjs +53 -0
- package/runtime/compile-cache.mjs +28 -1
- package/runtime/dev-client.mjs +32 -8
- package/runtime/dev-compiler-broker.mjs +20 -23
- package/runtime/dev-gateway.mjs +23 -6
- package/runtime/dev-route-selection.mjs +64 -0
- package/runtime/dev-runtime-pool.mjs +3 -2
- package/runtime/dev-runtime-spare.mjs +1 -1
- package/runtime/dev-session-retention.mjs +154 -0
- package/runtime/development-flight.mjs +59 -0
- package/runtime/development-queue.mjs +33 -0
- package/runtime/development.mjs +37 -16
- package/runtime/env.mjs +9 -0
- package/runtime/flight-delta.mjs +31 -5
- 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 +88 -0
- package/runtime/og/NOTICE.txt +1 -1
- package/runtime/og/index.node.mjs +76 -31216
- package/runtime/og/upstream.json +115 -0
- package/runtime/persistent/client.mjs +5 -3
- package/runtime/persistent/common.mjs +7 -18
- package/runtime/persistent/daemon.mjs +4 -12
- package/runtime/persistent/health.mjs +58 -0
- package/runtime/persistent/native-proxy.mjs +1 -1
- package/runtime/persistent/watchdog.mjs +12 -27
- package/runtime/prewarm.mjs +35 -0
- package/runtime/project-lock.mjs +71 -0
- package/runtime/request-transport.mjs +112 -32
- package/runtime/rsc-worker.mjs +58 -8
- package/runtime/storage-path.mjs +36 -0
- package/runtime/trace.mjs +44 -4
- package/runtime/worker.mjs +37 -17
- package/vendor/fontkit/NOTICE.txt +5 -0
- package/vendor/fontkit/SOURCE-NOTICES.txt +209 -0
- package/vendor/fontkit/index.mjs +3 -0
- package/vendor/fontkit/licenses/@swc__helpers/LICENSE +201 -0
- package/vendor/fontkit/licenses/base64-js/LICENSE +21 -0
- package/vendor/fontkit/licenses/brotli/readme.md +61 -0
- package/vendor/fontkit/licenses/clone/LICENSE +18 -0
- package/vendor/fontkit/licenses/dfa/README.md +83 -0
- package/vendor/fontkit/licenses/fast-deep-equal/LICENSE +21 -0
- package/vendor/fontkit/licenses/fontkit/README.md +266 -0
- package/vendor/fontkit/licenses/restructure/LICENSE +21 -0
- package/vendor/fontkit/licenses/tiny-inflate/LICENSE +21 -0
- package/vendor/fontkit/licenses/tslib/LICENSE.txt +12 -0
- package/vendor/fontkit/licenses/unicode-properties/LICENSE +8 -0
- package/vendor/fontkit/licenses/unicode-trie/LICENSE +8 -0
- package/vendor/fontkit/upstream.json +215 -0
package/runtime/development.mjs
CHANGED
|
@@ -20,11 +20,14 @@ import {createDevRuntimePool} from './dev-runtime-pool.mjs';
|
|
|
20
20
|
import {tryDevCssUpdate} from '../build/dev-css-update.mjs';
|
|
21
21
|
import {devBuildReason} from './dev-build-reasons.mjs';
|
|
22
22
|
import {publishDependencyGraph,affectedDependencies,canSkipUnopenedRoutes} from './dev-dependency-graph.mjs';
|
|
23
|
+
import {selectiveDevelopmentRoutes} from './dev-route-selection.mjs';
|
|
23
24
|
import {prepareDevNotification} from './dev-notification.mjs';
|
|
24
25
|
import {writeAtomicFile} from './atomic-file.mjs';
|
|
25
26
|
import {lazyImportIdentifier,materializeDevImport} from './dev-lazy-imports.mjs';
|
|
26
27
|
import {discardLazyBuild} from './dev-lazy-staging.mjs';
|
|
28
|
+
import {createDevelopmentQueue} from './development-queue.mjs';
|
|
27
29
|
import {pruneDevelopmentStyles} from './dev-style-retention.mjs';
|
|
30
|
+
import {createDevelopmentSessionOwner,pruneDevelopmentSessions,createRetiredGenerationCleanup} from './dev-session-retention.mjs';
|
|
28
31
|
|
|
29
32
|
const packageRoot=fileURLToPath(new URL('../',import.meta.url));
|
|
30
33
|
export function developmentRoute(routes,raw,config={}) {
|
|
@@ -83,7 +86,9 @@ function serverOptions(args) {
|
|
|
83
86
|
return {hostname,port,backend,hostAi,profile,safe};
|
|
84
87
|
}
|
|
85
88
|
export async function develop(projectRoot,args,binary) {
|
|
86
|
-
|
|
89
|
+
let entered=false;
|
|
90
|
+
try{return await withBuildLock(projectRoot,root=>{entered=true;return runDevelopment(root,args,binary);},{development:true});}
|
|
91
|
+
finally{if(entered)try{const {pruneCompilationStorage}=await import('../build/compilation-storage.mjs');await pruneCompilationStorage(projectRoot);}catch{/* Optional compilation-cache maintenance cannot change shutdown correctness. */}}
|
|
87
92
|
}
|
|
88
93
|
async function runDevelopment(projectRoot,args,binary) {
|
|
89
94
|
const root=await realpath(projectRoot),options=serverOptions(args),session=randomUUID();
|
|
@@ -95,16 +100,21 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
95
100
|
// The session is ours; never clean a path obtained from application config.
|
|
96
101
|
const container=path.join(root,'.prnext-dev'),info=await lstat(container).catch(error=>{if(error.code!=='ENOENT')throw error;});
|
|
97
102
|
if(info&&(!info.isDirectory()||info.isSymbolicLink()))throw Error('Development directory must be a real directory');
|
|
103
|
+
await pruneDevelopmentSessions(root);
|
|
98
104
|
await mkdir(home,{recursive:true});
|
|
105
|
+
const sessionOwner=createDevelopmentSessionOwner(home,{session});
|
|
106
|
+
if(options.safe)sessionOwner.disable();
|
|
99
107
|
await mkdir(diagnosticsDirectory,{recursive:true,mode:0o700});
|
|
100
108
|
let compiler,gateway,watcher,timer,tools,hostRelay,hostRelayTask,stopping=false,current,discovery,typeChecker,readyState={state:'building'},typeErrors=[],sequence=0;
|
|
101
109
|
let dependencyIndexValid=false;
|
|
102
110
|
const lifecycle=new AbortController();
|
|
103
|
-
|
|
111
|
+
const compilationQueue=createDevelopmentQueue({isStopping:()=>stopping});
|
|
112
|
+
let stateQueue=Promise.resolve(),output='.prnext',changes=new Set(),active=new Map(),failed=new Set();
|
|
104
113
|
const failedPaths=new Set();
|
|
105
114
|
const requestedImports=new Set();
|
|
106
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+'/')});
|
|
107
|
-
const
|
|
116
|
+
const retiredCleanup=createRetiredGenerationCleanup({root,home,gateway:{request:(...args)=>gateway.request(...args)},stop});
|
|
117
|
+
const compilerBroker=await createDevCompilerBroker({compile:(pattern,sources,admission)=>serialized(async()=>{
|
|
108
118
|
if(stopping)throw Error('Development is stopping');
|
|
109
119
|
if(!discovery)await discover();
|
|
110
120
|
const patterns=[...new Set([pattern,...sources])];
|
|
@@ -113,11 +123,11 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
113
123
|
for(const value of patterns)active.set(value,Date.now());
|
|
114
124
|
if(!current||patterns.some(p=>!current.patterns.has(p)))await rebuild([...active.keys()]);
|
|
115
125
|
return current.directory;
|
|
116
|
-
})});
|
|
117
|
-
const runtimePool=createDevRuntimePool({binary,root,home,environment:{PRNEXT_DEV_COMPILER:compilerBroker.descriptor,PRNEXT_INSPECT_DIR:diagnosticsDirectory,...(options.profile?{PRNEXT_PROFILE:options.profile}:{})},onChild:child=>captureOutput({stderr:child.stderr,once:child.once.bind(child)},'runtime',{forwardStdout:false})});
|
|
126
|
+
},admission)});
|
|
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})});
|
|
118
128
|
const toolSnapshot=()=>({...readyState,safeMode:options.safe,sequence:readyState.sequence??sequence,session,typeErrors,
|
|
119
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)})),
|
|
120
|
-
cache:{activeRoutes:active.size,pinnedRoutes:new Set([...pins.values()].map(pin=>pin.pattern)).size,retainedGenerations:generations.length,lookupEntries:routeCache.size,compiler:readyState.compiler,transforms:readyState.transforms,persistentTransforms:true,dependencyIndex:dependencyIndexValid?{valid:true,...current?.manifest.devDependencyIndex}:{valid:false}}});
|
|
130
|
+
cache:{activeRoutes:active.size,pinnedRoutes:new Set([...pins.values()].map(pin=>pin.pattern)).size,retainedGenerations:generations.length,retiredCleanupPending:retiredCleanup.pending,lookupEntries:routeCache.size,compiler:readyState.compiler,transforms:readyState.transforms,persistentTransforms:true,dependencyIndex:dependencyIndexValid?{valid:true,...current?.manifest.devDependencyIndex}:{valid:false}}});
|
|
121
131
|
function captureOutput(child,source,{forwardStdout=true}={}){
|
|
122
132
|
for(const [stream,level] of [[child.stdout,'info'],[child.stderr,'error']]){
|
|
123
133
|
stream?.setEncoding('utf8');
|
|
@@ -151,12 +161,14 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
151
161
|
});
|
|
152
162
|
stateQueue=task.catch(()=>{});return task;
|
|
153
163
|
};
|
|
154
|
-
const serialized=run=>
|
|
164
|
+
const serialized=(run,admission)=>compilationQueue.enqueue(run,admission);
|
|
155
165
|
async function compilerRequest(type,values={}){
|
|
156
166
|
if(!compiler||compiler.exitCode!==null||compiler.signalCode){
|
|
157
167
|
const cache=await privateCacheDirectory(root,'dev-compiler-v8');
|
|
158
168
|
await pruneCompilerCache(cache);
|
|
169
|
+
const ticket=sessionOwner.beforeSpawn();
|
|
159
170
|
compiler=spawn(process.execPath,[path.join(packageRoot,'build/dev-worker.mjs')],{stdio:['pipe','pipe','pipe','ipc'],windowsHide:true,env:{...process.env,NODE_ENV:process.env.NODE_ENV||'development',...(options.safe?{PRNEXT_BUILD_CACHE:'0'}:{}),...(cache?{NODE_COMPILE_CACHE:cache}:{})}});
|
|
171
|
+
sessionOwner.spawned(ticket,compiler);
|
|
160
172
|
captureOutput(compiler,'compiler');
|
|
161
173
|
}
|
|
162
174
|
const child=compiler;
|
|
@@ -174,14 +186,17 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
174
186
|
if(options.safe)discovery={...discovery,eager:true};
|
|
175
187
|
const {writeTypedRoutes}=await import('../build/typed-routes.mjs');
|
|
176
188
|
await writeTypedRoutes(root,discovery.project,discovery.config);
|
|
177
|
-
if(!typeChecker||typeChecker.inactive){const {startTypeChecker}=await import('../build/typescript-dev.mjs');typeChecker=await startTypeChecker(root,discovery.config,errors=>{typeErrors=errors;if(!stopping)void writeState().catch(console.error);});}
|
|
189
|
+
if(!typeChecker||typeChecker.inactive){const {startTypeChecker}=await import('../build/typescript-dev.mjs');typeChecker=await startTypeChecker(root,discovery.config,errors=>{typeErrors=errors;if(!stopping)void writeState().catch(console.error);},{onBeforeChild:()=>sessionOwner.beforeSpawn(),onChild:(child,ticket)=>sessionOwner.spawned(ticket,child)});}
|
|
178
190
|
}
|
|
179
191
|
async function launch(manifest,directory){
|
|
180
192
|
const runtime=options.safe?undefined:await runtimePool.take(directory).catch(error=>{tools?.addServerLog('warn','Worker warmup was unavailable; starting a fresh worker.',{process:'runtime'});return undefined;});
|
|
193
|
+
if(!runtime)sessionOwner.disable();
|
|
181
194
|
const env={...process.env,NODE_ENV:process.env.NODE_ENV||'development',PRNEXT_INSPECT_DIR:diagnosticsDirectory,PRNEXT_DEV_COMPILER:compilerBroker.descriptor};
|
|
182
195
|
delete env.PRNEXT_DEV_BROKER;
|
|
183
196
|
if(runtime)env.PRNEXT_DEV_BROKER=runtime.descriptor;
|
|
197
|
+
const ticket=sessionOwner.beforeSpawn();
|
|
184
198
|
const child=spawn(binary,['start',root,'--build-dir',directory,'--worker',path.join(root,directory,'runtime/worker.mjs'),'--node',process.execPath,'--hostname','127.0.0.1','--port','0','--shutdown-on-stdin-eof',...options.backend],{stdio:['pipe','pipe','pipe'],windowsHide:true,env});
|
|
199
|
+
sessionOwner.spawned(ticket,child);
|
|
185
200
|
captureOutput(child,'runtime',{forwardStdout:false});
|
|
186
201
|
let output='';
|
|
187
202
|
const port=await new Promise((resolve,reject)=>{
|
|
@@ -193,6 +208,8 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
193
208
|
return {id:manifest.buildId,child,runtime,port,directory};
|
|
194
209
|
}
|
|
195
210
|
async function rebuild(patterns,changedFiles=[],affectedRoutes,affectedModules,cause){
|
|
211
|
+
void retiredCleanup.retry();
|
|
212
|
+
if(retiredCleanup.pending>=64)throw Error('Development snapshots could not be removed. Close applications locking these files, then retry.');
|
|
196
213
|
const startedAt=Date.now(),started=performance.now(),phases={};let mark=started;
|
|
197
214
|
let reason=devBuildReason({initial:!current,safe:options.safe,changed:changedFiles,cause});
|
|
198
215
|
await writeState({state:'building',reason});
|
|
@@ -233,7 +250,11 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
233
250
|
const previous=current;current=next;current.compiler=result.compiler;current.transforms=result.transforms;generations.push(next);failed.clear();failedPaths.clear();
|
|
234
251
|
current.manifest=manifest;current.changed=changed;current.patterns=new Set(discovery.eager?discovery.project.routes.map(route=>route.pattern):patterns);
|
|
235
252
|
for(const id of requestedImports)if(!manifest.devLazy?.modules.some(item=>item.id===id))requestedImports.delete(id);
|
|
236
|
-
|
|
253
|
+
let affected;
|
|
254
|
+
if(dependencyIndexValid&&affectedRoutes) {
|
|
255
|
+
const nextAffected=await affectedDependencies(gateway,changedFiles).catch(()=>undefined);
|
|
256
|
+
affected=selectiveDevelopmentRoutes(previous?.manifest,manifest,{changed:changedFiles,affectedRoutes,affectedModules,nextAffected});
|
|
257
|
+
}
|
|
237
258
|
current.affectedRoutes=affected;
|
|
238
259
|
try{
|
|
239
260
|
await writeState({state:'ready',reason,buildId:manifest.buildId,clientManifest:manifest.devClient,sequence:manifest.devSequence,session,changed,affectedRoutes:affected,listenerPid:gateway.child.pid,compiler:result.compiler,transforms:result.transforms,routes:manifest.routes.filter(route=>!route.internal&&!route.devDeferred).map(route=>route.pattern)});
|
|
@@ -250,7 +271,7 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
250
271
|
while(generations.length>3){const old=generations.shift();
|
|
251
272
|
// A deferred request may itself be awaiting this build. Never wait for
|
|
252
273
|
// its generation lease while holding the serialized compiler queue.
|
|
253
|
-
const cleanup=
|
|
274
|
+
const cleanup=retiredCleanup.enqueue(old).finally(()=>retiring.delete(cleanup));retiring.add(cleanup);
|
|
254
275
|
}
|
|
255
276
|
}
|
|
256
277
|
await watcher?.refresh();
|
|
@@ -258,7 +279,7 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
258
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;}
|
|
259
280
|
}
|
|
260
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});
|
|
261
|
-
async function ensure(raw,client,exact=false){
|
|
282
|
+
async function ensure(raw,client,exact=false,admission={}){
|
|
262
283
|
if(new URL(raw,'http://local').pathname.startsWith('/_prnext/lazy/'))return serialized(async()=>{
|
|
263
284
|
const id=lazyImportIdentifier(raw);
|
|
264
285
|
if(!id||!current)throw Error('Unknown deferred import identifier');
|
|
@@ -270,7 +291,7 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
270
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;}}
|
|
271
292
|
requestedImports.add(id);
|
|
272
293
|
return {lazy};
|
|
273
|
-
});
|
|
294
|
+
},admission);
|
|
274
295
|
const resolve=()=>exact?discovery.project.routes.find(route=>route.pattern===raw):findRoute(raw);
|
|
275
296
|
const warm=discovery&&resolve();
|
|
276
297
|
if(!stopping&¤t&&warm&&active.has(warm.pattern)&¤t.patterns.has(warm.pattern)){
|
|
@@ -296,7 +317,7 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
296
317
|
try{await rebuild([...active.keys()]);}catch(error){if(route){failed.add(route.pattern);active.delete(route.pattern);}throw error;}
|
|
297
318
|
}
|
|
298
319
|
return updateInfo();
|
|
299
|
-
});}
|
|
320
|
+
},admission);}
|
|
300
321
|
async function changed(){
|
|
301
322
|
try{
|
|
302
323
|
const pending=[...changes];changes.clear();
|
|
@@ -329,8 +350,8 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
329
350
|
// Keep each native data-cache bridge alive until its externally owned
|
|
330
351
|
// Node process has drained after() work and cache fills on stdin EOF.
|
|
331
352
|
await runtimePool.close();
|
|
332
|
-
await Promise.all([tools?.close(),...generations.map(g=>stop(g.child)),...retiring]);
|
|
333
|
-
await
|
|
353
|
+
await Promise.all([tools?.close(),...generations.map(g=>stop(g.child)),...retiring,retiredCleanup.close()]);
|
|
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});
|
|
334
355
|
})();return closing;
|
|
335
356
|
};
|
|
336
357
|
const fatal=error=>{if(!stopping){console.error(error.message);process.exitCode=1;void shutdown();}};
|
|
@@ -344,7 +365,7 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
344
365
|
return {...updateInfo(),...result};
|
|
345
366
|
})});
|
|
346
367
|
await writeState({state:'building',session});
|
|
347
|
-
gateway=new DevGateway(binary,root,{...options,onEnsure:ensure,onFatal:fatal,
|
|
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),
|
|
348
369
|
onOpen:message=>{const route=discovery&&findRoute(message.path);if(route)pins.set(message.client,{pattern:route.pattern,connection:message.connection});},
|
|
349
370
|
onClose:message=>{if(pins.get(message.client)?.connection===message.connection)pins.delete(message.client);}});
|
|
350
371
|
const listener=await gateway.ready;
|
package/runtime/env.mjs
CHANGED
|
@@ -57,6 +57,15 @@ export function loadEnvConfig(projectRoot, { dev = false, env = process.env, mod
|
|
|
57
57
|
return { combinedEnv: env, parsedEnv, loadedEnvFiles };
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** Native runtime settings need the same project env as its Node workers.
|
|
61
|
+
* Return a fresh object: persistent apps must never share loaded credentials.
|
|
62
|
+
*/
|
|
63
|
+
export function productionEnvironment(projectRoot, {env=process.env}={}) {
|
|
64
|
+
const combined={...env,NODE_ENV:env.NODE_ENV||'production'};
|
|
65
|
+
loadEnvConfig(projectRoot,{dev:false,env:combined});
|
|
66
|
+
return combined;
|
|
67
|
+
}
|
|
68
|
+
|
|
60
69
|
export function shouldWatchProjectFile(filename) {
|
|
61
70
|
if (!filename) return false;
|
|
62
71
|
const parts = String(filename).split(/[\\/]/);
|
package/runtime/flight-delta.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes, createHmac } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { deflateRaw } from 'node:zlib';
|
|
3
3
|
import { TYPE, MAX_VALUE, collectStream, deltaStream } from '../compat/flight-delta.cjs';
|
|
4
4
|
|
|
5
5
|
export function createFlightStore({maxBytes=32*1024*1024,maxEntries=64,ttl=30000,maxCaptures=8,maxEncoders=2,now=Date.now,setTimer=setTimeout,clearTimer=clearTimeout}={}) {
|
|
@@ -39,7 +39,34 @@ export function createFlightStore({maxBytes=32*1024*1024,maxEntries=64,ttl=30000
|
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
const store=createFlightStore();
|
|
42
|
-
|
|
42
|
+
const compress=value=>new Promise((resolve,reject)=>deflateRaw(value,{level:1},(error,result)=>error?reject(error):resolve(result)));
|
|
43
|
+
|
|
44
|
+
// At most two probes (four small zlib jobs) across all stores and requests.
|
|
45
|
+
// Admission never queues: a saturated probe keeps ordinary Flight. An aborted
|
|
46
|
+
// caller stops waiting, but its slot remains occupied until native work ends.
|
|
47
|
+
export function createCompressionProbe({maxConcurrent=2,compress:run=compress}={}) {
|
|
48
|
+
if(!Number.isInteger(maxConcurrent)||maxConcurrent<1||maxConcurrent>8||typeof run!=='function')throw new TypeError('Invalid Flight compression probe policy');
|
|
49
|
+
let active=0;
|
|
50
|
+
const probe=(raw,encoded,signal)=>{
|
|
51
|
+
signal?.throwIfAborted();
|
|
52
|
+
if(raw.length>64*1024||encoded.length+32>=raw.length||active>=maxConcurrent)return false;
|
|
53
|
+
active++;
|
|
54
|
+
const work=Promise.allSettled([Promise.resolve().then(()=>run(encoded)),Promise.resolve().then(()=>run(raw))])
|
|
55
|
+
.then(results=>results.every(result=>result.status==='fulfilled')&&results[0].value.length+32<results[1].value.length*0.9)
|
|
56
|
+
.finally(()=>{active--;});
|
|
57
|
+
if(!signal)return work;
|
|
58
|
+
return new Promise((resolve,reject)=>{
|
|
59
|
+
const abort=()=>{signal.removeEventListener('abort',abort);reject(signal.reason);};
|
|
60
|
+
signal.addEventListener('abort',abort,{once:true});
|
|
61
|
+
if(signal.aborted)abort();
|
|
62
|
+
work.then(resolve,reject).finally(()=>signal.removeEventListener('abort',abort));
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
Object.defineProperty(probe,'inFlight',{get:()=>active});
|
|
66
|
+
return probe;
|
|
67
|
+
}
|
|
68
|
+
const compressionProbe=createCompressionProbe();
|
|
69
|
+
export function compressedDeltaPolicy(acceptEncoding='',{signal}={}) {
|
|
43
70
|
const compressed=acceptEncoding.split(',').some(value=>{
|
|
44
71
|
const [name,...parameters]=value.trim().toLowerCase().split(';');
|
|
45
72
|
return /^(gzip|deflate|br|zstd|\*)$/.test(name)&&!parameters.some(p=>/^\s*q\s*=\s*0(?:\.0*)?\s*$/.test(p));
|
|
@@ -48,8 +75,7 @@ export function compressedDeltaPolicy(acceptEncoding='') {
|
|
|
48
75
|
// This conservative, bounded probe is not the response compressor. It avoids
|
|
49
76
|
// choosing thousands of references to JSON that gzip already compresses well.
|
|
50
77
|
// A large/expensive chunk keeps ordinary Flight instead of delaying a flush.
|
|
51
|
-
return (raw,encoded)=>raw
|
|
52
|
-
deflateRawSync(encoded,{level:1}).length+32<deflateRawSync(raw,{level:1}).length*0.9;
|
|
78
|
+
return (raw,encoded)=>compressionProbe(raw,encoded,signal);
|
|
53
79
|
}
|
|
54
80
|
export async function differentialFlight(response,options,cache=store) {
|
|
55
81
|
const headers=new Headers(options.headers), outgoing=new Headers(response.headers);
|
|
@@ -64,7 +90,7 @@ export async function differentialFlight(response,options,cache=store) {
|
|
|
64
90
|
controller.enqueue(raw instanceof Uint8Array?raw:raw instanceof ArrayBuffer?new Uint8Array(raw):new TextEncoder().encode(String(raw??'')));controller.close();
|
|
65
91
|
}});
|
|
66
92
|
const collected=collectStream(body,{capture:!!releaseCapture,onComplete:bytes=>cache.set(token,identity,bytes),onFinally:releaseCapture});
|
|
67
|
-
encoded=base?await deltaStream(collected,base,{assess:compressedDeltaPolicy(headers.get('accept-encoding')||'')}):{delta:false,body:collected};
|
|
93
|
+
encoded=base?await deltaStream(collected,base,{assess:compressedDeltaPolicy(headers.get('accept-encoding')||'',{signal:options.signal})}):{delta:false,body:collected};
|
|
68
94
|
if(!encoded.delta)releaseEncoder?.();
|
|
69
95
|
else encoded.body=collectStream(encoded.body,{capture:false,onFinally:releaseEncoder});
|
|
70
96
|
}catch(error){releaseEncoder?.();releaseCapture?.();throw error;}
|
|
@@ -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);
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {createHash} from 'node:crypto';
|
|
2
|
+
import {readFile, stat} from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {escapeHtml} from './http.mjs';
|
|
5
|
+
|
|
6
|
+
export const NATIVE_PPR_ARTIFACT_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
export const NATIVE_PPR_PREFIX_BYTES = 128 * 1024;
|
|
8
|
+
export const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
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
|
+
|
|
65
|
+
export function partialShell(artifact, route) {
|
|
66
|
+
const bootstrap = '<script>self.__PRNEXT_FLIGHT_STREAM__=self.__PRNEXT_FLIGHT_STREAM__||[];</script>' +
|
|
67
|
+
(route.client ? `<script type="module" async src="${escapeHtml(route.client)}"></script>` : '');
|
|
68
|
+
const end = artifact.shell.lastIndexOf('</body>');
|
|
69
|
+
if (end < 0 || !/<\/body><\/html>$/i.test(artifact.shell)) throw new Error('Invalid partial prerender document');
|
|
70
|
+
return artifact.shell.slice(0, end) + bootstrap + artifact.shell.slice(end);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// This is a private transport field, never an HTTP header. Rust has already
|
|
74
|
+
// checked cache generation and committed these exact build bytes. Pinning the
|
|
75
|
+
// same artifact gives the request one snapshot even if invalidation races Node.
|
|
76
|
+
export async function nativePprArtifact({renderMode, manifest, route, url, distDir},cache=parsedArtifacts) {
|
|
77
|
+
if (!renderMode?.startsWith('native-ppr:')) return null;
|
|
78
|
+
if (!manifest.config?.nativePprShell || manifest.dev || !/^[a-f\d]{64}$/.test(renderMode.slice(11))) throw new Error('Invalid native PPR continuation');
|
|
79
|
+
const pathname = new URL(url).pathname;
|
|
80
|
+
const descriptor = manifest.config.nativePprArtifacts?.find(item => item.routeId === route.id && item.path === pathname && item.artifactHash === renderMode.slice(11));
|
|
81
|
+
if (!descriptor || new URL(url).search || descriptor.artifact !== route.ppr?.[pathname]) throw new Error('Native PPR artifact does not match the route');
|
|
82
|
+
const filename = path.resolve(distDir, descriptor.artifact);
|
|
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');
|
|
84
|
+
const bytes = await readFile(filename);
|
|
85
|
+
if (bytes.length > NATIVE_PPR_ARTIFACT_BYTES || sha256(bytes) !== descriptor.artifactHash) throw new Error('Native PPR artifact changed after publication');
|
|
86
|
+
const artifact = cache.parse(filename,bytes,route,descriptor);
|
|
87
|
+
return {artifact, descriptor};
|
|
88
|
+
}
|
package/runtime/og/NOTICE.txt
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Image renderer: @vercel/og 1.0.3 (https://github.com/vercel/og), with bundled Satori 0.33.5 (https://github.com/vercel/satori). PRNext replaces embedded fflate 0.7.3 with fflate 0.7.5, bundles it, adds Node CommonJS globals and includes adjacent WASM/font assets. Optional sharp backends require librsvg 2.63.2 or newer; otherwise rendering uses resvg. Upstream bundled licenses are retained. Rebuild: node scripts/prepare-og.mjs.
|
|
1
|
+
Image renderer: @vercel/og 1.0.3 (https://github.com/vercel/og), with bundled Satori 0.33.5 (https://github.com/vercel/satori). PRNext replaces embedded fflate 0.7.3 with fflate 0.7.5, bundles and minifies it with function/class names preserved, adds Node CommonJS globals and includes adjacent WASM/font assets. Optional sharp backends require librsvg 2.63.2 or newer; otherwise rendering uses resvg. Upstream bundled licenses are retained. Rebuild: node scripts/prepare-og.mjs.
|
|
2
2
|
Geist Regular 1.800: Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git), SIL OFL 1.1; LICENSE.Geist is the upstream OFL.txt.
|
|
3
3
|
HarfBuzzJS 0.10.0: https://github.com/harfbuzz/harfbuzzjs; see LICENSE.harfbuzzjs.
|
|
4
4
|
resvg-js 2.4.1: https://github.com/yisibl/resvg-js/tree/v2.4.1, MPL-2.0 (LICENSE).
|