@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.
@@ -6,6 +6,9 @@
6
6
  // as bytecode (38.93), where a function has no source to read back.
7
7
 
8
8
  import { CLIENT_ADDRESS } from '#shared/ADDRESSES.ts'
9
+ import { KEEP_HEADER } from '#shared/KEEP_HEADER.ts'
10
+ import { KEEP_OPENED } from '#shared/KEEP_OPENED.ts'
11
+ import { KEPT_HEADER } from '#shared/KEPT_HEADER.ts'
9
12
  import { MOUNT, mounted } from '#shared/mount.ts'
10
13
  import { NAVIGATION_HEADER } from '#shared/NAVIGATION_HEADER.ts'
11
14
  import {
@@ -35,7 +38,7 @@ async function dropOurs(keeping) {
35
38
  }
36
39
  `
37
40
 
38
- // 45.3 through 45.8, and 45.13.
41
+ // 45.3 through 45.8, 45.13, and 45.17 through 45.21.
39
42
  const SAVING = `
40
43
  self.addEventListener('install', (event) => {
41
44
  event.waitUntil(
@@ -53,10 +56,11 @@ self.addEventListener('activate', (event) => {
53
56
  const cache = await caches.open(BUNDLE)
54
57
  for (const request of await cache.keys())
55
58
  if (!FILES.has(request.url)) await cache.delete(request)
56
- await self.registration.navigationPreload?.enable()
59
+ // A preload would ask the network for every navigation, a kept copy answered or not.
60
+ await self.registration.navigationPreload?.disable()
57
61
  await self.clients.claim()
58
62
  for (const client of await self.clients.matchAll({ type: 'window' }))
59
- await fetch(client.url).then(
63
+ await fetch(pageRequest(client.url, undefined, '1')).then(
60
64
  (response) =>
61
65
  kept(response)
62
66
  ? keep(new Request(client.url), response)
@@ -94,32 +98,123 @@ self.addEventListener('fetch', (event) => {
94
98
  event.respondWith(answer(event, false))
95
99
  })
96
100
 
101
+ // 45.17 through 45.20 — THE COPY KEPT, BY ITS OWN cache-control: fresh is answered and the network
102
+ // not asked, stale within stale-while-revalidate is answered while the network is asked for the next
103
+ // one — save by a request asking force-cache, a call whose wire asks again itself once it reads the
104
+ // copy (RULEBOOK 16.80, 16.85), so asking here too would ask twice — and stale within stale-if-error
105
+ // stands in where the network gives nothing. A request asking past every cache (a refresh, a load
106
+ // after invalidate) is the network's alone; a navigation never is, since a reload offline would
107
+ // otherwise be the page saying nothing was saved.
97
108
  async function answer(event, page) {
98
- let response
109
+ const request = event.request
110
+ const bypass =
111
+ !page &&
112
+ (request.cache === 'no-cache' ||
113
+ request.cache === 'reload' ||
114
+ request.cache === 'no-store')
115
+ const saved = bypass
116
+ ? undefined
117
+ : await caches.match(request, { cacheName: SAVED, ignoreVary: true })
118
+ const life = saved === undefined ? undefined : lifeOf(saved)
119
+ if (life !== undefined) {
120
+ if (life.age < life.fresh) return marked(saved)
121
+ if (life.age < life.fresh + life.revalidate) {
122
+ if (request.cache !== 'force-cache')
123
+ event.waitUntil(
124
+ fromNetwork(event, page, false).then(
125
+ (response) => response.body?.cancel(),
126
+ () => {},
127
+ ),
128
+ )
129
+ return marked(saved)
130
+ }
131
+ }
99
132
  try {
100
- response =
101
- (page ? await event.preloadResponse : undefined) ??
102
- (await fetch(event.request))
133
+ return await fromNetwork(event, page, page)
103
134
  } catch {
104
- const saved = await caches.match(event.request, {
105
- cacheName: SAVED,
106
- ignoreVary: true,
107
- })
108
- return saved ?? (page ? offline() : Response.error())
135
+ if (life !== undefined && life.age < life.fresh + life.error)
136
+ return marked(saved)
137
+ return page ? offline() : Response.error()
109
138
  }
139
+ }
140
+
141
+ // 45.22 — A COPY KEPT SAYS SO, and when the server answered it, so a page tells it from the network's.
142
+ function marked(saved) {
143
+ const headers = new Headers(saved.headers)
144
+ headers.set(KEPT, saved.headers.get('date') ?? '')
145
+ return new Response(saved.body, {
146
+ status: saved.status,
147
+ statusText: saved.statusText,
148
+ headers,
149
+ })
150
+ }
151
+
152
+ // A page as the worker asks for it, whether a navigation or the worker keeping an open window's:
153
+ // past the browser's cache, as a page kept, and with its redirects left to the page — one followed
154
+ // would be saved under the address it was asked at, holding where it led (RULEBOOK 45.4, 45.21,
155
+ // 45.23).
156
+ function pageRequest(url, headers, keep) {
157
+ const asking = new Headers(headers)
158
+ asking.set(KEEP, keep)
159
+ return new Request(url, { headers: asking, redirect: 'manual', cache: 'no-cache' })
160
+ }
161
+
162
+ // 45.21 — PAST THE BROWSER'S OWN CACHE, which holds the same answers under the same headers and would
163
+ // hand a revalidation the copy it is replacing. A navigation's request takes no init of its own, so it
164
+ // is asked again by address, its redirects left to the page as a navigation's are — and 45.23, as a
165
+ // page kept, which a script's fetch is not otherwise told from (RULEBOOK 41.29, 41.30), and as the
166
+ // page the navigation opens where opened says it is, which then hydrates from what it seeded.
167
+ async function fromNetwork(event, page, opened) {
168
+ const request = event.request
169
+ const asked = page
170
+ ? pageRequest(request.url, request.headers, opened ? OPENED : '1')
171
+ : new Request(request, { cache: 'no-cache' })
172
+ const response = await fetch(asked)
110
173
  // A copy nothing reads is a tee holding every chunk, so one is cloned only to be saved.
111
174
  const copy = kept(response) ? response.clone() : undefined
112
175
  const rotated = response.headers.has(PRINCIPAL)
113
176
  event.waitUntil(
114
177
  (rotated ? caches.delete(SAVED) : Promise.resolve()).then(() =>
115
- copy === undefined ? undefined : keep(event.request, copy),
178
+ copy === undefined ? undefined : keep(request, copy),
116
179
  ),
117
180
  )
118
181
  return response
119
182
  }
120
183
 
184
+ // How old a kept copy is and how long each of its windows runs, in seconds; a copy with no date is
185
+ // as old as it can be.
186
+ function lifeOf(response) {
187
+ const directives = directivesOf(response)
188
+ const date = Date.parse(response.headers.get('date') ?? '')
189
+ // no-cache is never fresh; it and must-revalidate are never served stale.
190
+ const unfresh = directives.has('no-cache')
191
+ const unstale = unfresh || directives.has('must-revalidate')
192
+ const seconds = (name, closed) => {
193
+ const value = Number(directives.get(name))
194
+ return closed || !Number.isFinite(value) ? 0 : value
195
+ }
196
+ return {
197
+ age: Number.isNaN(date) ? Infinity : (Date.now() - date) / 1000,
198
+ fresh: seconds('max-age', unfresh),
199
+ revalidate: seconds('stale-while-revalidate', unstale),
200
+ error: seconds('stale-if-error', unstale),
201
+ }
202
+ }
203
+
204
+ // An answer's cache-control, by directive.
205
+ function directivesOf(response) {
206
+ const directives = new Map()
207
+ for (const part of (response.headers.get('cache-control') ?? '').split(',')) {
208
+ const [name, value] = part.trim().toLowerCase().split('=')
209
+ directives.set(name, value)
210
+ }
211
+ return directives
212
+ }
213
+
214
+ // 45.3, 45.4 — a 200 whose cache-control lets a private cache store it, and no stream.
121
215
  function kept(response) {
122
216
  if (response.status !== 200) return false
217
+ if (directivesOf(response).has('no-store')) return false
123
218
  const type = response.headers.get('content-type') ?? ''
124
219
  return !STREAMED.some((streamed) => type.startsWith(streamed))
125
220
  }
@@ -178,6 +273,9 @@ export function answerWorker(manifest: Manifest | undefined): Response {
178
273
  for (const name of bundleFiles(manifest))
179
274
  paths.push(mounted(`${CLIENT_ADDRESS}/${name}`))
180
275
  script = `${CACHES}const PRINCIPAL = ${JSON.stringify(PRINCIPAL_HEADER)}
276
+ const KEPT = ${JSON.stringify(KEPT_HEADER)}
277
+ const KEEP = ${JSON.stringify(KEEP_HEADER)}
278
+ const OPENED = ${JSON.stringify(KEEP_OPENED)}
181
279
  const NAVIGATION = ${JSON.stringify(NAVIGATION_HEADER)}
182
280
  // 45.4 — the types a stream 16.32 frames, which has no end to save.
183
281
  const STREAMED = ${JSON.stringify(STREAMED_TYPES)}
@@ -12,6 +12,7 @@ import { LONGEST_DELAY } from '#shared/LONGEST_DELAY.ts'
12
12
  import { namedLogger } from '#shared/log.ts'
13
13
  import { compose } from '#shared/Middleware.ts'
14
14
  import { MOUNT, unmounted } from '#shared/mount.ts'
15
+ import { PRINCIPAL_HEADER } from '#shared/PRINCIPAL_HEADER.ts'
15
16
  import { RPC_SERVER } from '#shared/RPC_SERVER.ts'
16
17
  import { servedTable } from './abideModules.ts'
17
18
  import { type AppSources, answerRequest } from './answerRequest.ts'
@@ -329,7 +330,7 @@ export async function bootEntered(
329
330
  reportUnexpected(thrown)
330
331
  answered = plainAnswer('internal server error', 500)
331
332
  }
332
- const stamped = beating(stamp(answered))
333
+ const stamped = beating(stamp(answered, booted.offline))
333
334
  // 28.7 — a closed namespace does not pay for the line it would have written.
334
335
  if (REQUEST_LOG.enabled())
335
336
  REQUEST_LOG.debug(
@@ -494,8 +495,11 @@ export async function bootEntered(
494
495
  *
495
496
  * 22.9 — AND WHAT WAS WRITTEN THROUGH `response`, laid over every answer here and nowhere else: a
496
497
  * lane builds its answer at the written status and leaves the headers to this.
498
+ *
499
+ * 21.24 — and where the worker keeps answers (`offline`), a sign-in or a sign-out has the browser's
500
+ * own cache drop what it kept for the caller before, as the worker drops its copies (45.7).
497
501
  */
498
- function stamp(answered: Response): Response {
502
+ function stamp(answered: Response, offline: boolean): Response {
499
503
  let stamped = answered
500
504
  try {
501
505
  stamped.headers.set('x-content-type-options', 'nosniff')
@@ -507,6 +511,8 @@ function stamp(answered: Response): Response {
507
511
  const timing = serverTiming()
508
512
  if (timing !== undefined) stamped.headers.set('server-timing', timing)
509
513
  const written = applyWritten(stamped.headers)
514
+ if (offline && stamped.headers.has(PRINCIPAL_HEADER))
515
+ stamped.headers.set('clear-site-data', '"cache"')
510
516
  const status = written !== 200 ? written : stamped.status
511
517
  // 17.8 — a failure nobody named a reason for is given its registered one.
512
518
  const statusText =
@@ -62,7 +62,7 @@ function placeFrames(error: Error, frames: CallSite[]): string {
62
62
  return out
63
63
  }
64
64
 
65
- // A call site's column counts from 0 where the stack bun writes counts from 1.
65
+ // A call site's line and column count from 1; a map's from 0.
66
66
  function placed(frame: CallSite): string {
67
67
  // A handler file's frames name the file the author wrote, not the face it is served as.
68
68
  const file = (frame.getFileName() ?? '<anonymous>').replace(
@@ -70,15 +70,19 @@ function placed(frame: CallSite): string {
70
70
  '',
71
71
  )
72
72
  const line = frame.getLineNumber() ?? 0
73
- const column = frame.getColumnNumber() ?? 0
73
+ const column = frame.getColumnNumber() ?? 1
74
74
  const registered = registeredFor(file)
75
75
  if (registered !== undefined && line > 0) {
76
76
  registered.lines ??= decodeMappings(registered.mappings)
77
- const place = placeAt(registered.lines, line - 1, column)
77
+ const place = placeAt(
78
+ registered.lines,
79
+ line - 1,
80
+ Math.max(column - 1, 0),
81
+ )
78
82
  if (place !== undefined)
79
83
  return `${registered.source}:${place[0] + 1}:${place[1] + 1}`
80
84
  }
81
- return `${file}:${line}:${column + 1}`
85
+ return `${file}:${line}:${column}`
82
86
  }
83
87
 
84
88
  function registeredFor(path: string): Registered | undefined {
@@ -379,8 +379,9 @@ function earlyCallScript(calls: PageCall[]): string {
379
379
  // THE RUNNER RECORDS, IT NEVER CLASSIFIES: the `Response`, and a painted call's body read to its end
380
380
  // — the one decision is `painted`, which the server made. `answerOf` reads the record as it reads an
381
381
  // answer it fetched, so no second reading of a content type lives in a string no typecheck reaches.
382
- // The shape is `EarlyCall`'s.
383
- export const EARLY_CALL_RUNNER = `(function(s,c){for(var i=0;i<c.length;i++)(function(k,u,n,h){var e=s[k]={painted:h,response:void 0,bytes:void 0};e.promise=fetch(u,n).then(function(r){return h?r.arrayBuffer().then(function(b){e.bytes=b;e.response=r}):e.response=r}).catch(function(){})})(c[i][0],c[i][1],c[i][2],c[i][3])})(self.${EARLY_CALLS}||(self.${EARLY_CALLS}={}),JSON.parse(document.currentScript.previousElementSibling.textContent))`
382
+ // The shape is `EarlyCall`'s. 16.80 — a page the worker answers has it answer the page's calls too,
383
+ // which the browser alone can tell: the server cannot see whether a worker will replay this page.
384
+ export const EARLY_CALL_RUNNER = `(function(s,c,w){for(var i=0;i<c.length;i++)(function(k,u,n,h){if(w)n.cache='force-cache';var e=s[k]={painted:h,response:void 0,bytes:void 0};e.promise=fetch(u,n).then(function(r){return h?r.arrayBuffer().then(function(b){e.bytes=b;e.response=r}):e.response=r}).catch(function(){})})(c[i][0],c[i][1],c[i][2],c[i][3])})(self.${EARLY_CALLS}||(self.${EARLY_CALLS}={}),JSON.parse(document.currentScript.previousElementSibling.textContent),(self.navigator.serviceWorker||{}).controller)`
384
385
 
385
386
  /** The early-call runner's hash, as `script-src` names it. 29.9. */
386
387
  export const EARLY_CALL_HASH = `'sha256-${new Bun.CryptoHasher('sha256').update(EARLY_CALL_RUNNER).digest('base64')}'`
@@ -83,6 +83,32 @@ type Entry = {
83
83
  // which is how a refresh of it is told from a call the render never made (41.12).
84
84
  const BUFFER = new Map<string, Map<object, Map<string, Entry[]>>>()
85
85
 
86
+ /** A render's one report of each call (1.8): the call's key where it is new, `undefined` where not. */
87
+ function reportsOnce(): (rpc: object, args: unknown) => string | undefined {
88
+ const reported = new Map<object, Set<string>>()
89
+ return (rpc, args) => {
90
+ const key = seedKey(args)
91
+ if (key === undefined) return undefined
92
+ let keys = reported.get(rpc)
93
+ if (keys === undefined) {
94
+ keys = new Set()
95
+ reported.set(rpc, keys)
96
+ }
97
+ if (keys.has(key)) return undefined
98
+ keys.add(key)
99
+ return key
100
+ }
101
+ }
102
+
103
+ /**
104
+ * 41.30 — the seed sink of a page the worker keeps: each call reported once, for the early-call
105
+ * script, and none of them left in the seed buffer (41.29) — the page is opened later, if at all.
106
+ */
107
+ export function callsOnly(): PageRender['seed'] {
108
+ const firstReport = reportsOnce()
109
+ return (rpc, args) => firstReport(rpc, args) !== undefined
110
+ }
111
+
86
112
  /**
87
113
  * The sink a page's render scope reports each call into, for one caller.
88
114
  *
@@ -91,17 +117,10 @@ const BUFFER = new Map<string, Map<object, Map<string, Entry[]>>>()
91
117
  * browser's `Rpc` joins a standing call (1.8).
92
118
  */
93
119
  export function seedFor(caller: string): PageRender['seed'] {
94
- const reported = new Map<object, Set<string>>()
120
+ const firstReport = reportsOnce()
95
121
  return (rpc, args, reached) => {
96
- const key = seedKey(args)
122
+ const key = firstReport(rpc, args)
97
123
  if (key === undefined) return false
98
- let keys = reported.get(rpc)
99
- if (keys === undefined) {
100
- keys = new Set()
101
- reported.set(rpc, keys)
102
- }
103
- if (keys.has(key)) return false
104
- keys.add(key)
105
124
  const entry: Entry = {
106
125
  rpc,
107
126
  key,
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The header the offline worker asks for a page it keeps under, so the page carries the early calls
3
+ * a later open of it starts (41.30, 45.23).
4
+ *
5
+ * A leaf, because the server writes it into the worker's script and reads it off the request.
6
+ */
7
+ export const KEEP_HEADER = 'abide-keep'
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The value of the offline worker's `abide-keep` header on the page a navigation opens, which then
3
+ * hydrates and so is seeded as one (41.29, 45.23) — a page saved behind the one opened is not.
4
+ *
5
+ * A leaf, because the server writes it into the worker's script and reads it off the request.
6
+ */
7
+ export const KEEP_OPENED = 'open'
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The header the offline worker lays on an answer it gave from a copy it kept, naming when (45.22).
3
+ *
4
+ * A leaf, because the server writes it into the worker's script and the wire reads it (16.85).
5
+ */
6
+ export const KEPT_HEADER = 'abide-kept'
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The `offline` of the call that is starting a load, while it starts.
3
+ *
4
+ * 16.81 — SET AROUND THE REACH as `LOAD_SIGNAL` is, and read by the browser's wire as its body
5
+ * starts: what stands in for an answer the call could not get. A leaf, because `callLoad` sets it
6
+ * and the wire reads it.
7
+ */
8
+ export const LOAD_OFFLINE: {
9
+ current: ((args: unknown) => unknown) | undefined
10
+ /**
11
+ * Where the body that queues the call asks for the value the call reached, which `callLoad`
12
+ * fills once the reach returns: what the delivered answer lands in (46.7).
13
+ */
14
+ standing: { value: unknown } | undefined
15
+ } = { current: undefined, standing: undefined }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Whether the outbox has a call in flight, as a bare `busy` asks it (46.11).
3
+ *
4
+ * A leaf, because `outbox` fills it and the core's `busy` reads it from below `outbox`. Unfilled — an
5
+ * app holding no handler — no call is ever in flight.
6
+ */
7
+ export const OUTBOX_SENDING: { any: (() => boolean) | undefined } = {
8
+ any: undefined,
9
+ }
@@ -11,6 +11,7 @@ import {
11
11
  framedChunks,
12
12
  framingOf,
13
13
  } from './framing.ts'
14
+ import { KEPT_HEADER } from './KEPT_HEADER.ts'
14
15
  import { principalRotated } from './principalRotated.ts'
15
16
  import { refuse } from './refuse.ts'
16
17
  import type { RpcRequest } from './rpcRequest.ts'
@@ -35,6 +36,8 @@ export type Answer = {
35
36
  version: number | undefined
36
37
  /** The `max-age` its `cache-control` named, in ms (18.14, 21.8). */
37
38
  life: number | undefined
39
+ /** 16.85 — whether the worker answered from a copy it kept rather than the network (45.22). */
40
+ kept: boolean
38
41
  text: string | undefined
39
42
  /**
40
43
  * A binary answer's body read to the end (16.34), set in place of `text` — a body read as text
@@ -92,7 +95,7 @@ async function fetched(
92
95
  if (signal?.aborted === true) throw error
93
96
  // 16.79 — NO ANSWER AT ALL, as a status the app can narrow on rather than the browser's
94
97
  // own `TypeError`, whose message differs in each.
95
- return unanswered()
98
+ return noAnswer()
96
99
  }
97
100
  // THE HANDLER SIGNED THIS CALLER IN OR OUT, and the cookie is already the browser's.
98
101
  principalRotated(response)
@@ -100,7 +103,7 @@ async function fetched(
100
103
  }
101
104
 
102
105
  /** The answer a call that reached no server stands in for: an empty 503, which `answered` refuses. */
103
- function unanswered(): Answer {
106
+ export function noAnswer(): Answer {
104
107
  return {
105
108
  status: 503,
106
109
  statusText: '',
@@ -109,12 +112,20 @@ function unanswered(): Answer {
109
112
  followable: false,
110
113
  version: undefined,
111
114
  life: undefined,
115
+ kept: false,
112
116
  text: '',
113
117
  bytes: undefined,
114
118
  body: undefined,
115
119
  }
116
120
  }
117
121
 
122
+ /** 46.4 — no answer at all (16.79), or a gateway's: try again later. */
123
+ export function unanswered(answer: Answer): boolean {
124
+ return (
125
+ answer.status === 502 || answer.status === 503 || answer.status === 504
126
+ )
127
+ }
128
+
118
129
  /**
119
130
  * `response`, classified: a body `read` to its end already (an early call the server painted)
120
131
  * decoded as what it carried, and one unread read as bytes where it is a binary (16.34), as text
@@ -139,6 +150,7 @@ function answerFrom(
139
150
  followable: ok && !binary,
140
151
  version: versionInEtag(headers.get(VERSION_HEADER)),
141
152
  life: seconds === null ? undefined : Number(seconds[1]) * 1000,
153
+ kept: headers.has(KEPT_HEADER),
142
154
  text: undefined,
143
155
  bytes: undefined,
144
156
  body: undefined,
@@ -55,6 +55,7 @@ export {
55
55
  // modules into the static closure of the entry every page imports.
56
56
  export { navigate } from './navigate.ts'
57
57
  export { online } from './online.ts'
58
+ export { outbox, type QueuedCall } from './outbox.ts'
58
59
  export { onMenu } from './onMenu.ts'
59
60
  export { type Principal, principal } from './principal.ts'
60
61
  // EMITTED CODE'S: a prop the child writes (33.6, 33.7).
@@ -93,6 +94,7 @@ export {
93
94
  type Shared,
94
95
  type Store,
95
96
  structural,
97
+ synced,
96
98
  type Transformer,
97
99
  watch,
98
100
  } from './reactive/index.ts'
@@ -0,0 +1,116 @@
1
+ // 46 — THE CALLS A BROWSER HAS NOT DELIVERED YET, asked of a selection as a probe is: `outbox()` for
2
+ // every one, `outbox(logCall)` and `logCall.outbox(pattern)` for one handler's. What it answers is a
3
+ // plain list, so a page counts, filters and walks it as it would any other, and each call carries
4
+ // what can be done with it.
5
+ //
6
+ // THE FACE, NOT THE QUEUE: the queue is the browser's (`#ui/outboxQueue.ts`), which the wire loads and
7
+ // which shows its line here through `showLine` and `showSending`. On a server nothing shows one, and
8
+ // `outbox` answers no calls (46.15).
9
+
10
+ import type { Failed } from './Failed.ts'
11
+ import type { JsonValue } from './JsonValue.ts'
12
+ import { ambient, type Reactive, type Selection } from './reactive/index.ts'
13
+ import { matchesPattern } from './reactive/matchesPattern.ts'
14
+ import { OUTBOX_SENDING } from './OUTBOX_SENDING.ts'
15
+ import type { Method } from './rpc.ts'
16
+
17
+ /** One call `outbox` holds: what it sends, when it was queued, how often it was sent, and its value. */
18
+ export type QueuedCall<Value = unknown> = {
19
+ /** The call's `idempotency-key`, the same on every send (46.10). */
20
+ id: string
21
+ method: Method
22
+ url: string
23
+ args: Record<string, JsonValue> | undefined
24
+ queuedAt: number
25
+ attempts: number
26
+ /**
27
+ * 46.18 — the value the call settled: its stand-in until the server answers, and its refusal as
28
+ * `error()` where the server refused it.
29
+ */
30
+ value: Reactive<Value, Value, Failed<string, unknown>>
31
+ /** 46.12 — sends it again, at once. */
32
+ retry(): void
33
+ /** 46.13 — drops it, sent or not. */
34
+ discard(): void
35
+ }
36
+
37
+ /** A call as the queue shows it: the handler it is for, which a selection is matched against. */
38
+ export type ShownCall = QueuedCall & { address: string }
39
+
40
+ /** What a handler's calls settle to, read off its own `outbox` member; `unknown` for any other. */
41
+ type ValueOf<Chosen> = Chosen extends {
42
+ outbox(pattern?: never): QueuedCall<infer Value>[]
43
+ }
44
+ ? Value
45
+ : unknown
46
+
47
+ /** A handler as a selection names it: its method and the address it is mounted at. */
48
+ type Handler = { method: Method; address: string | undefined }
49
+
50
+ /** A call as a selection is matched against it: its handler, and the args a pattern narrows. */
51
+ type Matched = Pick<ShownCall, 'method' | 'address' | 'args'>
52
+
53
+ const LINE = ambient<ShownCall[]>([])
54
+ /** The call in flight, or `undefined` where none is. */
55
+ const SENDING = ambient<Matched | undefined>(undefined)
56
+
57
+ // 46.11 — a bare `busy` hears a call in flight, which no value holds.
58
+ OUTBOX_SENDING.any = () => SENDING() !== undefined
59
+
60
+ /** 46.1 — shows the queue's line, oldest first. */
61
+ export function showLine(calls: ShownCall[]): void {
62
+ LINE.set(calls)
63
+ }
64
+
65
+ /** 46.11 — shows the call being sent, or that none is. */
66
+ export function showSending(call: Matched | undefined): void {
67
+ SENDING.set(call)
68
+ }
69
+
70
+ /**
71
+ * The calls waiting to be delivered and the ones refused, oldest first: every one, or the ones a
72
+ * handler names, narrowed by `pattern` as its own members narrow (46.1, 46.16).
73
+ */
74
+ export function outbox<Chosen extends Selection>(
75
+ selection?: Chosen,
76
+ pattern?: Record<string, unknown>,
77
+ ): QueuedCall<ValueOf<Chosen>>[] {
78
+ const line = LINE() as QueuedCall<ValueOf<Chosen>>[]
79
+ if (selection === undefined) return line
80
+ const handler = handlerOf(selection)
81
+ const named: QueuedCall<ValueOf<Chosen>>[] = []
82
+ if (handler === undefined) return named
83
+ for (const call of line)
84
+ if (forHandler(call as ShownCall, handler, pattern)) named.push(call)
85
+ return named
86
+ }
87
+
88
+ /** 46.11 — whether a call `outbox` holds for `handler`, matching `pattern`, is in flight. */
89
+ export function sendingFor(
90
+ handler: Handler,
91
+ pattern: Record<string, unknown> | undefined,
92
+ ): boolean {
93
+ const call = SENDING()
94
+ return call !== undefined && forHandler(call, handler, pattern)
95
+ }
96
+
97
+ function forHandler(
98
+ call: Matched,
99
+ handler: Handler,
100
+ pattern: Record<string, unknown> | undefined,
101
+ ): boolean {
102
+ return (
103
+ call.method === handler.method &&
104
+ call.address === handler.address &&
105
+ matchesPattern(call.args, pattern)
106
+ )
107
+ }
108
+
109
+ /** The handler a selection names, or `undefined` where it names none — a value, a key, or tags. */
110
+ function handlerOf(selection: object): Handler | undefined {
111
+ const handler = selection as Partial<Handler>
112
+ return typeof handler.method === 'string' &&
113
+ typeof handler.address === 'string'
114
+ ? (handler as Handler)
115
+ : undefined
116
+ }
@@ -66,6 +66,7 @@ export type Keyed<
66
66
  ) => Reactive<Stored, Accepted, Failures, Produced>) & {
67
67
  busy(pattern?: Partial<Args>): boolean
68
68
  ready(pattern?: Partial<Args>): boolean
69
+ synced(pattern?: Partial<Args>): boolean
69
70
  refresh(pattern?: Partial<Args>): void
70
71
  invalidate(pattern?: Partial<Args>): void
71
72
  /** The entries reached so far. Not app surface — the statics and the tests read it. */
@@ -462,6 +463,14 @@ export function createKeyed<
462
463
  return false
463
464
  return true
464
465
  },
466
+ // 13.18 — ALL, as `ready` asks: one entry written here is one the server has not seen.
467
+ synced: (pattern?: Partial<Args>): boolean => {
468
+ askMembership()
469
+ for (const entry of entries.values())
470
+ if (matchesPattern(argsOf(entry), pattern) && !entry.synced())
471
+ return false
472
+ return true
473
+ },
465
474
  refresh: (pattern?: Partial<Args>): void => {
466
475
  for (const entry of entries.values())
467
476
  if (matchesPattern(argsOf(entry), pattern)) entry.refresh()