@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.
- package/CHANGELOG.md +18 -0
- package/docs/REGISTRY.md +38 -16
- package/docs/RULEBOOK.md +173 -12
- package/docs/abide.md +266 -24
- package/package.json +4 -2
- package/src/compiler/REACTIVE_MEMBERS.ts +1 -0
- package/src/compiler/classifyTemplate.ts +21 -14
- package/src/compiler/emitClient.ts +5 -1
- package/src/server/BRIDGE_VERSION.ts +1 -1
- package/src/server/IMAGE_TYPES.ts +10 -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 +173 -28
- package/src/server/assistant.ts +81 -12
- package/src/server/claudeApi.ts +25 -20
- package/src/server/claudeCode.ts +30 -10
- package/src/server/createApp.ts +8 -2
- package/src/server/generatedSurfaces.ts +48 -14
- package/src/server/index.ts +2 -0
- 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/REACHED_MESSAGE.ts +7 -0
- package/src/shared/answerOf.ts +14 -2
- package/src/shared/framing.ts +10 -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/Router.ts +28 -7
- package/src/ui/bindFiles.ts +42 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/outboxQueue.ts +427 -0
- package/src/ui/socketWire.ts +31 -2
- package/src/ui/wire.ts +111 -10
package/src/shared/rpcRequest.ts
CHANGED
|
@@ -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 `
|
|
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
|
|
54
|
-
// because it went stale
|
|
55
|
-
// would answer it with the value it was dropping
|
|
56
|
-
// `
|
|
57
|
-
// the
|
|
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
|
|
64
|
+
? { method, cache }
|
|
61
65
|
: {
|
|
62
66
|
method,
|
|
63
|
-
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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()
|
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
|
*
|