@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.
Files changed (85) hide show
  1. package/build/app.mjs +46 -78
  2. package/build/build-lock.mjs +2 -71
  3. package/build/build-retention.mjs +119 -0
  4. package/build/bundle-runtime.mjs +9 -8
  5. package/build/cache-command.mjs +31 -0
  6. package/build/check.mjs +41 -3
  7. package/build/compilation-cache.mjs +32 -12
  8. package/build/compilation-storage.mjs +190 -0
  9. package/build/compiler.mjs +3 -3
  10. package/build/config.mjs +23 -2
  11. package/build/create.mjs +1 -1
  12. package/build/css-modules.mjs +4 -0
  13. package/build/dev-action-graph.mjs +21 -0
  14. package/build/font-cache.mjs +9 -6
  15. package/build/font-files.mjs +1 -1
  16. package/build/index.mjs +30 -13
  17. package/build/native-ppr.mjs +32 -0
  18. package/build/og.mjs +121 -1
  19. package/build/parallel-plan.mjs +55 -0
  20. package/build/persistent-cache.mjs +29 -24
  21. package/build/production-check.mjs +34 -0
  22. package/build/react-compiler-report.mjs +31 -8
  23. package/build/react-compiler.mjs +101 -10
  24. package/build/server-config.mjs +35 -0
  25. package/build/source-analysis.mjs +54 -4
  26. package/build/static-adapter.mjs +7 -3
  27. package/build/transform.mjs +38 -6
  28. package/build/typescript-dev.mjs +3 -1
  29. package/cli.mjs +14 -5
  30. package/compat/app-layout-context.cjs +7 -3
  31. package/compat/flight-delta.cjs +47 -12
  32. package/compat/index.d.ts +24 -1
  33. package/compat/navigation-authorization.cjs +8 -1
  34. package/docs/ai/compatibility.md +110 -7
  35. package/docs/ai/deployment.md +141 -0
  36. package/docs/ai/quickstart.md +5 -0
  37. package/package.json +12 -9
  38. package/release/fontkit.mjs +31 -0
  39. package/release/verify.mjs +3 -0
  40. package/runtime/api.mjs +5 -2
  41. package/runtime/app-client.mjs +150 -26
  42. package/runtime/app-partial.mjs +2 -10
  43. package/runtime/app-prefetch.mjs +2 -2
  44. package/runtime/app-render.mjs +11 -7
  45. package/runtime/app-stream-channel.mjs +3 -2
  46. package/runtime/app-styles.mjs +22 -4
  47. package/runtime/compile-cache-retention.mjs +53 -0
  48. package/runtime/compile-cache.mjs +28 -1
  49. package/runtime/dev-client.mjs +6 -3
  50. package/runtime/dev-gateway.mjs +3 -2
  51. package/runtime/dev-route-selection.mjs +64 -0
  52. package/runtime/dev-runtime-pool.mjs +3 -2
  53. package/runtime/dev-runtime-spare.mjs +1 -1
  54. package/runtime/dev-session-retention.mjs +154 -0
  55. package/runtime/development.mjs +27 -8
  56. package/runtime/env.mjs +9 -0
  57. package/runtime/flight-delta.mjs +31 -5
  58. package/runtime/native-ppr.mjs +35 -0
  59. package/runtime/og/NOTICE.txt +1 -1
  60. package/runtime/og/index.node.mjs +76 -31216
  61. package/runtime/og/upstream.json +115 -0
  62. package/runtime/persistent/client.mjs +4 -2
  63. package/runtime/persistent/daemon.mjs +3 -11
  64. package/runtime/persistent/health.mjs +58 -0
  65. package/runtime/project-lock.mjs +71 -0
  66. package/runtime/request-transport.mjs +112 -32
  67. package/runtime/storage-path.mjs +36 -0
  68. package/runtime/trace.mjs +44 -4
  69. package/runtime/worker.mjs +9 -4
  70. package/vendor/fontkit/NOTICE.txt +5 -0
  71. package/vendor/fontkit/SOURCE-NOTICES.txt +209 -0
  72. package/vendor/fontkit/index.mjs +3 -0
  73. package/vendor/fontkit/licenses/@swc__helpers/LICENSE +201 -0
  74. package/vendor/fontkit/licenses/base64-js/LICENSE +21 -0
  75. package/vendor/fontkit/licenses/brotli/readme.md +61 -0
  76. package/vendor/fontkit/licenses/clone/LICENSE +18 -0
  77. package/vendor/fontkit/licenses/dfa/README.md +83 -0
  78. package/vendor/fontkit/licenses/fast-deep-equal/LICENSE +21 -0
  79. package/vendor/fontkit/licenses/fontkit/README.md +266 -0
  80. package/vendor/fontkit/licenses/restructure/LICENSE +21 -0
  81. package/vendor/fontkit/licenses/tiny-inflate/LICENSE +21 -0
  82. package/vendor/fontkit/licenses/tslib/LICENSE.txt +12 -0
  83. package/vendor/fontkit/licenses/unicode-properties/LICENSE +8 -0
  84. package/vendor/fontkit/licenses/unicode-trie/LICENSE +8 -0
  85. 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(()=>{pending=undefined;try{flushCompileCache();}catch{}},1000);pending.unref();
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
  }
@@ -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&&affected&&!affected.some(pattern=>pattern.includes('[')||pattern===localPath||pattern+'/'===localPath)){
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)}`);
@@ -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
- this.child=spawn(binary,['dev-gateway',root,'--hostname',hostname,'--port',String(port)],{stdio:['pipe','pipe','inherit'],windowsHide:true});
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
+ }
@@ -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
- return withBuildLock(projectRoot,root=>runDevelopment(root,args,binary),{development:true});
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
- const affected=Object.keys(manifest.app?.actions||{}).length?undefined:affectedRoutes;
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=(async()=>{await gateway.request('retire',{generation:old.id});await old.runtime?.close();await stop(old.child);await gateway.request('forget',{generation:old.id});await rm(path.join(root,old.directory),{recursive:true,force:true,maxRetries:10,retryDelay:100});})().catch(error=>{if(!stopping)console.warn('Development generation cleanup: '+error.message);}).finally(()=>retiring.delete(cleanup));retiring.add(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(/[\\/]/);
@@ -1,5 +1,5 @@
1
1
  import { randomBytes, createHmac } from 'node:crypto';
2
- import { deflateRawSync } from 'node:zlib';
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
- export function compressedDeltaPolicy(acceptEncoding='') {
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.length<=64*1024&&encoded.length+32<raw.length&&
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
+ }
@@ -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).