@thomas.f/prnext 0.1.19-alpha → 0.1.21-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) 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/build-timings.mjs +38 -0
  5. package/build/bundle-runtime.mjs +17 -16
  6. package/build/cache-command.mjs +31 -0
  7. package/build/check.mjs +41 -3
  8. package/build/compilation-cache.mjs +32 -12
  9. package/build/compilation-storage.mjs +190 -0
  10. package/build/compiler.mjs +5 -4
  11. package/build/config.mjs +23 -2
  12. package/build/create.mjs +1 -1
  13. package/build/css-modules.mjs +4 -0
  14. package/build/dev-action-graph.mjs +21 -0
  15. package/build/flight-summary.mjs +64 -0
  16. package/build/font-cache.mjs +9 -6
  17. package/build/font-files.mjs +1 -1
  18. package/build/index.mjs +57 -36
  19. package/build/inspect.mjs +3 -0
  20. package/build/inspection-summary.mjs +7 -2
  21. package/build/native-discovery.mjs +18 -6
  22. package/build/native-ppr.mjs +32 -0
  23. package/build/npm-packages.mjs +2 -2
  24. package/build/og.mjs +121 -1
  25. package/build/parallel-plan.mjs +55 -0
  26. package/build/persistent-cache.mjs +29 -24
  27. package/build/production-check.mjs +34 -0
  28. package/build/react-compiler-report.mjs +31 -8
  29. package/build/react-compiler.mjs +101 -10
  30. package/build/server-config.mjs +35 -0
  31. package/build/source-analysis.mjs +108 -5
  32. package/build/standalone-copy.mjs +48 -0
  33. package/build/standalone.mjs +2 -22
  34. package/build/static-adapter.mjs +7 -3
  35. package/build/transform-cache.mjs +17 -3
  36. package/build/transform.mjs +38 -6
  37. package/build/typescript-dev.mjs +3 -1
  38. package/cli.mjs +27 -5
  39. package/compat/app-layout-context.cjs +7 -3
  40. package/compat/diagnostics.cjs +5 -4
  41. package/compat/flight-delta.cjs +47 -12
  42. package/compat/index.d.ts +24 -1
  43. package/compat/navigation-authorization.cjs +8 -1
  44. package/compat/runtime-sample.cjs +56 -0
  45. package/compat/use-cache.cjs +5 -2
  46. package/docs/ai/compatibility.md +113 -10
  47. package/docs/ai/deployment.md +144 -0
  48. package/docs/ai/quickstart.md +5 -0
  49. package/docs/ai/troubleshooting.md +22 -0
  50. package/package.json +25 -22
  51. package/release/fontkit.mjs +31 -0
  52. package/release/verify.mjs +3 -0
  53. package/runtime/api.mjs +5 -2
  54. package/runtime/app-client.mjs +246 -54
  55. package/runtime/app-partial.mjs +2 -10
  56. package/runtime/app-prefetch.mjs +2 -2
  57. package/runtime/app-render.mjs +42 -13
  58. package/runtime/app-stream-channel.mjs +20 -6
  59. package/runtime/app-stream-html.mjs +30 -11
  60. package/runtime/app-styles.mjs +22 -4
  61. package/runtime/cache-components-rsc.mjs +26 -6
  62. package/runtime/cache-result.mjs +58 -0
  63. package/runtime/compile-cache-retention.mjs +53 -0
  64. package/runtime/compile-cache.mjs +28 -1
  65. package/runtime/dev-client.mjs +32 -8
  66. package/runtime/dev-compiler-broker.mjs +20 -23
  67. package/runtime/dev-gateway.mjs +23 -6
  68. package/runtime/dev-route-selection.mjs +64 -0
  69. package/runtime/dev-runtime-pool.mjs +3 -2
  70. package/runtime/dev-runtime-spare.mjs +1 -1
  71. package/runtime/dev-session-retention.mjs +154 -0
  72. package/runtime/development-flight.mjs +59 -0
  73. package/runtime/development-queue.mjs +33 -0
  74. package/runtime/development.mjs +37 -16
  75. package/runtime/env.mjs +9 -0
  76. package/runtime/flight-delta.mjs +31 -5
  77. package/runtime/flight-diagnostics.mjs +48 -0
  78. package/runtime/flight-tap.mjs +10 -5
  79. package/runtime/html-envelope.mjs +34 -0
  80. package/runtime/loopback-rpc.mjs +92 -0
  81. package/runtime/native-ppr.mjs +88 -0
  82. package/runtime/og/NOTICE.txt +1 -1
  83. package/runtime/og/index.node.mjs +76 -31216
  84. package/runtime/og/upstream.json +115 -0
  85. package/runtime/persistent/client.mjs +5 -3
  86. package/runtime/persistent/common.mjs +7 -18
  87. package/runtime/persistent/daemon.mjs +4 -12
  88. package/runtime/persistent/health.mjs +58 -0
  89. package/runtime/persistent/native-proxy.mjs +1 -1
  90. package/runtime/persistent/watchdog.mjs +12 -27
  91. package/runtime/prewarm.mjs +35 -0
  92. package/runtime/project-lock.mjs +71 -0
  93. package/runtime/request-transport.mjs +112 -32
  94. package/runtime/rsc-worker.mjs +58 -8
  95. package/runtime/storage-path.mjs +36 -0
  96. package/runtime/trace.mjs +44 -4
  97. package/runtime/worker.mjs +37 -17
  98. package/vendor/fontkit/NOTICE.txt +5 -0
  99. package/vendor/fontkit/SOURCE-NOTICES.txt +209 -0
  100. package/vendor/fontkit/index.mjs +3 -0
  101. package/vendor/fontkit/licenses/@swc__helpers/LICENSE +201 -0
  102. package/vendor/fontkit/licenses/base64-js/LICENSE +21 -0
  103. package/vendor/fontkit/licenses/brotli/readme.md +61 -0
  104. package/vendor/fontkit/licenses/clone/LICENSE +18 -0
  105. package/vendor/fontkit/licenses/dfa/README.md +83 -0
  106. package/vendor/fontkit/licenses/fast-deep-equal/LICENSE +21 -0
  107. package/vendor/fontkit/licenses/fontkit/README.md +266 -0
  108. package/vendor/fontkit/licenses/restructure/LICENSE +21 -0
  109. package/vendor/fontkit/licenses/tiny-inflate/LICENSE +21 -0
  110. package/vendor/fontkit/licenses/tslib/LICENSE.txt +12 -0
  111. package/vendor/fontkit/licenses/unicode-properties/LICENSE +8 -0
  112. package/vendor/fontkit/licenses/unicode-trie/LICENSE +8 -0
  113. package/vendor/fontkit/upstream.json +215 -0
@@ -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,9 +155,13 @@ 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};
161
+ const active = state.refreshUpdate;
162
+ // Duplicate readiness messages must not cancel their own generation. The
163
+ // owner exists before any await, including before devCommit creates a GET.
164
+ if (active && message.buildId !== active.buildId && (message.sequence || 0) > active.sequence) active.abort.abort(new DOMException('Newer development generation', 'AbortError'));
160
165
  const pending=state.applied;await pending;
161
166
  if((message.sequence||0)>(state.sequence||0)&&state.applying)await state.applied;
162
167
  return;
@@ -174,7 +179,8 @@ async function applyUpdate(message, {navigation=false}={}) {
174
179
  const refreshStarted=performance.now();
175
180
  const localPath=location.pathname.slice((state.basePath||'').length)||'/';
176
181
  const affected=message.affectedRoutes;
177
- if(state.confirmed&&affected&&!affected.some(pattern=>pattern.includes('[')||pattern===localPath||pattern+'/'===localPath)){
182
+ if(canSkipDevelopmentUpdate({confirmed:state.confirmed,navigation,ensuring:state.ensuring,affectedRoutes:affected,routes:message.routes,pathname:localPath})){
183
+ state.skippedUpdate={buildId:message.buildId};
178
184
  state.buildId=message.buildId;state.sequence=message.sequence;state.lastRefresh={buildId:message.buildId,durationMs:0,changed,skipped:true};
179
185
  document.documentElement.dataset.prnextDevBuild=message.buildId;
180
186
  if(hasCriticalIssue()&&state.issueKind==='build'){clearError();if(state.typeErrors)typeDiagnostics(state.typeErrors);}
@@ -183,28 +189,35 @@ async function applyUpdate(message, {navigation=false}={}) {
183
189
  }
184
190
  state.applying = true; state.confirmed=false; state.unsafe = false; state.refreshSourceChanged = false; state.pending = null; state.indicator?.update();
185
191
  let applied;state.applied=new Promise(resolve=>{applied=resolve;});
192
+ const update = { buildId: message.buildId, sequence: message.sequence || 0, abort: new AbortController() };
193
+ state.refreshUpdate = update;
186
194
  state.dismissedErrors = new WeakSet();
187
195
  state.dismissedDigests = new Set();
188
196
  try {
189
- const response = await fetch(message.clientManifest, { cache: 'no-store', credentials: 'same-origin' });
197
+ const response = await fetch(message.clientManifest, { cache: 'no-store', credentials: 'same-origin', signal: update.abort.signal });
190
198
  if (!response.ok) throw new Error(`Development manifest failed (${response.status})`);
191
199
  const manifest = await response.json();
200
+ update.abort.signal.throwIfAborted();
192
201
  if (manifest.buildId !== message.buildId) throw new Error('Development build changed during refresh');
193
202
  const kind = state.currentKind;
194
203
  const application = await state.applications.get(kind);
204
+ update.abort.signal.throwIfAborted();
195
205
  const route = kind === 'pages' ? manifest.pages.find(route => route.pattern === application.router.snapshot().pathname) : null;
196
206
  const entry = kind === 'pages' ? route?.client : manifest.app?.client;
197
207
  if (!entry) { reload('The current route changed. Page reloaded.'); return; }
198
208
  if(!message.cssOnly){
199
209
  await import(/* webpackIgnore: true */ entry);
210
+ update.abort.signal.throwIfAborted();
200
211
  // Activated deferred modules must register their updated component
201
212
  // families before Refresh runs, even when their loader is already cached.
202
213
  await Promise.all((manifest.lazy||[]).map(module=>import(/* webpackIgnore: true */ module.url)));
214
+ update.abort.signal.throwIfAborted();
203
215
  }
204
216
  const pending = state.pending;
205
217
  if (pending) await application.devPrepare?.(pending.options);
218
+ update.abort.signal.throwIfAborted();
206
219
  if (state.unsafe) { reload('An edited module exports values outside React components. Page reloaded.'); return; }
207
- if (kind === 'app' && application.devPrepareStyles) await application.devPrepareStyles(manifest.app.routes);
220
+ if (kind === 'app' && application.devPrepareStyles) await application.devPrepareStyles(manifest.app.routes, update.abort.signal);
208
221
  else await updateStyles([...(kind === 'pages' ? route.css : application.devStyles?.(manifest.app.routes) || manifest.app.css),...(manifest.lazy||[]).flatMap(module=>module.css)]);
209
222
  if (kind === 'app') application.devStylesApplied?.(manifest.app.routes, { commit: !!message.cssOnly || !pending });
210
223
  // An unrelated type-only edit may publish a new generation without changing
@@ -216,19 +229,30 @@ async function applyUpdate(message, {navigation=false}={}) {
216
229
  }else if(hasCriticalIssue()&&(state.issueKind==='build'||errorKey(state.issue))){
217
230
  clearError();
218
231
  }
232
+ update.abort.signal.throwIfAborted();
233
+ if (!message.cssOnly && application.devBeforeRefresh?.({ navigation }) === false) return;
219
234
  if(!message.cssOnly)Refresh.performReactRefresh();
220
- if (pending) await (navigation ? application.devSync?.(pending.options) : application.devCommit?.(pending.options));
235
+ if (pending) {
236
+ const committed = await (navigation ? application.devSync?.(pending.options) : application.devCommit?.(pending.options, { signal: update.abort.signal }));
237
+ // A private 'superseded' result replaces only the UI; its compiler
238
+ // generation is still applied. False remains a failed/aborted refresh.
239
+ if (committed === false || update.abort.signal.aborted) return;
240
+ }
221
241
  state.buildId = message.buildId;
222
242
  if(message.sequence)state.sequence=message.sequence;
223
243
  state.manifest = manifest;
244
+ state.skippedUpdate=undefined;
224
245
  document.documentElement.dataset.prnextDevBuild = message.buildId;
225
246
  state.lastRefresh={buildId:message.buildId,durationMs:Math.round(performance.now()-refreshStarted),changed};
226
- await painted();state.confirmed=true;
247
+ await painted();
248
+ if (update.abort.signal.aborted) return;
249
+ state.confirmed=true;
227
250
  globalThis.dispatchEvent(new CustomEvent('prnext:refresh', { detail: state.lastRefresh }));
228
251
  globalThis.dispatchEvent(new CustomEvent('prnext:devtools:ready'));
229
252
  if(state.typeErrors)typeDiagnostics(state.typeErrors);
230
- } catch (error) { showError(error); }
253
+ } catch (error) { if (!update.abort.signal.aborted) showError(error); }
231
254
  finally {
255
+ if (state.refreshUpdate === update) state.refreshUpdate = undefined;
232
256
  state.applying = false; state.pending = null; state.indicator?.update();
233
257
  applied();
234
258
  if (state.queued) { const queued = state.queued; state.queued = null; if (queued.buildId !== state.buildId) void applyUpdate(queued,{navigation:queued.navigation}); }
@@ -277,7 +301,7 @@ function connect(options) {
277
301
  const response=await fetch(`${options.basePath||''}/_prnext/ensure?client=${state.clientId}&path=${encodeURIComponent(url.pathname+url.search)}`,{cache:'no-store',credentials:'same-origin'});
278
302
  if(!response.ok)throw Error(await response.text());
279
303
  const update=await response.json();
280
- if(update.buildId!==state.buildId)await applyUpdate(update,{navigation:true});
304
+ if(update.buildId!==state.buildId||state.skippedUpdate?.buildId===update.buildId)await applyUpdate(update,{navigation:true});
281
305
  }finally{state.ensuring--;}
282
306
  };
283
307
  const source = new EventSource(`${options.basePath || ''}/_prnext/dev?client=${state.clientId}&path=${encodeURIComponent(location.pathname+location.search)}`);
@@ -1,38 +1,35 @@
1
- import http from 'node:http';
2
- import {randomBytes,timingSafeEqual} from 'node:crypto';
1
+ import {createServer} from 'node:net';
2
+ import {randomBytes} from 'node:crypto';
3
3
  import {readFile,realpath} from 'node:fs/promises';
4
4
  import path from 'node:path';
5
+ import {requestLoopback,serveLoopback} from './loopback-rpc.mjs';
5
6
 
6
7
  // Private control plane. Request headers and bodies never cross this endpoint.
7
8
  export async function createDevCompilerBroker({compile,maxPending=64}) {
8
- const token=randomBytes(32).toString('base64url');let pending=0;
9
- const server=http.createServer(async(req,res)=>{
10
- const supplied=Buffer.from(req.headers.authorization||''),expected=Buffer.from('Bearer '+token);
11
- if(req.method!=='POST'||req.url!=='/compile'||supplied.length!==expected.length||!timingSafeEqual(supplied,expected)){res.writeHead(403).end();return;}
12
- if(pending>=maxPending){res.writeHead(503).end();return;}
13
- pending++;
14
- try{
15
- const chunks=[];let bytes=0;
16
- for await(const chunk of req){bytes+=chunk.length;if(bytes>65536)throw Error('Compiler request too large');chunks.push(chunk);}
17
- // Decode once: an HTTP chunk boundary may split a UTF-8 route name.
18
- const input=JSON.parse(Buffer.concat(chunks,bytes).toString('utf8'));if(typeof input.pattern!=='string'||!input.pattern.startsWith('/')||input.pattern.length>4096)throw Error('Invalid route pattern');
19
- if(input.sources!==undefined&&(!Array.isArray(input.sources)||input.sources.length>32||!input.sources.every(p=>typeof p==='string'&&p.startsWith('/')&&p.length<=4096)))throw Error('Invalid retained route patterns');
20
- const directory=await compile(input.pattern,input.sources||[]);
21
- res.writeHead(200,{'content-type':'application/json','cache-control':'no-store'}).end(JSON.stringify({directory}));
22
- }catch(error){res.writeHead(500,{'content-type':'application/json'}).end(JSON.stringify({error:String(error.message).slice(0,2048)}));}
23
- finally{pending--;}
9
+ const token=randomBytes(32).toString('base64url'),sockets=new Set();let pending=0;
10
+ const server=createServer(socket=>{
11
+ sockets.add(socket);socket.once('close',()=>sockets.delete(socket));
12
+ serveLoopback(socket,{token,context:'development-compiler',timeout:120000,maxResponseBytes:65536,handle:async(input,admission)=>{
13
+ if(pending>=maxPending)throw Error('Development compiler is busy. Retry shortly.');
14
+ pending++;
15
+ try{
16
+ if(typeof input.pattern!=='string'||!input.pattern.startsWith('/')||input.pattern.length>4096)throw Error('Invalid route pattern');
17
+ if(input.sources!==undefined&&(!Array.isArray(input.sources)||input.sources.length>32||!input.sources.every(p=>typeof p==='string'&&p.startsWith('/')&&p.length<=4096)))throw Error('Invalid retained route patterns');
18
+ const directory=await compile(input.pattern,input.sources||[],admission);
19
+ return {directory};
20
+ }finally{pending--;}
21
+ }});
24
22
  });
25
- server.requestTimeout=125000;server.headersTimeout=5000;
23
+ server.maxConnections=64;
26
24
  await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});
27
- return {descriptor:JSON.stringify({port:server.address().port,token}),close:()=>new Promise(resolve=>{server.close(resolve);server.closeIdleConnections();})};
25
+ return {descriptor:JSON.stringify({version:2,port:server.address().port,token}),close:()=>new Promise(resolve=>{server.close(resolve);for(const socket of sockets)socket.destroy();})};
28
26
  }
29
27
 
30
28
  export function deferredCompiler(descriptor,{root,home}) {
31
29
  const value=JSON.parse(descriptor);
32
- if(!Number.isInteger(value.port)||value.port<1||value.port>65535||!/^[\w-]{43}$/.test(value.token))throw Error('Invalid private compiler endpoint');
30
+ if(value.version!==2||!Number.isInteger(value.port)||value.port<1||value.port>65535||!/^[\w-]{43}$/.test(value.token))throw Error('Invalid private compiler endpoint');
33
31
  return async(pattern,signal,sources=[])=>{
34
- const response=await fetch(`http://127.0.0.1:${value.port}/compile`,{method:'POST',headers:{authorization:'Bearer '+value.token,'content-type':'application/json'},body:JSON.stringify({pattern,sources}),signal:AbortSignal.any([...(signal?[signal]:[]),AbortSignal.timeout(120000)])});
35
- const result=await response.json();if(!response.ok)throw Error(result.error||'Deferred route compilation failed');
32
+ const result=await requestLoopback(value,{pattern,sources},{context:'development-compiler',signal,timeout:120000,maxResponseBytes:65536});
36
33
  const directory=await realpath(path.resolve(root,result.directory)),parent=await realpath(home),relative=path.relative(parent,directory);
37
34
  if(!relative||relative.startsWith('..')||path.isAbsolute(relative))throw Error('Compiler generation escaped its development session');
38
35
  const manifest=JSON.parse(await readFile(path.join(directory,'manifest.json'),'utf8'));
@@ -1,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}) {
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});
4
+ constructor(binary,root,{hostname,port,onEnsure,onOpen,onClose,onFatal,onBeforeChild=()=>{},onChild=()=>{}},{spawnChild=spawn}={}) {
5
+ this.next=0;this.pending=new Map();this.ensures=new Map();this.stopping=false;
6
+ const ticket=onBeforeChild();
7
+ this.child=spawnChild(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);
@@ -16,18 +17,34 @@ export class DevGateway {
16
17
  if(message.type==='ready'){clearTimeout(timer);resolve(message);continue;}
17
18
  if(message.type==='open'){onOpen(message);continue;}
18
19
  if(message.type==='close'){onClose(message);continue;}
20
+ if(message.type==='ensure-cancel'){this.cancelEnsure(message.id);continue;}
19
21
  if(message.type==='ensure'){
20
- Promise.resolve().then(()=>onEnsure(message.path,message.client)).then(value=>this.request('result',{request:message.id,value}),error=>this.request('result',{request:message.id,value:{error:String(error.message).slice(0,8000)}})).catch(error=>{if(!this.stopping)onFatal(error);});continue;
22
+ const now=Date.now();
23
+ if(!Number.isSafeInteger(message.id)||!Number.isSafeInteger(message.deadlineMs)||this.ensures.has(message.id)||this.ensures.size>=64){this.child.kill();continue;}
24
+ if(message.deadlineMs<=now)continue;
25
+ const deadlineMs=Math.min(message.deadlineMs,now+120000);
26
+ const controller=new AbortController(),entry={controller,timer:setTimeout(()=>this.cancelEnsure(message.id),deadlineMs-now)};
27
+ this.ensures.set(message.id,entry);
28
+ const reply=value=>{
29
+ if(this.ensures.get(message.id)!==entry)return;
30
+ clearTimeout(entry.timer);this.ensures.delete(message.id);
31
+ return this.request('result',{request:message.id,value});
32
+ };
33
+ Promise.resolve().then(()=>{
34
+ if(controller.signal.aborted)return;
35
+ return onEnsure(message.path,message.client,{signal:controller.signal,deadlineMs});
36
+ }).then(reply,error=>reply({error:String(error.message).slice(0,8000)})).catch(error=>{if(!this.stopping)onFatal(error);});continue;
21
37
  }
22
38
  const pending=this.pending.get(message.id);if(!pending)continue;
23
39
  this.pending.delete(message.id);clearTimeout(pending.timer);message.ok?pending.resolve(message.value):pending.reject(Error(message.error));
24
40
  }
25
41
  });
26
- const failed=error=>{clearTimeout(timer);reject(error);for(const pending of this.pending.values()){clearTimeout(pending.timer);pending.reject(error);}this.pending.clear();if(!this.stopping)onFatal(error);};
42
+ const failed=error=>{clearTimeout(timer);reject(error);for(const id of this.ensures.keys())this.cancelEnsure(id);for(const pending of this.pending.values()){clearTimeout(pending.timer);pending.reject(error);}this.pending.clear();if(!this.stopping)onFatal(error);};
27
43
  this.child.once('error',failed);this.child.once('exit',(code,signal)=>failed(Error(`Development listener exited ${signal||code}`)));
28
44
  });
29
45
  this.child.stdin.on('error',()=>{});
30
46
  }
47
+ cancelEnsure(id){const entry=this.ensures.get(id);if(!entry)return;this.ensures.delete(id);clearTimeout(entry.timer);entry.controller.abort();}
31
48
  async request(command,values={}) {
32
49
  await this.ready;
33
50
  if(this.stopping||this.child.exitCode!==null)throw Error('Development listener is closed');
@@ -40,5 +57,5 @@ export class DevGateway {
40
57
  this.child.stdin.write(line);
41
58
  });
42
59
  }
43
- async close(){this.stopping=true;this.child.stdin.end();const timer=setTimeout(()=>this.child.kill('SIGKILL'),6000);try{await this.closed;}finally{clearTimeout(timer);}}
60
+ async close(){this.stopping=true;for(const id of this.ensures.keys())this.cancelEnsure(id);this.child.stdin.end();const timer=setTimeout(()=>this.child.kill('SIGKILL'),6000);try{await this.closed;}finally{clearTimeout(timer);}}
44
61
  }
@@ -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
+ }
@@ -0,0 +1,59 @@
1
+ // Only owned development navigation GETs opt into a partial Flight decoder. An intentional
2
+ // supersession closes that decoder while cancelling its sole upstream reader;
3
+ // genuine transport errors still reject. This stream never claims genuine EOF
4
+ // on behalf of the transport or retains response bytes as a reusable delta base.
5
+ export function supersedableDevelopmentFlight(source, signal) {
6
+ const reader = source.getReader();
7
+ let controller, closed = false;
8
+ const clean = () => {
9
+ signal.removeEventListener('abort', abort);
10
+ try { reader.releaseLock(); } catch {}
11
+ };
12
+ const cancel = reason => Promise.resolve(reader.cancel(reason)).catch(() => {}).finally(clean);
13
+ const abort = () => {
14
+ if (closed) return;
15
+ closed = true;
16
+ controller.close();
17
+ void cancel(signal.reason);
18
+ };
19
+ return new ReadableStream({
20
+ start(value) {
21
+ controller = value;
22
+ signal.addEventListener('abort', abort, { once: true });
23
+ if (signal.aborted) abort();
24
+ },
25
+ async pull(value) {
26
+ if (closed) return;
27
+ try {
28
+ const next = await reader.read();
29
+ if (closed) return;
30
+ // React's ordinary EOF rejects missing rows. With partial decoding
31
+ // enabled, reserve a clean close exclusively for owned supersession.
32
+ if (next.done) { closed = true; clean(); value.error(new Error('Connection closed.')); }
33
+ else value.enqueue(next.value);
34
+ } catch (error) {
35
+ if (closed) return;
36
+ closed = true;
37
+ value.error(error);
38
+ await cancel(error);
39
+ }
40
+ },
41
+ async cancel(reason) {
42
+ if (closed) return;
43
+ closed = true;
44
+ await cancel(reason);
45
+ },
46
+ }, { highWaterMark: 0 });
47
+ }
48
+
49
+ // A partial decoder also halts an unresolved root on supersession. Race its
50
+ // root promise explicitly so cancellation cannot leave navigation waiting on it.
51
+ export function developmentFlightRoot(root, signal) {
52
+ return new Promise((resolve, reject) => {
53
+ const clean = () => signal.removeEventListener('abort', abort);
54
+ const abort = () => { clean(); reject(signal.reason || new DOMException('Development refresh cancelled', 'AbortError')); };
55
+ signal.addEventListener('abort', abort, { once: true });
56
+ Promise.resolve(root).then(value => { clean(); resolve(value); }, error => { clean(); reject(error); });
57
+ if (signal.aborted) abort();
58
+ });
59
+ }
@@ -0,0 +1,33 @@
1
+ // Cancellation owns only a queued request. An executing build can also serve
2
+ // another navigation or a watcher update, and must finish normally.
3
+ export function createDevelopmentQueue({isStopping=()=>false,now=Date.now}={}) {
4
+ const waiting=[];let running=false;const idle=[];
5
+ const expired=()=>Error('Development request expired before compilation started');
6
+ function drain(){
7
+ if(running)return;
8
+ const entry=waiting.shift();
9
+ if(!entry){for(const resolve of idle.splice(0))resolve();return;}
10
+ entry.clean();running=true;
11
+ Promise.resolve().then(()=>{
12
+ if(isStopping())throw Error('Development is stopping');
13
+ if(entry.signal?.aborted||entry.deadlineMs!==undefined&&now()>=entry.deadlineMs)throw expired();
14
+ return entry.run();
15
+ }).then(entry.resolve,entry.reject).finally(()=>{running=false;drain();});
16
+ }
17
+ return {
18
+ enqueue(run,{signal,deadlineMs}={}) {
19
+ if(signal?.aborted||deadlineMs!==undefined&&(!Number.isSafeInteger(deadlineMs)||now()>=deadlineMs))return Promise.reject(expired());
20
+ return new Promise((resolve,reject)=>{
21
+ let timer;
22
+ const cancel=()=>{const index=waiting.indexOf(entry);if(index<0)return;waiting.splice(index,1);entry.clean();reject(expired());if(!running)drain();};
23
+ const entry={run,resolve,reject,signal,deadlineMs,clean(){clearTimeout(timer);signal?.removeEventListener('abort',cancel);}};
24
+ waiting.push(entry);signal?.addEventListener('abort',cancel,{once:true});
25
+ if(deadlineMs!==undefined)timer=setTimeout(cancel,Math.max(0,deadlineMs-now()));
26
+ // Defer the first start so same-turn cancellation cannot start work.
27
+ queueMicrotask(drain);
28
+ });
29
+ },
30
+ idle(){return running||waiting.length?new Promise(resolve=>idle.push(resolve)):Promise.resolve();},
31
+ get pending(){return waiting.length;},
32
+ };
33
+ }