@thomas.f/prnext 0.1.19-alpha → 0.1.20-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/bundle-runtime.mjs +9 -8
- 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 +3 -3
- 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/font-cache.mjs +9 -6
- package/build/font-files.mjs +1 -1
- package/build/index.mjs +30 -13
- package/build/native-ppr.mjs +32 -0
- 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 +54 -4
- package/build/static-adapter.mjs +7 -3
- package/build/transform.mjs +38 -6
- package/build/typescript-dev.mjs +3 -1
- package/cli.mjs +14 -5
- package/compat/app-layout-context.cjs +7 -3
- package/compat/flight-delta.cjs +47 -12
- package/compat/index.d.ts +24 -1
- package/compat/navigation-authorization.cjs +8 -1
- package/docs/ai/compatibility.md +110 -7
- package/docs/ai/deployment.md +141 -0
- package/docs/ai/quickstart.md +5 -0
- package/package.json +12 -9
- package/release/fontkit.mjs +31 -0
- package/release/verify.mjs +3 -0
- package/runtime/api.mjs +5 -2
- package/runtime/app-client.mjs +150 -26
- package/runtime/app-partial.mjs +2 -10
- package/runtime/app-prefetch.mjs +2 -2
- package/runtime/app-render.mjs +11 -7
- package/runtime/app-stream-channel.mjs +3 -2
- package/runtime/app-styles.mjs +22 -4
- package/runtime/compile-cache-retention.mjs +53 -0
- package/runtime/compile-cache.mjs +28 -1
- package/runtime/dev-client.mjs +6 -3
- package/runtime/dev-gateway.mjs +3 -2
- 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.mjs +27 -8
- package/runtime/env.mjs +9 -0
- package/runtime/flight-delta.mjs +31 -5
- package/runtime/native-ppr.mjs +35 -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 +4 -2
- package/runtime/persistent/daemon.mjs +3 -11
- package/runtime/persistent/health.mjs +58 -0
- package/runtime/project-lock.mjs +71 -0
- package/runtime/request-transport.mjs +112 -32
- package/runtime/storage-path.mjs +36 -0
- package/runtime/trace.mjs +44 -4
- package/runtime/worker.mjs +9 -4
- 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
|
@@ -1,8 +1,35 @@
|
|
|
1
1
|
import {flushCompileCache} from 'node:module';
|
|
2
|
+
import {lstatSync,realpathSync,writeFileSync,unlinkSync} from 'node:fs';
|
|
3
|
+
import {randomUUID} from 'node:crypto';
|
|
4
|
+
import {hostname} from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {pruneCompilerCache} from './compile-cache-retention.mjs';
|
|
2
7
|
let pending;
|
|
8
|
+
let managedDirectory;
|
|
9
|
+
let lastMaintenance=0,maintenance;
|
|
10
|
+
// Native workers opt in explicitly. Never maintain a caller-supplied global
|
|
11
|
+
// NODE_COMPILE_CACHE, and reject links in both framework-owned path components.
|
|
12
|
+
try{
|
|
13
|
+
const root=process.env.PRNEXT_MANAGED_COMPILE_CACHE_ROOT;
|
|
14
|
+
if(root&&process.env.NODE_COMPILE_CACHE&&process.env.NODE_DISABLE_COMPILE_CACHE!=='1'){
|
|
15
|
+
const canonical=realpathSync(root),base=path.join(canonical,'.prnext-cache'),directory=path.join(base,'node-compile-v1');
|
|
16
|
+
if(realpathSync(process.env.NODE_COMPILE_CACHE)===directory&&[base,directory,process.env.NODE_COMPILE_CACHE].every(folder=>{const info=lstatSync(folder);return info.isDirectory()&&!info.isSymbolicLink();})){
|
|
17
|
+
const lease=path.join(directory,`.lease-${process.pid}-${randomUUID()}.json`);
|
|
18
|
+
writeFileSync(lease,JSON.stringify({pid:process.pid,host:hostname()}),{flag:'wx',mode:0o600});
|
|
19
|
+
managedDirectory=directory;
|
|
20
|
+
process.once('exit',()=>{try{unlinkSync(lease);}catch{}});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}catch{/* A lease failure leaves this process's cache unmanaged. */}
|
|
3
24
|
// Flush after imports settle so later workers can reuse compiled code even
|
|
4
25
|
// while this worker stays warm. Disk failure is an optimization miss only.
|
|
5
26
|
export function scheduleCompileCacheFlush(){
|
|
6
27
|
if(pending||!process.env.NODE_COMPILE_CACHE||process.env.NODE_DISABLE_COMPILE_CACHE==='1')return;
|
|
7
|
-
pending=setTimeout(()=>{
|
|
28
|
+
pending=setTimeout(()=>{
|
|
29
|
+
pending=undefined;try{flushCompileCache();}catch{}
|
|
30
|
+
if(managedDirectory&&!maintenance&&Date.now()-lastMaintenance>=60000){
|
|
31
|
+
lastMaintenance=Date.now();
|
|
32
|
+
maintenance=pruneCompilerCache(managedDirectory,{maxBytes:128*1024*1024,maxFiles:4096}).finally(()=>{maintenance=undefined;});
|
|
33
|
+
}
|
|
34
|
+
},1000);pending.unref();
|
|
8
35
|
}
|
package/runtime/dev-client.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { createConsoleCapture, isDevicePreview } from './dev-console.mjs';
|
|
|
4
4
|
import { createDevToolsClient } from './dev-tools-client.mjs';
|
|
5
5
|
import { createDevErrorOverlay } from './dev-error-overlay.mjs';
|
|
6
6
|
import { collectDevIssues, formatDevIssuesForAI } from './dev-error-report.mjs';
|
|
7
|
+
import { canSkipDevelopmentUpdate } from './dev-route-selection.mjs';
|
|
7
8
|
|
|
8
9
|
const state = globalThis.__PRNEXT_DEV__ ||= {
|
|
9
10
|
applications: new Map(), modules: new Map(), buildId: null, manifest: null,
|
|
@@ -154,7 +155,7 @@ function typeDiagnostics(errors) {
|
|
|
154
155
|
}
|
|
155
156
|
async function applyUpdate(message, {navigation=false}={}) {
|
|
156
157
|
if(message.session&&state.session&&message.session!==state.session){reload('Development server restarted. Page reloaded.');return;}
|
|
157
|
-
if(message.sequence&&message.sequence<=(state.sequence||0))return;
|
|
158
|
+
if(message.sequence&&message.sequence<=(state.sequence||0)&&!(navigation&&state.skippedUpdate?.buildId===message.buildId))return;
|
|
158
159
|
if (state.applying) {
|
|
159
160
|
if(!state.queued||(message.sequence||0)>=(state.queued.sequence||0))state.queued={...message,navigation};
|
|
160
161
|
const pending=state.applied;await pending;
|
|
@@ -174,7 +175,8 @@ async function applyUpdate(message, {navigation=false}={}) {
|
|
|
174
175
|
const refreshStarted=performance.now();
|
|
175
176
|
const localPath=location.pathname.slice((state.basePath||'').length)||'/';
|
|
176
177
|
const affected=message.affectedRoutes;
|
|
177
|
-
if(state.confirmed
|
|
178
|
+
if(canSkipDevelopmentUpdate({confirmed:state.confirmed,navigation,ensuring:state.ensuring,affectedRoutes:affected,routes:message.routes,pathname:localPath})){
|
|
179
|
+
state.skippedUpdate={buildId:message.buildId};
|
|
178
180
|
state.buildId=message.buildId;state.sequence=message.sequence;state.lastRefresh={buildId:message.buildId,durationMs:0,changed,skipped:true};
|
|
179
181
|
document.documentElement.dataset.prnextDevBuild=message.buildId;
|
|
180
182
|
if(hasCriticalIssue()&&state.issueKind==='build'){clearError();if(state.typeErrors)typeDiagnostics(state.typeErrors);}
|
|
@@ -221,6 +223,7 @@ async function applyUpdate(message, {navigation=false}={}) {
|
|
|
221
223
|
state.buildId = message.buildId;
|
|
222
224
|
if(message.sequence)state.sequence=message.sequence;
|
|
223
225
|
state.manifest = manifest;
|
|
226
|
+
state.skippedUpdate=undefined;
|
|
224
227
|
document.documentElement.dataset.prnextDevBuild = message.buildId;
|
|
225
228
|
state.lastRefresh={buildId:message.buildId,durationMs:Math.round(performance.now()-refreshStarted),changed};
|
|
226
229
|
await painted();state.confirmed=true;
|
|
@@ -277,7 +280,7 @@ function connect(options) {
|
|
|
277
280
|
const response=await fetch(`${options.basePath||''}/_prnext/ensure?client=${state.clientId}&path=${encodeURIComponent(url.pathname+url.search)}`,{cache:'no-store',credentials:'same-origin'});
|
|
278
281
|
if(!response.ok)throw Error(await response.text());
|
|
279
282
|
const update=await response.json();
|
|
280
|
-
if(update.buildId!==state.buildId)await applyUpdate(update,{navigation:true});
|
|
283
|
+
if(update.buildId!==state.buildId||state.skippedUpdate?.buildId===update.buildId)await applyUpdate(update,{navigation:true});
|
|
281
284
|
}finally{state.ensuring--;}
|
|
282
285
|
};
|
|
283
286
|
const source = new EventSource(`${options.basePath || ''}/_prnext/dev?client=${state.clientId}&path=${encodeURIComponent(location.pathname+location.search)}`);
|
package/runtime/dev-gateway.mjs
CHANGED
|
@@ -1,9 +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}) {
|
|
4
|
+
constructor(binary,root,{hostname,port,onEnsure,onOpen,onClose,onFatal,onBeforeChild=()=>{},onChild=()=>{}}) {
|
|
5
5
|
this.next=0;this.pending=new Map();this.stopping=false;
|
|
6
|
-
|
|
6
|
+
const ticket=onBeforeChild();
|
|
7
|
+
this.child=spawn(binary,['dev-gateway',root,'--hostname',hostname,'--port',String(port)],{stdio:['pipe','pipe','inherit'],windowsHide:true});onChild(this.child,ticket);
|
|
7
8
|
this.closed=new Promise(resolve=>this.child.once('close',resolve));
|
|
8
9
|
this.ready=new Promise((resolve,reject)=>{
|
|
9
10
|
const timer=setTimeout(()=>{reject(Error('Development listener did not start'));this.child.kill();},15000);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const globalInput = /(?:^|\/)(?:package(?:-lock)?\.json|pnpm-lock\.yaml|yarn\.lock|(?:tsconfig|jsconfig)\.json|(?:prnext|next|contentlayer|postcss|tailwind)\.config\.|\.env|(?:src\/)?(?:middleware|proxy)\.[^/]+$|pages\/_document\.|(?:global-error|global-not-found)\.)/;
|
|
2
|
+
const generatedRouteFields = new Set(['module', 'client', 'css', 'cssScopes']);
|
|
3
|
+
function stable(value) {
|
|
4
|
+
if (Array.isArray(value)) return value.map(stable);
|
|
5
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]));
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
const signature = value => JSON.stringify(stable(value));
|
|
9
|
+
const routeContract = manifest => manifest.routes.map(route => Object.fromEntries(Object.entries(route).filter(([key]) => !generatedRouteFields.has(key)))).sort((a, b) => a.id.localeCompare(b.id));
|
|
10
|
+
function actionContract(manifest) {
|
|
11
|
+
const ids = Object.keys(manifest.app?.actions || {}).sort();
|
|
12
|
+
if (!ids.length) return [];
|
|
13
|
+
const metadata = manifest.devActions;
|
|
14
|
+
if (metadata?.version !== 1 || metadata.complete !== true || manifest.devDependencyStatus?.complete !== true || !Array.isArray(metadata.actions)) return;
|
|
15
|
+
const modules = new Set((manifest.devModules || []).map(record => record.route));
|
|
16
|
+
const routed = new Set((manifest.devDependencies || []).flatMap(record => record.files));
|
|
17
|
+
if (signature(metadata.actions.map(action => action.id).sort()) !== signature(ids)) return;
|
|
18
|
+
const owners = new Set();
|
|
19
|
+
for (const action of metadata.actions) {
|
|
20
|
+
const owner = action.file + '\0' + action.binding;
|
|
21
|
+
if (!modules.has(action.file) || !routed.has(action.file) || owners.has(owner) || !Array.isArray(action.runtimes)) return;
|
|
22
|
+
owners.add(owner);
|
|
23
|
+
}
|
|
24
|
+
return [...metadata.actions].sort((a, b) => (a.file + '\0' + a.binding).localeCompare(b.file + '\0' + b.binding));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Both committed graphs participate: removed imports belong to the old graph,
|
|
28
|
+
// newly introduced imports to the new one. No partial/unknown graph is proof.
|
|
29
|
+
export function selectiveDevelopmentRoutes(previous, next, { changed, affectedRoutes, affectedModules = [], nextAffected } = {}) {
|
|
30
|
+
if (!previous || !changed?.length || !Array.isArray(affectedRoutes) || !nextAffected || nextAffected.complete !== true || !Array.isArray(nextAffected.unknown) || nextAffected.unknown.length || !Array.isArray(nextAffected.routes) ||
|
|
31
|
+
previous.devDependencyStatus?.complete === false || next.devDependencyStatus?.complete === false || changed.some(file => globalInput.test(file))) return;
|
|
32
|
+
// The visible URL is not proof of the rendered route under server rewrites,
|
|
33
|
+
// localized routing, or intercepted/parallel branches. Keep these global
|
|
34
|
+
// until the browser can certify every visible route identity.
|
|
35
|
+
for (const manifest of [previous, next]) if (manifest.middleware || manifest.config?.i18n ||
|
|
36
|
+
Object.values(manifest.customRoutes?.rewrites || {}).some(rules => rules.length) ||
|
|
37
|
+
manifest.routes.some(route => route.parallel || route.interception)) return;
|
|
38
|
+
if (signature([previous.config, previous.customRoutes, routeContract(previous)]) !== signature([next.config, next.customRoutes, routeContract(next)])) return;
|
|
39
|
+
const before = actionContract(previous), after = actionContract(next);
|
|
40
|
+
if (!before || !after || previous.app?.actionKey !== next.app?.actionKey ||
|
|
41
|
+
signature(before.map(({id, ...action}) => action)) !== signature(after.map(({id, ...action}) => action))) return;
|
|
42
|
+
const modules = new Set([...affectedModules, ...(nextAffected.modules || [])]);
|
|
43
|
+
for (let i = 0; i < before.length; i++) if (before[i].id !== after[i].id && !modules.has(before[i].file)) return;
|
|
44
|
+
const routes = [...new Set([...affectedRoutes, ...nextAffected.routes])].sort();
|
|
45
|
+
return Buffer.byteLength(JSON.stringify(routes)) <= 32 * 1024 ? routes : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// An ensure/navigation transaction must prepare the new loader even when its
|
|
49
|
+
// destination differs from location.pathname until history is committed.
|
|
50
|
+
export function canSkipDevelopmentUpdate({ confirmed, navigation, ensuring, affectedRoutes, routes, pathname }) {
|
|
51
|
+
if (!confirmed || navigation || ensuring || !Array.isArray(affectedRoutes) || !Array.isArray(routes)) return false;
|
|
52
|
+
let parts; try { parts = pathname.split('/').filter(Boolean).map(decodeURIComponent); } catch { return false; }
|
|
53
|
+
const matches = pattern => {
|
|
54
|
+
const segments = pattern.split('/').filter(Boolean); let index = 0;
|
|
55
|
+
for (const segment of segments) {
|
|
56
|
+
if (/^\[\[\.\.\.[^\]]+\]\]$/.test(segment)) return true;
|
|
57
|
+
if (/^\[\.\.\.[^\]]+\]$/.test(segment)) return index < parts.length;
|
|
58
|
+
if (index >= parts.length || !(segment.startsWith('[') && segment.endsWith(']')) && segment !== parts[index]) return false;
|
|
59
|
+
index++;
|
|
60
|
+
}
|
|
61
|
+
return index === parts.length;
|
|
62
|
+
};
|
|
63
|
+
return routes.some(matches) && !affectedRoutes.some(matches);
|
|
64
|
+
}
|
|
@@ -5,11 +5,12 @@ import {fileURLToPath} from 'node:url';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import {EventEmitter} from 'node:events';
|
|
7
7
|
|
|
8
|
-
export function createDevRuntimePool({binary,root,home,environment={},onChild=()=>{}}){
|
|
8
|
+
export function createDevRuntimePool({binary,root,home,environment={},onBeforeChild=()=>{},onChild=()=>{},onReady=()=>{}}){
|
|
9
9
|
let spare,disposed=false;const children=new Set();
|
|
10
10
|
function create(){
|
|
11
11
|
if(disposed)throw Error('Development runtime is closed');
|
|
12
12
|
const token=randomBytes(32).toString('base64url');
|
|
13
|
+
const ticket=onBeforeChild();
|
|
13
14
|
const child=spawn(binary,['dev-spare',root,'--node',process.execPath,'--script',fileURLToPath(new URL('./dev-runtime-spare.mjs',import.meta.url))],{cwd:root,windowsHide:true,stdio:['pipe','pipe','pipe'],env:{...process.env,...environment,NODE_ENV:'development',PRNEXT_DEV_SPARE_TOKEN:token}});
|
|
14
15
|
children.add(child);onChild(child);
|
|
15
16
|
const messages=new EventEmitter();let buffer='';child.stdout.setEncoding('utf8');child.stdout.on('data',chunk=>{buffer+=chunk;if(buffer.length>65536){child.kill();return;}for(let end;(end=buffer.indexOf('\n'))>=0;){const line=buffer.slice(0,end);buffer=buffer.slice(end+1);try{const message=JSON.parse(line);if(message.type)messages.emit(message.type,message);}catch{}}});
|
|
@@ -22,7 +23,7 @@ export function createDevRuntimePool({binary,root,home,environment={},onChild=()
|
|
|
22
23
|
const message=value=>{clean();resolve(value);};
|
|
23
24
|
messages.on(type,message);child.once('exit',exit);child.once('error',fail);
|
|
24
25
|
});
|
|
25
|
-
const ready=wait('warm');ready.catch(()=>{if(spare?.child===child)spare=undefined;void stop(child);});
|
|
26
|
+
const ready=wait('warm').then(info=>{onReady(child,info,ticket);return info;});ready.catch(()=>{if(spare?.child===child)spare=undefined;void stop(child);});
|
|
26
27
|
return {child,token,ready,wait};
|
|
27
28
|
}
|
|
28
29
|
async function stop(child){
|
|
@@ -48,7 +48,7 @@ const broker=net.createServer(socket=>{
|
|
|
48
48
|
socket.on('close',()=>{const owned=controls.delete(socket);if(owned&&!controls.size&&ready)process.stdin.emit('end');});
|
|
49
49
|
});
|
|
50
50
|
broker.maxConnections=16;
|
|
51
|
-
broker.listen(0,'127.0.0.1',()=>{broker.unref();announce({type:'warm',port:broker.address().port});});
|
|
51
|
+
broker.listen(0,'127.0.0.1',()=>{broker.unref();announce({type:'warm',port:broker.address().port,pid:process.pid});});
|
|
52
52
|
let control='';const arm=chunk=>{control+=chunk;if(control.length>8192)process.exit(1);if(!control.includes('\n'))return;process.stdin.off('data',arm);
|
|
53
53
|
let message;try{message=JSON.parse(control.trim());}catch{process.exit(1);}if(message?.type!=='arm'||armed)process.exit(1);
|
|
54
54
|
void realpath(message.dist).then(dist=>{if(!dist.startsWith(root+path.sep))throw Error('Generation outside project');armed={dist};announce({type:'armed'});}).catch(()=>process.exit(1));
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
|
+
import {hostname} from 'node:os';
|
|
3
|
+
import {constants, writeFileSync, renameSync, rmSync} from 'node:fs';
|
|
4
|
+
import {lstat, open, opendir, rm} from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {captureStorageDirectory,verifyStorageDirectories,sameStoragePath} from './storage-path.mjs';
|
|
7
|
+
|
|
8
|
+
const marker = '.owner.json', disabledMarker = '.recovery-disabled', uuid = /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/;
|
|
9
|
+
const grace = 60_000, maxChildren = 4096, maxMarker = 64 * 1024;
|
|
10
|
+
const alive = pid => {try {process.kill(pid, 0); return true;} catch (error) {return error.code !== 'ESRCH';}};
|
|
11
|
+
const pid = value => Number.isSafeInteger(value) && value > 0;
|
|
12
|
+
|
|
13
|
+
/** A ticket is durable before a spawn. An interrupted spawn/metadata update
|
|
14
|
+
* leaves the session ineligible for automatic recovery, never falsely idle. */
|
|
15
|
+
export function createDevelopmentSessionOwner(home, {session, processId = process.pid, host = hostname(), now = Date.now, warn = console.warn} = {}) {
|
|
16
|
+
if (!uuid.test(session) || path.basename(home) !== session || !pid(processId)) throw Error('Invalid development session owner');
|
|
17
|
+
const state = {version: 1, session, token: randomUUID(), pid: processId, host, createdAt: now(), updatedAt: now(), eligible: true, children: [], pending: []};
|
|
18
|
+
function publish(initial = false) {
|
|
19
|
+
state.updatedAt = now();
|
|
20
|
+
const text = JSON.stringify(state) + '\n';
|
|
21
|
+
if (Buffer.byteLength(text) > maxMarker) throw Error('Development owner metadata exceeds its budget');
|
|
22
|
+
const file = path.join(home, marker);
|
|
23
|
+
if (initial) {writeFileSync(file, text, {flag: 'wx', mode: 0o600}); return;}
|
|
24
|
+
const temporary = file + '.' + randomUUID() + '.tmp';
|
|
25
|
+
try {writeFileSync(temporary, text, {flag: 'wx', mode: 0o600}); renameSync(temporary, file);}
|
|
26
|
+
finally {rmSync(temporary, {force: true});}
|
|
27
|
+
}
|
|
28
|
+
publish(true);
|
|
29
|
+
function disable() {
|
|
30
|
+
// A separate create-only marker also works if Windows has temporarily
|
|
31
|
+
// locked the owner receipt. A spawn may proceed only once this safeguard
|
|
32
|
+
// or its pending ticket is durable.
|
|
33
|
+
try {writeFileSync(path.join(home, disabledMarker), 'Incomplete development child ownership.\n', {flag: 'wx', mode: 0o600});}
|
|
34
|
+
catch (error) {if (error.code !== 'EEXIST') throw error;}
|
|
35
|
+
state.eligible = false; try {publish();} catch { /* The create-only marker forbids cleanup. */ }
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
beforeSpawn() {
|
|
39
|
+
if (!state.eligible) return null;
|
|
40
|
+
if (state.pending.length >= 128 || state.children.length >= maxChildren) {disable(); return null;}
|
|
41
|
+
const ticket = randomUUID(); state.pending.push(ticket); try {publish();} catch {disable();} return ticket;
|
|
42
|
+
},
|
|
43
|
+
spawned(ticket, child, descendants = []) {
|
|
44
|
+
if (!state.eligible) return;
|
|
45
|
+
if (!state.pending.includes(ticket)) {disable(); return;}
|
|
46
|
+
if (!pid(child?.pid) || descendants.some(value => !pid(value))) {state.eligible = false; try {disable();} catch { /* The pre-spawn ticket remains durable. */ } return;}
|
|
47
|
+
for (const value of [child.pid, ...descendants]) if (!state.children.includes(value)) state.children.push(value);
|
|
48
|
+
state.pending = state.pending.filter(value => value !== ticket);
|
|
49
|
+
try {publish();} catch (error) {state.eligible = false; try {disable();} catch { /* The pre-spawn ticket remains durable. */ } warn('Development session recovery disabled: ' + (error.code || 'metadata publication failed'));}
|
|
50
|
+
},
|
|
51
|
+
disable,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readOwner(home) {
|
|
56
|
+
const name = path.join(home, marker), info = await lstat(name);
|
|
57
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > maxMarker) return;
|
|
58
|
+
const file = await open(name, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
59
|
+
try {
|
|
60
|
+
const bytes = Buffer.alloc(maxMarker + 1), {bytesRead} = await file.read(bytes, 0, bytes.length, 0);
|
|
61
|
+
if (bytesRead > maxMarker) return;
|
|
62
|
+
const text = bytes.toString('utf8', 0, bytesRead), value = JSON.parse(text);
|
|
63
|
+
if (value.version !== 1 || value.session !== path.basename(home) || !uuid.test(value.session) || !uuid.test(value.token) || !pid(value.pid) || typeof value.host !== 'string' || value.host.length > 255 || !Number.isSafeInteger(value.createdAt) || !Number.isSafeInteger(value.updatedAt) || value.updatedAt < value.createdAt || value.eligible !== true || !Array.isArray(value.pending) || value.pending.length || !Array.isArray(value.children) || value.children.length > maxChildren || value.children.some(value => !pid(value))) return;
|
|
64
|
+
return {value, text};
|
|
65
|
+
} finally {await file.close();}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function ordinaryTree(home, budget, snapshots) {
|
|
69
|
+
const directory = await captureStorageDirectory(home);
|
|
70
|
+
if (!directory) return false;
|
|
71
|
+
snapshots.push(directory);
|
|
72
|
+
for await (const entry of await opendir(home)) {
|
|
73
|
+
if (++budget.files > 32_768) return false;
|
|
74
|
+
const file = path.join(home, entry.name), info = await lstat(file);
|
|
75
|
+
if (info.isSymbolicLink()) return false;
|
|
76
|
+
if (info.isDirectory()) {if (!await ordinaryTree(file, budget, snapshots)) return false;}
|
|
77
|
+
else if (!info.isFile() || (budget.bytes += info.size) > 2 * 1024 ** 3) return false;
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Called while the project's exclusive development lock is held. Legacy,
|
|
83
|
+
* foreign, active, ambiguous and unmarked sessions are always preserved. */
|
|
84
|
+
export async function pruneDevelopmentSessions(root, {host = hostname(), now = Date.now(), isAlive = alive, graceMs = grace, remove = rm} = {}) {
|
|
85
|
+
const report = {removed: 0, retained: 0, failed: 0};
|
|
86
|
+
// The caller already holds the canonical project's development lock. Do not
|
|
87
|
+
// recanonicalize a replaced root into a different project's storage.
|
|
88
|
+
root = path.resolve(root);
|
|
89
|
+
const container = path.join(root, '.prnext-dev');
|
|
90
|
+
let info;
|
|
91
|
+
try {info = await lstat(container);} catch (error) {if (error.code === 'ENOENT') return report; throw error;}
|
|
92
|
+
if (!info.isDirectory() || info.isSymbolicLink()) return report;
|
|
93
|
+
const ancestors = [await captureStorageDirectory(root), await captureStorageDirectory(container)];
|
|
94
|
+
if (!await verifyStorageDirectories(ancestors)) return report;
|
|
95
|
+
let scanned = 0;
|
|
96
|
+
for await (const entry of await opendir(container)) {
|
|
97
|
+
if (++scanned > 256) break;
|
|
98
|
+
if (!entry.isDirectory() || !uuid.test(entry.name)) {report.retained++; continue;}
|
|
99
|
+
const home = path.join(container, entry.name);
|
|
100
|
+
try {
|
|
101
|
+
const snapshots = [...ancestors, await captureStorageDirectory(home)];
|
|
102
|
+
if (!await verifyStorageDirectories(snapshots)) {report.retained++; continue;}
|
|
103
|
+
const owner = await readOwner(home);
|
|
104
|
+
if (await lstat(path.join(home, disabledMarker)).then(() => true, error => {if (error.code === 'ENOENT') return false; throw error;})) {report.retained++; continue;}
|
|
105
|
+
const inactive = value => value.host === host && value.updatedAt <= now - graceMs && value.createdAt <= now && ![value.pid, ...value.children].some(isAlive);
|
|
106
|
+
if (!owner || !inactive(owner.value) || !await verifyStorageDirectories(snapshots) || !await ordinaryTree(home, {files: 0, bytes: 0}, snapshots) || !await verifyStorageDirectories(snapshots)) {report.retained++; continue;}
|
|
107
|
+
// Validate the same marker and lifetime after the bounded tree walk. A
|
|
108
|
+
// currently running session can never be deleted merely because it is old.
|
|
109
|
+
const current = await readOwner(home), directory = await lstat(home);
|
|
110
|
+
const disabled = await lstat(path.join(home, disabledMarker)).then(() => true, error => {if (error.code === 'ENOENT') return false; throw error;});
|
|
111
|
+
if (disabled || !current || current.text !== owner.text || !inactive(current.value) || !directory.isDirectory() || directory.isSymbolicLink() || !await verifyStorageDirectories(snapshots)) {report.retained++; continue;}
|
|
112
|
+
await remove(home, {recursive: true, force: true, maxRetries: 3, retryDelay: 100}); report.removed++;
|
|
113
|
+
} catch {report.failed++;}
|
|
114
|
+
}
|
|
115
|
+
return report;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Failed generation cleanup remains in this retryable queue, including the
|
|
119
|
+
* exact completed phase. Nothing is deleted before requests have drained. */
|
|
120
|
+
export function createRetiredGenerationCleanup({root, home, gateway, stop, remove = rm, warn = console.warn}) {
|
|
121
|
+
const pending = new Map(); let running;
|
|
122
|
+
const ownedHome = path.resolve(home);
|
|
123
|
+
const ancestry = directory => [path.resolve(root), path.join(path.resolve(root), '.prnext-dev'), ownedHome, directory];
|
|
124
|
+
async function storage(item, directory) {
|
|
125
|
+
item.snapshots ??= await Promise.all(ancestry(directory).map(captureStorageDirectory));
|
|
126
|
+
return verifyStorageDirectories(item.snapshots);
|
|
127
|
+
}
|
|
128
|
+
async function clean(item) {
|
|
129
|
+
const directory = path.resolve(root, item.generation.directory);
|
|
130
|
+
if (!sameStoragePath(path.dirname(directory), ownedHome) || !uuid.test(path.basename(directory)) || item.generation.id !== path.basename(directory)) throw Error('Invalid retired development generation');
|
|
131
|
+
if (!await storage(item, directory)) throw Error('Development generation storage changed');
|
|
132
|
+
if (item.phase === 0) {await gateway.request('retire', {generation: item.generation.id}); item.phase++;}
|
|
133
|
+
if (!await storage(item, directory)) throw Error('Development generation storage changed');
|
|
134
|
+
if (item.phase === 1) {await item.generation.runtime?.close(); item.phase++;}
|
|
135
|
+
if (item.phase === 2) {await stop(item.generation.child); item.phase++;}
|
|
136
|
+
if (item.phase === 3) {await gateway.request('forget', {generation: item.generation.id}); item.phase++;}
|
|
137
|
+
const snapshots = [...item.snapshots];
|
|
138
|
+
if (!await storage(item, directory) || !await ordinaryTree(directory, {files: 0, bytes: 0}, snapshots) || !await verifyStorageDirectories(snapshots)) throw Error('Development generation storage changed');
|
|
139
|
+
await remove(directory, {recursive: true, force: true, maxRetries: 10, retryDelay: 100});
|
|
140
|
+
}
|
|
141
|
+
const retry = () => {
|
|
142
|
+
if (running) return running;
|
|
143
|
+
running = (async () => {
|
|
144
|
+
for (const [id, item] of [...pending]) {
|
|
145
|
+
try {await clean(item); pending.delete(id);}
|
|
146
|
+
catch (error) {warn('Development generation cleanup: ' + error.message);}
|
|
147
|
+
}
|
|
148
|
+
})().finally(() => {running = undefined;});
|
|
149
|
+
return running;
|
|
150
|
+
};
|
|
151
|
+
return {get pending() {return pending.size;}, enqueue(generation) {if (!pending.has(generation.id)) pending.set(generation.id, {generation, phase: 0}); return retry();}, retry,
|
|
152
|
+
async close() {await running; for (const item of pending.values()) {const directory=path.resolve(root,item.generation.directory);if(await storage(item,directory))await item.generation.runtime?.close();else await stop(item.generation.runtime?.child);await stop(item.generation.child);}},
|
|
153
|
+
};
|
|
154
|
+
}
|
package/runtime/development.mjs
CHANGED
|
@@ -20,11 +20,13 @@ 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';
|
|
27
28
|
import {pruneDevelopmentStyles} from './dev-style-retention.mjs';
|
|
29
|
+
import {createDevelopmentSessionOwner,pruneDevelopmentSessions,createRetiredGenerationCleanup} from './dev-session-retention.mjs';
|
|
28
30
|
|
|
29
31
|
const packageRoot=fileURLToPath(new URL('../',import.meta.url));
|
|
30
32
|
export function developmentRoute(routes,raw,config={}) {
|
|
@@ -83,7 +85,9 @@ function serverOptions(args) {
|
|
|
83
85
|
return {hostname,port,backend,hostAi,profile,safe};
|
|
84
86
|
}
|
|
85
87
|
export async function develop(projectRoot,args,binary) {
|
|
86
|
-
|
|
88
|
+
let entered=false;
|
|
89
|
+
try{return await withBuildLock(projectRoot,root=>{entered=true;return runDevelopment(root,args,binary);},{development:true});}
|
|
90
|
+
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
91
|
}
|
|
88
92
|
async function runDevelopment(projectRoot,args,binary) {
|
|
89
93
|
const root=await realpath(projectRoot),options=serverOptions(args),session=randomUUID();
|
|
@@ -95,7 +99,10 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
95
99
|
// The session is ours; never clean a path obtained from application config.
|
|
96
100
|
const container=path.join(root,'.prnext-dev'),info=await lstat(container).catch(error=>{if(error.code!=='ENOENT')throw error;});
|
|
97
101
|
if(info&&(!info.isDirectory()||info.isSymbolicLink()))throw Error('Development directory must be a real directory');
|
|
102
|
+
await pruneDevelopmentSessions(root);
|
|
98
103
|
await mkdir(home,{recursive:true});
|
|
104
|
+
const sessionOwner=createDevelopmentSessionOwner(home,{session});
|
|
105
|
+
if(options.safe)sessionOwner.disable();
|
|
99
106
|
await mkdir(diagnosticsDirectory,{recursive:true,mode:0o700});
|
|
100
107
|
let compiler,gateway,watcher,timer,tools,hostRelay,hostRelayTask,stopping=false,current,discovery,typeChecker,readyState={state:'building'},typeErrors=[],sequence=0;
|
|
101
108
|
let dependencyIndexValid=false;
|
|
@@ -104,6 +111,7 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
104
111
|
const failedPaths=new Set();
|
|
105
112
|
const requestedImports=new Set();
|
|
106
113
|
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
|
+
const retiredCleanup=createRetiredGenerationCleanup({root,home,gateway:{request:(...args)=>gateway.request(...args)},stop});
|
|
107
115
|
const compilerBroker=await createDevCompilerBroker({compile:(pattern,sources)=>serialized(async()=>{
|
|
108
116
|
if(stopping)throw Error('Development is stopping');
|
|
109
117
|
if(!discovery)await discover();
|
|
@@ -114,10 +122,10 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
114
122
|
if(!current||patterns.some(p=>!current.patterns.has(p)))await rebuild([...active.keys()]);
|
|
115
123
|
return current.directory;
|
|
116
124
|
})});
|
|
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})});
|
|
125
|
+
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
126
|
const toolSnapshot=()=>({...readyState,safeMode:options.safe,sequence:readyState.sequence??sequence,session,typeErrors,
|
|
119
127
|
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}}});
|
|
128
|
+
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
129
|
function captureOutput(child,source,{forwardStdout=true}={}){
|
|
122
130
|
for(const [stream,level] of [[child.stdout,'info'],[child.stderr,'error']]){
|
|
123
131
|
stream?.setEncoding('utf8');
|
|
@@ -156,7 +164,9 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
156
164
|
if(!compiler||compiler.exitCode!==null||compiler.signalCode){
|
|
157
165
|
const cache=await privateCacheDirectory(root,'dev-compiler-v8');
|
|
158
166
|
await pruneCompilerCache(cache);
|
|
167
|
+
const ticket=sessionOwner.beforeSpawn();
|
|
159
168
|
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}:{})}});
|
|
169
|
+
sessionOwner.spawned(ticket,compiler);
|
|
160
170
|
captureOutput(compiler,'compiler');
|
|
161
171
|
}
|
|
162
172
|
const child=compiler;
|
|
@@ -174,14 +184,17 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
174
184
|
if(options.safe)discovery={...discovery,eager:true};
|
|
175
185
|
const {writeTypedRoutes}=await import('../build/typed-routes.mjs');
|
|
176
186
|
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);});}
|
|
187
|
+
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
188
|
}
|
|
179
189
|
async function launch(manifest,directory){
|
|
180
190
|
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;});
|
|
191
|
+
if(!runtime)sessionOwner.disable();
|
|
181
192
|
const env={...process.env,NODE_ENV:process.env.NODE_ENV||'development',PRNEXT_INSPECT_DIR:diagnosticsDirectory,PRNEXT_DEV_COMPILER:compilerBroker.descriptor};
|
|
182
193
|
delete env.PRNEXT_DEV_BROKER;
|
|
183
194
|
if(runtime)env.PRNEXT_DEV_BROKER=runtime.descriptor;
|
|
195
|
+
const ticket=sessionOwner.beforeSpawn();
|
|
184
196
|
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});
|
|
197
|
+
sessionOwner.spawned(ticket,child);
|
|
185
198
|
captureOutput(child,'runtime',{forwardStdout:false});
|
|
186
199
|
let output='';
|
|
187
200
|
const port=await new Promise((resolve,reject)=>{
|
|
@@ -193,6 +206,8 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
193
206
|
return {id:manifest.buildId,child,runtime,port,directory};
|
|
194
207
|
}
|
|
195
208
|
async function rebuild(patterns,changedFiles=[],affectedRoutes,affectedModules,cause){
|
|
209
|
+
void retiredCleanup.retry();
|
|
210
|
+
if(retiredCleanup.pending>=64)throw Error('Development snapshots could not be removed. Close applications locking these files, then retry.');
|
|
196
211
|
const startedAt=Date.now(),started=performance.now(),phases={};let mark=started;
|
|
197
212
|
let reason=devBuildReason({initial:!current,safe:options.safe,changed:changedFiles,cause});
|
|
198
213
|
await writeState({state:'building',reason});
|
|
@@ -233,7 +248,11 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
233
248
|
const previous=current;current=next;current.compiler=result.compiler;current.transforms=result.transforms;generations.push(next);failed.clear();failedPaths.clear();
|
|
234
249
|
current.manifest=manifest;current.changed=changed;current.patterns=new Set(discovery.eager?discovery.project.routes.map(route=>route.pattern):patterns);
|
|
235
250
|
for(const id of requestedImports)if(!manifest.devLazy?.modules.some(item=>item.id===id))requestedImports.delete(id);
|
|
236
|
-
|
|
251
|
+
let affected;
|
|
252
|
+
if(dependencyIndexValid&&affectedRoutes) {
|
|
253
|
+
const nextAffected=await affectedDependencies(gateway,changedFiles).catch(()=>undefined);
|
|
254
|
+
affected=selectiveDevelopmentRoutes(previous?.manifest,manifest,{changed:changedFiles,affectedRoutes,affectedModules,nextAffected});
|
|
255
|
+
}
|
|
237
256
|
current.affectedRoutes=affected;
|
|
238
257
|
try{
|
|
239
258
|
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 +269,7 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
250
269
|
while(generations.length>3){const old=generations.shift();
|
|
251
270
|
// A deferred request may itself be awaiting this build. Never wait for
|
|
252
271
|
// its generation lease while holding the serialized compiler queue.
|
|
253
|
-
const cleanup=
|
|
272
|
+
const cleanup=retiredCleanup.enqueue(old).finally(()=>retiring.delete(cleanup));retiring.add(cleanup);
|
|
254
273
|
}
|
|
255
274
|
}
|
|
256
275
|
await watcher?.refresh();
|
|
@@ -329,7 +348,7 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
329
348
|
// Keep each native data-cache bridge alive until its externally owned
|
|
330
349
|
// Node process has drained after() work and cache fills on stdin EOF.
|
|
331
350
|
await runtimePool.close();
|
|
332
|
-
await Promise.all([tools?.close(),...generations.map(g=>stop(g.child)),...retiring]);
|
|
351
|
+
await Promise.all([tools?.close(),...generations.map(g=>stop(g.child)),...retiring,retiredCleanup.close()]);
|
|
333
352
|
await queue.catch(()=>{});await compilerBroker.close();await stateQueue;await rm(home,{recursive:true,force:true,maxRetries:10,retryDelay:100});await rm(stateFile,{force:true});
|
|
334
353
|
})();return closing;
|
|
335
354
|
};
|
|
@@ -344,7 +363,7 @@ async function runDevelopment(projectRoot,args,binary) {
|
|
|
344
363
|
return {...updateInfo(),...result};
|
|
345
364
|
})});
|
|
346
365
|
await writeState({state:'building',session});
|
|
347
|
-
gateway=new DevGateway(binary,root,{...options,onEnsure:ensure,onFatal:fatal,
|
|
366
|
+
gateway=new DevGateway(binary,root,{...options,onEnsure:ensure,onFatal:fatal,onBeforeChild:()=>sessionOwner.beforeSpawn(),onChild:(child,ticket)=>sessionOwner.spawned(ticket,child),
|
|
348
367
|
onOpen:message=>{const route=discovery&&findRoute(message.path);if(route)pins.set(message.client,{pattern:route.pattern,connection:message.connection});},
|
|
349
368
|
onClose:message=>{if(pins.get(message.client)?.connection===message.connection)pins.delete(message.client);}});
|
|
350
369
|
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,35 @@
|
|
|
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
|
+
export function partialShell(artifact, route) {
|
|
11
|
+
const bootstrap = '<script>self.__PRNEXT_FLIGHT_STREAM__=self.__PRNEXT_FLIGHT_STREAM__||[];</script>' +
|
|
12
|
+
(route.client ? `<script type="module" async src="${escapeHtml(route.client)}"></script>` : '');
|
|
13
|
+
const end = artifact.shell.lastIndexOf('</body>');
|
|
14
|
+
if (end < 0 || !/<\/body><\/html>$/i.test(artifact.shell)) throw new Error('Invalid partial prerender document');
|
|
15
|
+
return artifact.shell.slice(0, end) + bootstrap + artifact.shell.slice(end);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// This is a private transport field, never an HTTP header. Rust has already
|
|
19
|
+
// checked cache generation and committed these exact build bytes. Pinning the
|
|
20
|
+
// same artifact gives the request one snapshot even if invalidation races Node.
|
|
21
|
+
export async function nativePprArtifact({renderMode, manifest, route, url, distDir}) {
|
|
22
|
+
if (!renderMode?.startsWith('native-ppr:')) return null;
|
|
23
|
+
if (!manifest.config?.nativePprShell || manifest.dev || !/^[a-f\d]{64}$/.test(renderMode.slice(11))) throw new Error('Invalid native PPR continuation');
|
|
24
|
+
const pathname = new URL(url).pathname;
|
|
25
|
+
const descriptor = manifest.config.nativePprArtifacts?.find(item => item.routeId === route.id && item.path === pathname && item.artifactHash === renderMode.slice(11));
|
|
26
|
+
if (!descriptor || new URL(url).search || descriptor.artifact !== route.ppr?.[pathname]) throw new Error('Native PPR artifact does not match the route');
|
|
27
|
+
const filename = path.resolve(distDir, descriptor.artifact);
|
|
28
|
+
if (!descriptor.artifact.startsWith('static/') || !filename.startsWith(path.resolve(distDir) + path.sep) || (await stat(filename)).size > NATIVE_PPR_ARTIFACT_BYTES) throw new Error('Invalid native PPR artifact path or size');
|
|
29
|
+
const bytes = await readFile(filename);
|
|
30
|
+
if (bytes.length > NATIVE_PPR_ARTIFACT_BYTES || sha256(bytes) !== descriptor.artifactHash) throw new Error('Native PPR artifact changed after publication');
|
|
31
|
+
const artifact = JSON.parse(bytes);
|
|
32
|
+
const prefix = Buffer.from(partialShell(artifact, route).replace(/<\/body><\/html>$/i, ''));
|
|
33
|
+
if (artifact.version !== 1 || !artifact.postponed || artifact.complete || artifact.generic || sha256(prefix) !== descriptor.prefixHash || sha256(artifact.flight) !== descriptor.identity) throw new Error('Invalid native PPR snapshot');
|
|
34
|
+
return {artifact, descriptor};
|
|
35
|
+
}
|
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).
|