@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
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
(page ? await event.preloadResponse : undefined) ??
|
|
102
|
-
(await fetch(event.request))
|
|
133
|
+
return await fromNetwork(event, page, page)
|
|
103
134
|
} catch {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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(
|
|
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)}
|
package/src/server/createApp.ts
CHANGED
|
@@ -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
|
|
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() ??
|
|
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(
|
|
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
|
|
85
|
+
return `${file}:${line}:${column}`
|
|
82
86
|
}
|
|
83
87
|
|
|
84
88
|
function registeredFor(path: string): Registered | undefined {
|
package/src/server/render.ts
CHANGED
|
@@ -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
|
-
|
|
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')}'`
|
package/src/server/seedBuffer.ts
CHANGED
|
@@ -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
|
|
120
|
+
const firstReport = reportsOnce()
|
|
95
121
|
return (rpc, args, reached) => {
|
|
96
|
-
const key =
|
|
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,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
|
+
}
|
package/src/shared/answerOf.ts
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
package/src/shared/index.ts
CHANGED
|
@@ -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()
|