@abide/abide 0.114.1 → 0.116.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/docs/REGISTRY.md +38 -16
  3. package/docs/RULEBOOK.md +173 -12
  4. package/docs/abide.md +266 -24
  5. package/package.json +4 -2
  6. package/src/compiler/REACTIVE_MEMBERS.ts +1 -0
  7. package/src/compiler/classifyTemplate.ts +21 -14
  8. package/src/compiler/emitClient.ts +5 -1
  9. package/src/server/BRIDGE_VERSION.ts +1 -1
  10. package/src/server/IMAGE_TYPES.ts +10 -0
  11. package/src/server/OFFLINE_STALE.ts +10 -0
  12. package/src/server/answerRequest.ts +39 -10
  13. package/src/server/answerRpc.ts +25 -5
  14. package/src/server/answerWorker.ts +173 -28
  15. package/src/server/assistant.ts +81 -12
  16. package/src/server/claudeApi.ts +25 -20
  17. package/src/server/claudeCode.ts +30 -10
  18. package/src/server/createApp.ts +8 -2
  19. package/src/server/generatedSurfaces.ts +48 -14
  20. package/src/server/index.ts +2 -0
  21. package/src/server/render.ts +3 -2
  22. package/src/server/seedBuffer.ts +28 -9
  23. package/src/shared/KEEP_HEADER.ts +7 -0
  24. package/src/shared/KEEP_OPENED.ts +7 -0
  25. package/src/shared/KEPT_HEADER.ts +6 -0
  26. package/src/shared/LOAD_OFFLINE.ts +15 -0
  27. package/src/shared/OUTBOX_SENDING.ts +9 -0
  28. package/src/shared/REACHED_MESSAGE.ts +7 -0
  29. package/src/shared/answerOf.ts +14 -2
  30. package/src/shared/framing.ts +10 -2
  31. package/src/shared/index.ts +2 -0
  32. package/src/shared/outbox.ts +116 -0
  33. package/src/shared/reactive/Keyed.ts +9 -0
  34. package/src/shared/reactive/Reactive.ts +197 -3
  35. package/src/shared/reactive/SELECTION_MEMBERS.ts +1 -0
  36. package/src/shared/reactive/callLoad.ts +8 -0
  37. package/src/shared/reactive/index.ts +23 -1
  38. package/src/shared/rpc.ts +25 -5
  39. package/src/shared/rpcRequest.ts +12 -8
  40. package/src/ui/Router.ts +28 -7
  41. package/src/ui/bindFiles.ts +42 -0
  42. package/src/ui/index.ts +1 -0
  43. package/src/ui/outboxQueue.ts +427 -0
  44. package/src/ui/socketWire.ts +31 -2
  45. package/src/ui/wire.ts +111 -10
@@ -17,7 +17,7 @@ export type RpcRequest = {
17
17
  url: string
18
18
  /** The JSON body of a mutation, or `undefined` where the args ride in the URL. */
19
19
  body: string | undefined
20
- /** What `fetch` is handed: the method, the JSON content-type beside a body, and `no-cache`. */
20
+ /** What `fetch` is handed: the method, the JSON content-type beside a body, and its `cache`. */
21
21
  init: RequestInit
22
22
  key: string
23
23
  }
@@ -40,6 +40,7 @@ export function rpcRequest(
40
40
  method: Method,
41
41
  route: HandlerRoute | string,
42
42
  given: Record<string, unknown> | undefined,
43
+ cache: RequestCache = 'no-cache',
43
44
  ): RpcRequest {
44
45
  const { path, rest } = (
45
46
  typeof route === 'string' ? new HandlerRoute(route) : route
@@ -50,17 +51,20 @@ export function rpcRequest(
50
51
  const text = rest === undefined ? '' : queryText(rest)
51
52
  if (text !== '') url = `${url}?${text}`
52
53
  } else if (rest !== undefined) body = JSON.stringify(rest)
53
- // 18.14 — A COPY IS ITS OWN CACHE and keeps its own `max-age`. What it asks the server is asked
54
- // because it went stale or was invalidated, and the browser's HTTP cache, which hears neither,
55
- // would answer it with the value it was dropping. `rpc.raw` is handed the same, under whatever
56
- // `init` its caller passes. `no-cache` rather than `no-store`: a `fetch` the app writes itself to
57
- // the same address still reads 21.8's `max-age`.
54
+ // 18.14 — A COPY IS ITS OWN CACHE and keeps its own `max-age`. What it asks the server once it
55
+ // holds an answer is asked because it went stale, was invalidated or refreshed, and a cache
56
+ // below it, which hears none of the three, would answer it with the value it was dropping — so
57
+ // `no-cache`. A page's first load in a page the worker answers is the browser's to switch to
58
+ // `force-cache` (16.80), where a kept copy is what opens the page without the network (45.18);
59
+ // the outbox's send asks `no-store`. `rpc.raw` is handed `no-cache`, under whatever `init` its
60
+ // caller passes. `no-cache` rather than `no-store`: a `fetch` the app writes itself to the same
61
+ // address still reads 21.8's `max-age`.
58
62
  const init: RequestInit =
59
63
  body === undefined
60
- ? { method, cache: 'no-cache' }
64
+ ? { method, cache }
61
65
  : {
62
66
  method,
63
- cache: 'no-cache',
67
+ cache,
64
68
  headers: { 'content-type': 'application/json' },
65
69
  body,
66
70
  }
package/src/ui/Router.ts CHANGED
@@ -5,6 +5,7 @@ import { firstMatch, type Params, partsOf } from '#shared/matchRoute.ts'
5
5
  import { unmounted } from '#shared/mount.ts'
6
6
  import { NAVIGATION_HEADER } from '#shared/NAVIGATION_HEADER.ts'
7
7
  import { OFFLINE_SAVED } from '#shared/OFFLINE_CACHES.ts'
8
+ import { REACHED_MESSAGE } from '#shared/REACHED_MESSAGE.ts'
8
9
  import type { ShareScope } from '#shared/reactive/share.ts'
9
10
  import { arrive, route } from '#shared/route.ts'
10
11
  import { rankRoutes, type Segment } from '#shared/routePattern.ts'
@@ -400,14 +401,18 @@ export class Router {
400
401
  */
401
402
  private writeAddress(target: URL, replace: boolean, left: Scroll): void {
402
403
  const full = `${target.pathname}${target.search}${target.hash}`
403
- if (replace) {
404
- history.replaceState(entryAt(this.index, null), '', full)
405
- return
404
+ // A step to a fragment, or to the address standing, reaches no page the worker lacks.
405
+ const moved =
406
+ target.pathname !== location.pathname ||
407
+ target.search !== location.search
408
+ if (replace) history.replaceState(entryAt(this.index, null), '', full)
409
+ else {
410
+ this.remember(left)
411
+ this.index += 1
412
+ this.scrolls.length = this.index
413
+ history.pushState(entryAt(this.index, null), '', full)
406
414
  }
407
- this.remember(left)
408
- this.index += 1
409
- this.scrolls.length = this.index
410
- history.pushState(entryAt(this.index, null), '', full)
415
+ if (moved) reached(target)
411
416
  }
412
417
 
413
418
  /**
@@ -720,6 +725,22 @@ async function savedOffline(target: URL): Promise<boolean> {
720
725
  return saved !== undefined
721
726
  }
722
727
 
728
+ /**
729
+ * Tells the service worker an address a navigation reached, for it to keep (45.24).
730
+ *
731
+ * POSTED AS THE ADDRESS IS WRITTEN, rather than heard from 30.15's ask: a step within the standing
732
+ * route asks nothing, the ask's redirect lands elsewhere than it asked, and a superseded one writes
733
+ * no address.
734
+ */
735
+ function reached(at: URL): void {
736
+ const controller = navigator.serviceWorker?.controller
737
+ if (controller == null) return
738
+ // A page is kept by its address, which a fragment is no part of.
739
+ const address = new URL(at)
740
+ address.hash = ''
741
+ controller.postMessage({ [REACHED_MESSAGE]: address.href })
742
+ }
743
+
723
744
  /**
724
745
  * Asks the server's `middleware` about an address; `undefined` where the fetch failed.
725
746
  *
@@ -0,0 +1,42 @@
1
+ import { boundValue, writeBound } from '#shared/boundValue.ts'
2
+ import type { Scope } from './Scope.ts'
3
+
4
+ /**
5
+ * Keeps a file input's files in step with a `bind:files` value, both ways.
6
+ *
7
+ * 32.73 — A FILE INPUT'S FILES ARE A LIST, with or without `multiple`, and no markup: the server has
8
+ * none to write. A `FileList` cannot be built, so a list written in is handed over through a
9
+ * `DataTransfer`, and an empty one clears the input through `value`, the one write it takes.
10
+ */
11
+ export function bindFiles(
12
+ element: Element,
13
+ operand: unknown,
14
+ scope: Scope,
15
+ ): void {
16
+ const bound = element as HTMLInputElement
17
+ scope.watch(() => {
18
+ const files = (boundValue(operand) ?? []) as readonly File[]
19
+ if (holdsFiles(bound.files, files)) return
20
+ if (files.length === 0) {
21
+ bound.value = ''
22
+ return
23
+ }
24
+ const transfer = new DataTransfer()
25
+ for (const file of files) transfer.items.add(file)
26
+ bound.files = transfer.files
27
+ })
28
+ bound.addEventListener('change', () => {
29
+ const picked: File[] = []
30
+ for (const file of bound.files ?? []) picked.push(file)
31
+ writeBound(operand, picked)
32
+ })
33
+ }
34
+
35
+ /** Whether an input already holds `files`, the same files in the same order. */
36
+ function holdsFiles(held: FileList | null, files: readonly File[]): boolean {
37
+ const count = held?.length ?? 0
38
+ if (count !== files.length) return false
39
+ for (let index = 0; index < count; index++)
40
+ if ((held as FileList)[index] !== files[index]) return false
41
+ return true
42
+ }
package/src/ui/index.ts CHANGED
@@ -40,6 +40,7 @@ export {
40
40
  textHere,
41
41
  } from './adoptWalk.ts'
42
42
  export { bindElement } from './bindElement.ts'
43
+ export { bindFiles } from './bindFiles.ts'
43
44
  export { bindGroup } from './bindGroup.ts'
44
45
  export { bindProperty } from './bindProperty.ts'
45
46
  export { bindSpread } from './bindSpread.ts'
@@ -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
  *