@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
|
@@ -84,10 +84,10 @@ export type ClassifiedAttribute = {
|
|
|
84
84
|
// 32.11 — a spread's attributes, less the ones the element names itself, kept written by a
|
|
85
85
|
// binding of its own.
|
|
86
86
|
| { kind: 'spread'; explicit: string[] }
|
|
87
|
-
// 32.5, 32.7, 32.8 — the property and the value, kept in step both ways. `reflects` is
|
|
88
|
-
// attribute the property's value stands at in the tag, and `undefined` where it is not an
|
|
87
|
+
// 32.5, 32.7, 32.8, 32.73 — the property and the value, kept in step both ways. `reflects` is
|
|
88
|
+
// the attribute the property's value stands at in the tag, and `undefined` where it is not an
|
|
89
89
|
// attribute: a `<textarea>`'s value is its text and a `<select>`'s is its options' `selected`,
|
|
90
|
-
// which the markup writes inside the tag (32.61).
|
|
90
|
+
// which the markup writes inside the tag (32.61), and a file input's files are no markup at all.
|
|
91
91
|
| {
|
|
92
92
|
kind: 'bound'
|
|
93
93
|
bind: ValueBind
|
|
@@ -135,7 +135,7 @@ export function classifyAttributes(
|
|
|
135
135
|
readsAnItem(holder: object): boolean
|
|
136
136
|
},
|
|
137
137
|
): ClassifiedAttribute[] {
|
|
138
|
-
|
|
138
|
+
refuseBinds(node)
|
|
139
139
|
const groups = mergeGroupsOf(node)
|
|
140
140
|
const sharing: ReturnType<typeof sharingOf>[] = []
|
|
141
141
|
for (const group of groups) sharing.push(sharingOf(group, lowering))
|
|
@@ -210,7 +210,8 @@ export function classifyAttributes(
|
|
|
210
210
|
kind: 'bound',
|
|
211
211
|
bind,
|
|
212
212
|
reflects:
|
|
213
|
-
bind === '
|
|
213
|
+
bind === 'files' ||
|
|
214
|
+
(bind === 'value' && node.lowerTag !== 'input')
|
|
214
215
|
? undefined
|
|
215
216
|
: bind === 'group'
|
|
216
217
|
? 'checked'
|
|
@@ -354,15 +355,22 @@ function heldClasses(groups: MergeGroup[]): string[] {
|
|
|
354
355
|
}
|
|
355
356
|
|
|
356
357
|
/**
|
|
358
|
+
* 32.74 — A `bind:` AN ELEMENT HAS NO PROPERTY FOR IS REFUSED: `bind:vlaue` would otherwise be an
|
|
359
|
+
* attribute of that name, binding nothing and saying nothing.
|
|
360
|
+
*
|
|
357
361
|
* 32.72 — WHAT A `bind:` WRITES IS THE BIND'S ALONE: `checked` beside `bind:checked` or
|
|
358
362
|
* `bind:group`, `value` beside `bind:value`, any `bind:name` beside `name` — and an
|
|
359
363
|
* `<option selected>` inside a `<select bind:value>`, and a `<textarea bind:value>`'s text. Two
|
|
360
364
|
* owners of one value render whichever the browser keeps and move with whichever wrote last.
|
|
361
365
|
*/
|
|
362
|
-
function
|
|
366
|
+
function refuseBinds(node: ElementNode): void {
|
|
363
367
|
for (const attribute of node.attributes) {
|
|
364
368
|
const lower = attribute.lowerName
|
|
365
369
|
if (!lower.startsWith('bind:') || lower === 'bind:element') continue
|
|
370
|
+
if (valueBindOf(lower) === undefined)
|
|
371
|
+
throw new Error(
|
|
372
|
+
`abide: <${node.tag}> has ${attribute.name}, which binds nothing — an element binds ${VALUE_BINDS.join(', ')} or element. RULEBOOK 32.74.`,
|
|
373
|
+
)
|
|
366
374
|
const owned =
|
|
367
375
|
lower === 'bind:group' ? 'checked' : lower.slice('bind:'.length)
|
|
368
376
|
const beside = attributeOf(node, owned)
|
|
@@ -468,18 +476,17 @@ function isElementBind(lowerName: string): boolean {
|
|
|
468
476
|
/**
|
|
469
477
|
* The binds that keep a property and a value in step.
|
|
470
478
|
*
|
|
471
|
-
* 32.5, 32.7, 32.8 — both emitters ask: the server writes the value into the tag
|
|
472
|
-
* binds the property.
|
|
479
|
+
* 32.5, 32.7, 32.8, 32.73 — both emitters ask: the server writes the value into the tag, where it
|
|
480
|
+
* has one, and the client binds the property.
|
|
473
481
|
*/
|
|
474
|
-
|
|
482
|
+
const VALUE_BINDS = ['value', 'checked', 'open', 'group', 'files'] as const
|
|
483
|
+
type ValueBind = (typeof VALUE_BINDS)[number]
|
|
475
484
|
|
|
476
485
|
/** The value bind a folded attribute name is, or `undefined` for any other attribute. */
|
|
477
486
|
function valueBindOf(lowerName: string): ValueBind | undefined {
|
|
478
|
-
if (lowerName
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
if (lowerName === 'bind:group') return 'group'
|
|
482
|
-
return undefined
|
|
487
|
+
if (!lowerName.startsWith('bind:')) return undefined
|
|
488
|
+
const bind = lowerName.slice('bind:'.length) as ValueBind
|
|
489
|
+
return VALUE_BINDS.includes(bind) ? bind : undefined
|
|
483
490
|
}
|
|
484
491
|
|
|
485
492
|
/**
|
|
@@ -769,7 +769,7 @@ function elementUnits(
|
|
|
769
769
|
handlers.push(
|
|
770
770
|
`${element}.addEventListener(${JSON.stringify(one.event)}, ${lowering.listener(one.attribute)})`,
|
|
771
771
|
)
|
|
772
|
-
// 32.5, 32.7, 32.8 — THE PROPERTY AND THE VALUE, IN STEP BOTH WAYS: a watch on this
|
|
772
|
+
// 32.5, 32.7, 32.8, 32.73 — THE PROPERTY AND THE VALUE, IN STEP BOTH WAYS: a watch on this
|
|
773
773
|
// region's scope writes the property, and a listener writes it back.
|
|
774
774
|
// 32.8 — a group compares against the input's own value, read where the value is.
|
|
775
775
|
else if (one.kind === 'bound' && one.bind === 'group')
|
|
@@ -782,6 +782,10 @@ function elementUnits(
|
|
|
782
782
|
: JSON.stringify(one.own.value)
|
|
783
783
|
}), ${SCOPE})`,
|
|
784
784
|
)
|
|
785
|
+
else if (one.kind === 'bound' && one.bind === 'files')
|
|
786
|
+
binds.push(
|
|
787
|
+
`${lowering.use('bindFiles')}(${element}, ${lowering.reference(one.attribute)}, ${SCOPE})`,
|
|
788
|
+
)
|
|
785
789
|
else if (one.kind === 'bound')
|
|
786
790
|
binds.push(
|
|
787
791
|
`${lowering.use('bindProperty')}(${element}, ${JSON.stringify(one.bind)}, ${lowering.reference(one.attribute)}, ${SCOPE})`,
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// The `abide-bridge` release a pairing command names, so a visitor's `bunx` runs the relay this
|
|
2
2
|
// server speaks. `packages/abide/tests/claudeCode.test.ts` holds it to the bridge package's own.
|
|
3
|
-
export const BRIDGE_VERSION = '0.3.
|
|
3
|
+
export const BRIDGE_VERSION = '0.3.4'
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The image types a model is shown (44.36). A leaf, because `assistant` refuses a transcript image of
|
|
3
|
+
* any other and `/__abide/mcp` answers one of any other as its text (20.21).
|
|
4
|
+
*/
|
|
5
|
+
export const IMAGE_TYPES = new Set([
|
|
6
|
+
'image/png',
|
|
7
|
+
'image/jpeg',
|
|
8
|
+
'image/gif',
|
|
9
|
+
'image/webp',
|
|
10
|
+
])
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// 21.22, 21.23 — HOW LONG A COPY KEPT ON THE DEVICE STAYS SERVABLE once stale, where `ABIDE_OFFLINE`
|
|
2
|
+
// registers its worker: a week, while the network answers or fails to — as long as Safari keeps an
|
|
3
|
+
// unvisited site's storage.
|
|
4
|
+
//
|
|
5
|
+
// NO `max-age` BESIDE IT on a page or a `GET`: a copy reads `max-age` as its own life (18.14), and
|
|
6
|
+
// a followed copy stale from the moment it landed would reload on every read. With no lifetime and
|
|
7
|
+
// no `Last-Modified`, a cache holds the answer stale at once, which is all the two windows need.
|
|
8
|
+
|
|
9
|
+
export const OFFLINE_STALE =
|
|
10
|
+
'stale-while-revalidate=604800, stale-if-error=604800'
|
|
@@ -26,6 +26,8 @@ import { ERROR_META } from '#shared/ERROR_META.ts'
|
|
|
26
26
|
import { escapeAttribute } from '#shared/escapeText.ts'
|
|
27
27
|
import { type Failed, isFailed } from '#shared/Failed.ts'
|
|
28
28
|
import { HandlerRoute } from '#shared/HandlerRoute.ts'
|
|
29
|
+
import { KEEP_HEADER } from '#shared/KEEP_HEADER.ts'
|
|
30
|
+
import { KEEP_OPENED } from '#shared/KEEP_OPENED.ts'
|
|
29
31
|
import { health } from '#shared/health.ts'
|
|
30
32
|
import { appName } from '#shared/log.ts'
|
|
31
33
|
import { compose } from '#shared/Middleware.ts'
|
|
@@ -59,6 +61,7 @@ import { callerOrMint } from './callerOf.ts'
|
|
|
59
61
|
import { settingOn } from './config.ts'
|
|
60
62
|
import { compressAnswer } from './compressAnswer.ts'
|
|
61
63
|
import { DOCUMENT_HEADERS } from './DOCUMENT_HEADERS.ts'
|
|
64
|
+
import { OFFLINE_STALE } from './OFFLINE_STALE.ts'
|
|
62
65
|
import { noteCallInRender } from './deferredBuilds.ts'
|
|
63
66
|
import { answerMcp, openApi } from './generatedSurfaces.ts'
|
|
64
67
|
import { type AppRung, failureOf, rungsOver } from './hooks.ts'
|
|
@@ -84,7 +87,7 @@ import {
|
|
|
84
87
|
within,
|
|
85
88
|
writtenStatus,
|
|
86
89
|
} from './requestScope.ts'
|
|
87
|
-
import { seedFor } from './seedBuffer.ts'
|
|
90
|
+
import { callsOnly, seedFor } from './seedBuffer.ts'
|
|
88
91
|
|
|
89
92
|
// One row of the server's table the compiler writes (`SERVER_TABLE`). The loader is a dynamic import
|
|
90
93
|
// so a page's markup is off the boot path until an address asks for it, which is the same reason
|
|
@@ -159,6 +162,8 @@ export type AppSources = {
|
|
|
159
162
|
errors: RankedError[]
|
|
160
163
|
/** The document the error pages are painted into. */
|
|
161
164
|
errorDocument: string
|
|
165
|
+
/** 21.22, 21.23 — whether a page registers the worker `ABIDE_OFFLINE` turns on (45.1). */
|
|
166
|
+
offline: boolean
|
|
162
167
|
/** Every handler, keyed by its method and address (`handlerKey`), to what answers it. 16.2. */
|
|
163
168
|
handlers: Map<string, Mounted>
|
|
164
169
|
/**
|
|
@@ -186,11 +191,11 @@ export async function answerRequest(
|
|
|
186
191
|
const method = request.method === 'HEAD' ? 'GET' : request.method
|
|
187
192
|
|
|
188
193
|
// 25.1, 25.3, 26.17 — the principal and the account of the app are handlers abide mounts,
|
|
189
|
-
// answered as the app's are.
|
|
194
|
+
// answered as the app's are — 21.4, save that neither is the app's to keep on the device.
|
|
190
195
|
const own = OWN_HANDLERS.get(asked)
|
|
191
196
|
if (own !== undefined)
|
|
192
197
|
return own.method === method
|
|
193
|
-
? answerRpc(own, {}, url, request, app)
|
|
198
|
+
? answerRpc(own, {}, url, request, app, false)
|
|
194
199
|
: methodRefused(app, asked, [own.method])
|
|
195
200
|
// 20.5, 20.9, 36.7 — a surface of abide's own, or a 404 where it answers nothing.
|
|
196
201
|
const surface = SURFACES.get(asked)
|
|
@@ -229,7 +234,7 @@ export async function answerRequest(
|
|
|
229
234
|
const file = app.published.get(decodedOrKept(asked))
|
|
230
235
|
if (file !== undefined)
|
|
231
236
|
return readsOnly(request)
|
|
232
|
-
? publicResponse(request, file)
|
|
237
|
+
? publicResponse(request, file, app.offline)
|
|
233
238
|
: methodRefused(app, asked, READS)
|
|
234
239
|
|
|
235
240
|
// 15.16 — an address that will not percent-decode names nothing, and is the caller's fault.
|
|
@@ -312,7 +317,8 @@ async function answerMatched(
|
|
|
312
317
|
const url = new URL(request.url)
|
|
313
318
|
const asked = url.pathname
|
|
314
319
|
const params = (currentRequestScope() as RequestScope).params as Params
|
|
315
|
-
if ('handler' in found)
|
|
320
|
+
if ('handler' in found)
|
|
321
|
+
return answerRpc(found, params, url, request, app, app.offline)
|
|
316
322
|
const page = found
|
|
317
323
|
|
|
318
324
|
// 30.16 — THE CLIENT ROUTER ASKING, not a reader loading the page. `middleware` has already run
|
|
@@ -333,6 +339,7 @@ async function answerMatched(
|
|
|
333
339
|
// that stored the identity answer for a reader who sent no `accept-encoding` must not hand it
|
|
334
340
|
// to one who did, nor the reverse.
|
|
335
341
|
const headers = new Headers(DOCUMENT_HEADERS)
|
|
342
|
+
if (app.offline) headers.set('cache-control', `private, ${OFFLINE_STALE}`)
|
|
336
343
|
// THE HEADERS A `GET` WOULD CARRY, and no render for a body nobody is sent — nor a caller
|
|
337
344
|
// minted for a request that never becomes a page.
|
|
338
345
|
if (request.method === 'HEAD')
|
|
@@ -350,9 +357,21 @@ async function answerMatched(
|
|
|
350
357
|
// 11.32 — the page renders in a scope of its own, so what its values hold is this request's —
|
|
351
358
|
// and 41.13, every call it makes is left for the browser's own call to be answered from.
|
|
352
359
|
// 41.29 — BUT NOT A PAGE A SCRIPT FETCHED, which nothing hydrates: its entries would answer the
|
|
353
|
-
// next call this caller makes with what the render saw, a sign-in since included
|
|
354
|
-
|
|
355
|
-
|
|
360
|
+
// next call this caller makes with what the render saw, a sign-in since included — save the one
|
|
361
|
+
// the worker fetched for the navigation it answers, which is the page that hydrates (45.23).
|
|
362
|
+
const keep = request.headers.get(KEEP_HEADER)
|
|
363
|
+
const hydrated =
|
|
364
|
+
keep === KEEP_OPENED ||
|
|
365
|
+
request.headers.get('sec-fetch-dest') !== 'empty'
|
|
366
|
+
// 41.30 — SAVE ONE THE WORKER KEEPS, whose early calls are what a later open of it starts.
|
|
367
|
+
beginRender({
|
|
368
|
+
seed: hydrated
|
|
369
|
+
? seedFor(caller)
|
|
370
|
+
: keep !== null
|
|
371
|
+
? callsOnly()
|
|
372
|
+
: SEEDS_NOTHING,
|
|
373
|
+
calls: [],
|
|
374
|
+
})
|
|
356
375
|
// 41.1, 35.2 — THE DOCUMENT IS THE BUILT PAGE, read whole before there is a `Response` to commit a
|
|
357
376
|
// status to: a refusal the page read, or a throw, is answered by the nearest error page at its own
|
|
358
377
|
// status rather than as a 200 the stream then abandons.
|
|
@@ -561,6 +580,7 @@ export async function appSources(parts: SourceParts): Promise<AppSources> {
|
|
|
561
580
|
},
|
|
562
581
|
published,
|
|
563
582
|
explained: parts.explained,
|
|
583
|
+
offline: client.offline,
|
|
564
584
|
...mounted,
|
|
565
585
|
}
|
|
566
586
|
for (const row of sources.routes) {
|
|
@@ -738,14 +758,23 @@ function laneFailure(
|
|
|
738
758
|
* authored, and the content-type off the extension, which is what `Bun.file` already derives
|
|
739
759
|
* rather than a table kept here.
|
|
740
760
|
*/
|
|
741
|
-
function publicResponse(
|
|
761
|
+
function publicResponse(
|
|
762
|
+
request: Request,
|
|
763
|
+
file: PublicFile,
|
|
764
|
+
offline: boolean,
|
|
765
|
+
): Response {
|
|
742
766
|
return answerBlobTagged(
|
|
743
767
|
Bun.file(file.path, { type: file.type }),
|
|
744
768
|
file.etag,
|
|
745
769
|
request,
|
|
746
770
|
// 21.12. Revalidated every time and cached in between, which is what an unhashed name
|
|
747
771
|
// earns — the etag is what makes that one conditional request rather than one transfer.
|
|
748
|
-
|
|
772
|
+
// 21.23 — and served stale where the device keeps it, while that request is made.
|
|
773
|
+
new Headers({
|
|
774
|
+
'cache-control': offline
|
|
775
|
+
? `public, max-age=0, ${OFFLINE_STALE}`
|
|
776
|
+
: 'public, max-age=0, must-revalidate',
|
|
777
|
+
}),
|
|
749
778
|
200,
|
|
750
779
|
)
|
|
751
780
|
}
|
package/src/server/answerRpc.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
import { followAsEvents } from './answerSocket.ts'
|
|
30
30
|
import { answerValue, namesEtag, sseHeaders } from './answerValue.ts'
|
|
31
31
|
import { argsFor } from './argsFor.ts'
|
|
32
|
+
import { OFFLINE_STALE } from './OFFLINE_STALE.ts'
|
|
32
33
|
import { callerOf } from './callerOf.ts'
|
|
33
34
|
import {
|
|
34
35
|
argsDocumentOf,
|
|
@@ -233,6 +234,8 @@ export async function answerRpc(
|
|
|
233
234
|
request: Request,
|
|
234
235
|
// 16.38 — what paints a refusal for a request that came as a navigation.
|
|
235
236
|
pages: ErrorPages,
|
|
237
|
+
// 21.22 — whether the device keeps what is answered.
|
|
238
|
+
offline: boolean,
|
|
236
239
|
): Promise<Response> {
|
|
237
240
|
const pathname = url.pathname
|
|
238
241
|
const origin = request.headers.get('origin')
|
|
@@ -300,6 +303,7 @@ export async function answerRpc(
|
|
|
300
303
|
args,
|
|
301
304
|
key,
|
|
302
305
|
entry,
|
|
306
|
+
offline,
|
|
303
307
|
)
|
|
304
308
|
// A `Response` the handler built is its own — but the origin answer below is laid over it, so it
|
|
305
309
|
// varies on `Origin` too (21.7), which `handlerHeaders` never reached.
|
|
@@ -350,6 +354,7 @@ async function answerOutcome(
|
|
|
350
354
|
args: unknown,
|
|
351
355
|
key: string | undefined,
|
|
352
356
|
entry: unknown,
|
|
357
|
+
offline: boolean,
|
|
353
358
|
): Promise<Response> {
|
|
354
359
|
const handler = found.handler
|
|
355
360
|
if (!outcome.settled) return answered(failureOf(outcome.error))
|
|
@@ -380,13 +385,28 @@ async function answerOutcome(
|
|
|
380
385
|
headers.set(VERSION_HEADER, etag)
|
|
381
386
|
RPC_SERVER.served?.(handler, serving, key as string)
|
|
382
387
|
}
|
|
383
|
-
// 21.
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
|
|
388
|
+
// 21.22 — A `GET` THE DEVICE KEEPS, where the worker is there to keep it: a value, not a
|
|
389
|
+
// refusal (16.40) nor a stream with no end to keep (16.32) — a generator's, or the lines a
|
|
390
|
+
// `jsonl` or an `sse` fixed.
|
|
391
|
+
const framing = currentRequestScope()?.framing
|
|
392
|
+
const kept =
|
|
393
|
+
offline &&
|
|
394
|
+
found.method === 'GET' &&
|
|
395
|
+
!isFailed(value) &&
|
|
396
|
+
!streams(value) &&
|
|
397
|
+
framing !== 'jsonl' &&
|
|
398
|
+
framing !== 'sse' &&
|
|
399
|
+
writtenStatus() < 300
|
|
400
|
+
// 21.8, 21.9, 21.11 — WHAT IS LEFT OF THE LIFE OF THE PRODUCTION ANSWERED, in place of
|
|
401
|
+
// `no-store` or ahead of 21.22's directives, and nothing else. 21.17 — a `ttl` of Infinity is
|
|
402
|
+
// bounded by `invalidate` alone (11.35), which no cache downstream hears, so it keeps the default.
|
|
403
|
+
const life = Number.isFinite(deadline)
|
|
404
|
+
? `, max-age=${Math.max(0, Math.floor((deadline - Date.now()) / 1000))}`
|
|
405
|
+
: ''
|
|
406
|
+
if (life !== '' || kept)
|
|
387
407
|
headers.set(
|
|
388
408
|
'cache-control',
|
|
389
|
-
`private
|
|
409
|
+
`private${life}${kept ? `, ${OFFLINE_STALE}` : ''}`,
|
|
390
410
|
)
|
|
391
411
|
// 18.19 — A READER HOLDING THIS VERSION IS TOLD SO, and sent no body.
|
|
392
412
|
if (
|
|
@@ -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 {
|
|
@@ -14,6 +17,7 @@ import {
|
|
|
14
17
|
OFFLINE_SAVED,
|
|
15
18
|
} from '#shared/OFFLINE_CACHES.ts'
|
|
16
19
|
import { PRINCIPAL_HEADER } from '#shared/PRINCIPAL_HEADER.ts'
|
|
20
|
+
import { REACHED_MESSAGE } from '#shared/REACHED_MESSAGE.ts'
|
|
17
21
|
import type { Manifest } from './manifestOf.ts'
|
|
18
22
|
|
|
19
23
|
// What `framingOf` reads as a framed stream (16.32), and a binary's bytes (16.34), which may be one —
|
|
@@ -35,7 +39,7 @@ async function dropOurs(keeping) {
|
|
|
35
39
|
}
|
|
36
40
|
`
|
|
37
41
|
|
|
38
|
-
// 45.3 through 45.8, and 45.
|
|
42
|
+
// 45.3 through 45.8, 45.13, 45.17 through 45.21, and 45.24.
|
|
39
43
|
const SAVING = `
|
|
40
44
|
self.addEventListener('install', (event) => {
|
|
41
45
|
event.waitUntil(
|
|
@@ -53,29 +57,27 @@ self.addEventListener('activate', (event) => {
|
|
|
53
57
|
const cache = await caches.open(BUNDLE)
|
|
54
58
|
for (const request of await cache.keys())
|
|
55
59
|
if (!FILES.has(request.url)) await cache.delete(request)
|
|
56
|
-
|
|
60
|
+
// A preload would ask the network for every navigation, a kept copy answered or not.
|
|
61
|
+
await self.registration.navigationPreload?.disable()
|
|
57
62
|
await self.clients.claim()
|
|
58
63
|
for (const client of await self.clients.matchAll({ type: 'window' }))
|
|
59
|
-
await
|
|
60
|
-
(response) =>
|
|
61
|
-
kept(response)
|
|
62
|
-
? keep(new Request(client.url), response)
|
|
63
|
-
: response.body?.cancel(),
|
|
64
|
-
() => {},
|
|
65
|
-
)
|
|
64
|
+
await keepPage(client.url)
|
|
66
65
|
})(),
|
|
67
66
|
)
|
|
68
67
|
})
|
|
69
68
|
|
|
70
69
|
self.addEventListener('message', (event) => {
|
|
71
|
-
if (event.data === 'rotated') event.waitUntil(
|
|
70
|
+
if (event.data === 'rotated') event.waitUntil(dropSaved())
|
|
71
|
+
const reached = event.data?.[REACHED]
|
|
72
|
+
if (typeof reached === 'string' && reached.startsWith(\`\${self.location.origin}/\`))
|
|
73
|
+
event.waitUntil(keepReached(reached))
|
|
72
74
|
})
|
|
73
75
|
|
|
74
76
|
self.addEventListener('fetch', (event) => {
|
|
75
77
|
const request = event.request
|
|
76
78
|
if (request.mode === 'navigate') {
|
|
77
79
|
if (request.method === 'GET') event.respondWith(answer(event, true))
|
|
78
|
-
else event.waitUntil(
|
|
80
|
+
else event.waitUntil(dropSaved())
|
|
79
81
|
return
|
|
80
82
|
}
|
|
81
83
|
if (request.method !== 'GET') return
|
|
@@ -94,39 +96,178 @@ self.addEventListener('fetch', (event) => {
|
|
|
94
96
|
event.respondWith(answer(event, false))
|
|
95
97
|
})
|
|
96
98
|
|
|
99
|
+
// 45.17 through 45.20 — THE COPY KEPT, BY ITS OWN cache-control: fresh is answered and the network
|
|
100
|
+
// not asked, stale within stale-while-revalidate is answered while the network is asked for the next
|
|
101
|
+
// one — save by a request asking force-cache, a call whose wire asks again itself once it reads the
|
|
102
|
+
// copy (RULEBOOK 16.80, 16.85), so asking here too would ask twice — and stale within stale-if-error
|
|
103
|
+
// stands in where the network gives nothing. A request asking past every cache (a refresh, a load
|
|
104
|
+
// after invalidate) is the network's alone; a navigation never is, since a reload offline would
|
|
105
|
+
// otherwise be the page saying nothing was saved.
|
|
97
106
|
async function answer(event, page) {
|
|
98
|
-
|
|
107
|
+
const request = event.request
|
|
108
|
+
const bypass =
|
|
109
|
+
!page &&
|
|
110
|
+
(request.cache === 'no-cache' ||
|
|
111
|
+
request.cache === 'reload' ||
|
|
112
|
+
request.cache === 'no-store')
|
|
113
|
+
const saved = bypass
|
|
114
|
+
? undefined
|
|
115
|
+
: await caches.match(request, { cacheName: SAVED, ignoreVary: true })
|
|
116
|
+
const life = saved === undefined ? undefined : lifeOf(saved)
|
|
117
|
+
if (life !== undefined) {
|
|
118
|
+
if (life.age < life.fresh) return marked(saved)
|
|
119
|
+
if (life.age < life.fresh + life.revalidate) {
|
|
120
|
+
if (request.cache !== 'force-cache')
|
|
121
|
+
event.waitUntil(
|
|
122
|
+
fromNetwork(event, page, false).then(
|
|
123
|
+
(response) => response.body?.cancel(),
|
|
124
|
+
() => {},
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
return marked(saved)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
99
130
|
try {
|
|
100
|
-
|
|
101
|
-
(page ? await event.preloadResponse : undefined) ??
|
|
102
|
-
(await fetch(event.request))
|
|
131
|
+
return await fromNetwork(event, page, page)
|
|
103
132
|
} catch {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
ignoreVary: true,
|
|
107
|
-
})
|
|
108
|
-
return saved ?? (page ? offline() : Response.error())
|
|
133
|
+
if (life !== undefined && standsIn(life)) return marked(saved)
|
|
134
|
+
return page ? offline() : Response.error()
|
|
109
135
|
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 45.22 — A COPY KEPT SAYS SO, and when the server answered it, so a page tells it from the network's.
|
|
139
|
+
function marked(saved) {
|
|
140
|
+
const headers = new Headers(saved.headers)
|
|
141
|
+
headers.set(KEPT, saved.headers.get('date') ?? '')
|
|
142
|
+
return new Response(saved.body, {
|
|
143
|
+
status: saved.status,
|
|
144
|
+
statusText: saved.statusText,
|
|
145
|
+
headers,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// A page as the worker asks for it, whether a navigation or the worker keeping an open window's:
|
|
150
|
+
// past the browser's cache, as a page kept, and with its redirects left to the page — one followed
|
|
151
|
+
// would be saved under the address it was asked at, holding where it led (RULEBOOK 45.4, 45.21,
|
|
152
|
+
// 45.23).
|
|
153
|
+
function pageRequest(url, headers, keep) {
|
|
154
|
+
const asking = new Headers(headers)
|
|
155
|
+
asking.set(KEEP, keep)
|
|
156
|
+
return new Request(url, { headers: asking, redirect: 'manual', cache: 'no-cache' })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 45.21 — PAST THE BROWSER'S OWN CACHE, which holds the same answers under the same headers and would
|
|
160
|
+
// hand a revalidation the copy it is replacing. A navigation's request takes no init of its own, so it
|
|
161
|
+
// is asked again by address, its redirects left to the page as a navigation's are — and 45.23, as a
|
|
162
|
+
// page kept, which a script's fetch is not otherwise told from (RULEBOOK 41.29, 41.30), and as the
|
|
163
|
+
// page the navigation opens where opened says it is, which then hydrates from what it seeded.
|
|
164
|
+
async function fromNetwork(event, page, opened) {
|
|
165
|
+
const request = event.request
|
|
166
|
+
const asked = page
|
|
167
|
+
? pageRequest(request.url, request.headers, opened ? OPENED : '1')
|
|
168
|
+
: new Request(request, { cache: 'no-cache' })
|
|
169
|
+
const since = drops
|
|
170
|
+
const response = await fetch(asked)
|
|
110
171
|
// A copy nothing reads is a tee holding every chunk, so one is cloned only to be saved.
|
|
111
172
|
const copy = kept(response) ? response.clone() : undefined
|
|
112
|
-
|
|
113
|
-
event.waitUntil(
|
|
114
|
-
(rotated ? caches.delete(SAVED) : Promise.resolve()).then(() =>
|
|
115
|
-
copy === undefined ? undefined : keep(event.request, copy),
|
|
116
|
-
),
|
|
117
|
-
)
|
|
173
|
+
event.waitUntil(keep(request, copy, since, response.headers.has(PRINCIPAL)))
|
|
118
174
|
return response
|
|
119
175
|
}
|
|
120
176
|
|
|
177
|
+
// 45.13, 45.24 — A PAGE NO NAVIGATION ASKED FOR, kept as a page behind the one opened is: an open
|
|
178
|
+
// window's as the worker activates, and an address the router reached.
|
|
179
|
+
function keepPage(url) {
|
|
180
|
+
const since = drops
|
|
181
|
+
return fetch(pageRequest(url, undefined, '1')).then((response) => {
|
|
182
|
+
// Nothing reads this answer, so one 45.3 or 45.4 turns away is let go here.
|
|
183
|
+
const copy = kept(response) ? response : undefined
|
|
184
|
+
if (copy === undefined) response.body?.cancel()
|
|
185
|
+
return keep(new Request(url), copy, since, response.headers.has(PRINCIPAL))
|
|
186
|
+
}, () => {})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The addresses being kept, so one reached twice before its copy lands is asked for once.
|
|
190
|
+
const reaching = new Set()
|
|
191
|
+
|
|
192
|
+
// 45.24 — AN ADDRESS THE ROUTER REACHED, whose page no navigation asked for, so none is saved unless
|
|
193
|
+
// this asks — and only where no copy saved would stand in offline: one that would is refreshed by the
|
|
194
|
+
// page's own next load, and asking on every step renders each page twice. A page kept holds no seed
|
|
195
|
+
// (RULEBOOK 41.29), so what a reload paints stale is markup its calls then answer over.
|
|
196
|
+
async function keepReached(url) {
|
|
197
|
+
if (reaching.has(url)) return
|
|
198
|
+
reaching.add(url)
|
|
199
|
+
try {
|
|
200
|
+
const saved = await caches.match(url, { cacheName: SAVED, ignoreVary: true })
|
|
201
|
+
if (saved === undefined || !standsIn(lifeOf(saved))) await keepPage(url)
|
|
202
|
+
} finally {
|
|
203
|
+
reaching.delete(url)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 45.19 — whether a copy kept still stands in where the network gives nothing.
|
|
208
|
+
function standsIn(life) {
|
|
209
|
+
return life.age < life.fresh + life.error
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// How old a kept copy is and how long each of its windows runs, in seconds; a copy with no date is
|
|
213
|
+
// as old as it can be.
|
|
214
|
+
function lifeOf(response) {
|
|
215
|
+
const directives = directivesOf(response)
|
|
216
|
+
const date = Date.parse(response.headers.get('date') ?? '')
|
|
217
|
+
// no-cache is never fresh; it and must-revalidate are never served stale.
|
|
218
|
+
const unfresh = directives.has('no-cache')
|
|
219
|
+
const unstale = unfresh || directives.has('must-revalidate')
|
|
220
|
+
const seconds = (name, closed) => {
|
|
221
|
+
const value = Number(directives.get(name))
|
|
222
|
+
return closed || !Number.isFinite(value) ? 0 : value
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
age: Number.isNaN(date) ? Infinity : (Date.now() - date) / 1000,
|
|
226
|
+
fresh: seconds('max-age', unfresh),
|
|
227
|
+
revalidate: seconds('stale-while-revalidate', unstale),
|
|
228
|
+
error: seconds('stale-if-error', unstale),
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// An answer's cache-control, by directive.
|
|
233
|
+
function directivesOf(response) {
|
|
234
|
+
const directives = new Map()
|
|
235
|
+
for (const part of (response.headers.get('cache-control') ?? '').split(',')) {
|
|
236
|
+
const [name, value] = part.trim().toLowerCase().split('=')
|
|
237
|
+
directives.set(name, value)
|
|
238
|
+
}
|
|
239
|
+
return directives
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 45.3, 45.4 — a 200 whose cache-control lets a private cache store it, and no stream.
|
|
121
243
|
function kept(response) {
|
|
122
244
|
if (response.status !== 200) return false
|
|
245
|
+
if (directivesOf(response).has('no-store')) return false
|
|
123
246
|
const type = response.headers.get('content-type') ?? ''
|
|
124
247
|
return !STREAMED.some((streamed) => type.startsWith(streamed))
|
|
125
248
|
}
|
|
126
249
|
|
|
127
|
-
|
|
250
|
+
// 45.7 — EVERY PAGE SAVED DROPPED, and with them every copy asked for before the drop: one asked under
|
|
251
|
+
// the cookies of then would land after it holding the caller signed out since. The drop an answer's
|
|
252
|
+
// own abide-principal makes is the exception, since that answer is the new caller's.
|
|
253
|
+
let drops = 0
|
|
254
|
+
|
|
255
|
+
function dropSaved() {
|
|
256
|
+
drops += 1
|
|
257
|
+
return caches.delete(SAVED)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Saves a copy 45.3 and 45.4 let through where no drop has come since its request was asked — after
|
|
261
|
+
// the drop its own answer makes where that answer rotated the principal — and cancels one where one has.
|
|
262
|
+
async function keep(request, copy, since, rotated) {
|
|
263
|
+
if (rotated) {
|
|
264
|
+
await dropSaved()
|
|
265
|
+
since = drops
|
|
266
|
+
}
|
|
267
|
+
if (copy === undefined) return
|
|
268
|
+
if (since !== drops) return copy.body?.cancel()
|
|
128
269
|
const cache = await caches.open(SAVED)
|
|
129
|
-
await cache.put(request,
|
|
270
|
+
await cache.put(request, copy)
|
|
130
271
|
}
|
|
131
272
|
|
|
132
273
|
function offline() {
|
|
@@ -178,7 +319,11 @@ export function answerWorker(manifest: Manifest | undefined): Response {
|
|
|
178
319
|
for (const name of bundleFiles(manifest))
|
|
179
320
|
paths.push(mounted(`${CLIENT_ADDRESS}/${name}`))
|
|
180
321
|
script = `${CACHES}const PRINCIPAL = ${JSON.stringify(PRINCIPAL_HEADER)}
|
|
322
|
+
const KEPT = ${JSON.stringify(KEPT_HEADER)}
|
|
323
|
+
const KEEP = ${JSON.stringify(KEEP_HEADER)}
|
|
324
|
+
const OPENED = ${JSON.stringify(KEEP_OPENED)}
|
|
181
325
|
const NAVIGATION = ${JSON.stringify(NAVIGATION_HEADER)}
|
|
326
|
+
const REACHED = ${JSON.stringify(REACHED_MESSAGE)}
|
|
182
327
|
// 45.4 — the types a stream 16.32 frames, which has no end to save.
|
|
183
328
|
const STREAMED = ${JSON.stringify(STREAMED_TYPES)}
|
|
184
329
|
const OFFLINE = ${JSON.stringify(OFFLINE)}
|