@tinvar/react 0.0.0-stage → 0.4.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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +168 -4
  3. package/src/action-server.js +41 -0
  4. package/src/action.d.ts +68 -0
  5. package/src/action.js +167 -0
  6. package/src/bootstrap.js +153 -0
  7. package/src/channel.d.ts +46 -0
  8. package/src/channel.js +132 -0
  9. package/src/client-modules.d.ts +8 -0
  10. package/src/client-modules.js +88 -0
  11. package/src/client.d.ts +36 -0
  12. package/src/client.js +42 -0
  13. package/src/compose-route.d.ts +33 -0
  14. package/src/compose-route.js +107 -0
  15. package/src/csrf.d.ts +11 -0
  16. package/src/csrf.js +49 -0
  17. package/src/deferred-boundary.d.ts +61 -0
  18. package/src/deferred-boundary.js +646 -0
  19. package/src/deferred-groups.d.ts +24 -0
  20. package/src/deferred-groups.js +198 -0
  21. package/src/deferred-revalidation.d.ts +37 -0
  22. package/src/deferred-revalidation.js +108 -0
  23. package/src/deferred-triggers.d.ts +26 -0
  24. package/src/deferred-triggers.js +172 -0
  25. package/src/deferred.d.ts +24 -0
  26. package/src/deferred.jsx +100 -0
  27. package/src/document-load.js +95 -0
  28. package/src/endpoint.d.ts +87 -0
  29. package/src/endpoint.js +304 -0
  30. package/src/form-data.js +219 -0
  31. package/src/form.d.ts +177 -0
  32. package/src/form.js +615 -0
  33. package/src/forms.d.ts +4 -0
  34. package/src/forms.js +10 -0
  35. package/src/fragment-client.d.ts +70 -0
  36. package/src/fragment-client.js +510 -0
  37. package/src/head.d.ts +85 -0
  38. package/src/head.js +360 -0
  39. package/src/inbox-view.d.ts +17 -0
  40. package/src/inbox-view.js +105 -0
  41. package/src/inbox.d.ts +108 -0
  42. package/src/inbox.js +293 -0
  43. package/src/index.combined.d.ts +31 -0
  44. package/src/index.d.ts +5 -0
  45. package/src/index.js +29 -0
  46. package/src/index.server.d.ts +4 -0
  47. package/src/index.server.js +8 -0
  48. package/src/infinite-scroll.d.ts +108 -0
  49. package/src/infinite-scroll.js +715 -0
  50. package/src/layout-props.d.ts +30 -0
  51. package/src/layout-props.js +143 -0
  52. package/src/link-click.d.ts +3 -0
  53. package/src/link-click.js +68 -0
  54. package/src/link.d.ts +63 -0
  55. package/src/link.js +274 -0
  56. package/src/live.d.ts +65 -0
  57. package/src/live.js +977 -0
  58. package/src/merge-slot.d.ts +41 -0
  59. package/src/merge-slot.js +253 -0
  60. package/src/merge.d.ts +111 -0
  61. package/src/merge.js +149 -0
  62. package/src/once-slot.d.ts +14 -0
  63. package/src/once-slot.js +75 -0
  64. package/src/once-store.d.ts +26 -0
  65. package/src/once-store.js +211 -0
  66. package/src/once.d.ts +44 -0
  67. package/src/once.js +65 -0
  68. package/src/optimistic.d.ts +14 -0
  69. package/src/optimistic.js +159 -0
  70. package/src/optional.d.ts +10 -0
  71. package/src/optional.js +31 -0
  72. package/src/page-root.d.ts +56 -0
  73. package/src/page-root.js +455 -0
  74. package/src/payload.d.ts +15 -0
  75. package/src/payload.js +101 -0
  76. package/src/poll.d.ts +55 -0
  77. package/src/poll.js +224 -0
  78. package/src/precognition.js +190 -0
  79. package/src/prefetch-cache.d.ts +46 -0
  80. package/src/prefetch-cache.js +256 -0
  81. package/src/progress.d.ts +38 -0
  82. package/src/progress.js +243 -0
  83. package/src/remember-store.d.ts +50 -0
  84. package/src/remember-store.js +486 -0
  85. package/src/rescue.d.ts +10 -0
  86. package/src/rescue.js +30 -0
  87. package/src/resumable.js +822 -0
  88. package/src/resume-store.js +364 -0
  89. package/src/route.d.ts +50 -0
  90. package/src/route.js +227 -0
  91. package/src/router-hooks.js +234 -0
  92. package/src/router.d.ts +586 -0
  93. package/src/router.js +3555 -0
  94. package/src/segment.d.ts +36 -0
  95. package/src/segment.js +116 -0
  96. package/src/server.d.ts +71 -0
  97. package/src/server.js +242 -0
  98. package/src/submit.js +333 -0
  99. package/src/telemetry.d.ts +71 -0
  100. package/src/telemetry.js +851 -0
  101. package/src/testing.d.ts +89 -0
  102. package/src/testing.js +634 -0
  103. package/src/transfer.d.ts +48 -0
  104. package/src/transfer.js +135 -0
  105. package/src/transport.js +390 -0
  106. package/src/upload.d.ts +342 -0
  107. package/src/upload.js +1240 -0
  108. package/src/use-channel.d.ts +21 -0
  109. package/src/use-channel.js +51 -0
  110. package/src/use-form.d.ts +86 -0
  111. package/src/use-form.js +432 -0
  112. package/src/use-http.d.ts +56 -0
  113. package/src/use-http.js +248 -0
  114. package/src/use-inbox.d.ts +41 -0
  115. package/src/use-inbox.js +201 -0
  116. package/src/use-remember.d.ts +10 -0
  117. package/src/use-remember.js +132 -0
  118. package/src/when-mounted.d.ts +15 -0
  119. package/src/when-mounted.js +27 -0
  120. package/README.md +0 -3
package/src/live.js ADDED
@@ -0,0 +1,977 @@
1
+ // Live updates: the page on screen and its <Deferred> boundaries, fetched
2
+ // again when something they read changes -- in another tab, for someone else.
3
+ //
4
+ // tinvar-spec.md, "Deferred components": "When an action revalidates a tag,
5
+ // the client re-fetches any mounted fragment that read it, not the whole
6
+ // page." The client that ran the action learns it from the action's
7
+ // response (X-Invalidated-Tags). Every other client learns it here.
8
+ //
9
+ // What a render read comes with it as a live token (Tinvar\Live): signed by
10
+ // the server, bound to the session, and naming the hub topics of the tags it
11
+ // read -- a page's at the end of its document, in X-Live-Token, or beside
12
+ // an action's re-render of it; a fragment's in X-Live-Token. This module
13
+ // trades the tokens it holds at /_live/subscribe for the hub's access to
14
+ // their topics, which the server puts in a cookie script cannot read, and
15
+ // opens one stream on the hub for them. An update names a topic and a tag.
16
+ // Whatever read the topic is fetched again:
17
+ //
18
+ // - a <Deferred> boundary quietly, as refreshDeferred() fetches one -- no
19
+ // `reloading`, nothing dims, the content swapped when the new is ready,
20
+ // paced by the boundary's own backoff;
21
+ // - the page as a background reload, partial when only some segments'
22
+ // loaders read the tag, and re-rendering its #[Once] fields -- paced by
23
+ // the same floor and backoff a boundary has.
24
+ //
25
+ // Updates are gathered for a moment first, so a burst of writes is one
26
+ // refetch. The stream reconnects with backoff, stops while the tab is hidden
27
+ // -- and fetches everything once on return, since it heard nothing meanwhile
28
+ // -- and is renewed before the hub's grant runs out, the session checked
29
+ // again each time. A token the trade answers with null after running out
30
+ // -- a tab hidden past its lifetime -- has what held it fetched again for a
31
+ // new one, a channel's subscription asked for again. A 401 from the trade
32
+ // ends the session on this client, with a full page load, as any 401 does
33
+ // -- unless a document load has begun already, which it leaves to arrive
34
+ // (document-load.js); so does any change of session the router sees, short
35
+ // of the load.
36
+ //
37
+ // No React: the boundaries register through subscribeLive(), and the page
38
+ // through the router's events.
39
+ //
40
+ // Channels (ADR 0069) ride the same stream. A subscription's token, which
41
+ // /_channel/{name} hands out once the server has run the channel's checks,
42
+ // registers through subscribeChannel() and is traded with the rest -- the
43
+ // hub reads one cookie, so one trade grants every topic held -- and an
44
+ // update naming a channel is a message for whoever subscribed to that
45
+ // topic: delivered at once, never gathered, and never read as an
46
+ // invalidation.
47
+
48
+ import { bindingOf, revokeSession } from './router.js';
49
+ import { csrfHeaders } from './csrf.js';
50
+ import { reloadUnlessLoading } from './document-load.js';
51
+ import { MAX_FAILURES, MIN_INTERVAL_MS } from './fragment-client.js';
52
+
53
+ export const SUBSCRIBE_PATH = '/_live/subscribe';
54
+
55
+ /** The id of the data block a document carries its live token in, at its end. */
56
+ export const LIVE_BLOCK_ID = 'tinvar-live';
57
+
58
+ /** How long updates are gathered before anything is fetched again. */
59
+ export const DEBOUNCE_MS = 150;
60
+
61
+ /** The longest a stream of updates can put a refetch off. */
62
+ export const MAX_WAIT_MS = 1000;
63
+
64
+ /** Backoff between reconnects: from this, doubling, to MAX_RECONNECT_MS. */
65
+ export const RECONNECT_MS = 1000;
66
+ export const MAX_RECONNECT_MS = 30000;
67
+
68
+ /** A stream that stayed up this long was working: the backoff starts over. */
69
+ export const STABLE_MS = 10000;
70
+
71
+ /** How long before the hub's grant runs out the next one is asked for. */
72
+ export const RENEW_BEFORE_MS = 30000;
73
+
74
+ /** How long a trade is waited for before it is given up and tried again. */
75
+ export const TRADE_TIMEOUT_MS = 15000;
76
+
77
+ /** The server's cap on tokens in one trade. */
78
+ export const MAX_TOKENS = 32;
79
+
80
+ /** Page tokens kept across partial reloads, each naming what its segments read. */
81
+ const MAX_PAGE_TOKENS = 8;
82
+
83
+ /** The shape a token the server issued always has. */
84
+ const TOKEN_SHAPE = /^v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
85
+
86
+ /** A topic, as the server names them. */
87
+ const TOPIC_SHAPE = /^tinvar:[A-Za-z0-9_-]{22}$/;
88
+
89
+ /** The page as a whole, as a segment: no partial reload will do. */
90
+ const WHOLE = '*';
91
+
92
+ /** A token's longest life from its first issue, and the least grant a trade hands out (TokenClaims, SubscribeEndpoint). */
93
+ const MAX_LIFETIME_S = 86400;
94
+ const MIN_GRANT_S = 30;
95
+
96
+ /**
97
+ * How long a token is held before running out can be what its null means:
98
+ * one answered with null sooner -- its expiry past by a clock ahead of the
99
+ * server's, a renewal that brought a spent one -- is let go, so no page is
100
+ * fetched again and again for tokens the server will not answer.
101
+ */
102
+ const MIN_HELD_MS = 60000;
103
+
104
+ // ------------------------------------------------------------------ boundaries
105
+
106
+ /**
107
+ * The boundaries listening, by what each holds: its live token, and how to
108
+ * fetch it again. Module-level, so a <Deferred> registers without a handle
109
+ * on the live instance -- there is one per page, made by the app's entry.
110
+ *
111
+ * @typedef {{ tokens: string[], refresh: () => void }} Entry
112
+ * @type {Set<Entry>}
113
+ */
114
+ const boundaries = new Set();
115
+
116
+ /**
117
+ * The channel subscriptions listening, each with its token and what to call
118
+ * with each message -- and, after a gap in the stream, what to tell it that
119
+ * messages may have been missed.
120
+ *
121
+ * @typedef {{
122
+ * tokens: string[],
123
+ * message: (message: unknown) => void,
124
+ * gap?: () => void,
125
+ * renew?: () => Promise<string | null>,
126
+ * }} ChannelEntry
127
+ * @type {Set<ChannelEntry>}
128
+ */
129
+ const channels = new Set();
130
+
131
+ /** @type {Set<() => void>} */
132
+ const watchers = new Set();
133
+
134
+ function changed() {
135
+ for (const watcher of watchers) watcher();
136
+ }
137
+
138
+ /**
139
+ * Listens for changes to what a boundary's render read: `refresh` is called
140
+ * when the server says one of its tags was invalidated. Returns the
141
+ * unsubscribe.
142
+ *
143
+ * @param {string | null | undefined} token the live token its load came with
144
+ * @param {() => void} refresh
145
+ * @returns {() => void}
146
+ */
147
+ export function subscribeLive(token, refresh) {
148
+ if (typeof token !== 'string' || !TOKEN_SHAPE.test(token)) return () => {};
149
+ /** @type {Entry} */
150
+ const entry = { tokens: [token], refresh };
151
+ boundaries.add(entry);
152
+ changed();
153
+ return () => {
154
+ if (boundaries.delete(entry)) changed();
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Listens to a channel with the token its subscription was answered with:
160
+ * `message` is called with each message published to it, and `gap` -- if
161
+ * given -- after the stream was down, when messages may have been missed;
162
+ * the hub keeps none to replay. `renew` -- if given -- asks for the
163
+ * subscription again when its token ran out unrenewed, as it does in a tab
164
+ * hidden past its lifetime, and answers with the new token or null; `gap`
165
+ * follows once the new one is listened to. Returns the unsubscribe.
166
+ *
167
+ * @param {string | null | undefined} token
168
+ * @param {(message: unknown) => void} message
169
+ * @param {() => void} [gap]
170
+ * @param {() => Promise<string | null>} [renew]
171
+ * @returns {() => void}
172
+ */
173
+ export function subscribeChannel(token, message, gap, renew) {
174
+ if (typeof token !== 'string' || !TOKEN_SHAPE.test(token)) return () => {};
175
+ /** @type {ChannelEntry} */
176
+ const entry = { tokens: [token], message, gap, renew };
177
+ channels.add(entry);
178
+ changed();
179
+ return () => {
180
+ if (channels.delete(entry)) changed();
181
+ };
182
+ }
183
+
184
+ // ---------------------------------------------------------------------- tokens
185
+
186
+ /**
187
+ * The live token a document carries: the LAST block with the id, which the
188
+ * server writes after everything the page rendered, so no block in page
189
+ * content can stand in for it. Null when there is none, or it says none.
190
+ *
191
+ * @param {Document} [doc]
192
+ * @returns {string | null}
193
+ */
194
+ export function readLiveToken(doc = globalThis.document) {
195
+ if (typeof doc?.querySelectorAll !== 'function') return null;
196
+ const blocks = doc.querySelectorAll(`script[type="application/json"]#${LIVE_BLOCK_ID}`);
197
+ const element = blocks[blocks.length - 1];
198
+ if (!element) return null;
199
+ try {
200
+ const token = JSON.parse(element.textContent ?? '')?.token;
201
+ return typeof token === 'string' && TOKEN_SHAPE.test(token) ? token : null;
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
207
+ /** @type {Map<string, Map<string, string[]>>} */
208
+ const decoded = new Map();
209
+
210
+ /**
211
+ * The topics a live token names, each with the segments that read it. Read
212
+ * without verifying -- the payload is plain base64url JSON, and nothing here
213
+ * trusts it: it says which updates concern what this client holds, while the
214
+ * server decides what the client may hear.
215
+ *
216
+ * @param {string} token
217
+ * @returns {Map<string, string[]>}
218
+ */
219
+ export function topicsOf(token) {
220
+ const known = decoded.get(token);
221
+ if (known) return known;
222
+
223
+ /** @type {Map<string, string[]>} */
224
+ const topics = new Map();
225
+ try {
226
+ const body = token.split('.')[1] ?? '';
227
+ const claims = JSON.parse(atob(body.replace(/-/g, '+').replace(/_/g, '/')));
228
+ // A channel token names its one topic under `c`.
229
+ if (typeof claims?.c === 'string' && TOPIC_SHAPE.test(claims.c)) topics.set(claims.c, []);
230
+ for (const [topic, segments] of Object.entries(claims?.t ?? {})) {
231
+ if (!TOPIC_SHAPE.test(topic) || !Array.isArray(segments)) continue;
232
+ topics.set(
233
+ topic,
234
+ segments.filter((s) => typeof s === 'string'),
235
+ );
236
+ }
237
+ } catch {
238
+ // Not a shape this understands: it names nothing.
239
+ }
240
+
241
+ // Bounded: tokens are refreshed every few minutes, each a new string.
242
+ if (decoded.size >= 256) decoded.delete(decoded.keys().next().value);
243
+ decoded.set(token, topics);
244
+ return topics;
245
+ }
246
+
247
+ /**
248
+ * Whether a token the server answered with null had run out of time -- its
249
+ * expiry passed, or its lifetime from first issue all but spent -- rather
250
+ * than been refused: what held it is fetched again, for a token that will
251
+ * be answered. Read unverified, as topicsOf() reads; one refused for any
252
+ * other reason is let go, since fetching it again would be refused again.
253
+ *
254
+ * @param {string} token
255
+ * @param {number} nowMs
256
+ */
257
+ function outOfTime(token, nowMs) {
258
+ try {
259
+ const body = token.split('.')[1] ?? '';
260
+ const claims = JSON.parse(atob(body.replace(/-/g, '+').replace(/_/g, '/')));
261
+ const at = nowMs / 1000;
262
+ if (typeof claims?.e !== 'number') return false;
263
+ return claims.e <= at || (typeof claims.i === 'number' && claims.i + MAX_LIFETIME_S - MIN_GRANT_S <= at);
264
+ } catch {
265
+ return false;
266
+ }
267
+ }
268
+
269
+ // ------------------------------------------------------------------------ live
270
+
271
+ /**
272
+ * Starts listening for the page's router: the first page's token from the
273
+ * document, each later page's from the router's `success` events, and every
274
+ * boundary's as it registers.
275
+ *
276
+ * @param {{
277
+ * router: Pick<import('./router.js').Router, 'on' | 'reload' | 'routeTokens' | 'flushByCacheTags' | 'revoke'>,
278
+ * fetch?: typeof fetch,
279
+ * document?: Document,
280
+ * hardNavigate?: (url: string) => void,
281
+ * now?: () => number,
282
+ * random?: () => number,
283
+ * initialToken?: string | null,
284
+ * }} options
285
+ */
286
+ export function createLive({
287
+ router,
288
+ fetch: fetchImpl = globalThis.fetch?.bind(globalThis),
289
+ document: doc = globalThis.document,
290
+ hardNavigate,
291
+ now = () => Date.now(),
292
+ random = Math.random,
293
+ initialToken,
294
+ }) {
295
+ let running = false;
296
+ let paused = false;
297
+
298
+ /** The page's tokens: its render's, and its partial reloads'. */
299
+ let page = /** @type {string[]} */ ([]);
300
+
301
+ /** Whose session the tokens are for, as the router's route token says. */
302
+ let binding = /** @type {string | null} */ (null);
303
+
304
+ /**
305
+ * A stream on the hub, and -- until it is answered -- the one it replaces,
306
+ * still listened to so a renewal leaves no gap. Whatever ends the new one
307
+ * ends that one too: a stream nothing tracks would deliver beside the next.
308
+ *
309
+ * @typedef {{ controller: AbortController, key: string, startedAt: number, previous: Connection | null }} Connection
310
+ */
311
+ /** @type {Connection | null} */
312
+ let conn = null;
313
+ let trading = false;
314
+ /** The trade in flight: given up at its deadline, or when live stops. */
315
+ let tradeController = /** @type {AbortController | null} */ (null);
316
+ let tradeAgain = false;
317
+ /** Trade at the next sync even for the topics already granted: the binding moved. */
318
+ let rebound = false;
319
+ let attempts = 0;
320
+ /** A stretch without a stream: whatever happened in it was not heard. */
321
+ let missed = false;
322
+ /**
323
+ * Fetched again already, for a token that ran out, since the last stream
324
+ * opened: the catch-up after the gap leaves them be.
325
+ */
326
+ let refetched = { page: false, entries: /** @type {Set<Entry>} */ (new Set()) };
327
+ /** When each token was first held, by this client's clock. */
328
+ const firstHeld = /** @type {Map<string, number>} */ (new Map());
329
+ /** Channels renewed after a token ran out, told of the gap once the next stream opens. */
330
+ const gapOwed = /** @type {Set<ChannelEntry>} */ (new Set());
331
+
332
+ /** @type {ReturnType<typeof setTimeout> | null} */
333
+ let syncTimer = null;
334
+ /** @type {ReturnType<typeof setTimeout> | null} */
335
+ let retryTimer = null;
336
+ /** @type {ReturnType<typeof setTimeout> | null} */
337
+ let renewTimer = null;
338
+ /**
339
+ * Forgets the reload an ended session waits to make while a document load
340
+ * begun is under way (ended()). Not called by stop(): the session has
341
+ * ended whatever stops listening after it.
342
+ */
343
+ let stopReload = () => {};
344
+
345
+ /** Updates heard and not yet acted on. */
346
+ let pending = {
347
+ tags: /** @type {Set<string>} */ (new Set()),
348
+ segments: /** @type {Set<string>} */ (new Set()),
349
+ entries: /** @type {Set<Entry>} */ (new Set()),
350
+ };
351
+ /** @type {ReturnType<typeof setTimeout> | null} */
352
+ let flushTimer = null;
353
+ let firstHeard = 0;
354
+
355
+ /** The page's own refetches, paced as a boundary's are. */
356
+ const pageRefresh = {
357
+ inFlight: false,
358
+ /** @type {Set<string> | null} */
359
+ wanted: null,
360
+ failures: 0,
361
+ notBefore: 0,
362
+ /** @type {ReturnType<typeof setTimeout> | null} */
363
+ timer: null,
364
+ };
365
+
366
+ const offs = /** @type {(() => void)[]} */ ([]);
367
+
368
+ const hidden = () => doc?.visibilityState === 'hidden';
369
+
370
+ /** Every token held, each with whoever holds it -- a group's members share one. */
371
+ function held() {
372
+ /** @type {Map<string, { holders: Array<{ list: string[], index: number }> }>} */
373
+ const tokens = new Map();
374
+ const add = (/** @type {string[]} */ list) =>
375
+ list.forEach((token, index) => {
376
+ const known = tokens.get(token) ?? { holders: [] };
377
+ known.holders.push({ list, index });
378
+ tokens.set(token, known);
379
+ });
380
+ add(page);
381
+ for (const entry of boundaries) add(entry.tokens);
382
+ for (const entry of channels) add(entry.tokens);
383
+ const at = now();
384
+ for (const [token] of tokens) {
385
+ if (firstHeld.has(token)) continue;
386
+ // Bounded: tokens are refreshed every few minutes, each a new string.
387
+ if (firstHeld.size >= 256) firstHeld.delete(firstHeld.keys().next().value);
388
+ firstHeld.set(token, at);
389
+ }
390
+ // Within the server's cap: the page's first, which it was given first.
391
+ return [...tokens].slice(0, MAX_TOKENS);
392
+ }
393
+
394
+ /** @param {Iterable<string>} topics */
395
+ function keyOf(topics) {
396
+ return [...topics].sort().join(' ');
397
+ }
398
+
399
+ function wantedKey() {
400
+ const topics = new Set();
401
+ for (const [token] of held()) for (const topic of topicsOf(token).keys()) topics.add(topic);
402
+ return keyOf(topics);
403
+ }
404
+
405
+ function sync(delay = 50) {
406
+ if (!running || paused) return;
407
+ if (syncTimer !== null) clearTimeout(syncTimer);
408
+ syncTimer = setTimeout(() => {
409
+ syncTimer = null;
410
+ if (!running || paused) return;
411
+ const key = wantedKey();
412
+ if (key === '' || binding === null) return close();
413
+ if (conn !== null && conn.key === key && !rebound) return;
414
+ void trade();
415
+ }, delay);
416
+ }
417
+
418
+ /**
419
+ * Trades the tokens held for the hub's access to their topics, takes each
420
+ * one back refreshed, and opens a stream for what was granted.
421
+ */
422
+ async function trade() {
423
+ if (trading) {
424
+ tradeAgain = true;
425
+ return;
426
+ }
427
+ trading = true;
428
+ rebound = false;
429
+ if (retryTimer !== null) clearTimeout(retryTimer);
430
+ retryTimer = null;
431
+
432
+ try {
433
+ const tokens = held();
434
+ if (tokens.length === 0 || !fetchImpl) return close();
435
+
436
+ let response;
437
+ let text;
438
+ const controller = new AbortController();
439
+ tradeController = controller;
440
+ const deadline = setTimeout(() => controller.abort(), TRADE_TIMEOUT_MS);
441
+ try {
442
+ response = await fetchImpl(SUBSCRIBE_PATH, {
443
+ method: 'POST',
444
+ headers: { 'content-type': 'application/json', accept: 'application/json', ...csrfHeaders(doc) },
445
+ body: JSON.stringify({ tokens: tokens.map(([token]) => token) }),
446
+ credentials: 'same-origin',
447
+ mode: 'same-origin',
448
+ redirect: 'error',
449
+ cache: 'no-store',
450
+ signal: controller.signal,
451
+ });
452
+ // Read whole within the deadline too: an answer that stops halfway
453
+ // holds the trade as one that never came does.
454
+ text = await response.text();
455
+ } catch {
456
+ if (running) retry();
457
+ return;
458
+ } finally {
459
+ clearTimeout(deadline);
460
+ if (tradeController === controller) tradeController = null;
461
+ }
462
+ if (!running || paused) return;
463
+
464
+ // "Session revocation, which the server signals with a 401" -- a
465
+ // sign-out in another tab, a role change: this page belongs to a
466
+ // session that has ended.
467
+ if (response.status === 401) return ended();
468
+ if (!response.ok) return retry();
469
+
470
+ let body;
471
+ try {
472
+ body = JSON.parse(text);
473
+ } catch {
474
+ return retry();
475
+ }
476
+
477
+ // Refreshed in place, so the next trade sends the newest; a token
478
+ // answered with null is spent -- out of lifetime, or of a build no
479
+ // longer answered for -- and what held it listens no longer.
480
+ const refreshed = Array.isArray(body?.tokens) ? body.tokens : [];
481
+ const valid = (/** @type {unknown} */ t) => typeof t === 'string' && TOKEN_SHAPE.test(t);
482
+ // A token that ran out -- a tab hidden past its lifetime, a backoff
483
+ // that long -- is no refusal: what held it is fetched again, as the
484
+ // catch-up after a gap would, and that brings a token the next trade
485
+ // is answered for.
486
+ const stale = new Set(
487
+ tokens
488
+ .filter(
489
+ ([token], i) =>
490
+ !valid(refreshed[i]) &&
491
+ outOfTime(token, now()) &&
492
+ now() - (firstHeld.get(token) ?? now()) >= MIN_HELD_MS,
493
+ )
494
+ .map(([token]) => token),
495
+ );
496
+ const pageStale = page.some((token) => stale.has(token));
497
+ const entriesStale = [...boundaries].filter((entry) => entry.tokens.some((token) => stale.has(token)));
498
+ const channelsStale = [...channels].filter((entry) => entry.tokens.some((token) => stale.has(token)));
499
+ tokens.forEach(([, { holders }], i) => {
500
+ const next = refreshed[i];
501
+ for (const { list, index } of holders) {
502
+ list[index] = typeof next === 'string' && TOKEN_SHAPE.test(next) ? next : '';
503
+ }
504
+ });
505
+ page = page.filter((token) => token !== '');
506
+ for (const entry of boundaries) entry.tokens = entry.tokens.filter((token) => token !== '');
507
+ for (const entry of channels) entry.tokens = entry.tokens.filter((token) => token !== '');
508
+
509
+ if (pageStale && page.length === 0) {
510
+ refetched.page = true;
511
+ refreshPage([WHOLE]);
512
+ }
513
+ for (const entry of entriesStale) {
514
+ if (entry.tokens.length > 0) continue;
515
+ refetched.entries.add(entry);
516
+ entry.refresh();
517
+ }
518
+ for (const entry of channelsStale) if (entry.tokens.length === 0) renewChannel(entry);
519
+
520
+ const topics = Array.isArray(body?.topics) ? body.topics.filter((t) => typeof t === 'string') : [];
521
+ const url = typeof body?.url === 'string' ? body.url : null;
522
+ // Same-origin and a path: the stream is opened with this session's
523
+ // cookies, so never somewhere the answer merely names.
524
+ if (topics.length === 0 || url === null || !/^\/(?!\/)/.test(url)) return close();
525
+
526
+ open(url, keyOf(topics), typeof body.expiresAt === 'number' ? body.expiresAt : null);
527
+ } finally {
528
+ trading = false;
529
+ if (tradeAgain) {
530
+ tradeAgain = false;
531
+ sync(0);
532
+ }
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Asks for a channel's subscription again, its token run out: the new
538
+ * token is traded at the next sync, and the subscriber told of the gap
539
+ * once a stream carries it.
540
+ *
541
+ * @param {ChannelEntry} entry
542
+ */
543
+ function renewChannel(entry) {
544
+ if (!entry.renew) return;
545
+ entry.renew().then(
546
+ (token) => {
547
+ if (!running || !channels.has(entry) || typeof token !== 'string' || !TOKEN_SHAPE.test(token)) return;
548
+ entry.tokens = [token];
549
+ gapOwed.add(entry);
550
+ changed();
551
+ },
552
+ () => {},
553
+ );
554
+ }
555
+
556
+ /**
557
+ * Opens a stream on the hub. One already open is closed only once this
558
+ * one is answered, so a renewal leaves no gap for an update to fall in.
559
+ *
560
+ * @param {string} url
561
+ * @param {string} key
562
+ * @param {number | null} expiresAt the grant's end, in seconds
563
+ */
564
+ function open(url, key, expiresAt) {
565
+ // The stream still delivering: the current one, or -- while that is
566
+ // unanswered, and so superseded unheard -- the one it was replacing.
567
+ let previous = conn;
568
+ if (conn !== null && conn.previous !== null) {
569
+ previous = conn.previous;
570
+ conn.previous = null;
571
+ conn.controller.abort();
572
+ }
573
+ /** @type {Connection} */
574
+ const next = { controller: new AbortController(), key, startedAt: now(), previous };
575
+ conn = next;
576
+
577
+ if (renewTimer !== null) clearTimeout(renewTimer);
578
+ renewTimer = null;
579
+ if (expiresAt !== null) {
580
+ const due = expiresAt * 1000 - RENEW_BEFORE_MS - now();
581
+ renewTimer = setTimeout(
582
+ () => {
583
+ renewTimer = null;
584
+ if (running && !paused) void trade();
585
+ },
586
+ Math.max(MIN_INTERVAL_MS, due),
587
+ );
588
+ }
589
+
590
+ void read(next, url);
591
+ }
592
+
593
+ /**
594
+ * @param {Connection} c
595
+ * @param {string} url
596
+ */
597
+ async function read(c, url) {
598
+ const { signal } = c.controller;
599
+ let response;
600
+ try {
601
+ response = await fetchImpl(url, {
602
+ method: 'GET',
603
+ headers: { accept: 'text/event-stream' },
604
+ credentials: 'same-origin',
605
+ mode: 'same-origin',
606
+ redirect: 'error',
607
+ cache: 'no-store',
608
+ signal,
609
+ });
610
+ } catch {
611
+ if (!signal.aborted) lost(c);
612
+ return;
613
+ }
614
+
615
+ if (signal.aborted || c !== conn) {
616
+ void response.body?.cancel().catch(() => {});
617
+ return;
618
+ }
619
+
620
+ // The hub refused the grant -- run out, or a cookie that never landed.
621
+ // Traded again, after the backoff.
622
+ if (!response.ok || !response.body) {
623
+ void response.body?.cancel().catch(() => {});
624
+ return lost(c);
625
+ }
626
+
627
+ if (c.previous !== null) {
628
+ c.previous.controller.abort();
629
+ c.previous = null;
630
+ }
631
+
632
+ // Nothing was heard while there was no stream: whatever this client
633
+ // holds may have changed meanwhile, so all of it is fetched again, once.
634
+ if (missed) {
635
+ missed = false;
636
+ catchUp();
637
+ }
638
+ refetched = { page: false, entries: new Set() };
639
+ for (const entry of gapOwed) {
640
+ if (!channels.has(entry)) gapOwed.delete(entry);
641
+ else if (entry.tokens.length > 0) {
642
+ gapOwed.delete(entry);
643
+ entry.gap?.();
644
+ }
645
+ }
646
+
647
+ const reader = response.body.getReader();
648
+ const text = new TextDecoder();
649
+ let buffer = '';
650
+ try {
651
+ for (;;) {
652
+ const { value, done } = await reader.read();
653
+ if (done) break;
654
+ buffer += text.decode(value, { stream: true });
655
+ let end;
656
+ while ((end = buffer.search(/\r?\n\r?\n/)) !== -1) {
657
+ const event = buffer.slice(0, end);
658
+ buffer = buffer.slice(end).replace(/^\r?\n\r?\n/, '');
659
+ const data = event
660
+ .split(/\r?\n/)
661
+ .filter((line) => line.startsWith('data:'))
662
+ .map((line) => line.slice(5).replace(/^ /, ''))
663
+ .join('\n');
664
+ // Only while it is the stream listened to: one superseded, or
665
+ // left over from a stop(), delivers nothing.
666
+ if (data !== '' && running && (c === conn || c === conn?.previous)) heard(data);
667
+ }
668
+ }
669
+ } catch {
670
+ // Torn down -- aborted, or the connection dropped.
671
+ }
672
+
673
+ if (signal.aborted || c !== conn) return;
674
+
675
+ // The hub closed it: the grant ran out before a renewal opened another,
676
+ // or the hub restarted. A stream that was up for a while was working,
677
+ // so the backoff starts over.
678
+ if (now() - c.startedAt >= STABLE_MS) attempts = 0;
679
+ lost(c);
680
+ }
681
+
682
+ /** @param {Connection} c */
683
+ function lost(c) {
684
+ if (c !== conn) return;
685
+ // The stream it was replacing goes with it: the retry opens one afresh,
686
+ // and catches up on the gap.
687
+ c.previous?.controller.abort();
688
+ c.previous = null;
689
+ conn = null;
690
+ missed = true;
691
+ retry();
692
+ }
693
+
694
+ /** The next trade, after a backoff with jitter: a hub that restarted is not met by every tab at once. */
695
+ function retry() {
696
+ if (!running || paused) return;
697
+ attempts++;
698
+ const ceiling = Math.min(MAX_RECONNECT_MS, RECONNECT_MS * 2 ** (attempts - 1));
699
+ const delay = ceiling / 2 + (random() * ceiling) / 2;
700
+ if (retryTimer !== null) clearTimeout(retryTimer);
701
+ retryTimer = setTimeout(() => {
702
+ retryTimer = null;
703
+ if (running && !paused) void trade();
704
+ }, delay);
705
+ }
706
+
707
+ /** Stops listening, and keeps what is held. */
708
+ function close() {
709
+ if (renewTimer !== null) clearTimeout(renewTimer);
710
+ if (retryTimer !== null) clearTimeout(retryTimer);
711
+ renewTimer = null;
712
+ retryTimer = null;
713
+ conn?.previous?.controller.abort();
714
+ conn?.controller.abort();
715
+ conn = null;
716
+ }
717
+
718
+ /** @param {string} data */
719
+ function heard(data) {
720
+ let update;
721
+ try {
722
+ update = JSON.parse(data);
723
+ } catch {
724
+ return;
725
+ }
726
+ const topic = update?.topic;
727
+ if (typeof topic !== 'string') return;
728
+
729
+ // A channel's message, for its subscribers alone, as it came: what the
730
+ // server encoded, from a Data object, to a topic only a trade that ran
731
+ // the channel's checks granted.
732
+ if (typeof update.channel === 'string') {
733
+ for (const entry of channels) {
734
+ if (!entry.tokens.some((token) => topicsOf(token).has(topic))) continue;
735
+ try {
736
+ entry.message(update.message);
737
+ } catch (error) {
738
+ // One subscriber's failure is not the stream's.
739
+ globalThis.reportError?.(error);
740
+ }
741
+ }
742
+ return;
743
+ }
744
+
745
+ let concerned = false;
746
+ for (const token of page) {
747
+ const segments = topicsOf(token).get(topic);
748
+ if (!segments) continue;
749
+ concerned = true;
750
+ for (const segment of segments) pending.segments.add(segment);
751
+ }
752
+ for (const entry of boundaries) {
753
+ if (entry.tokens.some((token) => topicsOf(token).has(topic))) {
754
+ concerned = true;
755
+ pending.entries.add(entry);
756
+ }
757
+ }
758
+ if (!concerned) return;
759
+ if (typeof update.tag === 'string' && update.tag !== '') pending.tags.add(update.tag);
760
+
761
+ // Gathered: a burst of writes is one refetch, put off no longer than
762
+ // MAX_WAIT_MS however long the burst.
763
+ const at = now();
764
+ if (flushTimer === null) firstHeard = at;
765
+ else clearTimeout(flushTimer);
766
+ flushTimer = setTimeout(flush, Math.max(0, Math.min(DEBOUNCE_MS, firstHeard + MAX_WAIT_MS - at)));
767
+ }
768
+
769
+ function flush() {
770
+ flushTimer = null;
771
+ const { tags, segments, entries } = pending;
772
+ pending = { tags: new Set(), segments: new Set(), entries: new Set() };
773
+ if (!running) return;
774
+
775
+ // What the client keeps under the tags goes, as it would on reading
776
+ // X-Invalidated-Tags: prefetched pages, and #[Once] content no longer
777
+ // claimed.
778
+ if (tags.size > 0) router.flushByCacheTags([...tags]);
779
+ for (const entry of entries) if (boundaries.has(entry)) entry.refresh();
780
+ if (segments.size > 0) refreshPage(segments);
781
+ }
782
+
783
+ /** Everything held, fetched again: what a gap in the stream may have hidden. */
784
+ function catchUp() {
785
+ if (page.length > 0 && !refetched.page) refreshPage([WHOLE]);
786
+ for (const entry of boundaries)
787
+ if (entry.tokens.length > 0 && !refetched.entries.has(entry)) entry.refresh();
788
+ for (const entry of channels) {
789
+ if (entry.tokens.length === 0) continue;
790
+ gapOwed.delete(entry);
791
+ entry.gap?.();
792
+ }
793
+ }
794
+
795
+ /** @param {Iterable<string>} segments */
796
+ function refreshPage(segments) {
797
+ const wanted = (pageRefresh.wanted ??= new Set());
798
+ for (const segment of segments) wanted.add(segment);
799
+ pumpPage();
800
+ }
801
+
802
+ function pumpPage() {
803
+ const s = pageRefresh;
804
+ if (!running || s.inFlight || s.timer !== null || s.wanted === null) return;
805
+ const wait = s.notBefore - now();
806
+ if (wait <= 0) void runPageRefresh();
807
+ else s.timer = setTimeout(runPageRefresh, wait);
808
+ }
809
+
810
+ async function runPageRefresh() {
811
+ const s = pageRefresh;
812
+ s.timer = null;
813
+ if (!running || s.wanted === null) return;
814
+ const segments = s.wanted;
815
+ s.wanted = null;
816
+ s.inFlight = true;
817
+
818
+ /** @type {import('./router.js').VisitOptions} */
819
+ const options = { background: true, refreshOnce: true };
820
+ // Only the segments whose loaders read it, when that is all that did;
821
+ // a server not answering partial reloads renders the page whole.
822
+ if (!segments.has(WHOLE)) options.only = [...segments];
823
+
824
+ let mounted = false;
825
+ try {
826
+ mounted = (await router.reload(options)) === true;
827
+ } catch {
828
+ mounted = false;
829
+ }
830
+ s.inFlight = false;
831
+
832
+ if (mounted) {
833
+ s.failures = 0;
834
+ s.notBefore = now() + MIN_INTERVAL_MS;
835
+ } else {
836
+ // Not mounted: failed, or not sent while a visit was under way. Asked
837
+ // again with the boundaries' backoff, until it has failed as often
838
+ // as a boundary may.
839
+ s.failures++;
840
+ s.notBefore = now() + MIN_INTERVAL_MS * 2 ** s.failures;
841
+ if (s.failures < MAX_FAILURES) refreshPage(segments);
842
+ else s.failures = 0;
843
+ }
844
+ pumpPage();
845
+ }
846
+
847
+ /** The session ended: what is held goes, and the page is loaded again. */
848
+ function ended() {
849
+ // Every token held goes -- the page's, each boundary's and channel's --
850
+ // here rather than through the router's 'revoke', whose listener the
851
+ // stop below takes away.
852
+ purge();
853
+ // This client's own stop: a bare stop() here is the window's, which
854
+ // aborts the document's navigation and every fetch it has under way.
855
+ halt();
856
+ // Wiped first, as a fragment's 401 does: the reload alone left
857
+ // remembered drafts in the tab's storage for the next page to read.
858
+ revokeSession(router);
859
+ // Loaded again -- unless a document load has begun, which is the page
860
+ // being replaced already: an action that replaced the session and sent
861
+ // the browser on. It is left to arrive (document-load.js).
862
+ stopReload();
863
+ stopReload = reloadUnlessLoading(hardNavigate ?? ((u) => globalThis.location.assign(u)));
864
+ }
865
+
866
+ /** Everything held for the session, gone: client-held data. */
867
+ function purge() {
868
+ close();
869
+ page = [];
870
+ for (const entry of boundaries) entry.tokens = [];
871
+ for (const entry of channels) entry.tokens = [];
872
+ pending = { tags: new Set(), segments: new Set(), entries: new Set() };
873
+ if (flushTimer !== null) clearTimeout(flushTimer);
874
+ flushTimer = null;
875
+ missed = false;
876
+ refetched = { page: false, entries: new Set() };
877
+ gapOwed.clear();
878
+ }
879
+
880
+ function onVisibility() {
881
+ if (!running) return;
882
+ if (hidden()) {
883
+ // Paused: no stream held open for a tab nobody is looking at.
884
+ paused = true;
885
+ close();
886
+ return;
887
+ }
888
+ if (!paused) return;
889
+ paused = false;
890
+ // It heard nothing while away.
891
+ missed = wantedKey() !== '';
892
+ attempts = 0;
893
+ sync(0);
894
+ }
895
+
896
+ /** @param {CustomEvent} event */
897
+ function onSuccess(event) {
898
+ const detail = event?.detail ?? {};
899
+ // A page mounted with nothing said of its live token -- an action's
900
+ // re-render from a server that minted none -- says nothing about what
901
+ // the page reads; the tokens held stand.
902
+ if (!('liveToken' in detail)) return;
903
+
904
+ const current = bindingOf(router.routeTokens()[0] ?? null);
905
+ if (current !== binding) {
906
+ // Another session's page: what was held for the last one goes.
907
+ purge();
908
+ binding = current;
909
+ }
910
+
911
+ const token =
912
+ typeof detail.liveToken === 'string' && TOKEN_SHAPE.test(detail.liveToken) ? detail.liveToken : null;
913
+
914
+ // Drawn under another binding than the tokens held, with the route
915
+ // token kept: an action moved the user's auth epoch -- a role change --
916
+ // and re-rendered the page in place. The grant open is for the session
917
+ // as it was, so it is traded again at once, whether or not the topics
918
+ // differ: the server refuses with a 401 any token held from before the
919
+ // change, which ends the session here as any binding change does, and
920
+ // otherwise grants the new token's topics under the binding as it is.
921
+ if (token !== null && page.length > 0 && bindingOf(token) !== bindingOf(page[0])) rebound = true;
922
+
923
+ if (detail.partial) {
924
+ if (token !== null && !page.includes(token)) page = [...page, token].slice(-MAX_PAGE_TOKENS);
925
+ } else {
926
+ page = token === null ? [] : [token];
927
+ }
928
+ sync();
929
+ }
930
+
931
+ /** Stops listening: the stream closed, nothing more traded, the router's events let go of. */
932
+ function halt() {
933
+ if (!running) return;
934
+ running = false;
935
+ tradeController?.abort();
936
+ close();
937
+ for (const off of offs.splice(0)) off();
938
+ for (const timer of [syncTimer, flushTimer, pageRefresh.timer]) if (timer !== null) clearTimeout(timer);
939
+ syncTimer = null;
940
+ flushTimer = null;
941
+ pageRefresh.timer = null;
942
+ }
943
+
944
+ return {
945
+ start() {
946
+ if (running) return;
947
+ running = true;
948
+ paused = hidden();
949
+ binding = bindingOf(router.routeTokens()[0] ?? null);
950
+ const first = initialToken === undefined ? readLiveToken(doc) : initialToken;
951
+ page = typeof first === 'string' && TOKEN_SHAPE.test(first) ? [first] : [];
952
+ offs.push(router.on('success', onSuccess));
953
+ offs.push(router.on('revoke', purge));
954
+ offs.push(router.on('clearHistory', purge));
955
+ const watcher = () => sync();
956
+ watchers.add(watcher);
957
+ offs.push(() => watchers.delete(watcher));
958
+ doc?.addEventListener?.('visibilitychange', onVisibility);
959
+ offs.push(() => doc?.removeEventListener?.('visibilitychange', onVisibility));
960
+ sync(0);
961
+ },
962
+
963
+ stop: halt,
964
+
965
+ purge,
966
+
967
+ /** Whether a stream is open on the hub. */
968
+ get connected() {
969
+ return conn !== null;
970
+ },
971
+
972
+ /** The topics the open stream was granted, space-separated. */
973
+ get listening() {
974
+ return conn?.key ?? '';
975
+ },
976
+ };
977
+ }