@nifrajs/web 5.6.0 → 5.7.0

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 (129) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +21 -11
  3. package/dist/build.d.ts +1 -1
  4. package/dist/build.d.ts.map +1 -1
  5. package/dist/build.js +1 -0
  6. package/dist/build.js.map +1 -1
  7. package/dist/client.d.ts +1 -0
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +39 -16
  10. package/dist/client.js.map +1 -1
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/internal/codegen.d.ts.map +1 -1
  16. package/dist/internal/codegen.js +8 -2
  17. package/dist/internal/codegen.js.map +1 -1
  18. package/dist/internal/identity-epoch.d.ts +2 -2
  19. package/dist/internal/identity-epoch.d.ts.map +1 -1
  20. package/dist/internal/identity-epoch.js +15 -14
  21. package/dist/internal/identity-epoch.js.map +1 -1
  22. package/dist/internal/live-contract.d.ts +5 -0
  23. package/dist/internal/live-contract.d.ts.map +1 -1
  24. package/dist/internal/live-contract.js.map +1 -1
  25. package/dist/internal/page-execution.d.ts +2 -0
  26. package/dist/internal/page-execution.d.ts.map +1 -1
  27. package/dist/internal/page-execution.js +22 -5
  28. package/dist/internal/page-execution.js.map +1 -1
  29. package/dist/internal/parity.d.ts +3 -0
  30. package/dist/internal/parity.d.ts.map +1 -1
  31. package/dist/internal/parity.js +28 -5
  32. package/dist/internal/parity.js.map +1 -1
  33. package/dist/internal/ppr/runtime.d.ts +1 -1
  34. package/dist/internal/ppr/runtime.d.ts.map +1 -1
  35. package/dist/internal/ppr/runtime.js +4 -1
  36. package/dist/internal/ppr/runtime.js.map +1 -1
  37. package/dist/internal/ppr/server.d.ts.map +1 -1
  38. package/dist/internal/ppr/server.js +4 -2
  39. package/dist/internal/ppr/server.js.map +1 -1
  40. package/dist/internal/ppr/shell-store.d.ts +1 -0
  41. package/dist/internal/ppr/shell-store.d.ts.map +1 -1
  42. package/dist/internal/ppr/shell-store.js +17 -1
  43. package/dist/internal/ppr/shell-store.js.map +1 -1
  44. package/dist/internal/present.d.ts +11 -0
  45. package/dist/internal/present.d.ts.map +1 -0
  46. package/dist/internal/present.js +13 -0
  47. package/dist/internal/present.js.map +1 -0
  48. package/dist/internal/render-document.d.ts +6 -63
  49. package/dist/internal/render-document.d.ts.map +1 -1
  50. package/dist/internal/render-document.js +3 -50
  51. package/dist/internal/render-document.js.map +1 -1
  52. package/dist/internal/status-page.d.ts +68 -0
  53. package/dist/internal/status-page.d.ts.map +1 -0
  54. package/dist/internal/status-page.js +66 -0
  55. package/dist/internal/status-page.js.map +1 -0
  56. package/dist/internal/web-app.d.ts +3 -2
  57. package/dist/internal/web-app.d.ts.map +1 -1
  58. package/dist/internal/web-app.js +1 -0
  59. package/dist/internal/web-app.js.map +1 -1
  60. package/dist/keep-alive.d.ts.map +1 -1
  61. package/dist/keep-alive.js +46 -13
  62. package/dist/keep-alive.js.map +1 -1
  63. package/dist/live/bus.d.ts +16 -2
  64. package/dist/live/bus.d.ts.map +1 -1
  65. package/dist/live/bus.js +23 -0
  66. package/dist/live/bus.js.map +1 -1
  67. package/dist/live/hub.d.ts +4 -0
  68. package/dist/live/hub.d.ts.map +1 -1
  69. package/dist/live/hub.js +5 -0
  70. package/dist/live/hub.js.map +1 -1
  71. package/dist/live/keyring.d.ts +5 -0
  72. package/dist/live/keyring.d.ts.map +1 -1
  73. package/dist/live/keyring.js +6 -1
  74. package/dist/live/keyring.js.map +1 -1
  75. package/dist/live-client.d.ts.map +1 -1
  76. package/dist/live-client.js +45 -7
  77. package/dist/live-client.js.map +1 -1
  78. package/dist/live-redis.d.ts +14 -2
  79. package/dist/live-redis.d.ts.map +1 -1
  80. package/dist/live-redis.js +16 -5
  81. package/dist/live-redis.js.map +1 -1
  82. package/dist/live.d.ts +57 -1
  83. package/dist/live.d.ts.map +1 -1
  84. package/dist/live.js +119 -16
  85. package/dist/live.js.map +1 -1
  86. package/dist/render-seam.d.ts +2 -0
  87. package/dist/render-seam.d.ts.map +1 -1
  88. package/dist/render-seam.js +2 -0
  89. package/dist/render-seam.js.map +1 -1
  90. package/dist/router.d.ts.map +1 -1
  91. package/dist/router.js +49 -37
  92. package/dist/router.js.map +1 -1
  93. package/dist/server-components/definition.d.ts.map +1 -1
  94. package/dist/server-components/definition.js +2 -1
  95. package/dist/server-components/definition.js.map +1 -1
  96. package/dist/server-components/runtime.d.ts.map +1 -1
  97. package/dist/server-components/runtime.js +2 -1
  98. package/dist/server-components/runtime.js.map +1 -1
  99. package/dist/server-components/vite.d.ts.map +1 -1
  100. package/dist/server-components/vite.js +26 -9
  101. package/dist/server-components/vite.js.map +1 -1
  102. package/package.json +3 -3
  103. package/src/build.ts +1 -0
  104. package/src/client.ts +42 -19
  105. package/src/index.ts +1 -4
  106. package/src/internal/codegen.ts +8 -1
  107. package/src/internal/identity-epoch.ts +20 -16
  108. package/src/internal/live-contract.ts +5 -0
  109. package/src/internal/page-execution.ts +29 -8
  110. package/src/internal/parity.ts +35 -6
  111. package/src/internal/ppr/runtime.ts +4 -1
  112. package/src/internal/ppr/server.ts +4 -2
  113. package/src/internal/ppr/shell-store.ts +14 -1
  114. package/src/internal/present.ts +20 -0
  115. package/src/internal/render-document.ts +10 -92
  116. package/src/internal/status-page.ts +95 -0
  117. package/src/internal/web-app.ts +4 -2
  118. package/src/keep-alive.ts +55 -16
  119. package/src/live/bus.ts +29 -2
  120. package/src/live/hub.ts +5 -0
  121. package/src/live/keyring.ts +6 -1
  122. package/src/live-client.ts +45 -7
  123. package/src/live-redis.ts +31 -6
  124. package/src/live.ts +178 -17
  125. package/src/render-seam.ts +2 -0
  126. package/src/router.ts +47 -38
  127. package/src/server-components/definition.ts +2 -1
  128. package/src/server-components/runtime.ts +2 -1
  129. package/src/server-components/vite.ts +33 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/web",
3
- "version": "5.6.0",
3
+ "version": "5.7.0",
4
4
  "description": "Framework-agnostic SSR core for nifra - the render seam + HTML document orchestration.",
5
5
  "keywords": [
6
6
  "bun",
@@ -360,8 +360,8 @@
360
360
  "access": "public"
361
361
  },
362
362
  "dependencies": {
363
- "@nifrajs/core": "5.6.0",
364
- "@nifrajs/island-trigger": "5.6.0",
363
+ "@nifrajs/core": "5.7.0",
364
+ "@nifrajs/island-trigger": "5.7.0",
365
365
  "parse5": "8.0.1"
366
366
  },
367
367
  "peerDependencies": {
package/src/build.ts CHANGED
@@ -1338,6 +1338,7 @@ export const SERVER_ENTRY_OPTIONS = [
1338
1338
  "i18n",
1339
1339
  "hostPolicy",
1340
1340
  "live",
1341
+ "keepAlive",
1341
1342
  ] as const
1342
1343
  export type ServerEntryOption = (typeof SERVER_ENTRY_OPTIONS)[number]
1343
1344
  /** Each importable option mapped to the specifier of the module that exports it. */
package/src/client.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * a full page load). DOM-only - never imported on the server, so the store stays SSR-safe.
6
6
  */
7
7
  import { trustedHeadAttributes } from "./internal/head-attributes.ts"
8
+ import { setPresenter } from "./internal/present.ts"
8
9
  import { appUrl } from "./internal/route-path.ts"
9
10
  import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./internal/script-types.ts"
10
11
  import type { Meta } from "./manifest.ts"
@@ -44,6 +45,9 @@ export { identityChanged } from "./identity.ts"
44
45
  * before hydrating. The fix is for the entry not to name the server module in the first place.
45
46
  */
46
47
  export { mergeHeads, resolveChainMeta, resolveMeta } from "./internal/head-merge.ts"
48
+ // For route helpers shared by a loader and a component, which the browser loads too: imported from the
49
+ // root, they put its `node:crypto` import into Vite dev's browser deps on every page.
50
+ export { gone, notFound, statusPage } from "./internal/status-page.ts"
47
51
  export {
48
52
  closeBoundary,
49
53
  getBrowserNavigate,
@@ -211,6 +215,10 @@ export function waitForStyles(options: WaitForStylesOptions = {}): Promise<void>
211
215
  return Promise.all(promises).then(() => undefined)
212
216
  }
213
217
 
218
+ // How long a navigation's view transition waits for the frame that captures the page before its
219
+ // animation is skipped: several frames at any refresh rate, and short enough to go unnoticed.
220
+ const CAPTURE_DEADLINE_MS = 100
221
+
214
222
  /**
215
223
  * Resolve `href` against this page and keep it only when history may move there without a load: same
216
224
  * scheme and host as the document, or null. Compared by protocol + host rather than `URL.origin`,
@@ -325,17 +333,45 @@ export function installHistory(
325
333
  return document.getElementById(id)
326
334
  }
327
335
 
328
- // Wrap a navigation's render in a View Transition when the browser supports it - a graceful
329
- // enhancement (no-op elsewhere). The captured "before" is held across the data fetch, so pair
330
- // with link prefetch (hover/focus warms the cache) to keep most transitions instant.
336
+ // A navigation loads first and hands only its page swap here, so where the browser has View
337
+ // Transitions the page keeps painting, pending UI included, while the data loads. The browser runs
338
+ // the swap once a frame has captured the page; one drawing no frames while it reports itself visible
339
+ // (an occluded window) would hold it forever, so the deadline skips the animation, running the swap.
331
340
  type ViewTransition = {
332
341
  readonly ready: Promise<unknown>
333
342
  readonly finished: Promise<unknown>
334
343
  readonly updateCallbackDone: Promise<unknown>
344
+ skipTransition(): void
335
345
  }
336
346
  type ViewTransitionDocument = Document & {
337
347
  startViewTransition?: (cb: () => unknown) => ViewTransition
338
348
  }
349
+ setPresenter((swap) => {
350
+ const doc = document as ViewTransitionDocument
351
+ const start = doc.startViewTransition
352
+ if (typeof start !== "function") {
353
+ swap()
354
+ return Promise.resolve()
355
+ }
356
+ return new Promise((resolve, reject) => {
357
+ let swapped = false
358
+ const vt = start.call(doc, () => {
359
+ swapped = true
360
+ try {
361
+ swap()
362
+ resolve()
363
+ } catch (error) {
364
+ reject(error)
365
+ }
366
+ })
367
+ setTimeout(() => {
368
+ if (!swapped) vt.skipTransition()
369
+ }, CAPTURE_DEADLINE_MS)
370
+ // A newer navigation's transition, or the deadline, skips this one and rejects its promises -
371
+ // InvalidStateError or AbortError by engine. The swap still runs, so these are expected.
372
+ for (const p of [vt.ready, vt.finished, vt.updateCallbackDone]) p.catch(() => {})
373
+ })
374
+ })
339
375
  // A navigation that loads takes the page from a partially prerendered first document, which then
340
376
  // stops filling its holes and cancels the ones still loading on the server. `navigate` publishes
341
377
  // its `pendingPath` before its first await; a client-only search change, or a boundary already
@@ -353,20 +389,6 @@ export function installHistory(
353
389
  return run
354
390
  }
355
391
 
356
- const transition = (run: () => Promise<void>): void => {
357
- const doc = document as ViewTransitionDocument
358
- if (typeof doc.startViewTransition !== "function") {
359
- void run()
360
- return
361
- }
362
- const vt = doc.startViewTransition(run)
363
- // Rapid in-app clicks supersede an in-flight transition; the browser skips it and rejects its
364
- // promises. Reasons vary by engine - InvalidStateError "Transition was aborted because of
365
- // invalid state", or AbortError "Transition was skipped". The navigation still completes, so
366
- // we swallow these expected aborts rather than let them surface as unhandled rejections.
367
- for (const p of [vt.ready, vt.finished, vt.updateCallbackDone]) p.catch(() => {})
368
- }
369
-
370
392
  // Parse a path into the `{ pathname, search, hash }` a `BlockerFunction` decides on.
371
393
  const locationOf = (path: string): BlockerLocation => {
372
394
  const url = new URL(path, location.href)
@@ -443,7 +465,7 @@ export function installHistory(
443
465
  here = url.pathname + url.search + url.hash
444
466
  expected = routePath
445
467
  // The data layer fetches by path+search; the #hash is client-only (never sent to the server).
446
- transition(() => navigateFrom(routePath, path))
468
+ void navigateFrom(routePath, path)
447
469
  settle()
448
470
  }
449
471
 
@@ -600,7 +622,7 @@ export function installHistory(
600
622
  pendingScroll = { pos: scrollOf(history.state) }
601
623
  const routePath = location.pathname + location.search
602
624
  expected = routePath
603
- transition(() => navigateFrom(routePath, dest))
625
+ void navigateFrom(routePath, dest)
604
626
  settle()
605
627
  }
606
628
 
@@ -682,6 +704,7 @@ export function installHistory(
682
704
  inView?.disconnect()
683
705
  setBrowserNavigate(undefined) // stop routing `useNavigate` to a torn-down router
684
706
  setBlockerController(undefined) // stop routing `useBlocker` to a torn-down registry
707
+ setPresenter(undefined)
685
708
  document.removeEventListener("click", onClick)
686
709
  document.removeEventListener("pointerover", onPrefetch)
687
710
  document.removeEventListener("focusin", onPrefetch)
package/src/index.ts CHANGED
@@ -107,8 +107,6 @@ export {
107
107
  } from "./internal/identity-epoch.ts"
108
108
  export type { PrerenderedPages } from "./internal/prerendered-pages.ts"
109
109
  export {
110
- gone,
111
- notFound,
112
110
  type RedirectOptions,
113
111
  type RenderAssemblyCache,
114
112
  type RenderedPage,
@@ -120,9 +118,7 @@ export {
120
118
  renderPage,
121
119
  renderPageResult,
122
120
  revalidate,
123
- type StatusPageOptions,
124
121
  serializeData,
125
- statusPage,
126
122
  unsafeInlineScript,
127
123
  } from "./internal/render-document.ts"
128
124
  export {
@@ -137,6 +133,7 @@ export {
137
133
  * stubbed by both build pipelines and waved through by the guard meant to stop it leaking.
138
134
  */
139
135
  export { SERVER_FN_MODULE } from "./internal/server-boundary.ts"
136
+ export { gone, notFound, type StatusPageOptions, statusPage } from "./internal/status-page.ts"
140
137
  export {
141
138
  type CreateWebAppOptions,
142
139
  createWebApp,
@@ -10,6 +10,7 @@ import {
10
10
  DATA_GLOBAL,
11
11
  HANDOVER_ID,
12
12
  LAYOUT_DATA_GLOBAL,
13
+ LIVE_GLOBAL,
13
14
  LOCALES_GLOBAL,
14
15
  ROOT_ATTRIBUTE,
15
16
  ROUTE_GLOBAL,
@@ -354,7 +355,11 @@ export function generateClientEntry(
354
355
  "const handover = handovers[handovers.length - 1]",
355
356
  "if (handover !== undefined) {",
356
357
  ' const state = JSON.parse(handover.textContent || "{}")',
357
- ` for (const key of ${jsValueLiteral(options.copyChunks === true ? [...HANDOVER_GLOBALS, COPY_CHUNKS_GLOBAL] : HANDOVER_GLOBALS)}) {`,
358
+ ` for (const key of ${jsValueLiteral([
359
+ ...HANDOVER_GLOBALS,
360
+ ...(options.copyChunks === true ? [COPY_CHUNKS_GLOBAL] : []),
361
+ ...(live === undefined ? [] : [LIVE_GLOBAL]),
362
+ ])}) {`,
358
363
  " if (Object.prototype.hasOwnProperty.call(state, key)) Reflect.set(window, key, state[key])",
359
364
  " }",
360
365
  "}",
@@ -386,6 +391,8 @@ export function generateClientEntry(
386
391
  ` layoutData: window.${LAYOUT_DATA_GLOBAL} && window.${LAYOUT_DATA_GLOBAL}.map(mapDeferred),`,
387
392
  ` boundaries: mapDeferred(window.${BOUNDARY_GLOBAL}),`,
388
393
  ` serverComponents: window.${COMPONENT_GLOBAL},`,
394
+ // The seed the server rendered the page with: the live client exchanges it for its first grant.
395
+ ...(live === undefined ? [] : [` live: window.${LIVE_GLOBAL},`]),
389
396
  // actionData (only set after a form POST) is in the initial state so the binding hydrates
390
397
  // consistently with the server-rendered markup; mapped through `mapDeferred` too so a deferred
391
398
  // action's placeholders become registry markers (a no-op when the action didn't defer).
@@ -14,8 +14,8 @@ import { EPOCH_COOKIE, LOCAL_EPOCH_COOKIE } from "../identity.ts"
14
14
  export interface KeepAliveServerOptions {
15
15
  /**
16
16
  * Cookies whose change means a different visitor, on top of the session cookies of
17
- * `@nifrajs/auth`, Better Auth and Auth.js under their default names. Name a custom session cookie
18
- * here, or kept pages survive a sign-in or sign-out through it.
17
+ * `@nifrajs/auth` and Auth.js under their default names and Better Auth's under any `cookiePrefix`.
18
+ * Name a custom session cookie here, or kept pages survive a sign-in or sign-out through it.
19
19
  */
20
20
  readonly identityCookies?: readonly string[]
21
21
  /** `HttpOnly` cookies that say nothing about who is signed in; development stops asking about them. */
@@ -33,16 +33,9 @@ interface RequestView {
33
33
  }
34
34
 
35
35
  // Session cookies by their default names, without a `__Secure-` / `__Host-` prefix.
36
- const IDENTITY_DEFAULTS = [
37
- "nifra_session",
38
- "better-auth.session_token",
39
- "authjs.session-token",
40
- "next-auth.session-token",
41
- ]
36
+ const IDENTITY_DEFAULTS = ["nifra_session", "authjs.session-token", "next-auth.session-token"]
42
37
  // `HttpOnly` cookies the same integrations write that carry no identity of their own.
43
38
  const IGNORE_DEFAULTS = [
44
- "better-auth.session_data",
45
- "better-auth.dont_remember",
46
39
  "authjs.csrf-token",
47
40
  "authjs.callback-url",
48
41
  "authjs.pkce.code_verifier",
@@ -56,6 +49,11 @@ const IGNORE_DEFAULTS = [
56
49
  "__nifra_draft",
57
50
  ]
58
51
 
52
+ // Better Auth names its cookies `<advanced.cookiePrefix>.<name>`, "better-auth" unless the app sets
53
+ // one, so these count under any prefix.
54
+ const BETTER_AUTH_SESSION = ".session_token"
55
+ const BETTER_AUTH_IGNORED = [".session_data", ".dont_remember"]
56
+
59
57
  // `__Secure-a.0` and `__Host-a` are the cookie `a`: a prefix, then Auth.js's chunk index.
60
58
  const baseName = (name: string): string =>
61
59
  name.replace(/^__(?:secure|host)-/i, "").replace(/\.\d+$/, "")
@@ -148,8 +146,14 @@ export function identityEpochHook(
148
146
  options: KeepAliveServerOptions,
149
147
  development: boolean,
150
148
  ): (headers: HeaderView, request: RequestView) => void {
151
- const identity = new Set([...IDENTITY_DEFAULTS, ...(options.identityCookies ?? []).map(baseName)])
152
- const ignored = new Set([...IGNORE_DEFAULTS, ...(options.ignoreCookies ?? []).map(baseName)])
149
+ const named = new Set([...IDENTITY_DEFAULTS, ...(options.identityCookies ?? []).map(baseName)])
150
+ const told = new Set((options.ignoreCookies ?? []).map(baseName))
151
+ const ignored = new Set([...IGNORE_DEFAULTS, ...told])
152
+ // An app's own `ignoreCookies` entry outranks the Better Auth suffix, never a name it listed.
153
+ const isIdentity = (base: string): boolean =>
154
+ named.has(base) || (base.endsWith(BETTER_AUTH_SESSION) && !told.has(base))
155
+ const isIgnored = (base: string): boolean =>
156
+ ignored.has(base) || BETTER_AUTH_IGNORED.some((suffix) => base.endsWith(suffix))
153
157
  const reported = new Set<string>()
154
158
  return (headers, request) => {
155
159
  const joined = headers.get("set-cookie")
@@ -166,20 +170,20 @@ export function identityEpochHook(
166
170
  renewed === undefined &&
167
171
  !cookie.deleted &&
168
172
  (cookie.maxAge !== undefined || cookie.expires !== undefined) &&
169
- identity.has(base)
173
+ isIdentity(base)
170
174
  )
171
175
  renewed = cookie
172
176
  continue
173
177
  }
174
- if (identity.has(base)) {
178
+ if (isIdentity(base)) {
175
179
  // The first identity cookie still set gives the lifetime; a sign-out deletes them all.
176
180
  if (changed === undefined || (changed.deleted && !cookie.deleted)) changed = cookie
177
181
  continue
178
182
  }
179
- if (!development || !cookie.httpOnly || ignored.has(base) || reported.has(base)) continue
183
+ if (!development || !cookie.httpOnly || isIgnored(base) || reported.has(base)) continue
180
184
  reported.add(base)
181
185
  console.warn(
182
- `[nifra/web] NIFRA_KEEPALIVE_IDENTITY_COOKIE: this app keeps pages alive, and a response changed the HttpOnly cookie "${cookie.name}", which nifra does not know as a sign-in cookie. If it says who is signed in, add it to createWebApp({ keepAlive: { identityCookies: ["${base}"] } }) so a sign-in or sign-out drops the pages kept for the previous visitor; if it does not, add it to keepAlive.ignoreCookies.`,
186
+ `[nifra/web] NIFRA_KEEPALIVE_IDENTITY_COOKIE: this app keeps pages alive, and a response changed the HttpOnly cookie "${cookie.name}", which nifra does not know as a sign-in cookie. If it says who is signed in, name it in keepAlive: { identityCookies: ["${base}"] } so a sign-in or sign-out drops the pages kept for the previous visitor; if it does not, in keepAlive.ignoreCookies. An app run by \`nifra dev\` and \`nifra build\` exports keepAlive from backend/framework.ts; a server you write passes it to createWebApp.`,
183
187
  )
184
188
  }
185
189
  if (changed !== undefined) {
@@ -30,6 +30,11 @@ export interface LiveAttachment {
30
30
  readonly onRequest: (req: Request) => Promise<Request | Response> | undefined
31
31
  /** The live load a page request belongs to, or `undefined` for every request that is not one. */
32
32
  readonly loadOf: (req: Request) => LiveLoad | undefined
33
+ /**
34
+ * The seed a server-rendered page of `routeId` collects its keys into, or `undefined` when none can
35
+ * be issued now (the runtime not started, the bus down, Workers). Never admitted: it is no live load.
36
+ */
37
+ readonly seedOf: (req: Request, routeId: string) => LiveLoad | undefined
33
38
  }
34
39
 
35
40
  /** One live load, from the page handler's side. */
@@ -71,23 +71,18 @@ import {
71
71
  } from "./ppr/contract.ts"
72
72
  import {
73
73
  EMPTY_RETAIN,
74
- type HeadersLike,
75
74
  isControlFlow,
76
75
  isResponseResult,
77
76
  isRevalidateResult,
78
- isStatusSignal,
79
77
  type LoadedLayoutModules,
80
78
  layoutErrorId,
81
- notFound,
82
79
  type RenderAssemblyCache,
83
80
  type RenderedPage,
84
81
  type RenderPageOptions,
85
82
  type RevalidateResult,
86
83
  redirect,
87
84
  renderPageResult,
88
- STATUS_SIGNAL,
89
85
  STATUS_TEXT,
90
- type StatusSignal,
91
86
  scopeParams,
92
87
  tagLayoutError,
93
88
  withDuplicateInstanceHint,
@@ -105,6 +100,13 @@ import {
105
100
  privateOutcome,
106
101
  varyOnDataHeader,
107
102
  } from "./response-controls.ts"
103
+ import {
104
+ type HeadersLike,
105
+ isStatusSignal,
106
+ notFound,
107
+ STATUS_SIGNAL,
108
+ type StatusSignal,
109
+ } from "./status-page.ts"
108
110
 
109
111
  export type { NonceResolver } from "../nonce.ts"
110
112
 
@@ -409,6 +411,8 @@ export interface PageExecutionOptions<Env = unknown> {
409
411
  ) => Promise<void> | undefined
410
412
  /** The live load a request belongs to; only set when the app configures `live`. */
411
413
  readonly liveLoadOf?: ((request: Request) => LiveLoad | undefined) | undefined
414
+ /** The seed a rendered document collects its keys into; only set when the app configures `live`. */
415
+ readonly liveSeedOf?: ((request: Request, routeId: string) => LiveLoad | undefined) | undefined
412
416
  readonly onLoaderError?: (
413
417
  error: unknown,
414
418
  ctx: {
@@ -544,6 +548,7 @@ export function createPageRequestExecutor<Env = unknown>(
544
548
  )
545
549
  const routeById = new Map(manifest.routes.map((route) => [route.id, route]))
546
550
  const liveLoadOf = options.liveLoadOf
551
+ const liveSeedOf = options.liveSeedOf
547
552
 
548
553
  /** A live load holds one admission slot from before its middleware until its body is complete. */
549
554
  const admitLive =
@@ -1345,6 +1350,7 @@ export function createPageRequestExecutor<Env = unknown>(
1345
1350
  personalized: boolean,
1346
1351
  serverComponents?: import("../server-components/types.ts").ComponentState,
1347
1352
  live?: LiveEnvelope | Deferred<LiveEnvelope>,
1353
+ liveSeed?: LiveEnvelope,
1348
1354
  ): Promise<PageResponse> => {
1349
1355
  // Every layout loader settled before the page rendered, so a status or error page can reuse them.
1350
1356
  const settled: LayoutChainSource = async (_page, count) =>
@@ -1436,6 +1442,7 @@ export function createPageRequestExecutor<Env = unknown>(
1436
1442
  ...localeOption(req),
1437
1443
  }
1438
1444
  if (serverComponents !== undefined) page.serverComponents = serverComponents
1445
+ if (liveSeed?.grant !== undefined) page.live = liveSeed
1439
1446
  if (progressiveActionData.has(req)) page.actionData = progressiveActionData.get(req)
1440
1447
  const swapSlot = swapSlotOf(req)
1441
1448
  if (swapSlot !== undefined) page.swapSlot = swapSlot
@@ -1796,7 +1803,7 @@ export function createPageRequestExecutor<Env = unknown>(
1796
1803
  c: PageRouteContext<Env>,
1797
1804
  controls: PageResponseControls,
1798
1805
  ): Promise<PageResponse> => {
1799
- const live = liveLoadOf?.(c.req)
1806
+ let live = liveLoadOf?.(c.req)
1800
1807
  const componentRuntime = options.serverComponents
1801
1808
  const componentOwner = componentRuntime?.owns(route.id) === true
1802
1809
  let componentRequest:
@@ -1813,6 +1820,17 @@ export function createPageRequestExecutor<Env = unknown>(
1813
1820
  }
1814
1821
  const loaded = loadModule(route)
1815
1822
  const mod = cacheModules && !(loaded instanceof Promise) ? loaded : await loaded
1823
+ // A document's loaders declare their keys into a seed, which the page exchanges for a grant
1824
+ // once its socket opens, instead of loading itself again. Partially prerendered pages, whose
1825
+ // holes settle after the shell, and server-component routes load as before.
1826
+ const seeding =
1827
+ live === undefined &&
1828
+ liveSeedOf !== undefined &&
1829
+ !componentOwner &&
1830
+ componentRequest === undefined &&
1831
+ (options.ppr === undefined || mod.shellLoader === undefined) &&
1832
+ !isDataRequest(c)
1833
+ if (seeding) live = liveSeedOf(c.req, route.id)
1816
1834
  const draft = await draftFlag(c.req)
1817
1835
  // Unpublished content is the editor's alone: whatever cache-control a loader sets, no cache keeps it.
1818
1836
  if (draft) controls.personalize()
@@ -2094,7 +2112,7 @@ export function createPageRequestExecutor<Env = unknown>(
2094
2112
  }
2095
2113
  const isData = isDataRequest(c)
2096
2114
  const liveEnvelope =
2097
- live === undefined || !isData || isStatusSignal(data) || isControlFlow(data)
2115
+ live === undefined || (!isData && !seeding) || isStatusSignal(data) || isControlFlow(data)
2098
2116
  ? undefined
2099
2117
  : await liveEnvelopeOf(
2100
2118
  live,
@@ -2125,7 +2143,10 @@ export function createPageRequestExecutor<Env = unknown>(
2125
2143
  responseHeaders,
2126
2144
  controls.personalized,
2127
2145
  componentState,
2128
- liveEnvelope,
2146
+ seeding ? undefined : liveEnvelope,
2147
+ seeding && liveEnvelope !== undefined && !("__nifra_deferred" in liveEnvelope)
2148
+ ? liveEnvelope
2149
+ : undefined,
2129
2150
  )
2130
2151
  }
2131
2152
  return withResponseControls(liveLoadOf === undefined ? page : admitLive(page))
@@ -99,6 +99,9 @@ export interface IdentityParityFinding {
99
99
  * before anything loads. Reported, never fatal - see `SingleCopyCoverage`.
100
100
  */
101
101
  readonly deduplicated: boolean
102
+ /** Unbundled commands that still load both copies of a deduplicated package, because bunfig.toml
103
+ * does not preload the registrar for them. Absent when both are covered. */
104
+ readonly unenforced?: readonly ("bun run" | "bun test")[]
102
105
  }
103
106
 
104
107
  /**
@@ -743,11 +746,34 @@ const describeProvenance = (copies: readonly IdentityParityCopy[]): string | und
743
746
  return `reached through ${one ? "a symlink" : "symlinks"} that ${one ? "points" : "point"} outside the importer's install: ${lines.join(", ")}. Unless the importer declares ${one ? "it" : "them"} as a \`link:\` dependency, ${one ? "the link was" : "the links were"} planted by hand, by another project, or by \`bun link\` - remove ${one ? "it" : "them"} and reinstall there, so the importer resolves its own install's copy.`
744
747
  }
745
748
 
746
- /** What is left to do about a duplicate the declaration already covers. */
747
- const deduplicatedRemediation = (registration: SingleCopyRegistration): string =>
748
- registration.run || registration.test
749
- ? "Declared single-copy and enforced: every duplicate is rewritten to this app's copy, so one module instance loads."
750
- : `Declared single-copy, so bundled output is deduplicated by the build. Unbundled runs are NOT: Bun's runtime never offers a bare specifier to a resolver, so add preload = ["${SINGLE_COPY_REGISTER_SPECIFIER}"] to bunfig.toml (and under [test]) or \`bun test\` still loads both copies.`
749
+ /**
750
+ * What is left to do about a duplicate the declaration already covers. Bun reads the top-level
751
+ * `preload` for `bun run` (and the nifra CLI, which runs under the app's bunfig) and `[test] preload`
752
+ * for `bun test`, never one for the other, so each half is reported on its own.
753
+ */
754
+ const deduplicatedRemediation = (registration: SingleCopyRegistration): string => {
755
+ if (registration.run && registration.test)
756
+ return "Declared single-copy and enforced: every duplicate is rewritten to this app's copy, so one module instance loads."
757
+ const preload = `preload = ["${SINGLE_COPY_REGISTER_SPECIFIER}"]`
758
+ const ignored =
759
+ registration.ignored === undefined
760
+ ? ""
761
+ : ` The ${preload} under [${registration.ignored.join("], [")}] belongs to that table, so Bun never runs it.`
762
+ if (registration.test)
763
+ return `Declared single-copy and enforced under \`bun test\` only: \`bun run\`, \`nifra dev\` and the CLI's app imports still load both copies until bunfig.toml carries ${preload} at the top level, above the first [table].${ignored}`
764
+ if (registration.run)
765
+ return `Declared single-copy and enforced under \`bun run\` only: \`bun test\` still loads both copies until bunfig.toml carries ${preload} under [test].`
766
+ return `Declared single-copy, so bundled output is deduplicated by the build. Unbundled runs are NOT: Bun's runtime never offers a bare specifier to a resolver, so add ${preload} to bunfig.toml above the first [table], and again under [test], or \`bun run\` and \`bun test\` still load both copies.${ignored}`
767
+ }
768
+
769
+ const unenforcedCommands = (
770
+ registration: SingleCopyRegistration,
771
+ ): readonly ("bun run" | "bun test")[] => {
772
+ const commands: ("bun run" | "bun test")[] = []
773
+ if (!registration.run) commands.push("bun run")
774
+ if (!registration.test) commands.push("bun test")
775
+ return commands
776
+ }
751
777
 
752
778
  /**
753
779
  * Read the declaration and its runtime proof, preferring the directory the caller is in.
@@ -861,6 +887,7 @@ export async function collectIdentityParity(
861
887
  // so the skew stays fatal and the ranges have to be fixed.
862
888
  const covered =
863
889
  cause === "duplicate-path" && matchesSingleCopyDeclaration(singleCopy.declared, name)
890
+ const unenforced = covered ? unenforcedCommands(singleCopy.registration) : []
864
891
  const finding: IdentityParityFinding = {
865
892
  package: name,
866
893
  copies: resolvedCopies,
@@ -880,7 +907,9 @@ export async function collectIdentityParity(
880
907
  ...(provenance === undefined ? {} : { provenance }),
881
908
  deduplicated: covered,
882
909
  }
883
- ;(covered ? deduplicated : findings).push(finding)
910
+ ;(covered ? deduplicated : findings).push(
911
+ unenforced.length === 0 ? finding : { ...finding, unenforced },
912
+ )
884
913
  }
885
914
  return {
886
915
  workspaceRoot: scanRoot,
@@ -1,4 +1,5 @@
1
1
  import { hashSource } from "../../csp.ts"
2
+ import { REVIVE_DATES_SOURCE } from "../revive-dates.ts"
2
3
 
3
4
  /**
4
5
  * The PPR client runtime: one inline script in the shell, constant per release, listed in the CSP by
@@ -14,7 +15,9 @@ import { hashSource } from "../../csp.ts"
14
15
  * HTML before framing it; the policy only names the one sink.
15
16
  */
16
17
  export const PPR_RUNTIME =
17
- '(()=>{var w=window,d=document,h={},s={},dead=0,p;var pol=function(){if(!p){try{p=w.trustedTypes?w.trustedTypes.createPolicy("nifra-ppr",{createHTML:function(x){return x}}):null}catch(_){}p=p||{createHTML:function(x){return x}}}return p};var ap=function(t,n,v){t.innerHTML=pol().createHTML(typeof v.html==="string"?v.html:"");t.setAttribute("data-nifra-filled","");t.setAttribute("aria-busy","false");if(v.error)t.setAttribute("data-nifra-hole-error","");else t.removeAttribute("data-nifra-hole-error");var o={props:v.props,error:!!v.error};if(v.component){o.component=v.component;o.html=v.html}h[n]=o;return o};var f=function(n){if(dead)return;var e=d.getElementById("nifra-ppr:"+n);if(!e)return;var v;try{v=JSON.parse(e.textContent)}catch(_){return}e.remove();var t=d.querySelector(\'[data-nifra-hole="\'+n+\'"]\');if(!t||t.hasAttribute("data-nifra-filled"))return;var o=ap(t,n,v);(s[n]||[]).slice().forEach(function(c){c(o)})};f.put=function(n,v,t){if(v&&typeof v==="object"&&t&&t.getAttribute&&t.getAttribute("data-nifra-hole")===n)ap(t,n,v)};f.get=function(n){return h[n]};f.subscribe=function(n,c){(s[n]||(s[n]=[])).push(c);return function(){s[n]=(s[n]||[]).filter(function(x){return x!==c})}};f.leave=function(){if(dead)return;dead=1;s={};var r=d.getElementById("nifra-ppr-req");if(!r)return;var q;try{q=JSON.parse(r.textContent)}catch(_){return}if(q.cancel&&q.holes.some(function(n){return!h[n]})&&navigator.sendBeacon)navigator.sendBeacon(q.cancel,q.token)};f.dead=function(){return!!dead};f.boot=function(){var c=d.currentScript,e=c&&c.previousElementSibling;if(e&&e.rel==="modulepreload"&&e.href)import(e.href)};w.__nifraPpr=f})()'
18
+ "(()=>{" +
19
+ REVIVE_DATES_SOURCE +
20
+ ';var w=window,d=document,h={},s={},dead=0,p;var pol=function(){if(!p){try{p=w.trustedTypes?w.trustedTypes.createPolicy("nifra-ppr",{createHTML:function(x){return x}}):null}catch(_){}p=p||{createHTML:function(x){return x}}}return p};var ap=function(t,n,v){t.innerHTML=pol().createHTML(typeof v.html==="string"?v.html:"");t.setAttribute("data-nifra-filled","");t.setAttribute("aria-busy","false");if(v.error)t.setAttribute("data-nifra-hole-error","");else t.removeAttribute("data-nifra-hole-error");var o={props:__nifraDates(v.props),error:!!v.error};if(v.component){o.component=v.component;o.html=v.html}h[n]=o;return o};var f=function(n){if(dead)return;var e=d.getElementById("nifra-ppr:"+n);if(!e)return;var v;try{v=JSON.parse(e.textContent)}catch(_){return}e.remove();var t=d.querySelector(\'[data-nifra-hole="\'+n+\'"]\');if(!t||t.hasAttribute("data-nifra-filled"))return;var o=ap(t,n,v);(s[n]||[]).slice().forEach(function(c){c(o)})};f.put=function(n,v,t){if(v&&typeof v==="object"&&t&&t.getAttribute&&t.getAttribute("data-nifra-hole")===n)ap(t,n,v)};f.get=function(n){return h[n]};f.subscribe=function(n,c){(s[n]||(s[n]=[])).push(c);return function(){s[n]=(s[n]||[]).filter(function(x){return x!==c})}};f.leave=function(){if(dead)return;dead=1;s={};var r=d.getElementById("nifra-ppr-req");if(!r)return;var q;try{q=JSON.parse(r.textContent)}catch(_){return}if(q.cancel&&q.holes.some(function(n){return!h[n]})&&navigator.sendBeacon)navigator.sendBeacon(q.cancel,q.token)};f.dead=function(){return!!dead};f.boot=function(){var c=d.currentScript,e=c&&c.previousElementSibling;if(e&&e.rel==="modulepreload"&&e.href)import(e.href)};w.__nifraPpr=f})()'
18
21
 
19
22
  /**
20
23
  * What loads each module script of a shell, after a `<link rel="modulepreload">` naming it. WebKit
@@ -10,7 +10,8 @@ import { type CspPolicy, cspHeaderWithSources, prepareCspPolicy } from "../../cs
10
10
  import { defer } from "../../deferred.ts"
11
11
  import type { RenderAdapter } from "../../render-seam.ts"
12
12
  import { HOLE_ERROR_HTML } from "../hole-runtime.ts"
13
- import { isControlFlow, isStatusSignal, notFound } from "../render-document.ts"
13
+ import { isControlFlow } from "../render-document.ts"
14
+ import { isStatusSignal, notFound } from "../status-page.ts"
14
15
  import { HoleAdmission, type HoleRun, type HoleSlot, watchAbort } from "./admission.ts"
15
16
  import type { ShellCheckRecord } from "./compile-shell.ts"
16
17
  import {
@@ -178,8 +179,9 @@ const pendingStates = (holes: readonly HoleRegistration[]): BoundaryStates => {
178
179
  const shellKey = (deployment: string, request: PprRequest): string =>
179
180
  `ppr:v1|${deployment}|${request.routeId}|${request.locale ?? ""}|${request.path}|${variationQuery(request.variations)}`
180
181
 
182
+ // No `?.`: a bundler's NODE_ENV define replaces only the plain member read, and this runs per request.
181
183
  const isProduction = (): boolean =>
182
- typeof process !== "undefined" && process.env?.NODE_ENV === "production"
184
+ typeof process !== "undefined" && process.env.NODE_ENV === "production"
183
185
 
184
186
  const concat = (chunks: readonly Uint8Array[]): Uint8Array => {
185
187
  let length = 0
@@ -5,7 +5,9 @@
5
5
  * fails the check is a miss.
6
6
  */
7
7
  import type { CachedResponse, CacheStore } from "../../isr.ts"
8
+ import { DATE_TAG } from "../revive-dates.ts"
8
9
  import { storeSigner } from "../store-signature.ts"
10
+ import { wireDate } from "../wire-date.ts"
9
11
 
10
12
  /** A shell or a shared hole's frame as the shared store holds it: everything a process needs to
11
13
  * serve it. */
@@ -61,6 +63,7 @@ const isStoredEntry = (value: unknown): value is StoredEntry => {
61
63
  )
62
64
  }
63
65
 
66
+ /** Read and write signed deployment-scoped PPR entries, treating malformed data as cache misses. */
64
67
  export function sharedShellStore(
65
68
  store: CacheStore,
66
69
  secret: string,
@@ -81,7 +84,17 @@ export function sharedShellStore(
81
84
  }
82
85
  let payload: unknown
83
86
  try {
84
- payload = JSON.parse(cached.body)
87
+ // Match page-data revival, but keep the server's tagged toJSON for subsequent responses.
88
+ // JSON.parse defines own properties (including __proto__) without invoking setters.
89
+ payload = JSON.parse(cached.body, (_key, value: unknown) => {
90
+ if (typeof value !== "object" || value === null) return value
91
+ const tagged: unknown = Reflect.get(value, DATE_TAG)
92
+ if (typeof tagged !== "string" || Object.keys(value).length !== 1) return value
93
+ const time = Date.parse(tagged)
94
+ // An invalid wireDate would throw later from toJSON, outside this cache-miss boundary.
95
+ if (!Number.isFinite(time)) throw new RangeError("invalid cached date tag")
96
+ return wireDate(time)
97
+ })
85
98
  } catch {
86
99
  return undefined
87
100
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * How a client navigation's page swap reaches the screen. The router loads a page first and hands only
3
+ * the swap here, so the history layer can wrap the swap alone in a view transition rather than the
4
+ * whole load. Module-scoped: one app per page; unset on the server and without `installHistory`.
5
+ */
6
+ export type Presenter = (swap: () => void) => Promise<void>
7
+
8
+ let presenter: Presenter | undefined
9
+
10
+ /** Set (or clear, with `undefined`) the presenter - called by `installHistory`. */
11
+ export function setPresenter(next: Presenter | undefined): void {
12
+ presenter = next
13
+ }
14
+
15
+ /** Run `swap` through the presenter, or at once when none is set. */
16
+ export function present(swap: () => void): Promise<void> | undefined {
17
+ if (presenter !== undefined) return presenter(swap)
18
+ swap()
19
+ return undefined
20
+ }