@thomas.f/prnext 0.1.19-alpha → 0.1.20-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/app.mjs +46 -78
- package/build/build-lock.mjs +2 -71
- package/build/build-retention.mjs +119 -0
- package/build/bundle-runtime.mjs +9 -8
- package/build/cache-command.mjs +31 -0
- package/build/check.mjs +41 -3
- package/build/compilation-cache.mjs +32 -12
- package/build/compilation-storage.mjs +190 -0
- package/build/compiler.mjs +3 -3
- package/build/config.mjs +23 -2
- package/build/create.mjs +1 -1
- package/build/css-modules.mjs +4 -0
- package/build/dev-action-graph.mjs +21 -0
- package/build/font-cache.mjs +9 -6
- package/build/font-files.mjs +1 -1
- package/build/index.mjs +30 -13
- package/build/native-ppr.mjs +32 -0
- package/build/og.mjs +121 -1
- package/build/parallel-plan.mjs +55 -0
- package/build/persistent-cache.mjs +29 -24
- package/build/production-check.mjs +34 -0
- package/build/react-compiler-report.mjs +31 -8
- package/build/react-compiler.mjs +101 -10
- package/build/server-config.mjs +35 -0
- package/build/source-analysis.mjs +54 -4
- package/build/static-adapter.mjs +7 -3
- package/build/transform.mjs +38 -6
- package/build/typescript-dev.mjs +3 -1
- package/cli.mjs +14 -5
- package/compat/app-layout-context.cjs +7 -3
- package/compat/flight-delta.cjs +47 -12
- package/compat/index.d.ts +24 -1
- package/compat/navigation-authorization.cjs +8 -1
- package/docs/ai/compatibility.md +110 -7
- package/docs/ai/deployment.md +141 -0
- package/docs/ai/quickstart.md +5 -0
- package/package.json +12 -9
- package/release/fontkit.mjs +31 -0
- package/release/verify.mjs +3 -0
- package/runtime/api.mjs +5 -2
- package/runtime/app-client.mjs +150 -26
- package/runtime/app-partial.mjs +2 -10
- package/runtime/app-prefetch.mjs +2 -2
- package/runtime/app-render.mjs +11 -7
- package/runtime/app-stream-channel.mjs +3 -2
- package/runtime/app-styles.mjs +22 -4
- package/runtime/compile-cache-retention.mjs +53 -0
- package/runtime/compile-cache.mjs +28 -1
- package/runtime/dev-client.mjs +6 -3
- package/runtime/dev-gateway.mjs +3 -2
- package/runtime/dev-route-selection.mjs +64 -0
- package/runtime/dev-runtime-pool.mjs +3 -2
- package/runtime/dev-runtime-spare.mjs +1 -1
- package/runtime/dev-session-retention.mjs +154 -0
- package/runtime/development.mjs +27 -8
- package/runtime/env.mjs +9 -0
- package/runtime/flight-delta.mjs +31 -5
- package/runtime/native-ppr.mjs +35 -0
- package/runtime/og/NOTICE.txt +1 -1
- package/runtime/og/index.node.mjs +76 -31216
- package/runtime/og/upstream.json +115 -0
- package/runtime/persistent/client.mjs +4 -2
- package/runtime/persistent/daemon.mjs +3 -11
- package/runtime/persistent/health.mjs +58 -0
- package/runtime/project-lock.mjs +71 -0
- package/runtime/request-transport.mjs +112 -32
- package/runtime/storage-path.mjs +36 -0
- package/runtime/trace.mjs +44 -4
- package/runtime/worker.mjs +9 -4
- package/vendor/fontkit/NOTICE.txt +5 -0
- package/vendor/fontkit/SOURCE-NOTICES.txt +209 -0
- package/vendor/fontkit/index.mjs +3 -0
- package/vendor/fontkit/licenses/@swc__helpers/LICENSE +201 -0
- package/vendor/fontkit/licenses/base64-js/LICENSE +21 -0
- package/vendor/fontkit/licenses/brotli/readme.md +61 -0
- package/vendor/fontkit/licenses/clone/LICENSE +18 -0
- package/vendor/fontkit/licenses/dfa/README.md +83 -0
- package/vendor/fontkit/licenses/fast-deep-equal/LICENSE +21 -0
- package/vendor/fontkit/licenses/fontkit/README.md +266 -0
- package/vendor/fontkit/licenses/restructure/LICENSE +21 -0
- package/vendor/fontkit/licenses/tiny-inflate/LICENSE +21 -0
- package/vendor/fontkit/licenses/tslib/LICENSE.txt +12 -0
- package/vendor/fontkit/licenses/unicode-properties/LICENSE +8 -0
- package/vendor/fontkit/licenses/unicode-trie/LICENSE +8 -0
- package/vendor/fontkit/upstream.json +215 -0
package/runtime/app-client.mjs
CHANGED
|
@@ -26,6 +26,7 @@ const PREFETCH_LIMIT = 8;
|
|
|
26
26
|
const PREFETCH_LIFETIME = 30_000;
|
|
27
27
|
let application;
|
|
28
28
|
let strictMode = true;
|
|
29
|
+
const navigationStreams = new WeakMap();
|
|
29
30
|
|
|
30
31
|
function loadClientModule(source) {
|
|
31
32
|
if (typeof source === 'function') return source();
|
|
@@ -144,10 +145,11 @@ function NormalStyles({ css, fonts }) {
|
|
|
144
145
|
|
|
145
146
|
function AppRoot({ model, controller, committed, url, globalErrorComponent, globalErrorCss, retentionEpoch, retentionAuthorizationEpoch }) {
|
|
146
147
|
const [hydrated, setHydrated] = React.useState(false);
|
|
148
|
+
const flight = navigationStreams.get(model);
|
|
147
149
|
const cache = React.useRef(null);
|
|
148
150
|
if (!cache.current) cache.current = createActivityCache({ limit: controller._navigationCache?.maxEntries, ttl: controller._navigationCache?.ttlMs });
|
|
149
151
|
const activityCache = React.useMemo(() => ({ cache: cache.current, epoch: retentionEpoch, authorizationEpoch: retentionAuthorizationEpoch,
|
|
150
|
-
authorizationKey: model.authorizationKey, strictAuthorization: controller._navigationCache?.authCookies !== undefined }), [retentionEpoch, retentionAuthorizationEpoch, model.authorizationKey]);
|
|
152
|
+
authorizationKey: model.authorizationKey, strictAuthorization: controller._navigationCache?.authCookies !== undefined, flight }), [retentionEpoch, retentionAuthorizationEpoch, model.authorizationKey, flight]);
|
|
151
153
|
const previewDynamic = React.useCallback(() => { throw model.partialPreview.suspended; }, [model.partialPreview]);
|
|
152
154
|
useLayoutEffect(() => {
|
|
153
155
|
if (process.env.NODE_ENV === 'development' && model.instantDiagnostics?.length) globalThis.__PRNEXT_DEV__?.reportError(Object.assign(new Error(model.instantDiagnostics.join('\n')), { digest: 'PRNEXT_INSTANT_BLOCKING' }));
|
|
@@ -255,13 +257,13 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
|
|
|
255
257
|
// entry with the previous view's routing state on the next scroll event.
|
|
256
258
|
let historyRouting = currentRouting;
|
|
257
259
|
let currentModel = initialModel;
|
|
258
|
-
let committedView = { model: initialModel, routing: currentRouting, url: currentUrl.href };
|
|
259
|
-
let pendingView;
|
|
260
|
+
let committedView = { model: initialModel, routing: currentRouting, url: currentUrl.href, retentionEpoch: 0, authorizationEpoch: 0 };
|
|
261
|
+
let pendingView, pendingNavigation;
|
|
260
262
|
const historyFocus = new Map();
|
|
261
263
|
let currentKey = window.history.state?.__prnext?.key || 0;
|
|
262
264
|
let nextKey = currentKey + 1;
|
|
263
265
|
let navigation = 0;
|
|
264
|
-
let activeRequest, finishNavigation, finishCommit, pageHidden = false, notifyingPrefetch = false;
|
|
266
|
+
let activeRequest, finishNavigation, finishCommit, documentTimer, pageHidden = false, notifyingPrefetch = false, documentFallback = false;
|
|
265
267
|
const prefetches = new Map(), scheduler = createPrefetchScheduler();
|
|
266
268
|
const partialSegments = (typeof __PRNEXT_CACHE_COMPONENTS__ === 'undefined' ? cacheComponents : __PRNEXT_CACHE_COMPONENTS__) ? new Map() : null;
|
|
267
269
|
let segmentBytes = 0;
|
|
@@ -278,23 +280,49 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
|
|
|
278
280
|
}
|
|
279
281
|
}
|
|
280
282
|
|
|
281
|
-
function renderView(model, url, { token = navigation, canonical = true, committed } = {}) {
|
|
282
|
-
const view = { model, token };
|
|
283
|
+
function renderView(model, url, { token = navigation, canonical = true, kind = 'navigation', committed } = {}) {
|
|
284
|
+
const flight = navigationStreams.get(model), view = { model, token, kind, committed: false };
|
|
283
285
|
pendingView = view;
|
|
286
|
+
const promote = () => {
|
|
287
|
+
if (token !== navigation || pendingView !== view || !view.committed || flight?.complete === false) return;
|
|
288
|
+
pendingView = undefined;
|
|
289
|
+
if (flight) flight.committed = undefined;
|
|
290
|
+
// A committed loading shell can still contain rows owned by a live
|
|
291
|
+
// fetch. Keep the last fully received view available before aborting it.
|
|
292
|
+
// Authorized PPR previews likewise never become the canonical model.
|
|
293
|
+
if (canonical) committedView = { model, routing: model.routing, url, retentionEpoch: controller._retentionEpoch, authorizationEpoch: controller._authorizationEpoch };
|
|
294
|
+
};
|
|
295
|
+
if (flight) flight.committed = promote;
|
|
284
296
|
startTransition(() => root.render(appView({ model, controller, url, globalErrorComponent, globalErrorCss,
|
|
285
297
|
committed: () => {
|
|
286
298
|
if (token !== navigation || pendingView !== view) return;
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
// from which a later navigation should inherit its layout cache.
|
|
290
|
-
if (canonical) committedView = { model, routing: model.routing, url };
|
|
299
|
+
view.committed = true;
|
|
300
|
+
promote();
|
|
291
301
|
committed?.();
|
|
292
302
|
},
|
|
293
303
|
})));
|
|
294
304
|
}
|
|
295
305
|
|
|
296
306
|
function abandonPendingView() {
|
|
297
|
-
if (!pendingView) return;
|
|
307
|
+
if (!pendingView) return true;
|
|
308
|
+
const flight = navigationStreams.get(pendingView.model);
|
|
309
|
+
const strictAuthorization = controller._navigationCache?.authCookies !== undefined;
|
|
310
|
+
if (flight) flight.committed = undefined;
|
|
311
|
+
if (pendingView.kind === 'action' && committedView.authorizationEpoch === controller._authorizationEpoch &&
|
|
312
|
+
(!strictAuthorization || committedView.model.authorizationKey === currentModel.authorizationKey)) {
|
|
313
|
+
// An action's received result can invoke a returned action before React
|
|
314
|
+
// commits its UI. This response is never owned/aborted by activeRequest;
|
|
315
|
+
// adopt its model instead of rolling back the prior mutation's epoch.
|
|
316
|
+
pendingView = undefined;
|
|
317
|
+
committedView = { model: currentModel, routing: currentRouting, url: currentUrl.href, retentionEpoch: controller._retentionEpoch, authorizationEpoch: controller._authorizationEpoch };
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
// A new authorized shell or explicit invalidation can make the last full
|
|
321
|
+
// model private to an obsolete session. Reauthorize through a document
|
|
322
|
+
// request instead of briefly restoring that model while C is pending.
|
|
323
|
+
if ((strictAuthorization && committedView.model.authorizationKey !== currentModel.authorizationKey) ||
|
|
324
|
+
committedView.retentionEpoch !== controller._retentionEpoch ||
|
|
325
|
+
committedView.authorizationEpoch !== controller._authorizationEpoch) return false;
|
|
298
326
|
pendingView = undefined;
|
|
299
327
|
currentModel = committedView.model;
|
|
300
328
|
currentRouting = committedView.routing;
|
|
@@ -302,8 +330,46 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
|
|
|
302
330
|
// the root model. Replace that suspended render before abort rejects its
|
|
303
331
|
// remaining rows; it must not poison the retained layout or page cache.
|
|
304
332
|
root.render(appView({ model: committedView.model, controller, url: committedView.url, globalErrorComponent, globalErrorCss }));
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function fallbackDocument(url, replace = true, defer = false) {
|
|
337
|
+
// The invalidated document stays blocked until its replacement arrives.
|
|
338
|
+
// Repeated synchronous actions must neither dispatch nor start reloads.
|
|
339
|
+
if (documentFallback) return;
|
|
340
|
+
documentFallback = true;
|
|
341
|
+
navigation++;
|
|
342
|
+
activeRequest?.abort();
|
|
343
|
+
appStyleManager(document).cancel();
|
|
344
|
+
finishNavigation?.(); finishNavigation = undefined;
|
|
345
|
+
invalidatePrefetches();
|
|
346
|
+
flightTransport.clear();
|
|
347
|
+
if (controller._navigationCache?.authCookies !== undefined) document.documentElement.style.visibility = 'hidden';
|
|
348
|
+
const request = () => {
|
|
349
|
+
documentTimer = undefined;
|
|
350
|
+
if (pageHidden) return;
|
|
351
|
+
if (replace) window.location.replace(url);
|
|
352
|
+
else window.location.assign(url);
|
|
353
|
+
};
|
|
354
|
+
// An executed action must settle its promise chain before document
|
|
355
|
+
// replacement suspends the old realm. Its POST guard is already latched.
|
|
356
|
+
if (defer) documentTimer = setTimeout(request, 0);
|
|
357
|
+
else request();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function requiresAuthorizedDocument(model) {
|
|
361
|
+
if (controller._navigationCache?.authCookies === undefined) return false;
|
|
362
|
+
const identity = model?.authorizationKey;
|
|
363
|
+
// Activity entries are only part of the retained tree: shared client
|
|
364
|
+
// layouts and server layout-cache props can also belong to the old user.
|
|
365
|
+
// An unidentified static model cannot prove that HttpOnly cookies stayed
|
|
366
|
+
// the same. Cross this boundary before adopting any cached layout state.
|
|
367
|
+
return typeof identity !== 'string' || !identity || identity !== currentModel.authorizationKey ||
|
|
368
|
+
committedView.authorizationEpoch !== controller._authorizationEpoch;
|
|
305
369
|
}
|
|
306
370
|
|
|
371
|
+
const actionReloadError = () => new Error('The session changed while this page was loading. Reload before repeating the action.');
|
|
372
|
+
|
|
307
373
|
const flightTransport = createFlightTransport();
|
|
308
374
|
async function requestFlight(url, signal, routing = currentRouting, onPartial, {skipEnsure=false,fullFlight=false}={}) {
|
|
309
375
|
if(process.env.NODE_ENV==='development'&&!routing?.refresh&&!skipEnsure)await globalThis.__PRNEXT_DEV__?.ensureRoute?.(url.href);
|
|
@@ -326,12 +392,14 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
|
|
|
326
392
|
}
|
|
327
393
|
await onPartial?.(response, destination);
|
|
328
394
|
let model;
|
|
329
|
-
|
|
395
|
+
const flight = { complete: false };
|
|
396
|
+
try { model = assertModel(await createFromFetch(Promise.resolve(await flightTransport.response(response,base,{onEnd(){flight.complete = true;flight.committed?.();}})), flightOptions)); }
|
|
330
397
|
catch(error) {
|
|
331
398
|
if(!delta||fullFlight||signal?.aborted)throw error;
|
|
332
399
|
flightTransport.clear();
|
|
333
400
|
return requestFlight(url,signal,routing,onPartial,{skipEnsure:true,fullFlight:true});
|
|
334
401
|
}
|
|
402
|
+
navigationStreams.set(model, flight);
|
|
335
403
|
model.router.basePath = basePath;
|
|
336
404
|
const rewrite = readRewriteHeader(response);
|
|
337
405
|
if (rewrite) model.rewrite = rewrite;
|
|
@@ -408,15 +476,20 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
|
|
|
408
476
|
|
|
409
477
|
function callServer(id, args) {
|
|
410
478
|
if (!root) return Promise.reject(new Error('Server Actions cannot run during initial rendering'));
|
|
479
|
+
if (pageHidden || documentFallback) return Promise.reject(actionReloadError());
|
|
411
480
|
const token = ++navigation;
|
|
412
481
|
finishCommit?.();
|
|
413
482
|
const target = new URL(window.location.href);
|
|
414
483
|
saveScroll();
|
|
415
|
-
abandonPendingView();
|
|
484
|
+
const restored = abandonPendingView();
|
|
416
485
|
activeRequest?.abort();
|
|
417
486
|
appStyleManager(document).cancel();
|
|
418
487
|
finishNavigation?.(); finishNavigation = undefined;
|
|
419
488
|
activeRequest = undefined;
|
|
489
|
+
if (!restored) {
|
|
490
|
+
fallbackDocument(target.href);
|
|
491
|
+
return Promise.reject(actionReloadError());
|
|
492
|
+
}
|
|
420
493
|
purgeRetainedPages();
|
|
421
494
|
// Mutations are dispatched in order, including Set-Cookie effects required
|
|
422
495
|
// by a subsequent action. Capture their route priority when invoked: a
|
|
@@ -427,6 +500,7 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
|
|
|
427
500
|
}
|
|
428
501
|
|
|
429
502
|
async function dispatchAction(id, args, token, target) {
|
|
503
|
+
if (pageHidden || documentFallback) throw actionReloadError();
|
|
430
504
|
const release = scheduler.navigation();
|
|
431
505
|
try { return await performAction(id, args, token, target); } finally { release(); }
|
|
432
506
|
}
|
|
@@ -448,6 +522,7 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
|
|
|
448
522
|
invalidatePrefetches();
|
|
449
523
|
const temporaryReferences = createTemporaryReferenceSet();
|
|
450
524
|
const body = await encodeReply(args, { temporaryReferences });
|
|
525
|
+
if (pageHidden || documentFallback) throw actionReloadError();
|
|
451
526
|
const response = await fetch(target.href, {
|
|
452
527
|
method: 'POST',
|
|
453
528
|
headers: { ...deploymentHeaders(deploymentId), 'Next-Action': id, RSC: '1', Accept: 'text/x-component', ...(currentRouting ? { 'x-prnext-router-state': encodeURIComponent(JSON.stringify({ ...currentRouting, refresh: true })) } : {}) },
|
|
@@ -474,8 +549,21 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
|
|
|
474
549
|
};
|
|
475
550
|
if (process.env.NODE_ENV === 'development') await waitForDevelopmentUpdate();
|
|
476
551
|
controller._retentionEpoch++;
|
|
552
|
+
const validOutcome = model && typeof model === 'object' && (Object.hasOwn(model, 'tree') || Object.hasOwn(model, 'actionResult') || model.actionError || model.actionRedirect || model.routingInvalid);
|
|
553
|
+
if (!validOutcome) throw new Error('The Server Action response did not contain a result');
|
|
554
|
+
if (Object.hasOwn(model, 'tree') && !model.routingInvalid) assertModel(model);
|
|
555
|
+
if (requiresAuthorizedDocument(model)) {
|
|
556
|
+
// A queued action or newer navigation can supersede the render token,
|
|
557
|
+
// but cannot make an observed account change safe to ignore. Complete
|
|
558
|
+
// this one POST and block the queue before any subsequent mutation.
|
|
559
|
+
const latest = pendingNavigation?.token === navigation ? pendingNavigation : undefined;
|
|
560
|
+
const redirected = token === navigation && model.actionRedirect ? navigationUrl(model.actionRedirect.url) : undefined;
|
|
561
|
+
const destination = latest?.url || redirected?.href || window.location.href;
|
|
562
|
+
fallbackDocument(destination, latest ? latest.replace : redirected ? model.actionRedirect.type === 'replace' : true, true);
|
|
563
|
+
return outcome();
|
|
564
|
+
}
|
|
477
565
|
if (model?.routingInvalid || (model?.rootLayout && currentModel.rootLayout && model.rootLayout !== currentModel.rootLayout)) {
|
|
478
|
-
if (token === navigation)
|
|
566
|
+
if (token === navigation) fallbackDocument(target.href);
|
|
479
567
|
return outcome();
|
|
480
568
|
}
|
|
481
569
|
if (model?.actionRedirect) {
|
|
@@ -497,7 +585,7 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
|
|
|
497
585
|
currentModel = model;
|
|
498
586
|
historyRouting = currentRouting;
|
|
499
587
|
appStyleManager(document).handoff(model.globalError ? [] : model.css || []);
|
|
500
|
-
renderView(model, target.href, { token });
|
|
588
|
+
renderView(model, target.href, { token, kind: 'action' });
|
|
501
589
|
}
|
|
502
590
|
if (model?.actionError) return outcome();
|
|
503
591
|
// Flight's JSON reviver removes object fields encoded as $undefined. A
|
|
@@ -509,21 +597,26 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
|
|
|
509
597
|
}
|
|
510
598
|
|
|
511
599
|
async function navigate(href, { replace = false, scroll = true, pop = false, savedScroll, savedRouting, refresh = false } = {}) {
|
|
512
|
-
if (pageHidden) return;
|
|
600
|
+
if (pageHidden || documentFallback) return;
|
|
513
601
|
const target = navigationUrl(href);
|
|
514
602
|
if (!isApplicationURL(target, window.location.origin, basePath) || isHtmlDocumentRoute(htmlOnlyRoutes, removeBasePath(target.pathname, basePath))) {
|
|
515
|
-
|
|
516
|
-
else window.location.assign(target.href);
|
|
603
|
+
fallbackDocument(target.href, replace);
|
|
517
604
|
return;
|
|
518
605
|
}
|
|
519
606
|
const token = ++navigation;
|
|
607
|
+
const intent = { token, url: target.href, replace: replace || pop || refresh };
|
|
608
|
+
pendingNavigation = intent;
|
|
520
609
|
finishCommit?.();
|
|
521
610
|
saveScroll();
|
|
522
|
-
abandonPendingView();
|
|
611
|
+
const restored = abandonPendingView();
|
|
523
612
|
activeRequest?.abort();
|
|
524
613
|
appStyleManager(document).cancel();
|
|
525
614
|
finishNavigation?.(); finishNavigation = undefined;
|
|
526
615
|
activeRequest = undefined;
|
|
616
|
+
if (!restored) {
|
|
617
|
+
fallbackDocument(target.href, replace || pop || refresh);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
527
620
|
|
|
528
621
|
function updateHistory(destination) {
|
|
529
622
|
historyRouting = currentRouting;
|
|
@@ -545,6 +638,7 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
|
|
|
545
638
|
target.pathname === committedUrl.pathname && target.search === committedUrl.search) {
|
|
546
639
|
updateHistory(target);
|
|
547
640
|
if (scroll) scrollToTarget(target, savedScroll);
|
|
641
|
+
if (pendingNavigation === intent) pendingNavigation = undefined;
|
|
548
642
|
return;
|
|
549
643
|
}
|
|
550
644
|
|
|
@@ -575,6 +669,10 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
|
|
|
575
669
|
const abort = new AbortController();
|
|
576
670
|
activeRequest = abort;
|
|
577
671
|
result = await navigateFlight(target, abort.signal, currentRouting, async (response, destination) => {
|
|
672
|
+
// A cached shell (including any cached authorization key) cannot
|
|
673
|
+
// establish this request's live session identity. Strict account
|
|
674
|
+
// isolation waits for the freshly authorized Flight model instead.
|
|
675
|
+
if (controller._navigationCache?.authCookies !== undefined) return;
|
|
578
676
|
if (!preview || token !== navigation || response.headers.get('x-prnext-ppr-id') !== preview.id ||
|
|
579
677
|
(preview.model.rootLayout && currentModel.rootLayout && preview.model.rootLayout !== currentModel.rootLayout)) return;
|
|
580
678
|
// Auth, middleware and rewrites ran again. A changed target or a
|
|
@@ -599,6 +697,11 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
|
|
|
599
697
|
if (token !== navigation) return;
|
|
600
698
|
if (!isApplicationURL(result.url, window.location.origin, basePath)) throw new Error('The navigation destination requires a document request');
|
|
601
699
|
if (result.model.rootLayout && currentModel.rootLayout && result.model.rootLayout !== currentModel.rootLayout) throw new Error('A different root layout requires a document request');
|
|
700
|
+
if (requiresAuthorizedDocument(result.model)) {
|
|
701
|
+
activeRequest?.abort();
|
|
702
|
+
fallbackDocument(result.url.href, replace || pop || refresh);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
602
705
|
prepareLayoutCache(result.model, currentModel);
|
|
603
706
|
await appStyleManager(document).prepare(result.model.globalError ? [] : result.model.css || [], activeRequest?.signal);
|
|
604
707
|
if (token !== navigation) return;
|
|
@@ -629,22 +732,29 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
|
|
|
629
732
|
}
|
|
630
733
|
// Pages Router routes, public files, and unavailable Flight endpoints use
|
|
631
734
|
// a normal document navigation, including the browser's redirect handling.
|
|
632
|
-
|
|
633
|
-
else window.location.assign(target.href);
|
|
735
|
+
fallbackDocument(target.href, replace || pop || refresh);
|
|
634
736
|
} finally {
|
|
737
|
+
if (pendingNavigation === intent) pendingNavigation = undefined;
|
|
635
738
|
if (finishNavigation === releaseNavigation) finishNavigation = undefined;
|
|
636
739
|
releaseNavigation();
|
|
637
740
|
}
|
|
638
741
|
}
|
|
639
742
|
|
|
640
743
|
function purgeRetainedPages() {
|
|
744
|
+
if (pageHidden || documentFallback) return false;
|
|
641
745
|
saveScroll();
|
|
642
|
-
abandonPendingView()
|
|
746
|
+
if (!abandonPendingView()) {
|
|
747
|
+
activeRequest?.abort();
|
|
748
|
+
fallbackDocument(currentUrl.href);
|
|
749
|
+
return false;
|
|
750
|
+
}
|
|
643
751
|
controller._retentionEpoch++;
|
|
644
752
|
historyFocus.clear();
|
|
753
|
+
if (committedView.model === currentModel && committedView.authorizationEpoch === controller._authorizationEpoch) committedView = { ...committedView, retentionEpoch: controller._retentionEpoch };
|
|
645
754
|
// This only updates retention for the already committed model. It must
|
|
646
755
|
// not become a new pending Flight view or a development commit barrier.
|
|
647
756
|
startTransition(() => root.render(appView({ model: currentModel, controller, url: committedView.url, globalErrorComponent, globalErrorCss })));
|
|
757
|
+
return true;
|
|
648
758
|
}
|
|
649
759
|
|
|
650
760
|
const controller = {
|
|
@@ -656,13 +766,13 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
|
|
|
656
766
|
replace: (href, options) => { void navigate(navigationUrl(href), { ...options, replace: true }); },
|
|
657
767
|
refresh: () => {
|
|
658
768
|
invalidatePrefetches();
|
|
659
|
-
purgeRetainedPages();
|
|
769
|
+
if (!purgeRetainedPages()) return;
|
|
660
770
|
void navigate(new URL(window.location.href), { refresh: true });
|
|
661
771
|
},
|
|
662
772
|
back: () => { saveScroll(); window.history.back(); },
|
|
663
773
|
forward: () => { saveScroll(); window.history.forward(); },
|
|
664
774
|
prefetch(href, options = {}) {
|
|
665
|
-
if (pageHidden) return;
|
|
775
|
+
if (pageHidden || documentFallback) return;
|
|
666
776
|
const target = navigationUrl(href);
|
|
667
777
|
if (!isApplicationURL(target, window.location.origin, basePath) || isHtmlDocumentRoute(htmlOnlyRoutes, removeBasePath(target.pathname, basePath))) return;
|
|
668
778
|
target.hash = '';
|
|
@@ -727,8 +837,22 @@ async function startApp({ deploymentId, htmlOnlyRoutes, clientModules = {}, base
|
|
|
727
837
|
window.addEventListener('popstate', event => {
|
|
728
838
|
void navigate(new URL(window.location.href), { pop: true, savedScroll: event.state?.__prnext?.scroll, savedRouting: event.state?.__prnext?.routing });
|
|
729
839
|
});
|
|
730
|
-
window.addEventListener('pagehide', () => {
|
|
731
|
-
|
|
840
|
+
window.addEventListener('pagehide', () => {
|
|
841
|
+
saveScroll(); pageHidden = true; navigation++; finishCommit?.(); abandonPendingView();
|
|
842
|
+
clearTimeout(documentTimer); documentTimer = undefined;
|
|
843
|
+
activeRequest?.abort(); appStyleManager(document).cancel(); finishNavigation?.(); finishNavigation = undefined; invalidatePrefetches();
|
|
844
|
+
// A cached document may contain an account's shared layout state. Keep it
|
|
845
|
+
// hidden if restored from BFCache until the next document authorizes it.
|
|
846
|
+
if (controller._navigationCache?.authCookies !== undefined) document.documentElement.style.visibility = 'hidden';
|
|
847
|
+
});
|
|
848
|
+
window.addEventListener('pageshow', event => {
|
|
849
|
+
pageHidden = false;
|
|
850
|
+
if (!event.persisted) return;
|
|
851
|
+
if (controller._navigationCache?.authCookies !== undefined) {
|
|
852
|
+
documentFallback = false;
|
|
853
|
+
fallbackDocument(window.location.href);
|
|
854
|
+
} else controller.refresh();
|
|
855
|
+
});
|
|
732
856
|
window.addEventListener('prnext:invalidate-navigation', () => { controller._authorizationEpoch++; controller.refresh(); });
|
|
733
857
|
let scrollFrame;
|
|
734
858
|
window.addEventListener('scroll', () => {
|
package/runtime/app-partial.mjs
CHANGED
|
@@ -8,9 +8,10 @@ import { appContent } from './app-content.mjs';
|
|
|
8
8
|
import { runRequestContext, currentRequest } from '../compat/headers.cjs';
|
|
9
9
|
import { cachedValue, cacheGeneration, flushCacheWork } from '../compat/data-cache.cjs';
|
|
10
10
|
import { partialModel, preparePartialModel } from './app-partial-model.mjs';
|
|
11
|
-
import {
|
|
11
|
+
import { escapeHtml, MAX_RESPONSE_BYTES } from './http.mjs';
|
|
12
12
|
import { fontPreloads } from './font-preload.mjs';
|
|
13
13
|
import { PartialArtifactCache } from './partial-artifact-cache.mjs';
|
|
14
|
+
export {partialShell} from './native-ppr.mjs';
|
|
14
15
|
|
|
15
16
|
const artifacts = new Map();
|
|
16
17
|
const parsedArtifacts = new PartialArtifactCache();
|
|
@@ -32,7 +33,6 @@ async function readArtifact(file) {
|
|
|
32
33
|
}
|
|
33
34
|
return value;
|
|
34
35
|
}
|
|
35
|
-
|
|
36
36
|
async function bytes(stream) {
|
|
37
37
|
const chunks = [];
|
|
38
38
|
let length = 0;
|
|
@@ -120,11 +120,3 @@ export async function partialArtifact({ request, route, manifest, filename, iden
|
|
|
120
120
|
} finally { await flushCacheWork(context); }
|
|
121
121
|
});
|
|
122
122
|
}
|
|
123
|
-
|
|
124
|
-
export function partialShell(artifact, route) {
|
|
125
|
-
const bootstrap = '<script>self.__PRNEXT_FLIGHT_STREAM__=self.__PRNEXT_FLIGHT_STREAM__||[];</script>' +
|
|
126
|
-
(route.client ? `<script type="module" async src="${escapeHtml(route.client)}"></script>` : '');
|
|
127
|
-
const end = artifact.shell.lastIndexOf('</body>');
|
|
128
|
-
if (end < 0 || !/<\/body><\/html>$/i.test(artifact.shell)) throw new Error('Invalid partial prerender document');
|
|
129
|
-
return artifact.shell.slice(0, end) + bootstrap + artifact.shell.slice(end);
|
|
130
|
-
}
|
package/runtime/app-prefetch.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Prefetch contains only prerendered segments. Request-dependent holes are
|
|
2
2
|
// resolved by a fresh, authenticated navigation; they never enter this cache.
|
|
3
3
|
import { PARTIAL_PREFETCH_BYTES } from './app-prefetch-constants.mjs';
|
|
4
|
+
import { decodeBase64 } from '../compat/base64.cjs';
|
|
4
5
|
export { PARTIAL_PREFETCH_TYPE, PARTIAL_PREFETCH_BYTES, PARTIAL_PREFETCH_CACHE_BYTES } from './app-prefetch-constants.mjs';
|
|
5
6
|
|
|
6
7
|
export async function readPartialPrefetch(response) {
|
|
@@ -27,7 +28,6 @@ export async function readPartialPrefetch(response) {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export function partialFlightStream(base64) {
|
|
30
|
-
const
|
|
31
|
-
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
|
31
|
+
const bytes = decodeBase64(base64);
|
|
32
32
|
return new ReadableStream({ start(controller) { controller.enqueue(bytes); controller.close(); } });
|
|
33
33
|
}
|
package/runtime/app-render.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import {reportRequestError,acknowledgeRequestError} from './instrumentation.mjs';
|
|
2
2
|
import {currentTraceparent} from './trace.mjs';
|
|
3
3
|
import React from 'react';
|
|
4
|
-
import { randomUUID, createHmac
|
|
4
|
+
import { randomUUID, createHmac } from 'node:crypto';
|
|
5
|
+
import { developmentNavigationSecret } from '../compat/navigation-authorization.cjs';
|
|
5
6
|
import diagnostics from '../compat/diagnostics.cjs';
|
|
6
7
|
import { rscResourceLimits } from './profile.mjs';
|
|
7
8
|
import { fontPreloads } from './font-preload.mjs';
|
|
@@ -28,6 +29,7 @@ import { installFetchCache } from './fetch-cache.mjs';
|
|
|
28
29
|
import { partialArtifact, partialShell } from './app-partial.mjs';
|
|
29
30
|
import { partialKeyMap } from './app-partial-model.mjs';
|
|
30
31
|
import { partialIdentity } from './partial-artifact-cache.mjs';
|
|
32
|
+
import {nativePprArtifact} from './native-ppr.mjs';
|
|
31
33
|
import { isDraftRequest } from '../compat/draft.cjs';
|
|
32
34
|
import { createRscLifecycle } from './rsc-lifecycle.mjs';
|
|
33
35
|
import { trackRequestWork } from './request-work.mjs';
|
|
@@ -43,7 +45,6 @@ const metadata = new WeakMap();
|
|
|
43
45
|
const artifacts = new WeakMap();
|
|
44
46
|
const edgeManifests = new WeakMap();
|
|
45
47
|
const emptyManifest = Object.freeze({});
|
|
46
|
-
const developmentAuthorizationSecret = randomBytes(32).toString('hex');
|
|
47
48
|
let nextId = 0;
|
|
48
49
|
const pending = new Map();
|
|
49
50
|
const noop = () => {};
|
|
@@ -506,7 +507,7 @@ export async function renderAppPage(options) {
|
|
|
506
507
|
return differentialFlight(response, options);
|
|
507
508
|
}
|
|
508
509
|
|
|
509
|
-
async function renderAppPageInner({ modulePath, route = {}, manifest = {}, distDir = path.dirname(path.dirname(modulePath)), url = 'http://localhost/', originalUrl, params = {}, method = 'GET', headers = {}, routingRequestHeaders, routingResolver, body = '', production = process.env.NODE_ENV === 'production', stream = false, signal, deferFinalizer }) {
|
|
510
|
+
async function renderAppPageInner({ modulePath, route = {}, manifest = {}, distDir = path.dirname(path.dirname(modulePath)), url = 'http://localhost/', originalUrl, params = {}, method = 'GET', headers = {}, routingRequestHeaders, routingResolver, body = '', production = process.env.NODE_ENV === 'production', stream = false, signal, deferFinalizer, renderMode }) {
|
|
510
511
|
if (route.htmlOnly) return { status:409, headers:{'content-type':'text/plain; charset=utf-8','cache-control':'private, no-store','x-prnext-document-navigation':'1'}, body:Buffer.from('This HTML-only page requires its prerendered document.') };
|
|
511
512
|
let clientModules = manifest.app?.clientModules || emptyManifest;
|
|
512
513
|
if (route.cacheConfig?.runtime === 'edge') {
|
|
@@ -522,7 +523,7 @@ async function renderAppPageInner({ modulePath, route = {}, manifest = {}, distD
|
|
|
522
523
|
}
|
|
523
524
|
const responseHeaders = { 'cache-control': 'private, no-cache, no-store, max-age=0', vary: 'RSC' };
|
|
524
525
|
const request = { modulePath, routePattern: route.pattern, cacheConfig: route.cacheConfig, css: route.css || [], cssScopes: route.cssScopes || [], fonts: route.fonts || [], nonce: scriptNonce(headers), basePath: manifest.config?.basePath || '', trailingSlash: manifest?.config?.trailingSlash || false, skipTrailingSlashRedirect: manifest?.config?.skipTrailingSlashRedirect || false, url, originalUrl, params, method, headers, clientModules, production,
|
|
525
|
-
distDir, traceparent:currentTraceparent(), instrumentation:manifest.instrumentation, strictMode: manifest.config?.reactStrictMode !== false, cacheComponents: manifest.config?.cacheComponents, navigationCache: manifest.config?.navigationCache, authorizationSecret: production ? manifest.app?.actionKey :
|
|
526
|
+
distDir, traceparent:currentTraceparent(), instrumentation:manifest.instrumentation, strictMode: manifest.config?.reactStrictMode !== false, cacheComponents: manifest.config?.cacheComponents, navigationCache: manifest.config?.navigationCache, authorizationSecret: production ? manifest.app?.actionKey : developmentNavigationSecret(manifest.app?.actionKey), partialPrefetching: manifest.config?.partialPrefetching, cacheLife: manifest.config?.cacheLife, cacheHandlers: manifest.config?.cacheHandlers, cacheHandler: manifest.config?.cacheHandler, cacheMaxMemorySize: manifest.config?.cacheMaxMemorySize, serverActions: manifest.config?.serverActions, previewModeId: manifest.previewModeId, actions: manifest.app?.actions || emptyManifest, actionKey: manifest.app?.actionKey };
|
|
526
527
|
if (route.parallel) {
|
|
527
528
|
request.routingResolver = routingResolver;
|
|
528
529
|
request.skipProxyUrlNormalize = manifest.config?.skipProxyUrlNormalize;
|
|
@@ -554,6 +555,8 @@ async function renderAppPageInner({ modulePath, route = {}, manifest = {}, distD
|
|
|
554
555
|
}
|
|
555
556
|
}
|
|
556
557
|
const header = name => Object.entries(headers).find(([key]) => key.toLowerCase() === name)?.[1];
|
|
558
|
+
const native = await nativePprArtifact({renderMode, manifest, route, url, distDir});
|
|
559
|
+
if (native) request.nativePpr = native.descriptor;
|
|
557
560
|
if (request.partialPrefetching && manifest.app?.actionKey) {
|
|
558
561
|
// Preview identity includes authorization, but exposes no cookie material or
|
|
559
562
|
// guessable credential hash. A changed login cannot reveal an old preview.
|
|
@@ -591,7 +594,7 @@ async function renderAppPageInner({ modulePath, route = {}, manifest = {}, distD
|
|
|
591
594
|
const generic = route.pprGeneric?.find(item => Object.entries(item.params).every(([name, value]) => JSON.stringify(params[name]) === JSON.stringify(value)));
|
|
592
595
|
const filename = (!originalUrl ? route.ppr?.[pathname] : undefined) || generic?.file;
|
|
593
596
|
if (filename || route.pprFallback) {
|
|
594
|
-
const artifact = await partialArtifact({ request, route, manifest, filename, identity: filename === generic?.file ? filename : `${pathname}:${originalUrl ? new URL(originalUrl).pathname : ''}`, signal,
|
|
597
|
+
const artifact = native?.artifact || await partialArtifact({ request, route, manifest, filename, identity: filename === generic?.file ? filename : `${pathname}:${originalUrl ? new URL(originalUrl).pathname : ''}`, signal,
|
|
595
598
|
regenerate: async build => (await import('./app-static.mjs')).prerenderAppRoute({ modulePath, route, manifest, distDir, production, renderMode:'isr',
|
|
596
599
|
path: build?.generic?.path || pathname, originalUrl: build?.generic ? undefined : originalUrl,
|
|
597
600
|
params: build?.generic?.params || params, partialParams: build?.generic?.unknown }),
|
|
@@ -672,6 +675,7 @@ async function renderPartialAppPage({ request, route, artifact, responseHeaders,
|
|
|
672
675
|
const keys = [...partialKeyMap(artifact.keyScopes, request.params, removeBasePath(new URL(request.url).pathname, request.basePath))];
|
|
673
676
|
const partialRequest = { ...request, partialFlight: artifact.flight, ...(keys.length ? { partialKeys: keys } : {}) };
|
|
674
677
|
const headers = { ...artifact.headers, ...responseHeaders, 'x-prnext-prerender': artifact.complete ? 'complete' : 'partial', 'content-type': rsc ? 'text/x-component; charset=utf-8' : 'text/html; charset=utf-8' };
|
|
678
|
+
if (request.nativePpr) headers['x-prnext-native-ppr'] = request.nativePpr.artifactHash;
|
|
675
679
|
const identity = partialIdentity(artifact);
|
|
676
680
|
headers['x-prnext-ppr-id'] = identity;
|
|
677
681
|
if (prefetch) {
|
|
@@ -703,7 +707,7 @@ async function renderPartialAppPage({ request, route, artifact, responseHeaders,
|
|
|
703
707
|
let rendered;
|
|
704
708
|
const cancel = async reason => { abort.abort(reason || new Error('Partial response cancelled')); await rendered?.cancel?.(reason); };
|
|
705
709
|
const body = (async function* () {
|
|
706
|
-
let sent = 0;
|
|
710
|
+
let sent = request.nativePpr ? Buffer.byteLength(shell.replace(/<\/body><\/html>$/i, '')) : 0;
|
|
707
711
|
const bounded = chunk => {
|
|
708
712
|
sent += chunk.byteLength;
|
|
709
713
|
if (sent > MAX_RESPONSE_BYTES) throw new Error('Response exceeds the 16 MiB PRNext limit');
|
|
@@ -712,7 +716,7 @@ async function renderPartialAppPage({ request, route, artifact, responseHeaders,
|
|
|
712
716
|
try {
|
|
713
717
|
// The committed bytes come straight from prerender. Even a blocked
|
|
714
718
|
// request component cannot hold back this shared document shell.
|
|
715
|
-
yield bounded(Buffer.from(shell.replace(/<\/body><\/html>$/i, '')));
|
|
719
|
+
if (!request.nativePpr) yield bounded(Buffer.from(shell.replace(/<\/body><\/html>$/i, '')));
|
|
716
720
|
const result = await continueFlight(combinedSignal, true);
|
|
717
721
|
rendered = await renderProgressiveAppHtml({ result, request, route, responseHeaders, decodeFlight, signal: combinedSignal,
|
|
718
722
|
partial: { shell, postponed: artifact.postponed, shellSent: true, resumeKeys: keys.map(([from, to]) => [to, from]) } });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// One initial transferable chunk, then one chunk per reader pull. The initial
|
|
2
2
|
// credit removes a round trip while keeping the queue bounded to 64 KiB.
|
|
3
|
+
import { ownFlightStream } from '../compat/flight-delta.cjs';
|
|
3
4
|
export const APP_STREAM_CHUNK_BYTES = 64 * 1024;
|
|
4
5
|
|
|
5
6
|
export function workerStreamChannel(port, id) {
|
|
@@ -62,7 +63,7 @@ export function parentStreamChannel(port, id, cancelled) {
|
|
|
62
63
|
let controller;
|
|
63
64
|
let settlePull;
|
|
64
65
|
let closed = false;
|
|
65
|
-
const body = new ReadableStream({
|
|
66
|
+
const body = ownFlightStream(new ReadableStream({
|
|
66
67
|
start(value) { controller = value; },
|
|
67
68
|
pull() {
|
|
68
69
|
return new Promise(resolve => {
|
|
@@ -76,7 +77,7 @@ export function parentStreamChannel(port, id, cancelled) {
|
|
|
76
77
|
port.postMessage({ id, type: 'cancel' });
|
|
77
78
|
cancelled(reason);
|
|
78
79
|
},
|
|
79
|
-
}, { highWaterMark: 0 });
|
|
80
|
+
}, { highWaterMark: 0 }));
|
|
80
81
|
return {
|
|
81
82
|
body,
|
|
82
83
|
chunk(buffer, offset = 0, length = buffer?.byteLength) {
|
package/runtime/app-styles.mjs
CHANGED
|
@@ -32,7 +32,23 @@ export function appStyleManager(document, { timeout = 15_000 } = {}) {
|
|
|
32
32
|
if (!records.length) sheets.delete(href);
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
|
|
35
|
+
// Initial HTML can contain many sheets. Index it once per synchronous
|
|
36
|
+
// prepare/commit operation, lazily when an untracked URL needs adoption.
|
|
37
|
+
// Never retain this snapshot across operations: external links may change.
|
|
38
|
+
function initialSheets() {
|
|
39
|
+
let index;
|
|
40
|
+
return (href, tracked) => {
|
|
41
|
+
if (!index) {
|
|
42
|
+
index = new Map();
|
|
43
|
+
for (const link of document.querySelectorAll('link[rel="stylesheet"]')) {
|
|
44
|
+
const url = link.href, links = index.get(url) || [];
|
|
45
|
+
links.push(link); index.set(url, links);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return index.get(href)?.find(link => !tracked.has(link));
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function acquire(href, before, replace = false, initial = initialSheets()) {
|
|
36
52
|
const records = sheets.get(href) || [];
|
|
37
53
|
const current = records.at(-1);
|
|
38
54
|
if (current && !replace) return current;
|
|
@@ -40,7 +56,7 @@ export function appStyleManager(document, { timeout = 15_000 } = {}) {
|
|
|
40
56
|
// in place until commit. Discard older abandoned candidates immediately.
|
|
41
57
|
if (replace) for (const record of [...records]) if (!record.active && !record.recovery && !reserved(record)) remove(href, record);
|
|
42
58
|
const tracked = new Set(records.map(record => record.link));
|
|
43
|
-
const existing = replace ? undefined :
|
|
59
|
+
const existing = replace ? undefined : initial(href, tracked);
|
|
44
60
|
const link = existing || document.createElement('link');
|
|
45
61
|
const sheet = { link, active: !!existing, ready: !!existing?.sheet, recovery: false };
|
|
46
62
|
const retained = sheets.get(href) || [];
|
|
@@ -77,6 +93,7 @@ export function appStyleManager(document, { timeout = 15_000 } = {}) {
|
|
|
77
93
|
async prepare(values = [], signal) {
|
|
78
94
|
if (signal?.aborted) throw aborted();
|
|
79
95
|
const wanted = urls(values);
|
|
96
|
+
const initial = initialSheets();
|
|
80
97
|
const prepared = [];
|
|
81
98
|
let before;
|
|
82
99
|
// Establish cascade order before downloading. Re-inserting an already
|
|
@@ -85,7 +102,7 @@ export function appStyleManager(document, { timeout = 15_000 } = {}) {
|
|
|
85
102
|
// when an existing sheet would otherwise need to move across that suffix.
|
|
86
103
|
for (let index = wanted.length - 1; index >= 0; index--) {
|
|
87
104
|
const href = wanted[index];
|
|
88
|
-
let sheet = acquire(href, before);
|
|
105
|
+
let sheet = acquire(href, before, false, initial);
|
|
89
106
|
if (before && !(sheet.link.compareDocumentPosition(before) & 4)) sheet = acquire(href, before, true);
|
|
90
107
|
prepared.push(sheet.promise);
|
|
91
108
|
before = sheet.link;
|
|
@@ -113,12 +130,13 @@ export function appStyleManager(document, { timeout = 15_000 } = {}) {
|
|
|
113
130
|
commit(values = []) {
|
|
114
131
|
cleanup++;
|
|
115
132
|
const wanted = new Set(urls(values));
|
|
133
|
+
const initial = initialSheets();
|
|
116
134
|
const handedUrls = [...handed.keys()];
|
|
117
135
|
const pinned = wanted.size === handed.size && [...wanted].every((href, index) => handedUrls[index] === href) ? handed : null;
|
|
118
136
|
for (const href of wanted) {
|
|
119
137
|
const records = sheets.get(href) || [];
|
|
120
138
|
const prior = !pinned && handed.size ? records.find(record => record.active) || records.find(record => record.recovery) : undefined;
|
|
121
|
-
const sheet = pinned?.get(href) || prior || records.findLast(record => record.ready) || acquire(href);
|
|
139
|
+
const sheet = pinned?.get(href) || prior || records.findLast(record => record.ready) || acquire(href, undefined, false, initial);
|
|
122
140
|
// Initial HTML links are render-blocking. A missing sheet is restored
|
|
123
141
|
// on explicit recovery; normal navigation always prepares first.
|
|
124
142
|
sheet.active = true; sheet.recovery = false; sheet.link.media = '';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {lstat,opendir,unlink,realpath} from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {captureStorageDirectory,verifyStorageDirectories,sameStoragePath} from './storage-path.mjs';
|
|
4
|
+
import {withBuildLock} from './project-lock.mjs';
|
|
5
|
+
|
|
6
|
+
/** Bounded maintenance of a known, disposable compiler namespace. Open V8
|
|
7
|
+
* cache readers remain valid; an evicted future lookup is a cache miss. */
|
|
8
|
+
export async function pruneCompilerCache(directory,{maxBytes=64*1024*1024,maxFiles=2048}={}){
|
|
9
|
+
if(!directory)return;
|
|
10
|
+
try{
|
|
11
|
+
const snapshot=await captureStorageDirectory(directory);if(!snapshot)return;
|
|
12
|
+
return await withBuildLock(directory,async canonical=>{
|
|
13
|
+
if(!sameStoragePath(canonical,directory)||!await verifyStorageDirectories([snapshot]))return;
|
|
14
|
+
return prune(canonical,{maxBytes,maxFiles});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
catch{/* A busy or unverifiable maintenance owner wins; never wait on it. */}
|
|
18
|
+
}
|
|
19
|
+
async function prune(directory,{maxBytes,maxFiles}){
|
|
20
|
+
try{
|
|
21
|
+
const files=[];let scanned=0,complete=true;
|
|
22
|
+
const input=await lstat(directory);if(!input.isDirectory()||input.isSymbolicLink())return;
|
|
23
|
+
directory=await realpath(directory);
|
|
24
|
+
const initial=await captureStorageDirectory(directory);if(!initial)return;
|
|
25
|
+
async function collect(folder,depth,parents){
|
|
26
|
+
if(depth>3){complete=false;return;}
|
|
27
|
+
const snapshot=await captureStorageDirectory(folder),guards=[...parents,snapshot];
|
|
28
|
+
if(!snapshot||!await verifyStorageDirectories(guards)){complete=false;return;}
|
|
29
|
+
const entries=await opendir(folder);
|
|
30
|
+
for await(const entry of entries){
|
|
31
|
+
if(++scanned>8192){complete=false;break;}
|
|
32
|
+
if(entry.name.startsWith('.'))continue;
|
|
33
|
+
const name=path.join(folder,entry.name),info=await lstat(name).catch(()=>null);
|
|
34
|
+
if(!info||info.isSymbolicLink())continue;
|
|
35
|
+
if(info.isDirectory())await collect(name,depth+1,guards);
|
|
36
|
+
else if(info.isFile())files.push({name,size:info.size,time:info.mtimeMs,ino:info.ino,dev:info.dev,guards});
|
|
37
|
+
if(!complete)break;
|
|
38
|
+
}
|
|
39
|
+
if(!await verifyStorageDirectories(guards))complete=false;
|
|
40
|
+
}
|
|
41
|
+
const info=await lstat(directory);if(!info.isDirectory()||info.isSymbolicLink())return;
|
|
42
|
+
await collect(directory,0,[initial]);if(!complete)return;
|
|
43
|
+
files.sort((a,b)=>b.time-a.time);let bytes=0,retained=0;
|
|
44
|
+
for(let i=0;i<files.length;i++){
|
|
45
|
+
const file=files[i];
|
|
46
|
+
if(retained>=maxFiles||bytes+file.size>maxBytes){
|
|
47
|
+
if(!await verifyStorageDirectories(file.guards))return;
|
|
48
|
+
const current=await lstat(file.name).catch(()=>null);
|
|
49
|
+
if(current?.isFile()&&!current.isSymbolicLink()&¤t.ino===file.ino&¤t.dev===file.dev&¤t.mtimeMs===file.time&¤t.size===file.size&&sameStoragePath(await realpath(file.name),file.name)&&await verifyStorageDirectories(file.guards))await unlink(file.name).catch(()=>{});
|
|
50
|
+
}else{bytes+=file.size;retained++;}
|
|
51
|
+
}
|
|
52
|
+
}catch{/* Compiler cache retention cannot fail a request or build. */}
|
|
53
|
+
}
|