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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/build/app.mjs +46 -78
  2. package/build/build-lock.mjs +2 -71
  3. package/build/build-retention.mjs +119 -0
  4. package/build/bundle-runtime.mjs +9 -8
  5. package/build/cache-command.mjs +31 -0
  6. package/build/check.mjs +41 -3
  7. package/build/compilation-cache.mjs +32 -12
  8. package/build/compilation-storage.mjs +190 -0
  9. package/build/compiler.mjs +3 -3
  10. package/build/config.mjs +23 -2
  11. package/build/create.mjs +1 -1
  12. package/build/css-modules.mjs +4 -0
  13. package/build/dev-action-graph.mjs +21 -0
  14. package/build/font-cache.mjs +9 -6
  15. package/build/font-files.mjs +1 -1
  16. package/build/index.mjs +30 -13
  17. package/build/native-ppr.mjs +32 -0
  18. package/build/og.mjs +121 -1
  19. package/build/parallel-plan.mjs +55 -0
  20. package/build/persistent-cache.mjs +29 -24
  21. package/build/production-check.mjs +34 -0
  22. package/build/react-compiler-report.mjs +31 -8
  23. package/build/react-compiler.mjs +101 -10
  24. package/build/server-config.mjs +35 -0
  25. package/build/source-analysis.mjs +54 -4
  26. package/build/static-adapter.mjs +7 -3
  27. package/build/transform.mjs +38 -6
  28. package/build/typescript-dev.mjs +3 -1
  29. package/cli.mjs +14 -5
  30. package/compat/app-layout-context.cjs +7 -3
  31. package/compat/flight-delta.cjs +47 -12
  32. package/compat/index.d.ts +24 -1
  33. package/compat/navigation-authorization.cjs +8 -1
  34. package/docs/ai/compatibility.md +110 -7
  35. package/docs/ai/deployment.md +141 -0
  36. package/docs/ai/quickstart.md +5 -0
  37. package/package.json +12 -9
  38. package/release/fontkit.mjs +31 -0
  39. package/release/verify.mjs +3 -0
  40. package/runtime/api.mjs +5 -2
  41. package/runtime/app-client.mjs +150 -26
  42. package/runtime/app-partial.mjs +2 -10
  43. package/runtime/app-prefetch.mjs +2 -2
  44. package/runtime/app-render.mjs +11 -7
  45. package/runtime/app-stream-channel.mjs +3 -2
  46. package/runtime/app-styles.mjs +22 -4
  47. package/runtime/compile-cache-retention.mjs +53 -0
  48. package/runtime/compile-cache.mjs +28 -1
  49. package/runtime/dev-client.mjs +6 -3
  50. package/runtime/dev-gateway.mjs +3 -2
  51. package/runtime/dev-route-selection.mjs +64 -0
  52. package/runtime/dev-runtime-pool.mjs +3 -2
  53. package/runtime/dev-runtime-spare.mjs +1 -1
  54. package/runtime/dev-session-retention.mjs +154 -0
  55. package/runtime/development.mjs +27 -8
  56. package/runtime/env.mjs +9 -0
  57. package/runtime/flight-delta.mjs +31 -5
  58. package/runtime/native-ppr.mjs +35 -0
  59. package/runtime/og/NOTICE.txt +1 -1
  60. package/runtime/og/index.node.mjs +76 -31216
  61. package/runtime/og/upstream.json +115 -0
  62. package/runtime/persistent/client.mjs +4 -2
  63. package/runtime/persistent/daemon.mjs +3 -11
  64. package/runtime/persistent/health.mjs +58 -0
  65. package/runtime/project-lock.mjs +71 -0
  66. package/runtime/request-transport.mjs +112 -32
  67. package/runtime/storage-path.mjs +36 -0
  68. package/runtime/trace.mjs +44 -4
  69. package/runtime/worker.mjs +9 -4
  70. package/vendor/fontkit/NOTICE.txt +5 -0
  71. package/vendor/fontkit/SOURCE-NOTICES.txt +209 -0
  72. package/vendor/fontkit/index.mjs +3 -0
  73. package/vendor/fontkit/licenses/@swc__helpers/LICENSE +201 -0
  74. package/vendor/fontkit/licenses/base64-js/LICENSE +21 -0
  75. package/vendor/fontkit/licenses/brotli/readme.md +61 -0
  76. package/vendor/fontkit/licenses/clone/LICENSE +18 -0
  77. package/vendor/fontkit/licenses/dfa/README.md +83 -0
  78. package/vendor/fontkit/licenses/fast-deep-equal/LICENSE +21 -0
  79. package/vendor/fontkit/licenses/fontkit/README.md +266 -0
  80. package/vendor/fontkit/licenses/restructure/LICENSE +21 -0
  81. package/vendor/fontkit/licenses/tiny-inflate/LICENSE +21 -0
  82. package/vendor/fontkit/licenses/tslib/LICENSE.txt +12 -0
  83. package/vendor/fontkit/licenses/unicode-properties/LICENSE +8 -0
  84. package/vendor/fontkit/licenses/unicode-trie/LICENSE +8 -0
  85. package/vendor/fontkit/upstream.json +215 -0
@@ -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
- pendingView = undefined;
288
- // A PPR preview is authorized UI, but not the completed route model
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
- try { model = assertModel(await createFromFetch(Promise.resolve(exported?response:await flightTransport.response(response,base)), flightOptions)); }
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) window.location.replace(target.href);
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
- if (replace) window.location.replace(target.href);
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
- if (replace || pop || refresh) window.location.replace(target.href);
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', () => { saveScroll(); pageHidden = true; navigation++; finishCommit?.(); abandonPendingView(); activeRequest?.abort(); appStyleManager(document).cancel(); finishNavigation?.(); finishNavigation = undefined; invalidatePrefetches(); });
731
- window.addEventListener('pageshow', event => { pageHidden = false; if (event.persisted) controller.refresh(); });
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', () => {
@@ -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 { MAX_RESPONSE_BYTES, escapeHtml } from './http.mjs';
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
- }
@@ -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 binary = atob(base64);
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
  }
@@ -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, randomBytes } from 'node:crypto';
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 : developmentAuthorizationSecret, 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
+ 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) {
@@ -32,7 +32,23 @@ export function appStyleManager(document, { timeout = 15_000 } = {}) {
32
32
  if (!records.length) sheets.delete(href);
33
33
  }
34
34
  }
35
- function acquire(href, before, replace = false) {
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 : [...document.querySelectorAll('link[rel="stylesheet"]')].find(link => link.href === href && !tracked.has(link));
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()&&current.ino===file.ino&&current.dev===file.dev&&current.mtimeMs===file.time&&current.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
+ }