@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
@@ -34,6 +34,7 @@ import {
34
34
  DATA_GLOBAL,
35
35
  HANDOVER_ID,
36
36
  LAYOUT_DATA_GLOBAL,
37
+ LIVE_GLOBAL,
37
38
  LOCALES_GLOBAL,
38
39
  ROOT_ATTRIBUTE,
39
40
  ROUTE_GLOBAL,
@@ -45,6 +46,7 @@ import { isStaticMeta, mergeHeads } from "./head-merge.ts"
45
46
  import { reportDeferredRejection } from "./request-abort.ts"
46
47
  import { PRE_HYDRATION_GUARD } from "./runtime-contract.ts"
47
48
  import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./script-types.ts"
49
+ import type { HeadersLike } from "./status-page.ts"
48
50
 
49
51
  const TEXT_ENCODER = new TextEncoder()
50
52
 
@@ -108,6 +110,11 @@ export interface RenderPageOptions {
108
110
  /** An action's data return (POST only) - surfaced to the page as `actionData` + serialized
109
111
  * for the client so post-POST hydration matches. Omit on GETs. */
110
112
  readonly actionData?: unknown
113
+ /**
114
+ * A live runtime's seed for this page (`createWebApp({ live })` sets it): handed to the client
115
+ * entry, which exchanges it for a grant instead of loading the page again.
116
+ */
117
+ readonly live?: unknown
111
118
  /**
112
119
  * URL of the built client entry (loaded as a module script).
113
120
  *
@@ -416,6 +423,8 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
416
423
  options.serverComponents === undefined
417
424
  ? ""
418
425
  : `,"__NIFRA_COMPONENTS__":${serializeData(options.serverComponents)}`
426
+ const liveTail =
427
+ options.live === undefined ? "" : `,"${LIVE_GLOBAL}":${serializeData(options.live)}`
419
428
  const deferredRuntime =
420
429
  allDeferred.length > 0 ? `<script${nonceAttr}>${DEFERRED_RUNTIME}</script>` : ""
421
430
  // The regex only injects the CSP nonce into the (constant) hydration head; with no nonce it's a
@@ -528,7 +537,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
528
537
  // Tail - the page-state handover + the client module. Module scripts defer (run after parse), so
529
538
  // the handover + every streamed deferred resolution are in the document before the entry hydrates.
530
539
  const tailHtml = hydrate
531
- ? `${slot.tailPre}${serializeData(forClient)}${action}${layoutTail}${boundaryTail}${componentTail}${slot.tailPost}`
540
+ ? `${slot.tailPre}${serializeData(forClient)}${action}${layoutTail}${boundaryTail}${componentTail}${liveTail}${slot.tailPost}`
532
541
  : (slot.tailPost as string)
533
542
  const headers: Record<string, string> = { "content-type": "text/html; charset=utf-8" }
534
543
  if (locale !== undefined) headers["content-language"] = lang
@@ -941,24 +950,6 @@ export function redirect(location: string, options: RedirectOptions = {}): Respo
941
950
  })
942
951
  }
943
952
 
944
- /**
945
- * Brand marking a `Response` as a **terminal-status signal** rather than a response to serve verbatim.
946
- *
947
- * A loader that throws a bare `Response` has always been passed straight through - that is how
948
- * `throw redirect(...)` works, and apps rely on it. So "render the `_404` boundary at 404" cannot be
949
- * expressed by throwing a plain `Response`: the framework would have no way to tell it apart from a
950
- * hand-rolled body the app wants served exactly as written. The brand is that distinction, and
951
- * checking it *before* the pass-through is what keeps every existing throw working unchanged.
952
- *
953
- * `Symbol.for` matches `RESPONSE_RESULT` above: a registry symbol survives two copies of this module,
954
- * which a `unique symbol` would not - and a duplicate-install turning a 404 into a raw 404 body would
955
- * be a maddening bug to trace.
956
- */
957
- /** Derived from the `Headers` constructor rather than the DOM lib's `HeadersInit`: `@nifrajs/web`
958
- * is consumed from DOM-free programs (the root tsconfig runs `types: ["bun"]`), and naming the DOM
959
- * type here would break their build. Same workaround as `SSEInit.headers` in core. */
960
- export type HeadersLike = ConstructorParameters<typeof Headers>[0]
961
-
962
953
  /**
963
954
  * The signatures a **duplicate module instance** produces during SSR, across engines.
964
955
  *
@@ -1061,8 +1052,6 @@ export const layoutErrorId = (err: unknown): string | undefined =>
1061
1052
  ? ((err as Record<symbol, unknown>)[LAYOUT_ERROR_ID] as string | undefined)
1062
1053
  : undefined
1063
1054
 
1064
- export const STATUS_SIGNAL = Symbol.for("nifra.web.status-signal")
1065
-
1066
1055
  /** Reason phrases for the plain-text fallback, used only when the app authored no `_<status>` and no
1067
1056
  * `_404` page. Deliberately not exhaustive - anything unlisted falls back to "Error", which is more
1068
1057
  * useful than shipping a table of every RFC status for a body almost nobody will see. */
@@ -1077,77 +1066,6 @@ export const STATUS_TEXT: Readonly<Record<number, string>> = {
1077
1066
  500: "Internal Server Error",
1078
1067
  }
1079
1068
 
1080
- export interface StatusSignal extends Response {
1081
- readonly [STATUS_SIGNAL]: { readonly status: number; readonly headers?: HeadersLike }
1082
- }
1083
-
1084
- export function isStatusSignal(value: unknown): value is StatusSignal {
1085
- return value instanceof Response && STATUS_SIGNAL in value
1086
- }
1087
-
1088
- function statusSignal(status: number, options: StatusPageOptions): never {
1089
- if (!Number.isInteger(status) || status < 400 || status > 599) {
1090
- throw new Error(
1091
- `[nifra/web] statusPage(${JSON.stringify(status)}) must be an integer 4xx or 5xx status. Use redirect() for 3xx, and return data for a successful render.`,
1092
- )
1093
- }
1094
- const response = new Response(null, { status })
1095
- // Non-enumerable so the brand never lands in a structured clone or a JSON log of the response.
1096
- Object.defineProperty(response, STATUS_SIGNAL, {
1097
- value: { status, ...(options.headers !== undefined ? { headers: options.headers } : {}) },
1098
- enumerable: false,
1099
- })
1100
- throw response
1101
- }
1102
-
1103
- /** Options shared by {@link notFound}, {@link gone}, and {@link statusPage}. */
1104
- export interface StatusPageOptions {
1105
- /**
1106
- * Extra headers for the rendered response - `cache-control` above all.
1107
- *
1108
- * The defaults differ by status for a reason: a 404 may be a race with publication, so it wants a
1109
- * short TTL, while a 410 is a promise that the URL is permanently gone and can be cached hard.
1110
- * Getting this wrong is the difference between a crawler dropping a dead URL and re-fetching it
1111
- * for weeks.
1112
- */
1113
- readonly headers?: HeadersLike
1114
- }
1115
-
1116
- /**
1117
- * Render the nearest `_404` page at status **404**. `throw` it from a loader when the record does not
1118
- * exist.
1119
- *
1120
- * This is the fix for the soft 404: a matched route whose loader finds nothing has otherwise no way to
1121
- * answer 404, so the path of least resistance is to return empty data and render "not found" inside a
1122
- * **200**. That looks correct in a browser and is invisible in review, which is why it ships and stays
1123
- * shipped - while search engines penalise it and keep the dead URL indexed.
1124
- *
1125
- * Returns `never`: these throw, so a loader narrows without a redundant `return`, and the type states
1126
- * the thing the "loaders `throw` redirect, actions `return` it" rule already trips people on.
1127
- */
1128
- export function notFound(options: StatusPageOptions = {}): never {
1129
- return statusSignal(404, options)
1130
- }
1131
-
1132
- /**
1133
- * Render a terminal page at status **410 Gone**. `throw` it from a loader for a record that existed and
1134
- * was deliberately removed - a withdrawn listing, a deleted post.
1135
- *
1136
- * 410 is not a pedantic 404: it tells a crawler to **drop** the URL rather than re-fetch it for weeks
1137
- * on the assumption the 404 was transient. Uses `_410.tsx` if the app has one, otherwise `_404`.
1138
- */
1139
- export function gone(options: StatusPageOptions = {}): never {
1140
- return statusSignal(410, options)
1141
- }
1142
-
1143
- /**
1144
- * Render a terminal page at any 4xx/5xx status - the escape hatch behind {@link notFound} and
1145
- * {@link gone} (402, 451, …). Uses `_<status>.tsx` if present, otherwise `_404`.
1146
- */
1147
- export function statusPage(status: number, options: StatusPageOptions = {}): never {
1148
- return statusSignal(status, options)
1149
- }
1150
-
1151
1069
  /** The wrapper `revalidate()` returns: the action's `data` plus the paths it changed. A plain tagged
1152
1070
  * shape (not a class) so `@nifrajs/client`'s `ActionData` can unwrap it structurally without importing
1153
1071
  * from `@nifrajs/web`. `createWebApp` strips the wrapper - the client receives `data` as the body and
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The terminal-status helpers and their brand, free of server imports: `@nifrajs/web/client` re-exports
3
+ * them for route helpers the browser also loads, which the root entry would drag `node:crypto` into.
4
+ */
5
+
6
+ /** Derived from the `Headers` constructor rather than the DOM lib's `HeadersInit`: `@nifrajs/web`
7
+ * is consumed from DOM-free programs (the root tsconfig runs `types: ["bun"]`), and naming the DOM
8
+ * type here would break their build. Same workaround as `SSEInit.headers` in core. */
9
+ export type HeadersLike = ConstructorParameters<typeof Headers>[0]
10
+
11
+ /**
12
+ * Brand marking a `Response` as a **terminal-status signal** rather than a response to serve verbatim.
13
+ *
14
+ * A loader that throws a bare `Response` has always been passed straight through - that is how
15
+ * `throw redirect(...)` works, and apps rely on it. So "render the `_404` boundary at 404" cannot be
16
+ * expressed by throwing a plain `Response`: the framework would have no way to tell it apart from a
17
+ * hand-rolled body the app wants served exactly as written. The brand is that distinction, and
18
+ * checking it *before* the pass-through is what keeps every existing throw working unchanged.
19
+ *
20
+ * `Symbol.for` matches core's `RESPONSE_RESULT`: a registry symbol survives two copies of this module,
21
+ * which a `unique symbol` would not - and a duplicate-install turning a 404 into a raw 404 body would
22
+ * be a maddening bug to trace.
23
+ */
24
+ export const STATUS_SIGNAL = Symbol.for("nifra.web.status-signal")
25
+
26
+ export interface StatusSignal extends Response {
27
+ readonly [STATUS_SIGNAL]: { readonly status: number; readonly headers?: HeadersLike }
28
+ }
29
+
30
+ export function isStatusSignal(value: unknown): value is StatusSignal {
31
+ return value instanceof Response && STATUS_SIGNAL in value
32
+ }
33
+
34
+ function statusSignal(status: number, options: StatusPageOptions): never {
35
+ if (!Number.isInteger(status) || status < 400 || status > 599) {
36
+ throw new Error(
37
+ `[nifra/web] statusPage(${JSON.stringify(status)}) must be an integer 4xx or 5xx status. Use redirect() for 3xx, and return data for a successful render.`,
38
+ )
39
+ }
40
+ const response = new Response(null, { status })
41
+ // Non-enumerable so the brand never lands in a structured clone or a JSON log of the response.
42
+ Object.defineProperty(response, STATUS_SIGNAL, {
43
+ value: { status, ...(options.headers !== undefined ? { headers: options.headers } : {}) },
44
+ enumerable: false,
45
+ })
46
+ throw response
47
+ }
48
+
49
+ /** Options shared by {@link notFound}, {@link gone}, and {@link statusPage}. */
50
+ export interface StatusPageOptions {
51
+ /**
52
+ * Extra headers for the rendered response - `cache-control` above all.
53
+ *
54
+ * The defaults differ by status for a reason: a 404 may be a race with publication, so it wants a
55
+ * short TTL, while a 410 is a promise that the URL is permanently gone and can be cached hard.
56
+ * Getting this wrong is the difference between a crawler dropping a dead URL and re-fetching it
57
+ * for weeks.
58
+ */
59
+ readonly headers?: HeadersLike
60
+ }
61
+
62
+ /**
63
+ * Render the nearest `_404` page at status **404**. `throw` it from a loader when the record does not
64
+ * exist.
65
+ *
66
+ * This is the fix for the soft 404: a matched route whose loader finds nothing has otherwise no way to
67
+ * answer 404, so the path of least resistance is to return empty data and render "not found" inside a
68
+ * **200**. That looks correct in a browser and is invisible in review, which is why it ships and stays
69
+ * shipped - while search engines penalise it and keep the dead URL indexed.
70
+ *
71
+ * Returns `never`: these throw, so a loader narrows without a redundant `return`, and the type states
72
+ * the thing the "loaders `throw` redirect, actions `return` it" rule already trips people on.
73
+ */
74
+ export function notFound(options: StatusPageOptions = {}): never {
75
+ return statusSignal(404, options)
76
+ }
77
+
78
+ /**
79
+ * Render a terminal page at status **410 Gone**. `throw` it from a loader for a record that existed and
80
+ * was deliberately removed - a withdrawn listing, a deleted post.
81
+ *
82
+ * 410 is not a pedantic 404: it tells a crawler to **drop** the URL rather than re-fetch it for weeks
83
+ * on the assumption the 404 was transient. Uses `_410.tsx` if the app has one, otherwise `_404`.
84
+ */
85
+ export function gone(options: StatusPageOptions = {}): never {
86
+ return statusSignal(410, options)
87
+ }
88
+
89
+ /**
90
+ * Render a terminal page at any 4xx/5xx status - the escape hatch behind {@link notFound} and
91
+ * {@link gone} (402, 451, …). Uses `_<status>.tsx` if present, otherwise `_404`.
92
+ */
93
+ export function statusPage(status: number, options: StatusPageOptions = {}): never {
94
+ return statusSignal(status, options)
95
+ }
@@ -299,8 +299,9 @@ export interface CreateWebAppOptions<Env = unknown> {
299
299
  /**
300
300
  * Which cookies say who is signed in, for an app with a `keepAlive` route. A response that changes
301
301
  * or deletes one rotates the readable identity epoch cookie, and every tab drops the pages it kept
302
- * for the previous visitor. The session cookies of `@nifrajs/auth`, Better Auth and Auth.js count
303
- * under their default names; name any other in `identityCookies`. Development reports, once per
302
+ * for the previous visitor. The session cookies of `@nifrajs/auth` and Auth.js count under their
303
+ * default names, Better Auth's under any `cookiePrefix`; name any other in `identityCookies`. An app
304
+ * run by `nifra dev` and `nifra build` exports this from `backend/framework.ts`. Development reports, once per
304
305
  * name, an `HttpOnly` cookie that changes and is in neither list (`NIFRA_KEEPALIVE_IDENTITY_COOKIE`).
305
306
  */
306
307
  readonly keepAlive?: KeepAliveServerOptions
@@ -654,6 +655,7 @@ export function createWebApp<Env = unknown>(
654
655
  ...(ppr === undefined ? {} : { ppr }),
655
656
  invalidate,
656
657
  liveLoadOf: liveAttachment?.loadOf,
658
+ liveSeedOf: liveAttachment?.seedOf,
657
659
  })
658
660
  if (ppr !== undefined) {
659
661
  Object.defineProperty(app, PPR_SERVER, { value: ppr.server })
package/src/keep-alive.ts CHANGED
@@ -106,6 +106,7 @@ interface Kept {
106
106
  readonly page: KeptPage
107
107
  readonly index: number
108
108
  readonly epoch: string
109
+ readonly identity: number
109
110
  readonly sources: Sources
110
111
  readonly hiddenAt: number
111
112
  readonly maxAge: number
@@ -118,6 +119,7 @@ interface Active {
118
119
  readonly index: number
119
120
  readonly settled: RouterState
120
121
  readonly epoch: string
122
+ readonly identity: number
121
123
  readonly sources: Sources
122
124
  }
123
125
 
@@ -281,6 +283,8 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
281
283
  const wrappedFetchers = new Map<string, Fetcher>()
282
284
  let keptPages: readonly KeptPage[] | undefined
283
285
  let lastEpoch = readEpoch()
286
+ // Explicit identity signals also cover bearer auth, where the cookie never changes.
287
+ let identity = 0
284
288
 
285
289
  const first = readEntry()
286
290
  const initial = inner.snapshot()
@@ -294,10 +298,13 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
294
298
  index: first.index,
295
299
  settled: initial,
296
300
  epoch: lastEpoch,
301
+ identity,
297
302
  sources: sourcesOf(initial),
298
303
  }
299
304
  stampEntry(active.instance)
300
- if (typeof process !== "undefined" && process.env.NODE_ENV !== "production")
305
+ // Browser-only code reads `process.env.NODE_ENV` bare: every bundler replaces it, but none replaces
306
+ // `typeof process`, and a browser has no `process`, so that guard turned these off in Vite dev.
307
+ if (process.env.NODE_ENV !== "production")
301
308
  Reflect.set(globalThis, KEPT_PAGES_GLOBAL, {
302
309
  pages: () => describeKept(records.values()),
303
310
  })
@@ -385,8 +392,15 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
385
392
  dropKept("identity")
386
393
  }
387
394
 
388
- const keep = ({ instance, index, settled: state, epoch, sources }: Active): void => {
389
- if (!keepable(state) || epoch !== readEpoch()) return
395
+ const keep = ({
396
+ instance,
397
+ index,
398
+ settled: state,
399
+ epoch,
400
+ identity: loadedIdentity,
401
+ sources,
402
+ }: Active): void => {
403
+ if (!keepable(state) || epoch !== readEpoch() || loadedIdentity !== identity) return
390
404
  const setting = keepAliveOf(state.routeId)
391
405
  const settings = typeof setting === "object" ? setting : {}
392
406
  records.delete(instance)
@@ -394,6 +408,7 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
394
408
  page: { instance, state },
395
409
  index,
396
410
  epoch,
411
+ identity: loadedIdentity,
397
412
  sources,
398
413
  hiddenAt: now(),
399
414
  maxAge: settings.maxAge ?? KEEP_ALIVE_MAX_AGE_MS,
@@ -404,8 +419,13 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
404
419
  if (records.size > max) drop([...records.keys()].slice(0, records.size - max), "lru")
405
420
  }
406
421
 
422
+ let loading: { readonly identity: number; readonly epoch: string } | undefined
423
+
407
424
  const settle = (next: RouterState): void => {
408
425
  const entry = readEntry()
426
+ const loadedIdentity = loading?.identity ?? identity
427
+ const loadedEpoch = loading?.epoch ?? readEpoch()
428
+ loading = undefined
409
429
  if (sameInstance(active.settled, next)) {
410
430
  // Data the router did not load again is still the data it was loaded with.
411
431
  const same = next.data === active.settled.data
@@ -413,7 +433,8 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
413
433
  instance: active.instance,
414
434
  index: entry.index,
415
435
  settled: next,
416
- epoch: same ? active.epoch : readEpoch(),
436
+ epoch: same ? active.epoch : loadedEpoch,
437
+ identity: same ? active.identity : loadedIdentity,
417
438
  sources: same ? active.sources : sourcesOf(next),
418
439
  }
419
440
  } else {
@@ -437,7 +458,8 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
437
458
  instance: reuse ?? newInstance(),
438
459
  index: entry.index,
439
460
  settled: next,
440
- epoch: readEpoch(),
461
+ epoch: loadedEpoch,
462
+ identity: loadedIdentity,
441
463
  sources: sourcesOf(next),
442
464
  }
443
465
  }
@@ -488,7 +510,10 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
488
510
  instance: active.instance,
489
511
  index: active.index,
490
512
  settled: next,
491
- epoch: restore.reload ? readEpoch() : active.epoch,
513
+ // The epoch and identity the restore started under: a reload's response, or the kept
514
+ // response a failed reload falls back to, never belongs to a visitor who arrived since.
515
+ epoch: active.epoch,
516
+ identity: active.identity,
492
517
  // A reload that failed shows the kept data, from the kept sources.
493
518
  sources: sourcesOf(next) ?? active.sources,
494
519
  }
@@ -504,6 +529,8 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
504
529
  settle(next)
505
530
  return
506
531
  }
532
+ // A response belongs to the identity that started its load, not the visitor at arrival.
533
+ loading ??= { identity, epoch: readEpoch() }
507
534
  shown = next
508
535
  flags = undefined
509
536
  leaving = next.routeId !== active.settled.routeId && !leftGone && keepable(active.settled)
@@ -511,13 +538,21 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
511
538
  })
512
539
 
513
540
  const restore = (path: string, instance: string, record: Kept): Promise<void> => {
541
+ loading = undefined
514
542
  const entry = readEntry()
515
543
  records.delete(instance)
516
544
  keptPages = undefined
517
545
  if (!leftGone) keep(active)
518
546
  const kept = record.page.state
519
547
  const { sources } = record
520
- active = { instance, index: entry.index, settled: kept, epoch: record.epoch, sources }
548
+ active = {
549
+ instance,
550
+ index: entry.index,
551
+ settled: kept,
552
+ epoch: record.epoch,
553
+ identity: record.identity,
554
+ sources,
555
+ }
521
556
  fresh.clear()
522
557
  // Data a client loader built is shown again only from what it was built from.
523
558
  const unsourced =
@@ -572,6 +607,7 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
572
607
  return restore(path, instance, record)
573
608
  }
574
609
  endRestore()
610
+ loading = { identity, epoch: readEpoch() }
575
611
  return inner.navigate(path)
576
612
  }
577
613
 
@@ -594,6 +630,7 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
594
630
  typeof BroadcastChannel === "undefined" ? undefined : new BroadcastChannel(IDENTITY_CHANNEL)
595
631
  const identityDrop = (): void => {
596
632
  lastEpoch = readEpoch()
633
+ identity++
597
634
  dropKept("identity")
598
635
  }
599
636
  channel?.addEventListener("message", identityDrop)
@@ -629,9 +666,14 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
629
666
  invalidate: (paths) => {
630
667
  markStale(paths)
631
668
  // At once when nothing is restoring: the live client names its load around this call.
632
- return restoring === undefined
633
- ? inner.invalidate(paths)
634
- : afterRestore().then(() => inner.invalidate(paths))
669
+ const invalidate = (): Promise<void> => {
670
+ const capture = { identity, epoch: readEpoch() }
671
+ loading = capture
672
+ return inner.invalidate(paths).finally(() => {
673
+ if (loading === capture) loading = undefined
674
+ })
675
+ }
676
+ return restoring === undefined ? invalidate() : afterRestore().then(invalidate)
635
677
  },
636
678
  prefetch: (path) => inner.prefetch(path),
637
679
  fetcher: (key) => {
@@ -1104,8 +1146,7 @@ export function createSlotParking(): SlotParking {
1104
1146
  ref = (element) => {
1105
1147
  if (element === null) return
1106
1148
  slotOf(key).page = element
1107
- if (typeof process !== "undefined" && process.env.NODE_ENV !== "production")
1108
- noteKeptView(key, () => [element])
1149
+ if (process.env.NODE_ENV !== "production") noteKeptView(key, () => [element])
1109
1150
  }
1110
1151
  pageRefs.set(key, ref)
1111
1152
  }
@@ -1162,8 +1203,7 @@ export function createSlotParking(): SlotParking {
1162
1203
  slot.focus = undefined
1163
1204
  if (!slot.shown && !slot.checked) {
1164
1205
  slot.checked = true
1165
- if (typeof process !== "undefined" && process.env.NODE_ENV !== "production")
1166
- checkSlotCss(slot.entry, page)
1206
+ if (process.env.NODE_ENV !== "production") checkSlotCss(slot.entry, page)
1167
1207
  }
1168
1208
  slot.shown = true
1169
1209
  }
@@ -1174,8 +1214,7 @@ export function createSlotParking(): SlotParking {
1174
1214
  slots.delete(key)
1175
1215
  entryRefs.delete(key)
1176
1216
  pageRefs.delete(key)
1177
- if (typeof process !== "undefined" && process.env.NODE_ENV !== "production")
1178
- noteKeptView(key, undefined)
1217
+ if (process.env.NODE_ENV !== "production") noteKeptView(key, undefined)
1179
1218
  }
1180
1219
  },
1181
1220
  }
package/src/live/bus.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  * keeps counting upward, and an entry's age can be read off its seq.
8
8
  */
9
9
 
10
+ import { KEY_PATTERN } from "./keyring.ts"
11
+
10
12
  /** Something a loader declared with `ctx.live` changed. */
11
13
  export interface LiveChange {
12
14
  readonly type: "change"
@@ -35,8 +37,11 @@ export interface LiveBusListener {
35
37
  * unknown, and delivery resumes with the events after it.
36
38
  */
37
39
  gap(head: number): void
38
- /** The bus is unreachable; the next {@link gap} reports where delivery resumes. */
39
- down(): void
40
+ /**
41
+ * The bus is unreachable; the next {@link gap} reports where delivery resumes. `error` is what the
42
+ * last read failed with, when the bus knows.
43
+ */
44
+ down(error?: unknown): void
40
45
  }
41
46
 
42
47
  export interface LiveBusSubscription {
@@ -75,3 +80,25 @@ export function memoryBus(): LiveBus {
75
80
  },
76
81
  }
77
82
  }
83
+
84
+ /**
85
+ * Publish changes from a process that serves no live pages: a worker, or another app whose writes
86
+ * change what the pages show. Give it the bus the serving processes read; each applies the change as
87
+ * if it had published it. It needs no grant secret, and reads nothing from the bus.
88
+ *
89
+ * ```ts
90
+ * const publish = livePublisher(redisLiveBus({ send: (command, args) => redis.call(command, ...args) }))
91
+ * await publish(`org:${orgId}:usage`)
92
+ * ```
93
+ */
94
+ export function livePublisher(bus: Pick<LiveBus, "append">): (...keys: string[]) => Promise<void> {
95
+ return async (...keys) => {
96
+ for (const key of keys) {
97
+ if (typeof key !== "string" || !KEY_PATTERN.test(key)) {
98
+ throw new TypeError("[nifra/web] livePublisher takes keys in the ctx.live() charset")
99
+ }
100
+ }
101
+ if (keys.length === 0) return
102
+ await bus.append({ type: "change", keys: [...new Set(keys)] })
103
+ }
104
+ }
package/src/live/hub.ts CHANGED
@@ -18,6 +18,8 @@ export const CLOSE_REVOKED = 4403
18
18
  export const CLOSE_SCOPE = 4409
19
19
  export const CLOSE_CAPACITY = 1013
20
20
  export const CLOSE_PROTOCOL = 1008
21
+ /** The runtime closed: reconnect, most likely to another process. */
22
+ export const CLOSE_GOING_AWAY = 1001
21
23
  /** Nothing to watch: a tab with no tag to hear about reconnects only once it has one. */
22
24
  export const CLOSE_UNWATCHED = 1000
23
25
 
@@ -119,6 +121,8 @@ interface Pending {
119
121
  export class LiveHub {
120
122
  /** Bus unreachable: no watch is accepted and no socket kept. */
121
123
  down = false
124
+ /** `changed` frames sent, one per subscribed socket: each makes a page reload. */
125
+ notified = 0
122
126
  private readonly sockets = new Set<NifraWebSocket<LiveSocket>>()
123
127
  /** Local subscribers per tag: the intern table, and the input to `spread`. */
124
128
  private readonly members = new Map<string, number>()
@@ -416,6 +420,7 @@ export class LiveHub {
416
420
  for (const tag of tags) {
417
421
  const subscribers = this.members.get(tag)
418
422
  if (subscribers === undefined) continue
423
+ this.notified += subscribers
419
424
  const frame = JSON.stringify({ t: "changed", tag, spread: this.spread(subscribers) })
420
425
  for (const publish of this.publishers) publish(this.topic(tag), frame)
421
426
  }
@@ -18,6 +18,11 @@ export const MAX_KEYS_PER_FRAME = 64
18
18
  /** The cache-tag charset: a letter, then up to 127 of `A-Z a-z 0-9 . _ : / -`. */
19
19
  export const KEY_PATTERN = /^[A-Za-z][A-Za-z0-9._:/-]{0,127}$/
20
20
  export const SID_PATTERN = /^[0-9a-f]{32}$/
21
+ /**
22
+ * The socket id of a seed: the grant a server-rendered page carries before it has a socket. No socket
23
+ * has it, so no watch accepts a seed; the load endpoint exchanges one for a grant bound to a socket.
24
+ */
25
+ export const SEED_SID = ""
21
26
 
22
27
  const KID_BYTES = 16
23
28
  const NONCE_BYTES = 12
@@ -309,7 +314,7 @@ function validClaims(
309
314
  if (!isRecord(value)) return false
310
315
  const { v, sid, sub, path, frame, owners, s, r, iat, exp, aud } = value
311
316
  if (v !== 1 || aud !== appId) return false
312
- if (typeof sid !== "string" || !SID_PATTERN.test(sid)) return false
317
+ if (typeof sid !== "string" || (sid !== SEED_SID && !SID_PATTERN.test(sid))) return false
313
318
  if (sub !== null && typeof sub !== "string") return false
314
319
  if (typeof path !== "string" || typeof frame !== "string") return false
315
320
  if (!isCount(s) || !isCount(r) || !isInteger(iat) || !isInteger(exp)) return false