@thomas.f/prnext 0.1.19-alpha → 0.1.21-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/app.mjs +46 -78
- package/build/build-lock.mjs +2 -71
- package/build/build-retention.mjs +119 -0
- package/build/build-timings.mjs +38 -0
- package/build/bundle-runtime.mjs +17 -16
- package/build/cache-command.mjs +31 -0
- package/build/check.mjs +41 -3
- package/build/compilation-cache.mjs +32 -12
- package/build/compilation-storage.mjs +190 -0
- package/build/compiler.mjs +5 -4
- package/build/config.mjs +23 -2
- package/build/create.mjs +1 -1
- package/build/css-modules.mjs +4 -0
- package/build/dev-action-graph.mjs +21 -0
- package/build/flight-summary.mjs +64 -0
- package/build/font-cache.mjs +9 -6
- package/build/font-files.mjs +1 -1
- package/build/index.mjs +57 -36
- package/build/inspect.mjs +3 -0
- package/build/inspection-summary.mjs +7 -2
- package/build/native-discovery.mjs +18 -6
- package/build/native-ppr.mjs +32 -0
- package/build/npm-packages.mjs +2 -2
- package/build/og.mjs +121 -1
- package/build/parallel-plan.mjs +55 -0
- package/build/persistent-cache.mjs +29 -24
- package/build/production-check.mjs +34 -0
- package/build/react-compiler-report.mjs +31 -8
- package/build/react-compiler.mjs +101 -10
- package/build/server-config.mjs +35 -0
- package/build/source-analysis.mjs +108 -5
- package/build/standalone-copy.mjs +48 -0
- package/build/standalone.mjs +2 -22
- package/build/static-adapter.mjs +7 -3
- package/build/transform-cache.mjs +17 -3
- package/build/transform.mjs +38 -6
- package/build/typescript-dev.mjs +3 -1
- package/cli.mjs +27 -5
- package/compat/app-layout-context.cjs +7 -3
- package/compat/diagnostics.cjs +5 -4
- package/compat/flight-delta.cjs +47 -12
- package/compat/index.d.ts +24 -1
- package/compat/navigation-authorization.cjs +8 -1
- package/compat/runtime-sample.cjs +56 -0
- package/compat/use-cache.cjs +5 -2
- package/docs/ai/compatibility.md +113 -10
- package/docs/ai/deployment.md +144 -0
- package/docs/ai/quickstart.md +5 -0
- package/docs/ai/troubleshooting.md +22 -0
- package/package.json +25 -22
- package/release/fontkit.mjs +31 -0
- package/release/verify.mjs +3 -0
- package/runtime/api.mjs +5 -2
- package/runtime/app-client.mjs +246 -54
- package/runtime/app-partial.mjs +2 -10
- package/runtime/app-prefetch.mjs +2 -2
- package/runtime/app-render.mjs +42 -13
- package/runtime/app-stream-channel.mjs +20 -6
- package/runtime/app-stream-html.mjs +30 -11
- package/runtime/app-styles.mjs +22 -4
- package/runtime/cache-components-rsc.mjs +26 -6
- package/runtime/cache-result.mjs +58 -0
- package/runtime/compile-cache-retention.mjs +53 -0
- package/runtime/compile-cache.mjs +28 -1
- package/runtime/dev-client.mjs +32 -8
- package/runtime/dev-compiler-broker.mjs +20 -23
- package/runtime/dev-gateway.mjs +23 -6
- package/runtime/dev-route-selection.mjs +64 -0
- package/runtime/dev-runtime-pool.mjs +3 -2
- package/runtime/dev-runtime-spare.mjs +1 -1
- package/runtime/dev-session-retention.mjs +154 -0
- package/runtime/development-flight.mjs +59 -0
- package/runtime/development-queue.mjs +33 -0
- package/runtime/development.mjs +37 -16
- package/runtime/env.mjs +9 -0
- package/runtime/flight-delta.mjs +31 -5
- package/runtime/flight-diagnostics.mjs +48 -0
- package/runtime/flight-tap.mjs +10 -5
- package/runtime/html-envelope.mjs +34 -0
- package/runtime/loopback-rpc.mjs +92 -0
- package/runtime/native-ppr.mjs +88 -0
- package/runtime/og/NOTICE.txt +1 -1
- package/runtime/og/index.node.mjs +76 -31216
- package/runtime/og/upstream.json +115 -0
- package/runtime/persistent/client.mjs +5 -3
- package/runtime/persistent/common.mjs +7 -18
- package/runtime/persistent/daemon.mjs +4 -12
- package/runtime/persistent/health.mjs +58 -0
- package/runtime/persistent/native-proxy.mjs +1 -1
- package/runtime/persistent/watchdog.mjs +12 -27
- package/runtime/prewarm.mjs +35 -0
- package/runtime/project-lock.mjs +71 -0
- package/runtime/request-transport.mjs +112 -32
- package/runtime/rsc-worker.mjs +58 -8
- package/runtime/storage-path.mjs +36 -0
- package/runtime/trace.mjs +44 -4
- package/runtime/worker.mjs +37 -17
- package/vendor/fontkit/NOTICE.txt +5 -0
- package/vendor/fontkit/SOURCE-NOTICES.txt +209 -0
- package/vendor/fontkit/index.mjs +3 -0
- package/vendor/fontkit/licenses/@swc__helpers/LICENSE +201 -0
- package/vendor/fontkit/licenses/base64-js/LICENSE +21 -0
- package/vendor/fontkit/licenses/brotli/readme.md +61 -0
- package/vendor/fontkit/licenses/clone/LICENSE +18 -0
- package/vendor/fontkit/licenses/dfa/README.md +83 -0
- package/vendor/fontkit/licenses/fast-deep-equal/LICENSE +21 -0
- package/vendor/fontkit/licenses/fontkit/README.md +266 -0
- package/vendor/fontkit/licenses/restructure/LICENSE +21 -0
- package/vendor/fontkit/licenses/tiny-inflate/LICENSE +21 -0
- package/vendor/fontkit/licenses/tslib/LICENSE.txt +12 -0
- package/vendor/fontkit/licenses/unicode-properties/LICENSE +8 -0
- package/vendor/fontkit/licenses/unicode-trie/LICENSE +8 -0
- package/vendor/fontkit/upstream.json +215 -0
package/runtime/dev-client.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { createConsoleCapture, isDevicePreview } from './dev-console.mjs';
|
|
|
4
4
|
import { createDevToolsClient } from './dev-tools-client.mjs';
|
|
5
5
|
import { createDevErrorOverlay } from './dev-error-overlay.mjs';
|
|
6
6
|
import { collectDevIssues, formatDevIssuesForAI } from './dev-error-report.mjs';
|
|
7
|
+
import { canSkipDevelopmentUpdate } from './dev-route-selection.mjs';
|
|
7
8
|
|
|
8
9
|
const state = globalThis.__PRNEXT_DEV__ ||= {
|
|
9
10
|
applications: new Map(), modules: new Map(), buildId: null, manifest: null,
|
|
@@ -154,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
|
|
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)
|
|
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();
|
|
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
|
|
2
|
-
import {randomBytes
|
|
1
|
+
import {createServer} from 'node:net';
|
|
2
|
+
import {randomBytes} from 'node:crypto';
|
|
3
3
|
import {readFile,realpath} from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import {requestLoopback,serveLoopback} from './loopback-rpc.mjs';
|
|
5
6
|
|
|
6
7
|
// Private control plane. Request headers and bodies never cross this endpoint.
|
|
7
8
|
export async function createDevCompilerBroker({compile,maxPending=64}) {
|
|
8
|
-
const token=randomBytes(32).toString('base64url');let pending=0;
|
|
9
|
-
const server=
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
res.writeHead(200,{'content-type':'application/json','cache-control':'no-store'}).end(JSON.stringify({directory}));
|
|
22
|
-
}catch(error){res.writeHead(500,{'content-type':'application/json'}).end(JSON.stringify({error:String(error.message).slice(0,2048)}));}
|
|
23
|
-
finally{pending--;}
|
|
9
|
+
const token=randomBytes(32).toString('base64url'),sockets=new Set();let pending=0;
|
|
10
|
+
const server=createServer(socket=>{
|
|
11
|
+
sockets.add(socket);socket.once('close',()=>sockets.delete(socket));
|
|
12
|
+
serveLoopback(socket,{token,context:'development-compiler',timeout:120000,maxResponseBytes:65536,handle:async(input,admission)=>{
|
|
13
|
+
if(pending>=maxPending)throw Error('Development compiler is busy. Retry shortly.');
|
|
14
|
+
pending++;
|
|
15
|
+
try{
|
|
16
|
+
if(typeof input.pattern!=='string'||!input.pattern.startsWith('/')||input.pattern.length>4096)throw Error('Invalid route pattern');
|
|
17
|
+
if(input.sources!==undefined&&(!Array.isArray(input.sources)||input.sources.length>32||!input.sources.every(p=>typeof p==='string'&&p.startsWith('/')&&p.length<=4096)))throw Error('Invalid retained route patterns');
|
|
18
|
+
const directory=await compile(input.pattern,input.sources||[],admission);
|
|
19
|
+
return {directory};
|
|
20
|
+
}finally{pending--;}
|
|
21
|
+
}});
|
|
24
22
|
});
|
|
25
|
-
server.
|
|
23
|
+
server.maxConnections=64;
|
|
26
24
|
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});
|
|
27
|
-
return {descriptor:JSON.stringify({port:server.address().port,token}),close:()=>new Promise(resolve=>{server.close(resolve);
|
|
25
|
+
return {descriptor:JSON.stringify({version:2,port:server.address().port,token}),close:()=>new Promise(resolve=>{server.close(resolve);for(const socket of sockets)socket.destroy();})};
|
|
28
26
|
}
|
|
29
27
|
|
|
30
28
|
export function deferredCompiler(descriptor,{root,home}) {
|
|
31
29
|
const value=JSON.parse(descriptor);
|
|
32
|
-
if(
|
|
30
|
+
if(value.version!==2||!Number.isInteger(value.port)||value.port<1||value.port>65535||!/^[\w-]{43}$/.test(value.token))throw Error('Invalid private compiler endpoint');
|
|
33
31
|
return async(pattern,signal,sources=[])=>{
|
|
34
|
-
const
|
|
35
|
-
const result=await response.json();if(!response.ok)throw Error(result.error||'Deferred route compilation failed');
|
|
32
|
+
const result=await requestLoopback(value,{pattern,sources},{context:'development-compiler',signal,timeout:120000,maxResponseBytes:65536});
|
|
36
33
|
const directory=await realpath(path.resolve(root,result.directory)),parent=await realpath(home),relative=path.relative(parent,directory);
|
|
37
34
|
if(!relative||relative.startsWith('..')||path.isAbsolute(relative))throw Error('Compiler generation escaped its development session');
|
|
38
35
|
const manifest=JSON.parse(await readFile(path.join(directory,'manifest.json'),'utf8'));
|
package/runtime/dev-gateway.mjs
CHANGED
|
@@ -1,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
|
-
|
|
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
|
-
|
|
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
|
+
}
|