@abide/abide 0.114.0 → 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.
- package/CHANGELOG.md +12 -0
- package/docs/REGISTRY.md +24 -5
- package/docs/RULEBOOK.md +137 -11
- package/docs/abide.md +198 -8
- package/package.json +5 -3
- package/src/compiler/REACTIVE_MEMBERS.ts +1 -0
- package/src/server/OFFLINE_STALE.ts +10 -0
- package/src/server/answerRequest.ts +39 -10
- package/src/server/answerRpc.ts +25 -5
- package/src/server/answerWorker.ts +111 -13
- package/src/server/createApp.ts +8 -2
- package/src/server/installSourceMaps.ts +8 -4
- package/src/server/render.ts +3 -2
- package/src/server/seedBuffer.ts +28 -9
- package/src/shared/KEEP_HEADER.ts +7 -0
- package/src/shared/KEEP_OPENED.ts +7 -0
- package/src/shared/KEPT_HEADER.ts +6 -0
- package/src/shared/LOAD_OFFLINE.ts +15 -0
- package/src/shared/OUTBOX_SENDING.ts +9 -0
- package/src/shared/answerOf.ts +14 -2
- package/src/shared/index.ts +2 -0
- package/src/shared/outbox.ts +116 -0
- package/src/shared/reactive/Keyed.ts +9 -0
- package/src/shared/reactive/Reactive.ts +197 -3
- package/src/shared/reactive/SELECTION_MEMBERS.ts +1 -0
- package/src/shared/reactive/callLoad.ts +8 -0
- package/src/shared/reactive/index.ts +23 -1
- package/src/shared/rpc.ts +25 -5
- package/src/shared/rpcRequest.ts +12 -8
- package/src/ui/outboxQueue.ts +427 -0
- package/src/ui/socketWire.ts +31 -2
- package/src/ui/wire.ts +111 -10
|
@@ -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()
|
package/src/ui/socketWire.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|
|
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
|
|
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(
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
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
|
*
|