@nifrajs/web 5.6.0 → 5.8.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 +97 -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
@@ -69,6 +69,8 @@ const CONNECTS_BEFORE_POLLING = 3
69
69
  const MAX_SPREAD_MS = 5_000
70
70
  const PAGE = "page"
71
71
  const SID = /^[0-9a-f]{32}$/
72
+ /** The socket id of a seed: the grant the server rendered the page with, bound to no socket yet. */
73
+ const SEED = ""
72
74
 
73
75
  /**
74
76
  * What a live load handed the router, carried in its state beside the data it loaded. A load whose
@@ -124,6 +126,8 @@ interface Frame {
124
126
  dirty: Set<string>
125
127
  /** Every owner reloads: there is no grant to keep any of them by. */
126
128
  whole: boolean
129
+ /** The frame holds a seed and a socket is open: one load keeping every owner trades it for a grant. */
130
+ exchange: boolean
127
131
  /** The watch generation that installed this frame's grant, once `ready` confirmed it. */
128
132
  installed: boolean
129
133
  /** Set while a watch carrying this frame's grant waits for its `ready`. */
@@ -247,7 +251,10 @@ export function liveClient(options: LiveClientOptions = {}): LiveClient {
247
251
  if (id !== undefined && page?.dirty.has(`L:${id}`) === false) retain.push(`L:${id}`)
248
252
  }
249
253
  }
250
- const grant = held?.grant !== undefined && held.sid === bound ? held.grant : undefined
254
+ const grant =
255
+ held?.grant !== undefined && (held.sid === bound || held.sid === SEED)
256
+ ? held.grant
257
+ : undefined
251
258
  const res = await send(appUrl(loadPath), {
252
259
  method: "POST",
253
260
  headers: { "content-type": "application/json" },
@@ -323,6 +330,30 @@ export function liveClient(options: LiveClientOptions = {}): LiveClient {
323
330
  }
324
331
  }
325
332
 
333
+ /**
334
+ * The server-rendered page's seed, in the router's initial state: held as the page's load, it is
335
+ * traded for a socket's grant once one opens. Anything else there is ignored.
336
+ */
337
+ const adoptSeed = (state: { readonly routeId: string; readonly live?: unknown }): void => {
338
+ const value = state.live
339
+ if (typeof value !== "object" || value === null || issued.has(value)) return
340
+ const live = envelopeOf(value)
341
+ if (live?.grant === undefined) return
342
+ const loaded: Loaded = {
343
+ routeId: state.routeId,
344
+ sid: SEED,
345
+ grant: undefined,
346
+ tags: new Map(),
347
+ expiresAt: Number.POSITIVE_INFINITY,
348
+ ttlMs: Number.POSITIVE_INFINITY,
349
+ tick: advanceLiveTick(),
350
+ query: undefined,
351
+ trailing: false,
352
+ }
353
+ settleLoaded(loaded, live)
354
+ issued.set(value, loaded)
355
+ }
356
+
326
357
  /** A trailing grant arrived, or will not: the frame holding `loaded` watches or polls from now. */
327
358
  const granted = (loaded: Loaded, live: ServerEnvelope | undefined): void => {
328
359
  settleLoaded(loaded, live)
@@ -367,6 +398,7 @@ export function liveClient(options: LiveClientOptions = {}): LiveClient {
367
398
  shownAt: now,
368
399
  dirty: new Set(),
369
400
  whole: false,
401
+ exchange: false,
370
402
  installed: false,
371
403
  pending: undefined,
372
404
  loading: false,
@@ -416,7 +448,9 @@ export function liveClient(options: LiveClientOptions = {}): LiveClient {
416
448
  !tried.has(`${sid}\n${frame.path}`)
417
449
  ) {
418
450
  tried.add(`${sid}\n${frame.path}`)
419
- frame.whole = true
451
+ // A seed needs no loader to run: the server keeps every owner and binds a grant to this socket.
452
+ if (frame.loaded?.sid === SEED) frame.exchange = true
453
+ else frame.whole = true
420
454
  // A new socket is a new chance: no backoff carries over, only the spread it was given.
421
455
  frame.failures = 0
422
456
  frame.retryAt = welcomeSpread > 0 ? now + Math.random() * welcomeSpread : 0
@@ -450,7 +484,7 @@ export function liveClient(options: LiveClientOptions = {}): LiveClient {
450
484
  const now = Date.now()
451
485
  for (const frame of frames) {
452
486
  if (frame.loading || wakeOf(frame) > now) continue
453
- if (frame.whole || frame.dirty.size > 0) reload(frame, "load")
487
+ if (frame.whole || frame.exchange || frame.dirty.size > 0) reload(frame, "load")
454
488
  else if (watchable(frame)) reload(frame, "renew")
455
489
  else reload(frame, "load", true)
456
490
  // One load at a time: the router runs it, and its next state runs this again.
@@ -465,7 +499,7 @@ export function liveClient(options: LiveClientOptions = {}): LiveClient {
465
499
  */
466
500
  const wakeOf = (frame: Frame): number => {
467
501
  let due: number
468
- if (frame.whole || frame.dirty.size > 0) due = 0
502
+ if (frame.whole || frame.exchange || frame.dirty.size > 0) due = 0
469
503
  // Its grant trails the holes still loading; it arrives with or without one, and runs this again.
470
504
  else if (frame.loaded?.trailing === true) due = Number.POSITIVE_INFINITY
471
505
  // A renewal waits for the pending watch's `ready`, which runs this again.
@@ -494,7 +528,9 @@ export function liveClient(options: LiveClientOptions = {}): LiveClient {
494
528
  const state = current.snapshot()
495
529
  const page = frame.slot === PAGE
496
530
  const whole = frame.whole
497
- const held = whole ? undefined : frame.loaded
531
+ const exchange = frame.exchange
532
+ // A poll is for fresh data: no change notice reaches the frame, so no owner can be kept.
533
+ const held = whole || polled ? undefined : frame.loaded
498
534
  intent = {
499
535
  path: frame.path,
500
536
  mode: held === undefined ? "load" : mode,
@@ -511,6 +547,7 @@ export function liveClient(options: LiveClientOptions = {}): LiveClient {
511
547
  }
512
548
  frame.loading = true
513
549
  frame.whole = false
550
+ frame.exchange = false
514
551
  let running: Promise<void>
515
552
  try {
516
553
  running = current.invalidate([frame.path])
@@ -527,8 +564,8 @@ export function liveClient(options: LiveClientOptions = {}): LiveClient {
527
564
  }
528
565
  if (!ok && !watchable(frame))
529
566
  pollFailures.set(frame.path, (pollFailures.get(frame.path) ?? 0) + 1)
530
- // Still no grant to keep an owner by: the retry loads the frame whole again.
531
- if (whole) frame.whole = true
567
+ // Still no grant to keep an owner by, or a seed it could not trade: the retry loads it whole.
568
+ if (whole || exchange) frame.whole = true
532
569
  frame.failures++
533
570
  // Backs off to five minutes, never further: a frame that gives up shows stale data for good.
534
571
  // A change, a visibility change or a new socket tries again at once.
@@ -843,6 +880,7 @@ export function liveClient(options: LiveClientOptions = {}): LiveClient {
843
880
  attach(target) {
844
881
  router = target
845
882
  stopped = false
883
+ adoptSeed(target.snapshot())
846
884
  const unsubscribe = target.subscribe(reconcile)
847
885
  unwatchQueries = watchLiveQueries(scheduleMatch)
848
886
  globalThis.document?.addEventListener("visibilitychange", onVisible)
package/src/live-redis.ts CHANGED
@@ -14,6 +14,15 @@
14
14
  * read: (command, args) => reader.send(command, [...args]),
15
15
  * })
16
16
  * const runtime = live({ secret, subject, bus, instances: 4 })
17
+ * app.onStop(async () => {
18
+ * await runtime.close() // stops the reader first, so closing its connection is no outage
19
+ * reader.close()
20
+ * shared.close()
21
+ * })
22
+ *
23
+ * Any client with a raw-command call fits: with ioredis, `(command, args) => redis.call(command,
24
+ * ...args)`, the reader on a `redis.duplicate()`. A process that only publishes (a worker, another
25
+ * app) passes `send` alone and hands the bus to `livePublisher`.
17
26
  *
18
27
  * Every entry also carries a running count, so a reader that fell behind a trim - or lost the
19
28
  * connection - reports the gap instead of skipping what it missed.
@@ -26,8 +35,11 @@ export type RedisCommand = (command: string, args: readonly string[]) => Promise
26
35
  export interface RedisLiveBusOptions {
27
36
  /** Appends and reads the clock; any connection. */
28
37
  readonly send: RedisCommand
29
- /** Reads the stream. Its own connection: a blocking `XREAD` holds it. */
30
- readonly read: RedisCommand
38
+ /**
39
+ * Reads the stream. Its own connection: a blocking `XREAD` holds it. Required to subscribe, so for
40
+ * `live()`; a process that only appends leaves it out.
41
+ */
42
+ readonly read?: RedisCommand
31
43
  /** The stream; the clock lives at `<key>:clock`. Default `nifra:live`. */
32
44
  readonly key?: string
33
45
  /** Entries kept, roughly. Default 100,000, the most the live history keeps. */
@@ -89,9 +101,15 @@ export function redisLiveBus(options: RedisLiveBusOptions): LiveBus {
89
101
  },
90
102
 
91
103
  async subscribe(listener) {
104
+ const read = options.read
105
+ if (read === undefined) {
106
+ throw new TypeError(
107
+ "[nifra/web] live-redis: subscribing needs `read`, a connection of its own; a bus without one only appends",
108
+ )
109
+ }
92
110
  const start = await position()
93
111
  let closed = false
94
- void follow(start.seq, start.n, listener, () => closed)
112
+ void follow(read, start.seq, start.n, listener, () => closed)
95
113
  return {
96
114
  head: start.seq,
97
115
  close: () => {
@@ -102,6 +120,7 @@ export function redisLiveBus(options: RedisLiveBusOptions): LiveBus {
102
120
  }
103
121
 
104
122
  async function follow(
123
+ read: RedisCommand,
105
124
  head: number,
106
125
  count: number,
107
126
  listener: LiveBusListener,
@@ -116,22 +135,28 @@ export function redisLiveBus(options: RedisLiveBusOptions): LiveBus {
116
135
  let down = false
117
136
  while (!isClosed()) {
118
137
  let entries: Entry[] | undefined
138
+ let error: unknown
119
139
  try {
120
140
  const reply = await within(
121
- options.read("XREAD", ["BLOCK", blockMs, "COUNT", "1000", "STREAMS", key, cursor]),
141
+ read("XREAD", ["BLOCK", blockMs, "COUNT", "1000", "STREAMS", key, cursor]),
122
142
  readTimeoutMs,
123
143
  )
124
144
  entries = reply === null || reply === undefined ? [] : entriesOf(reply)
125
- } catch {
145
+ if (entries === undefined)
146
+ error = new Error("[nifra/web] live-redis: XREAD replied in no known shape")
147
+ } catch (failure) {
126
148
  entries = undefined
149
+ error = failure
127
150
  }
151
+ // Closed while the read was blocked: closing its connection next is the caller's shutdown.
152
+ if (isClosed()) return
128
153
  if (entries === undefined) {
129
154
  // A reply of no known shape counts as a failed read, so it backs off instead of spinning.
130
155
  failures++
131
156
  // One failed read may be a hiccup; a second in a row is an outage.
132
157
  if (failures === 2) {
133
158
  down = true
134
- listener.down()
159
+ listener.down(error)
135
160
  }
136
161
  await wait(RETRY_MS[Math.min(failures - 1, RETRY_MS.length - 1)] ?? 5_000)
137
162
  continue
package/src/live.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * whole app: every hook, middleware, gate and loader sees the target path, exactly as a navigation.
15
15
  * Changes reach open pages over one WebSocket per tab, as opaque tags of the keys they watch.
16
16
  */
17
- import { isSameOriginForm, replacedRequestOf } from "@nifrajs/core/server"
17
+ import { isSameOriginForm, jsonLogger, type Logger, replacedRequestOf } from "@nifrajs/core/server"
18
18
  import { websocket } from "@nifrajs/core/ws"
19
19
  import {
20
20
  LIVE_ATTACH,
@@ -26,10 +26,23 @@ import {
26
26
  type LiveRuntimeHandle,
27
27
  } from "./internal/live-contract.ts"
28
28
  import { queryDigester, querySalt } from "./internal/query-digest.ts"
29
- import { type LiveBus, type LiveBusEvent, type LiveBusInput, memoryBus } from "./live/bus.ts"
29
+ import {
30
+ type LiveBus,
31
+ type LiveBusEvent,
32
+ type LiveBusInput,
33
+ type LiveBusSubscription,
34
+ memoryBus,
35
+ } from "./live/bus.ts"
30
36
  import { applyEvent, type LiveCore } from "./live/core.ts"
31
37
  import { History, keyHasher } from "./live/history.ts"
32
- import { CLOSE_CAPACITY, LiveHub, type LiveSocket, tagCache, unref } from "./live/hub.ts"
38
+ import {
39
+ CLOSE_CAPACITY,
40
+ CLOSE_GOING_AWAY,
41
+ LiveHub,
42
+ type LiveSocket,
43
+ tagCache,
44
+ unref,
45
+ } from "./live/hub.ts"
33
46
  import {
34
47
  HUB_APPEND_PATH,
35
48
  HUB_AUTHORITY_PATH,
@@ -52,6 +65,7 @@ import {
52
65
  MAX_GRANT_CHARS,
53
66
  MAX_KEYS_PER_FRAME,
54
67
  type OpenedGrant,
68
+ SEED_SID,
55
69
  SID_PATTERN,
56
70
  toBase64Url,
57
71
  } from "./live/keyring.ts"
@@ -83,7 +97,7 @@ export type {
83
97
  LiveChange,
84
98
  LiveRevocation,
85
99
  } from "./live/bus.ts"
86
- export { memoryBus, nextSeq } from "./live/bus.ts"
100
+ export { livePublisher, memoryBus, nextSeq } from "./live/bus.ts"
87
101
 
88
102
  export interface LiveOptions {
89
103
  /**
@@ -148,6 +162,38 @@ export interface LiveOptions {
148
162
  * one of them, so leave it off when key names hold anything the viewer should not see.
149
163
  */
150
164
  readonly queryKeys?: boolean
165
+ /**
166
+ * Where the runtime reports what it cannot fix by itself: the bus stopping to answer and answering
167
+ * again, events lost to a trim, an event it could not apply, a publish it did not read back.
168
+ * Default `jsonLogger()`; `false` reports nothing. {@link Live.status} has the same state to poll.
169
+ */
170
+ readonly logger?: Logger | false
171
+ }
172
+
173
+ /** What one process's live runtime is doing now; counters run from its start. */
174
+ export interface LiveStatus {
175
+ /** The keyring is derived and the bus read: grants can be issued. */
176
+ readonly ready: boolean
177
+ /**
178
+ * No grant is issued and no socket kept: the bus (or the Workers hub) is unreachable, or the
179
+ * runtime was closed. Open pages poll meanwhile.
180
+ */
181
+ readonly down: boolean
182
+ /** Since when `down` has held, in epoch ms. */
183
+ readonly downSince: number | undefined
184
+ /** Live sockets open in this process. On Workers the hub holds them, so this is 0. */
185
+ readonly sockets: number
186
+ /** Live loads running in this process. */
187
+ readonly loads: number
188
+ /** Bus events this process applied. */
189
+ readonly events: number
190
+ /** `changed` frames this process sent, one per watching socket: each makes a page reload. */
191
+ readonly notified: number
192
+ /**
193
+ * How long after its append the last event was applied here, in ms. The append time comes from the
194
+ * bus's clock, so skew between hosts shows in it.
195
+ */
196
+ readonly lagMs: number | undefined
151
197
  }
152
198
 
153
199
  /** A live runtime: pass it to `createWebApp({ live })`. */
@@ -170,6 +216,14 @@ export interface Live extends LiveRuntimeHandle {
170
216
  * runs that owner's loader, which decides again.
171
217
  */
172
218
  revokeKey(key: string, subject?: string): Promise<void>
219
+ /** What this process's runtime is doing now: for a readiness check or metrics. */
220
+ status(): LiveStatus
221
+ /**
222
+ * Stop reading the bus and close every live socket (1001); open pages poll until they reach another
223
+ * process. Call it before closing the bus's connections, so the reader does not count that as an
224
+ * outage. A publish after it throws. Idempotent.
225
+ */
226
+ close(): Promise<void>
173
227
  }
174
228
 
175
229
  const DEFAULT_LOAD_PATH = "/_nifra/live/load"
@@ -185,6 +239,12 @@ const MAX_HOSTS = 32
185
239
  const MAX_HOST_CHARS = 32
186
240
  const HOST = /^[a-z0-9-]+$/
187
241
  const PAGE_FRAME = "page"
242
+ /**
243
+ * How old a seed may be when its page exchanges it: the time from the render to the socket's welcome.
244
+ * The exchanged grant keeps owners no loader re-checked, so this bounds how stale that check is.
245
+ */
246
+ const SEED_MAX_AGE_SECONDS = 60
247
+ const NO_OWNERS: readonly string[] = []
188
248
  /** Renewals re-signed without loaders before one runs them all. */
189
249
  const MAX_QUIET_RENEWALS = 2
190
250
  /** How long a publish waits to read its own event back; the event is in the log either way. */
@@ -235,7 +295,7 @@ const REFUSE_ALL: RevocationCheck = { grantRevoked: () => true, keyRevoked: () =
235
295
  type Queued =
236
296
  | { readonly kind: "event"; readonly event: LiveBusEvent }
237
297
  | { readonly kind: "gap"; readonly head: number }
238
- | { readonly kind: "down" }
298
+ | { readonly kind: "down"; readonly error: unknown }
239
299
 
240
300
  /** Create the live runtime for one app. */
241
301
  export function live(options: LiveOptions): Live {
@@ -269,6 +329,7 @@ export function live(options: LiveOptions): Live {
269
329
  const subjectOf = options.subject
270
330
  const queryKeys = options.queryKeys === true
271
331
  const production = isProduction()
332
+ const logger = options.logger === false ? undefined : (options.logger ?? jsonLogger())
272
333
  if (hubNamespace !== undefined) {
273
334
  if (options.bus !== undefined) {
274
335
  throw new TypeError("[nifra/web] live: `hub` and `bus` are exclusive; the hub owns the log")
@@ -291,8 +352,22 @@ export function live(options: LiveOptions): Live {
291
352
  const waiters: { readonly seq: number; readonly resolve: () => void }[] = []
292
353
  /** A publish already found this process behind the bus; the next applied event clears it. */
293
354
  let lagging = false
355
+ let subscription: LiveBusSubscription | undefined
356
+ let closed = false
357
+ let closing: Promise<void> | undefined
358
+ let closedAt = 0
359
+ let downSince: number | undefined
360
+ let events = 0
361
+ let lagMs: number | undefined
294
362
 
295
363
  const keyring = createKeyring({ secrets, appId, ttl })
364
+ let keyringReady = false
365
+ keyring.then(
366
+ () => {
367
+ keyringReady = true
368
+ },
369
+ () => {},
370
+ )
296
371
  // One cache for the tags a load hands out and the tags this process's sockets join.
297
372
  const tags = keyring.then((ring) => tagCache(ring))
298
373
  tags.catch(() => {})
@@ -300,14 +375,16 @@ export function live(options: LiveOptions): Live {
300
375
  const ring = await keyring
301
376
  // On Workers the hub holds the log: this isolate reads nothing.
302
377
  if (hubNamespace !== undefined) return new Promise<Core>(() => {})
303
- const subscription = await bus.subscribe({
378
+ const subscribed = await bus.subscribe({
304
379
  event: (event) => enqueue({ kind: "event", event }),
305
380
  gap: (head) => enqueue({ kind: "gap", head }),
306
- down: () => enqueue({ kind: "down" }),
381
+ down: (error) => enqueue({ kind: "down", error }),
307
382
  })
308
- seen = subscription.head
309
- const history = new History(subscription.head)
310
- const revocations = new Revocations(subscription.head, ttl * 1000)
383
+ subscription = subscribed
384
+ if (closed) subscribed.close()
385
+ seen = subscribed.head
386
+ const history = new History(subscribed.head)
387
+ const revocations = new Revocations(subscribed.head, ttl * 1000)
311
388
  const hub = new LiveHub(
312
389
  ring,
313
390
  history,
@@ -326,6 +403,7 @@ export function live(options: LiveOptions): Live {
326
403
  publishers,
327
404
  )
328
405
  core = { ring, history, revocations, hub }
406
+ if (closed) hub.down = true
329
407
  void drain()
330
408
  return core
331
409
  })()
@@ -385,7 +463,11 @@ export function live(options: LiveOptions): Live {
385
463
  await hubCall(namespace, ring, HUB_AUTHORITY_PATH, { subjects, keys }),
386
464
  )
387
465
  // Unreachable: revocations are unknown, so nothing presented is honored and no grant is issued.
388
- if (reply === undefined) return { seq: 0, down: true, revocations: REFUSE_ALL }
466
+ if (reply === undefined) {
467
+ downSince ??= Date.now()
468
+ return { seq: 0, down: true, revocations: REFUSE_ALL }
469
+ }
470
+ downSince = undefined
389
471
  return { seq: reply.seq, down: false, revocations: new RevocationView(reply) }
390
472
  }
391
473
 
@@ -404,7 +486,7 @@ export function live(options: LiveOptions): Live {
404
486
  }
405
487
 
406
488
  async function drain(): Promise<void> {
407
- if (draining || core === undefined) return
489
+ if (draining || core === undefined || closed) return
408
490
  draining = true
409
491
  try {
410
492
  for (let item = queue.shift(); item !== undefined; item = queue.shift()) {
@@ -417,26 +499,48 @@ export function live(options: LiveOptions): Live {
417
499
 
418
500
  async function apply(c: Core, item: Queued): Promise<void> {
419
501
  if (item.kind === "down") {
502
+ if (!c.hub.down) {
503
+ downSince = Date.now()
504
+ logger?.error(
505
+ "live bus unreachable: live sockets closed, open pages poll until it answers",
506
+ {
507
+ error: messageOf(item.error),
508
+ },
509
+ )
510
+ }
420
511
  c.hub.down = true
421
512
  c.hub.closeAll(CLOSE_CAPACITY, "unavailable")
422
513
  return
423
514
  }
424
- if (item.kind === "gap") return forgetUntil(c, item.head)
515
+ if (item.kind === "gap") {
516
+ if (c.hub.down) {
517
+ logger?.info("live bus answering again: live sockets resume", {
518
+ downMs: Date.now() - (downSince ?? Date.now()),
519
+ })
520
+ } else logger?.warn("live bus lost events before this process read them: live sockets resync")
521
+ return forgetUntil(c, item.head)
522
+ }
425
523
  const event = item.event
426
524
  // At-least-once delivery: a repeat is already applied.
427
525
  if (!Number.isSafeInteger(event.seq) || event.seq <= seen) return
428
526
  try {
429
527
  await applyEvent(c, event, hash)
528
+ events++
529
+ // The seq is the bus clock's microseconds at the append.
530
+ lagMs = Math.max(0, Date.now() - Math.floor(event.seq / 1000))
430
531
  settle(event.seq)
431
532
  } catch (err) {
432
533
  // An event this process could not apply leaves its history and revocations unknown.
433
- console.error("[nifra/web] live: a bus event could not be applied; closing live sockets", err)
534
+ logger?.error("live bus event could not be applied: live sockets resync", {
535
+ error: messageOf(err),
536
+ })
434
537
  forgetUntil(c, event.seq)
435
538
  }
436
539
  }
437
540
 
438
541
  /** Everything up to `head` is unknown: refuse older grants, report all owners stale, reconnect. */
439
542
  function forgetUntil(c: Core, head: number): void {
543
+ downSince = undefined
440
544
  // Never behind what this process applied: loads already issued watermarks up to `seen`.
441
545
  const at = Math.max(head, seen)
442
546
  c.history.reset(at)
@@ -460,6 +564,7 @@ export function live(options: LiveOptions): Live {
460
564
 
461
565
  /** Append to the bus, then wait until this process has applied it, at most {@link APPLY_WAIT_MS}. */
462
566
  async function append(input: Parameters<LiveBus["append"]>[0]): Promise<void> {
567
+ if (closed) throw new Error("[nifra/web] live: the runtime is closed; nothing was published")
463
568
  if (hubNamespace !== undefined) return appendToHub(hubNamespace, input)
464
569
  await started
465
570
  const seq = await bus.append(input)
@@ -479,8 +584,9 @@ export function live(options: LiveOptions): Live {
479
584
  if (at !== -1) waiters.splice(at, 1)
480
585
  if (!lagging) {
481
586
  lagging = true
482
- console.error(
483
- `[nifra/web] live: this process did not read a publish back from the bus within ${APPLY_WAIT_MS} ms; its sockets miss changes until the bus reader recovers`,
587
+ logger?.error(
588
+ "live publish not read back from the bus: this process's sockets miss changes until its reader recovers",
589
+ { waitedMs: APPLY_WAIT_MS },
484
590
  )
485
591
  }
486
592
  resolve()
@@ -572,7 +678,13 @@ export function live(options: LiveOptions): Live {
572
678
  const opened = await auth.ring.open(token, now)
573
679
  if (opened === undefined) continue
574
680
  const claims = opened.claims
575
- if (claims.sid !== body.sid || claims.frame !== body.frame) continue
681
+ // A seed is exchanged by a load of the page it was rendered with, while it is fresh.
682
+ const seed =
683
+ claims.sid === SEED_SID &&
684
+ body.mode === "load" &&
685
+ claims.path === path &&
686
+ now / 1000 - claims.iat <= SEED_MAX_AGE_SECONDS
687
+ if ((!seed && claims.sid !== body.sid) || claims.frame !== body.frame) continue
576
688
  // A navigation presents the grant of the page it leaves: only its layouts can carry over, and
577
689
  // only where the layout itself would keep its data. A renewal is for the same page.
578
690
  if (claims.path !== path && body.mode === "renew") continue
@@ -653,6 +765,24 @@ export function live(options: LiveOptions): Live {
653
765
  }
654
766
  return undefined
655
767
  },
768
+ seedOf: (req, routeId) => {
769
+ const c = core
770
+ // A render never waits on the bus: with no core yet, or none that could vouch, it carries none.
771
+ if (hubNamespace !== undefined || c === undefined || closed || c.hub.down) return undefined
772
+ const url = new URL(req.url)
773
+ const path = canonicalPath(url.pathname + url.search, req.url)
774
+ if (path === undefined) return undefined
775
+ const body: LoadBody = {
776
+ path,
777
+ sid: SEED_SID,
778
+ frame: PAGE_FRAME,
779
+ mode: "load",
780
+ retain: NO_OWNERS,
781
+ grants: NO_OWNERS,
782
+ fill: NO_OWNERS,
783
+ }
784
+ return new LoadState(c.ring, false, body, path, seen, undefined, routeId, false)
785
+ },
656
786
  loadOf: (req) => {
657
787
  // An app hook after the endpoint may hand on a replacement; the load is its original's.
658
788
  let current: Request | undefined = req
@@ -910,10 +1040,41 @@ export function live(options: LiveOptions): Live {
910
1040
  }
911
1041
  await append({ type: "revoke", sub: subject ?? null, key })
912
1042
  },
1043
+ status() {
1044
+ const hub = core?.hub
1045
+ return {
1046
+ ready: hubNamespace === undefined ? hub !== undefined : keyringReady,
1047
+ down: closed || downSince !== undefined || hub?.down === true,
1048
+ downSince: closed ? (downSince ?? closedAt) : downSince,
1049
+ sockets: hub?.size ?? 0,
1050
+ loads: inflight,
1051
+ events,
1052
+ notified: hub?.notified ?? 0,
1053
+ lagMs,
1054
+ }
1055
+ },
1056
+ close() {
1057
+ closing ??= (async () => {
1058
+ closed = true
1059
+ closedAt = Date.now()
1060
+ subscription?.close()
1061
+ const c = core
1062
+ if (c !== undefined) {
1063
+ c.hub.down = true
1064
+ c.hub.closeAll(CLOSE_GOING_AWAY, "closed")
1065
+ }
1066
+ // A publish waiting to read its event back never will now.
1067
+ for (const waiter of waiters.splice(0)) waiter.resolve()
1068
+ })()
1069
+ return closing
1070
+ },
913
1071
  [LIVE_ATTACH]: attach,
914
1072
  }
915
1073
  }
916
1074
 
1075
+ const messageOf = (error: unknown): string | undefined =>
1076
+ error instanceof Error ? error.message : error === undefined ? undefined : String(error)
1077
+
917
1078
  /**
918
1079
  * `app.publish`, holding the app weakly: a dev server builds a new app on every edit, and a discarded
919
1080
  * one must not stay reachable, nor keep being published to, through the runtime. Built out here so
@@ -244,6 +244,8 @@ export const ACTION_GLOBAL = "__NIFRA_ACTION__"
244
244
  /** Dynamic-boundary states for hydration; absent when a route declares no boundaries. */
245
245
  export const BOUNDARY_GLOBAL = "__NIFRA_BOUNDARIES__"
246
246
  export const COMPONENT_GLOBAL = "__NIFRA_COMPONENTS__"
247
+ /** A live app's seed for the page: the client exchanges it for a grant once its socket opens. */
248
+ export const LIVE_GLOBAL = "__NIFRA_LIVE__"
247
249
  /** Locale key -> copy chunk URL, in a production build that split copy tables by locale. The name
248
250
  * `@nifrajs/i18n/copy` reads. */
249
251
  export const COPY_CHUNKS_GLOBAL = "__NIFRA_COPY__"