@abide/abide 0.114.1 → 0.115.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.
@@ -0,0 +1,427 @@
1
+ // 46 — THE BROWSER'S OUTBOX: the mutation calls a page could not deliver, kept on the device and sent
2
+ // once the network answers, oldest first and one at a time, by one tab for all of them.
3
+ //
4
+ // KEPT IN IndexedDB, where the browser has it (46.2): the one store a page can write that outlives
5
+ // it, holds what JSON cannot (a photo's bytes ride in a call's args), and the worker can read too.
6
+ // Where there is none — a DOM emulator — the calls are kept in memory, which is the same queue for
7
+ // as long as the page lives.
8
+ //
9
+ // WHAT `outbox` SHOWS IS WHAT IS KEPT: every change is written first and published after, so a
10
+ // reader is never shown a call a reload would lose.
11
+
12
+ import {
13
+ type Answer,
14
+ answered as answeredValue,
15
+ answerOf,
16
+ unanswered,
17
+ } from '#shared/answerOf.ts'
18
+ import { failedFrom, isFailed, problemOf } from '#shared/Failed.ts'
19
+ import type { Problem } from '#shared/Failed.ts'
20
+ import { type ShownCall, showLine, showSending } from '#shared/outbox.ts'
21
+ import { onPrincipalRotated } from '#shared/principalRotated.ts'
22
+ import {
23
+ isReactive,
24
+ produceInto,
25
+ type Reactive,
26
+ reactive,
27
+ refuseInto,
28
+ standIn,
29
+ } from '#shared/reactive/index.ts'
30
+ import { rpcRequest } from '#shared/rpcRequest.ts'
31
+
32
+ const DATABASE = 'abide-outbox'
33
+ /** The `localStorage` key set while the line holds a call, so a page with none opens no database. */
34
+ const MARKER = 'abide-outbox'
35
+ const STORE = 'calls'
36
+ const CHANNEL = 'abide-outbox'
37
+ const RETRY_FLOOR_MS = 1_000
38
+ const RETRY_CEILING_MS = 60_000
39
+
40
+ /** One call as it is kept: what `outbox` shows of it, its place in line, and how it was refused. */
41
+ type Kept = Omit<ShownCall, 'value' | 'retry' | 'discard'> & {
42
+ seq: number | undefined
43
+ error: Problem | undefined
44
+ /** 46.19 — what the call settled to, where the browser could keep it. */
45
+ standIn: unknown
46
+ }
47
+
48
+ /** Where the calls are kept: IndexedDB where the browser has it, this page's memory where not. */
49
+ type Shelf = {
50
+ load(): Promise<Kept[]>
51
+ add(call: Kept): Promise<number>
52
+ put(call: Kept): Promise<void>
53
+ remove(seq: number): Promise<void>
54
+ }
55
+
56
+ /** The line, oldest first, as this tab last read it. */
57
+ let LINE: Kept[] = []
58
+ /** Whether this tab is sending. */
59
+ let sending = false
60
+ /** 46.6 — a call refused 401 holds the line until `principal` changes or `retry` is called. */
61
+ let held = false
62
+ let retrying: ReturnType<typeof setTimeout> | undefined
63
+ let backoff = RETRY_FLOOR_MS
64
+ let askedToPersist = false
65
+ /**
66
+ * The value each call stands in for, by its id: the one its call settled, where this page made it,
67
+ * and one rebuilt from what was kept where not (46.18) — what `outbox` shows as `value`, and what
68
+ * the answer lands in (46.7). Held for as long as the call is in the line.
69
+ */
70
+ const VALUES = new Map<string, Reactive<unknown>>()
71
+ /** What wants to hear the line empty of calls not yet delivered (18.35). */
72
+ const DRAINED: (() => void)[] = []
73
+
74
+ const shelf: Shelf =
75
+ typeof indexedDB === 'undefined' ? memoryShelf() : databaseShelf()
76
+ const channel =
77
+ typeof BroadcastChannel === 'undefined'
78
+ ? undefined
79
+ : new BroadcastChannel(CHANNEL)
80
+
81
+ /** The calls not yet delivered: what is waiting, as against what was refused. */
82
+ export function undelivered(): number {
83
+ let count = 0
84
+ for (const call of LINE) if (call.error === undefined) count += 1
85
+ return count
86
+ }
87
+
88
+ /** Runs `listener` each time the line holds no call waiting to be delivered (18.35). */
89
+ export function whenDelivered(listener: () => void): void {
90
+ DRAINED.push(listener)
91
+ }
92
+
93
+ /**
94
+ * Queues a call that could not be delivered; answers whether it is kept (16.82).
95
+ *
96
+ * `standing` holds the value the call settled, and `settledTo` is the stand-in it settled to.
97
+ */
98
+ export async function enqueue(
99
+ call: Pick<Kept, 'id' | 'method' | 'address' | 'url' | 'args'>,
100
+ standing: { value: unknown },
101
+ settledTo: unknown,
102
+ ): Promise<boolean> {
103
+ const kept: Kept = {
104
+ seq: undefined,
105
+ ...call,
106
+ queuedAt: Date.now(),
107
+ attempts: 1,
108
+ error: undefined,
109
+ standIn: settledTo,
110
+ }
111
+ try {
112
+ kept.seq = await shelf.add(kept)
113
+ } catch (refused) {
114
+ // 46.19 — A STAND-IN THE BROWSER CANNOT KEEP — a function, a DOM node — is left out, and the
115
+ // call is kept without it; anything else that stops the write stops the call (16.82).
116
+ if ((refused as { name?: string })?.name !== 'DataCloneError')
117
+ return false
118
+ kept.standIn = undefined
119
+ try {
120
+ kept.seq = await shelf.add(kept)
121
+ } catch {
122
+ return false
123
+ }
124
+ }
125
+ const reached = standing.value
126
+ if (isReactive(reached)) VALUES.set(call.id, reached as Reactive<unknown>)
127
+ LINE.push(kept)
128
+ publish()
129
+ changed()
130
+ // 46.14 — a call waiting on the network is one the browser must not clear to make room.
131
+ if (!askedToPersist) {
132
+ askedToPersist = true
133
+ void navigator.storage?.persist?.().catch(() => {})
134
+ }
135
+ retryLater()
136
+ return true
137
+ }
138
+
139
+ /** Sends every call waiting, oldest first and one at a time, where this tab may (46.3, 46.9). */
140
+ export function deliver(): void {
141
+ // Another tab's calls reach this one's line through `changed`, and an earlier visit's at boot.
142
+ if (sending || held || undelivered() === 0) return
143
+ if (typeof navigator !== 'undefined' && navigator.onLine === false) return
144
+ // Where there is none — a DOM emulator's is `null` — this tab is the only one there is to ask.
145
+ const locks = (navigator as { locks?: LockManager | null }).locks
146
+ if (locks === undefined || locks === null) {
147
+ void sendLine()
148
+ return
149
+ }
150
+ void locks
151
+ .request(DATABASE, { ifAvailable: true }, async (lock) => {
152
+ if (lock !== null) await sendLine()
153
+ })
154
+ .catch(() => {})
155
+ }
156
+
157
+ async function sendLine(): Promise<void> {
158
+ sending = true
159
+ try {
160
+ // Another tab may have sent or queued since this one last read the line.
161
+ LINE = await shelf.load()
162
+ publish()
163
+ for (const call of [...LINE]) {
164
+ if (call.error !== undefined) continue
165
+ // 46.13 — a call discarded since the line was read is not sent.
166
+ if (!LINE.some((each) => each.id === call.id)) continue
167
+ showSending(call)
168
+ const answer = await send(call)
169
+ showSending(undefined)
170
+ if (unanswered(answer)) {
171
+ call.attempts += 1
172
+ await shelf.put(call)
173
+ publish()
174
+ retryLater()
175
+ return
176
+ }
177
+ const value = answeredValue(answer)
178
+ if (isFailed(value)) {
179
+ call.error = problemOf(value)
180
+ await shelf.put(call)
181
+ publish()
182
+ refuseInto(valueStanding(call), value)
183
+ // 46.6 — whoever is signed in has to change before the line can go on.
184
+ if (value.status === 401) {
185
+ held = true
186
+ return
187
+ }
188
+ continue
189
+ }
190
+ await shelf.remove(call.seq as number)
191
+ LINE = LINE.filter((each) => each.id !== call.id)
192
+ publish()
193
+ produceInto(valueStanding(call), value)
194
+ VALUES.delete(call.id)
195
+ }
196
+ backoff = RETRY_FLOOR_MS
197
+ } catch {
198
+ retryLater()
199
+ } finally {
200
+ sending = false
201
+ showSending(undefined)
202
+ changed()
203
+ }
204
+ }
205
+
206
+ /** One send of a call, carrying the key it carries every time (46.10). */
207
+ async function send(call: Kept): Promise<Answer> {
208
+ const request = rpcRequest(call.method, call.address, call.args, 'no-store')
209
+ request.init.headers = {
210
+ ...(request.init.headers as Record<string, string> | undefined),
211
+ 'idempotency-key': call.id,
212
+ }
213
+ return await answerOf(request, false, undefined)
214
+ }
215
+
216
+ /**
217
+ * The value `kept` stands in for: this page's own, or one rebuilt from what was kept (46.18) —
218
+ * written here, so its `synced` is false (46.19), and refused where the call was.
219
+ */
220
+ function valueStanding(kept: Kept): Reactive<unknown> {
221
+ let value = VALUES.get(kept.id)
222
+ if (value === undefined) {
223
+ const settled = kept.standIn
224
+ value = reactive(() => standIn(settled)) as Reactive<unknown>
225
+ // Landed now, so a refusal written below is not cleared by the body's first run (4.11).
226
+ value.peek()
227
+ if (kept.error !== undefined) refuseInto(value, failedFrom(kept.error))
228
+ VALUES.set(kept.id, value)
229
+ }
230
+ return value
231
+ }
232
+
233
+ /** 46.8 — the next try, backing off from a second to a minute while nothing answers. */
234
+ function retryLater(): void {
235
+ clearTimeout(retrying)
236
+ retrying = setTimeout(() => {
237
+ retrying = undefined
238
+ deliver()
239
+ }, backoff)
240
+ backoff = Math.min(backoff * 2, RETRY_CEILING_MS)
241
+ }
242
+
243
+ /** Whether the line last held a call waiting, so the move to none is heard once. */
244
+ let waiting = false
245
+
246
+ /** Shows the line as `outbox` answers it, and tells who asked once nothing is waiting (18.35). */
247
+ function publish(): void {
248
+ const now = undelivered() !== 0
249
+ if (waiting && !now)
250
+ queueMicrotask(() => {
251
+ for (const listener of DRAINED) listener()
252
+ })
253
+ waiting = now
254
+ mark(LINE.length !== 0)
255
+ const calls: ShownCall[] = []
256
+ for (const call of LINE) {
257
+ const id = call.id
258
+ calls.push({
259
+ id,
260
+ method: call.method,
261
+ address: call.address,
262
+ url: call.url,
263
+ args: call.args,
264
+ queuedAt: call.queuedAt,
265
+ attempts: call.attempts,
266
+ value: valueStanding(call),
267
+ retry: () => void retry(id),
268
+ discard: () => void discard(id),
269
+ })
270
+ }
271
+ showLine(calls)
272
+ }
273
+
274
+ /**
275
+ * Says whether the line holds a call, for the next page this browser opens to read before it opens
276
+ * the database (`listen`). Written on every change rather than when it flips: another tab may have
277
+ * emptied the line since this one last wrote it.
278
+ */
279
+ function mark(holds: boolean): void {
280
+ try {
281
+ if (holds) localStorage.setItem(MARKER, '1')
282
+ else localStorage.removeItem(MARKER)
283
+ } catch {}
284
+ }
285
+
286
+ /** Whether the line held a call when it last changed — `true` where `localStorage` cannot say. */
287
+ function marked(): boolean {
288
+ try {
289
+ return localStorage.getItem(MARKER) !== null
290
+ } catch {
291
+ return true
292
+ }
293
+ }
294
+
295
+ /** 46.9 — tells every other tab the line moved, so each shows the one line. */
296
+ function changed(): void {
297
+ channel?.postMessage('changed')
298
+ }
299
+
300
+ async function reread(): Promise<void> {
301
+ LINE = await shelf.load()
302
+ publish()
303
+ }
304
+
305
+ /** 46.12 — sends the call `id` names again, at once, and lets go of a line 46.6 held. */
306
+ async function retry(id: string): Promise<void> {
307
+ held = false
308
+ for (const call of LINE)
309
+ if (call.id === id && call.error !== undefined) {
310
+ call.error = undefined
311
+ await shelf.put(call)
312
+ // An accepted write clears the refusal (4.11) and is a write here, so the value is
313
+ // waiting again rather than refused, and still not the server's.
314
+ const value = valueStanding(call)
315
+ value.set(value.peek())
316
+ }
317
+ publish()
318
+ changed()
319
+ backoff = RETRY_FLOOR_MS
320
+ deliver()
321
+ }
322
+
323
+ /** 46.13 — drops the call `id` names, sent or not. */
324
+ async function discard(id: string): Promise<void> {
325
+ const kept: Kept[] = []
326
+ for (const call of LINE) {
327
+ if (call.id === id) {
328
+ await shelf.remove(call.seq as number)
329
+ VALUES.delete(call.id)
330
+ } else kept.push(call)
331
+ }
332
+ LINE = kept
333
+ publish()
334
+ changed()
335
+ }
336
+
337
+ function memoryShelf(): Shelf {
338
+ const calls = new Map<number, Kept>()
339
+ let next = 1
340
+ return {
341
+ load: async () => [...calls.values()].map((call) => ({ ...call })),
342
+ add: async (call) => {
343
+ const seq = next
344
+ next += 1
345
+ calls.set(seq, { ...call, seq })
346
+ return seq
347
+ },
348
+ put: async (call) => {
349
+ calls.set(call.seq as number, { ...call })
350
+ },
351
+ remove: async (seq) => {
352
+ calls.delete(seq)
353
+ },
354
+ }
355
+ }
356
+
357
+ function databaseShelf(): Shelf {
358
+ let opening: Promise<IDBDatabase> | undefined
359
+ const database = (): Promise<IDBDatabase> => {
360
+ opening ??= new Promise((resolve, reject) => {
361
+ const asked = indexedDB.open(DATABASE, 1)
362
+ asked.onupgradeneeded = () =>
363
+ asked.result.createObjectStore(STORE, {
364
+ keyPath: 'seq',
365
+ autoIncrement: true,
366
+ })
367
+ asked.onsuccess = () => resolve(asked.result)
368
+ asked.onerror = () => reject(asked.error)
369
+ })
370
+ return opening
371
+ }
372
+ const inStore = async <Result>(
373
+ mode: IDBTransactionMode,
374
+ work: (store: IDBObjectStore) => IDBRequest<Result>,
375
+ ): Promise<Result> => {
376
+ const store = (await database())
377
+ .transaction(STORE, mode)
378
+ .objectStore(STORE)
379
+ return new Promise((resolve, reject) => {
380
+ const request = work(store)
381
+ request.onsuccess = () => resolve(request.result)
382
+ request.onerror = () => reject(request.error)
383
+ })
384
+ }
385
+ return {
386
+ load: () => inStore('readonly', (store) => store.getAll()),
387
+ add: async (call) => {
388
+ const { seq: _, ...unnumbered } = call
389
+ return (await inStore('readwrite', (store) =>
390
+ store.add(unnumbered),
391
+ )) as number
392
+ },
393
+ put: async (call) => {
394
+ await inStore('readwrite', (store) => store.put(call))
395
+ },
396
+ remove: async (seq) => {
397
+ await inStore('readwrite', (store) => store.delete(seq))
398
+ },
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Reads the line kept, and sends on what 46.8 names.
404
+ *
405
+ * 46.8 — WHAT SENDS: the page arriving with calls kept, the network coming back, the page coming into
406
+ * view, and `principal` changing, which also lets go of a line 46.6 held.
407
+ */
408
+ function listen(): void {
409
+ if (typeof window === 'undefined') return
410
+ // 46.2, 46.20 — a line kept from an earlier visit, read where one was left: a page with no call kept
411
+ // opens no database, which costs every page with a handler a read before it hydrates.
412
+ if (marked()) void reread().then(deliver, () => {})
413
+ window.addEventListener('online', () => {
414
+ backoff = RETRY_FLOOR_MS
415
+ deliver()
416
+ })
417
+ document.addEventListener('visibilitychange', () => {
418
+ if (document.visibilityState === 'visible') deliver()
419
+ })
420
+ onPrincipalRotated(() => {
421
+ held = false
422
+ deliver()
423
+ }, true)
424
+ channel?.addEventListener('message', () => void reread())
425
+ }
426
+
427
+ listen()
@@ -8,13 +8,17 @@ import { canonicalKey } from '#shared/reactive/canonicalKey.ts'
8
8
  import {
9
9
  heldGenerationOf,
10
10
  loadOf,
11
+ patchInto,
12
+ produceInto,
11
13
  type Reactive,
14
+ writtenLocally,
12
15
  } from '#shared/reactive/index.ts'
13
16
  import { PATCHES } from '#shared/reactive/PATCHES.ts'
14
17
  import type { BrowserFrame, ServerFrame } from '#shared/muxFrames.ts'
15
18
  import { applyPatch } from '#shared/applyPatch.ts'
16
19
  import { onPrincipalRotated } from '#shared/principalRotated.ts'
17
20
  import { HYDRATE_LOG } from './Mismatch.ts'
21
+ import { undelivered, whenDelivered } from './outboxQueue.ts'
18
22
 
19
23
  const RETRY_FLOOR_MS = 250
20
24
  const RETRY_CEILING_MS = 10_000
@@ -104,9 +108,18 @@ class Following {
104
108
 
105
109
  receive(frame: ServerFrame): void {
106
110
  const copy = this.entry() as Reactive<unknown>
111
+ // 18.35 — A COPY WRITTEN HERE WAITS OUT THE CALLS THAT WROTE IT: what the server sends before
112
+ // they are delivered is the value without them, and taking it would undo what is on screen.
113
+ if (
114
+ (frame.op === 'value' || frame.op === 'patch') &&
115
+ writtenLocally(copy) &&
116
+ undelivered() !== 0
117
+ )
118
+ return
107
119
  if (frame.op === 'value') {
108
120
  this.version = frame.version
109
- copy.set(frame.value)
121
+ // 18.10 — the server's value is the copy's producer's, not a write made here (3.11).
122
+ produceInto(copy, frame.value)
110
123
  this.inStep()
111
124
  } else if (frame.op === 'patch') {
112
125
  // 18.12 — WRITES MADE OVER ANOTHER VERSION WOULD LAND SOMEWHERE ELSE, and 18.21 over a
@@ -116,7 +129,7 @@ class Following {
116
129
  copy.refresh()
117
130
  else {
118
131
  this.version = frame.version
119
- copy.patch((value) => applyPatch(value, frame.ops ?? []))
132
+ patchInto(copy, (value) => applyPatch(value, frame.ops ?? []))
120
133
  this.inStep()
121
134
  }
122
135
  } else if (frame.op === 'invalidate') copy.invalidate()
@@ -246,6 +259,8 @@ function muxFor(address: string): Mux {
246
259
  url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
247
260
  let mux = MUXES.get(url.href)
248
261
  if (mux === undefined) {
262
+ // Heard from the first follow on, which is the first copy it could read again.
263
+ if (MUXES.size === 0) whenDelivered(readAgainWritten)
249
264
  mux = new Mux(url.href)
250
265
  MUXES.set(url.href, mux)
251
266
  }
@@ -260,6 +275,20 @@ function muxFor(address: string): Mux {
260
275
  */
261
276
  const FOLLOWING = new Map<string, Following>()
262
277
 
278
+ /**
279
+ * Reads again every followed copy written here, once the outbox holds no call waiting.
280
+ *
281
+ * 18.35 — the server's value, with every queued call that was delivered in it and none of one it
282
+ * refused. Every such copy, not only one a frame was held back from: a refused call may have left
283
+ * the server with nothing to send.
284
+ */
285
+ function readAgainWritten(): void {
286
+ for (const following of FOLLOWING.values()) {
287
+ const copy = following.entry() as Reactive<unknown>
288
+ if (writtenLocally(copy)) copy.refresh()
289
+ }
290
+ }
291
+
263
292
  /**
264
293
  * Follows a loaded `GET`'s copy over the mux, raising its version and resetting its expiry.
265
294
  *
package/src/ui/wire.ts CHANGED
@@ -17,19 +17,28 @@
17
17
  // An entry of its own (`@abide/abide/wire`) rather than a name on the runtime barrel: only a generated
18
18
  // wrapper imports it, and a page with no handler pays nothing for it.
19
19
 
20
- import { type Answer, answered, answerOf } from '#shared/answerOf.ts'
20
+ import {
21
+ type Answer,
22
+ answered,
23
+ answerOf,
24
+ noAnswer,
25
+ unanswered,
26
+ } from '#shared/answerOf.ts'
21
27
  import type { CopyOptions } from '#shared/CopyOptions.ts'
22
28
  import { isFailed } from '#shared/Failed.ts'
23
29
  import { answerChunks } from '#shared/framing.ts'
24
30
  import { HandlerRoute } from '#shared/HandlerRoute.ts'
31
+ import type { JsonValue } from '#shared/JsonValue.ts'
32
+ import { LOAD_OFFLINE } from '#shared/LOAD_OFFLINE.ts'
25
33
  import { LOAD_SIGNAL } from '#shared/LOAD_SIGNAL.ts'
26
- import { reactive, structural } from '#shared/reactive/index.ts'
34
+ import { reactive, standIn, structural } from '#shared/reactive/index.ts'
27
35
  import {
28
36
  type Defaults,
29
37
  declareDefaults,
30
38
  } from '#shared/reactive/parameterDefaults.ts'
31
39
  import { declare, type Method } from '#shared/rpc.ts'
32
- import { rpcRequest } from '#shared/rpcRequest.ts'
40
+ import { type RpcRequest, rpcRequest } from '#shared/rpcRequest.ts'
41
+ import { enqueue, undelivered } from './outboxQueue.ts'
33
42
  import { followRpc } from './socketWire.ts'
34
43
 
35
44
  /** The browser's `Rpc` for a handler: the server's declaration over a body that fetches. */
@@ -43,18 +52,90 @@ export function wire(
43
52
  ): unknown {
44
53
  // 16.9 — the handler's route, compiled once for every call through the copy.
45
54
  const route = new HandlerRoute(address)
55
+ // 16.80 — the requests this page has had an answer to, whose next load asks past every cache.
56
+ // A first load is the worker's to answer from its copy, where one answers this page: anywhere
57
+ // else the cache below is the browser's, which would hand back a week-stale answer as the server's.
58
+ const answeredOnce = new Set<string>()
59
+ const requestOf = (args: Record<string, unknown> | undefined) => {
60
+ const request = rpcRequest(method, route, args)
61
+ if (
62
+ !answeredOnce.has(request.key) &&
63
+ navigator.serviceWorker?.controller
64
+ )
65
+ request.init.cache = 'force-cache'
66
+ return request
67
+ }
68
+ // 16.81 — WHAT STANDS IN FOR A `GET`'s ANSWER, per args, as the last call reaching them gave it:
69
+ // a reload no call starts — a refresh, the network coming back — has no `offline` of its own.
70
+ const standIns = new Map<string, (args: unknown) => unknown>()
46
71
  // ONE PARAMETER, so the value is keyed by args whatever the handler declared (16.6's arity rule):
47
72
  // an unkeyed handler is reached with no args, which is one key.
48
73
  const body = (args: Record<string, unknown> | undefined) => {
49
- // 16.49 — the load's own signal, set by the call starting it and read before anything awaits.
74
+ // 16.49, 16.81 — the load's own signal and stand-in, set by the call starting it and read
75
+ // before anything awaits.
50
76
  const signal = LOAD_SIGNAL.current
77
+ const request = requestOf(args)
78
+ let offline = LOAD_OFFLINE.current
79
+ // A reload no call started — a refresh, or the one `whenOnline` asks — that gets no answer
80
+ // waits before asking again, where asking at once would get none on every tick.
81
+ const retryIn = offline === undefined ? UNANSWERED_RETRY_MS : 0
82
+ if (method === 'GET') {
83
+ if (offline === undefined) offline = standIns.get(request.key)
84
+ else standIns.set(request.key, offline)
85
+ }
86
+ // A STAND-IN IS FOR A COPY HOLDING NOTHING: a reload that gets no answer over one this page
87
+ // already holds keeps serving it (7.4, 7.19) rather than trading it for a default.
88
+ const first = !answeredOnce.has(request.key)
89
+ // 16.82, 46.10 — a mutation that may be queued carries the key it is sent under every time.
90
+ const queued =
91
+ offline !== undefined && method !== 'GET'
92
+ ? queuedCall(request, address, args)
93
+ : undefined
94
+ let standing: { value: unknown } | undefined
95
+ if (queued !== undefined)
96
+ LOAD_OFFLINE.standing = standing = { value: undefined }
97
+ const stoodIn = (failed: Answer): unknown => {
98
+ const value = (offline as (args: unknown) => unknown)(args)
99
+ if (queued === undefined) {
100
+ whenOnline(args, retryIn)
101
+ return standIn(value)
102
+ }
103
+ return enqueue(queued, standing as { value: unknown }, value).then(
104
+ (kept) => (kept ? standIn(value) : answered(failed)),
105
+ )
106
+ }
107
+ // 16.83 — BEHIND CALLS NOT YET DELIVERED A CALL WAITS ITS TURN, rather than overtaking them.
108
+ if (queued !== undefined && undelivered() !== 0)
109
+ return Promise.resolve(stoodIn(noAnswer()))
51
110
  const settle = (answer: Answer): unknown => {
111
+ if (answer.ok) answeredOnce.add(request.key)
112
+ if (
113
+ offline !== undefined &&
114
+ unanswered(answer) &&
115
+ (first || queued !== undefined)
116
+ )
117
+ return stoodIn(answer)
52
118
  follow(args, answer)
119
+ // 16.85 — A COPY THE WORKER KEPT IS NOT THE SERVER'S ANSWER: held as written here, and
120
+ // asked of the network once there is one.
121
+ if (answer.kept) {
122
+ whenOnline(args, 0)
123
+ return standIn(answered(answer))
124
+ }
53
125
  return answered(answer)
54
126
  }
55
- const answer = answerOf(rpcRequest(method, route, args), false, signal)
127
+ const answer = answerOf(request, false, signal)
56
128
  return answer instanceof Promise ? answer.then(settle) : settle(answer)
57
129
  }
130
+ // 16.81, 16.85 — A COPY HOLDING A STAND-IN ASKS THE NETWORK AGAIN once there is one: now, past the
131
+ // landing (or `delay` past it), where the browser says it is online, and when it says so otherwise.
132
+ const whenOnline = (args: unknown, delay: number): void => {
133
+ if (method !== 'GET') return
134
+ const again = (): void =>
135
+ (value as (args: unknown) => { refresh(): void })(args).refresh()
136
+ if (navigator.onLine) setTimeout(again, delay)
137
+ else addEventListener('online', again, { once: true })
138
+ }
58
139
  // 16.32 — A STREAMING HANDLER'S COPY IS A STREAM OF ITS OWN, whose chunks land as the answer's
59
140
  // lines arrive: collected into one value, a page reading it showed nothing until the handler's
60
141
  // last chunk. A refusal is thrown, which is how a stream refuses (9.6).
@@ -64,11 +145,9 @@ export function wire(
64
145
  // 16.49 — read on the first line: the core's pump takes the first step inside the reach, so
65
146
  // this runs while the call that started the load still has its signal set.
66
147
  const signal = LOAD_SIGNAL.current
67
- const answer = await answerOf(
68
- rpcRequest(method, route, args),
69
- true,
70
- signal,
71
- )
148
+ const request = requestOf(args)
149
+ const answer = await answerOf(request, true, signal)
150
+ if (answer.ok) answeredOnce.add(request.key)
72
151
  follow(args, answer)
73
152
  if (answer.body !== undefined && answer.body !== null) {
74
153
  yield* answerChunks(answer.body, answer.framing)
@@ -112,6 +191,28 @@ export function wire(
112
191
  return rpc
113
192
  }
114
193
 
194
+ /** How long a reload that got no answer waits to ask again where the browser says it is online. */
195
+ const UNANSWERED_RETRY_MS = 5_000
196
+
197
+ /** A mutation's call as the outbox keeps it, under the key it is sent with every time (46.10). */
198
+ function queuedCall(
199
+ request: RpcRequest,
200
+ address: string,
201
+ args: Record<string, unknown> | undefined,
202
+ ): Parameters<typeof enqueue>[0] {
203
+ const id = crypto.randomUUID()
204
+ const headers = new Headers(request.init.headers)
205
+ headers.set('idempotency-key', id)
206
+ request.init.headers = headers
207
+ return {
208
+ id,
209
+ method: request.method,
210
+ address,
211
+ url: request.url,
212
+ args: args as Record<string, JsonValue> | undefined,
213
+ }
214
+ }
215
+
115
216
  /**
116
217
  * The build's copy options as a value takes them, `structural` resolved to its function.
117
218
  *