@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.
- package/CHANGELOG.md +89 -0
- package/README.md +21 -11
- package/dist/build.d.ts +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +1 -0
- package/dist/build.js.map +1 -1
- package/dist/client.d.ts +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +39 -16
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +8 -2
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/identity-epoch.d.ts +2 -2
- package/dist/internal/identity-epoch.d.ts.map +1 -1
- package/dist/internal/identity-epoch.js +15 -14
- package/dist/internal/identity-epoch.js.map +1 -1
- package/dist/internal/live-contract.d.ts +5 -0
- package/dist/internal/live-contract.d.ts.map +1 -1
- package/dist/internal/live-contract.js.map +1 -1
- package/dist/internal/page-execution.d.ts +2 -0
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +22 -5
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/parity.d.ts +3 -0
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +28 -5
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/ppr/runtime.d.ts +1 -1
- package/dist/internal/ppr/runtime.d.ts.map +1 -1
- package/dist/internal/ppr/runtime.js +4 -1
- package/dist/internal/ppr/runtime.js.map +1 -1
- package/dist/internal/ppr/server.d.ts.map +1 -1
- package/dist/internal/ppr/server.js +4 -2
- package/dist/internal/ppr/server.js.map +1 -1
- package/dist/internal/ppr/shell-store.d.ts +1 -0
- package/dist/internal/ppr/shell-store.d.ts.map +1 -1
- package/dist/internal/ppr/shell-store.js +17 -1
- package/dist/internal/ppr/shell-store.js.map +1 -1
- package/dist/internal/present.d.ts +11 -0
- package/dist/internal/present.d.ts.map +1 -0
- package/dist/internal/present.js +13 -0
- package/dist/internal/present.js.map +1 -0
- package/dist/internal/render-document.d.ts +6 -63
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +3 -50
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/status-page.d.ts +68 -0
- package/dist/internal/status-page.d.ts.map +1 -0
- package/dist/internal/status-page.js +66 -0
- package/dist/internal/status-page.js.map +1 -0
- package/dist/internal/web-app.d.ts +3 -2
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +1 -0
- package/dist/internal/web-app.js.map +1 -1
- package/dist/keep-alive.d.ts.map +1 -1
- package/dist/keep-alive.js +46 -13
- package/dist/keep-alive.js.map +1 -1
- package/dist/live/bus.d.ts +16 -2
- package/dist/live/bus.d.ts.map +1 -1
- package/dist/live/bus.js +23 -0
- package/dist/live/bus.js.map +1 -1
- package/dist/live/hub.d.ts +4 -0
- package/dist/live/hub.d.ts.map +1 -1
- package/dist/live/hub.js +5 -0
- package/dist/live/hub.js.map +1 -1
- package/dist/live/keyring.d.ts +5 -0
- package/dist/live/keyring.d.ts.map +1 -1
- package/dist/live/keyring.js +6 -1
- package/dist/live/keyring.js.map +1 -1
- package/dist/live-client.d.ts.map +1 -1
- package/dist/live-client.js +45 -7
- package/dist/live-client.js.map +1 -1
- package/dist/live-redis.d.ts +14 -2
- package/dist/live-redis.d.ts.map +1 -1
- package/dist/live-redis.js +16 -5
- package/dist/live-redis.js.map +1 -1
- package/dist/live.d.ts +57 -1
- package/dist/live.d.ts.map +1 -1
- package/dist/live.js +119 -16
- package/dist/live.js.map +1 -1
- package/dist/render-seam.d.ts +2 -0
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +2 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +49 -37
- package/dist/router.js.map +1 -1
- package/dist/server-components/definition.d.ts.map +1 -1
- package/dist/server-components/definition.js +2 -1
- package/dist/server-components/definition.js.map +1 -1
- package/dist/server-components/runtime.d.ts.map +1 -1
- package/dist/server-components/runtime.js +2 -1
- package/dist/server-components/runtime.js.map +1 -1
- package/dist/server-components/vite.d.ts.map +1 -1
- package/dist/server-components/vite.js +26 -9
- package/dist/server-components/vite.js.map +1 -1
- package/package.json +3 -3
- package/src/build.ts +1 -0
- package/src/client.ts +42 -19
- package/src/index.ts +1 -4
- package/src/internal/codegen.ts +8 -1
- package/src/internal/identity-epoch.ts +20 -16
- package/src/internal/live-contract.ts +5 -0
- package/src/internal/page-execution.ts +29 -8
- package/src/internal/parity.ts +35 -6
- package/src/internal/ppr/runtime.ts +4 -1
- package/src/internal/ppr/server.ts +4 -2
- package/src/internal/ppr/shell-store.ts +14 -1
- package/src/internal/present.ts +20 -0
- package/src/internal/render-document.ts +10 -92
- package/src/internal/status-page.ts +95 -0
- package/src/internal/web-app.ts +4 -2
- package/src/keep-alive.ts +55 -16
- package/src/live/bus.ts +29 -2
- package/src/live/hub.ts +5 -0
- package/src/live/keyring.ts +6 -1
- package/src/live-client.ts +45 -7
- package/src/live-redis.ts +31 -6
- package/src/live.ts +178 -17
- package/src/render-seam.ts +2 -0
- package/src/router.ts +47 -38
- package/src/server-components/definition.ts +2 -1
- package/src/server-components/runtime.ts +2 -1
- package/src/server-components/vite.ts +33 -14
package/src/live-client.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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)
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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
|
package/src/render-seam.ts
CHANGED
|
@@ -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__"
|