@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.
- package/LICENSE +21 -0
- package/package.json +168 -4
- package/src/action-server.js +41 -0
- package/src/action.d.ts +68 -0
- package/src/action.js +167 -0
- package/src/bootstrap.js +153 -0
- package/src/channel.d.ts +46 -0
- package/src/channel.js +132 -0
- package/src/client-modules.d.ts +8 -0
- package/src/client-modules.js +88 -0
- package/src/client.d.ts +36 -0
- package/src/client.js +42 -0
- package/src/compose-route.d.ts +33 -0
- package/src/compose-route.js +107 -0
- package/src/csrf.d.ts +11 -0
- package/src/csrf.js +49 -0
- package/src/deferred-boundary.d.ts +61 -0
- package/src/deferred-boundary.js +646 -0
- package/src/deferred-groups.d.ts +24 -0
- package/src/deferred-groups.js +198 -0
- package/src/deferred-revalidation.d.ts +37 -0
- package/src/deferred-revalidation.js +108 -0
- package/src/deferred-triggers.d.ts +26 -0
- package/src/deferred-triggers.js +172 -0
- package/src/deferred.d.ts +24 -0
- package/src/deferred.jsx +100 -0
- package/src/document-load.js +95 -0
- package/src/endpoint.d.ts +87 -0
- package/src/endpoint.js +304 -0
- package/src/form-data.js +219 -0
- package/src/form.d.ts +177 -0
- package/src/form.js +615 -0
- package/src/forms.d.ts +4 -0
- package/src/forms.js +10 -0
- package/src/fragment-client.d.ts +70 -0
- package/src/fragment-client.js +510 -0
- package/src/head.d.ts +85 -0
- package/src/head.js +360 -0
- package/src/inbox-view.d.ts +17 -0
- package/src/inbox-view.js +105 -0
- package/src/inbox.d.ts +108 -0
- package/src/inbox.js +293 -0
- package/src/index.combined.d.ts +31 -0
- package/src/index.d.ts +5 -0
- package/src/index.js +29 -0
- package/src/index.server.d.ts +4 -0
- package/src/index.server.js +8 -0
- package/src/infinite-scroll.d.ts +108 -0
- package/src/infinite-scroll.js +715 -0
- package/src/layout-props.d.ts +30 -0
- package/src/layout-props.js +143 -0
- package/src/link-click.d.ts +3 -0
- package/src/link-click.js +68 -0
- package/src/link.d.ts +63 -0
- package/src/link.js +274 -0
- package/src/live.d.ts +65 -0
- package/src/live.js +977 -0
- package/src/merge-slot.d.ts +41 -0
- package/src/merge-slot.js +253 -0
- package/src/merge.d.ts +111 -0
- package/src/merge.js +149 -0
- package/src/once-slot.d.ts +14 -0
- package/src/once-slot.js +75 -0
- package/src/once-store.d.ts +26 -0
- package/src/once-store.js +211 -0
- package/src/once.d.ts +44 -0
- package/src/once.js +65 -0
- package/src/optimistic.d.ts +14 -0
- package/src/optimistic.js +159 -0
- package/src/optional.d.ts +10 -0
- package/src/optional.js +31 -0
- package/src/page-root.d.ts +56 -0
- package/src/page-root.js +455 -0
- package/src/payload.d.ts +15 -0
- package/src/payload.js +101 -0
- package/src/poll.d.ts +55 -0
- package/src/poll.js +224 -0
- package/src/precognition.js +190 -0
- package/src/prefetch-cache.d.ts +46 -0
- package/src/prefetch-cache.js +256 -0
- package/src/progress.d.ts +38 -0
- package/src/progress.js +243 -0
- package/src/remember-store.d.ts +50 -0
- package/src/remember-store.js +486 -0
- package/src/rescue.d.ts +10 -0
- package/src/rescue.js +30 -0
- package/src/resumable.js +822 -0
- package/src/resume-store.js +364 -0
- package/src/route.d.ts +50 -0
- package/src/route.js +227 -0
- package/src/router-hooks.js +234 -0
- package/src/router.d.ts +586 -0
- package/src/router.js +3555 -0
- package/src/segment.d.ts +36 -0
- package/src/segment.js +116 -0
- package/src/server.d.ts +71 -0
- package/src/server.js +242 -0
- package/src/submit.js +333 -0
- package/src/telemetry.d.ts +71 -0
- package/src/telemetry.js +851 -0
- package/src/testing.d.ts +89 -0
- package/src/testing.js +634 -0
- package/src/transfer.d.ts +48 -0
- package/src/transfer.js +135 -0
- package/src/transport.js +390 -0
- package/src/upload.d.ts +342 -0
- package/src/upload.js +1240 -0
- package/src/use-channel.d.ts +21 -0
- package/src/use-channel.js +51 -0
- package/src/use-form.d.ts +86 -0
- package/src/use-form.js +432 -0
- package/src/use-http.d.ts +56 -0
- package/src/use-http.js +248 -0
- package/src/use-inbox.d.ts +41 -0
- package/src/use-inbox.js +201 -0
- package/src/use-remember.d.ts +10 -0
- package/src/use-remember.js +132 -0
- package/src/when-mounted.d.ts +15 -0
- package/src/when-mounted.js +27 -0
- 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
|
+
}
|