@abide/abide 0.114.1 → 0.116.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/docs/REGISTRY.md +38 -16
  3. package/docs/RULEBOOK.md +173 -12
  4. package/docs/abide.md +266 -24
  5. package/package.json +4 -2
  6. package/src/compiler/REACTIVE_MEMBERS.ts +1 -0
  7. package/src/compiler/classifyTemplate.ts +21 -14
  8. package/src/compiler/emitClient.ts +5 -1
  9. package/src/server/BRIDGE_VERSION.ts +1 -1
  10. package/src/server/IMAGE_TYPES.ts +10 -0
  11. package/src/server/OFFLINE_STALE.ts +10 -0
  12. package/src/server/answerRequest.ts +39 -10
  13. package/src/server/answerRpc.ts +25 -5
  14. package/src/server/answerWorker.ts +173 -28
  15. package/src/server/assistant.ts +81 -12
  16. package/src/server/claudeApi.ts +25 -20
  17. package/src/server/claudeCode.ts +30 -10
  18. package/src/server/createApp.ts +8 -2
  19. package/src/server/generatedSurfaces.ts +48 -14
  20. package/src/server/index.ts +2 -0
  21. package/src/server/render.ts +3 -2
  22. package/src/server/seedBuffer.ts +28 -9
  23. package/src/shared/KEEP_HEADER.ts +7 -0
  24. package/src/shared/KEEP_OPENED.ts +7 -0
  25. package/src/shared/KEPT_HEADER.ts +6 -0
  26. package/src/shared/LOAD_OFFLINE.ts +15 -0
  27. package/src/shared/OUTBOX_SENDING.ts +9 -0
  28. package/src/shared/REACHED_MESSAGE.ts +7 -0
  29. package/src/shared/answerOf.ts +14 -2
  30. package/src/shared/framing.ts +10 -2
  31. package/src/shared/index.ts +2 -0
  32. package/src/shared/outbox.ts +116 -0
  33. package/src/shared/reactive/Keyed.ts +9 -0
  34. package/src/shared/reactive/Reactive.ts +197 -3
  35. package/src/shared/reactive/SELECTION_MEMBERS.ts +1 -0
  36. package/src/shared/reactive/callLoad.ts +8 -0
  37. package/src/shared/reactive/index.ts +23 -1
  38. package/src/shared/rpc.ts +25 -5
  39. package/src/shared/rpcRequest.ts +12 -8
  40. package/src/ui/Router.ts +28 -7
  41. package/src/ui/bindFiles.ts +42 -0
  42. package/src/ui/index.ts +1 -0
  43. package/src/ui/outboxQueue.ts +427 -0
  44. package/src/ui/socketWire.ts +31 -2
  45. package/src/ui/wire.ts +111 -10
@@ -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 the
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
- refuseTwoOwners(node)
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 === 'value' && node.lowerTag !== 'input'
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 refuseTwoOwners(node: ElementNode): void {
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 and the client
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
- type ValueBind = 'value' | 'checked' | 'open' | 'group'
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 === 'bind:value') return 'value'
479
- if (lowerName === 'bind:checked') return 'checked'
480
- if (lowerName === 'bind:open') return 'open'
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'
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) return answerRpc(found, params, url, request, app)
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
- const hydrated = request.headers.get('sec-fetch-dest') !== 'empty'
355
- beginRender({ seed: hydrated ? seedFor(caller) : SEEDS_NOTHING, calls: [] })
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(request: Request, file: PublicFile): Response {
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
- new Headers({ 'cache-control': 'public, max-age=0, must-revalidate' }),
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
  }
@@ -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.8, 21.9, 21.11 — WHAT IS LEFT OF THE LIFE OF THE PRODUCTION ANSWERED, in place of `no-store`
384
- // and nothing else. A `ttl` of Infinity is bounded by `invalidate` alone (11.35), which no cache
385
- // downstream hears, so it keeps `no-store`.
386
- if (Number.isFinite(deadline))
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, max-age=${Math.max(0, Math.floor((deadline - Date.now()) / 1000))}`,
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.13.
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
- await self.registration.navigationPreload?.enable()
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 fetch(client.url).then(
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(caches.delete(SAVED))
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(caches.delete(SAVED))
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
- let response
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
- response =
101
- (page ? await event.preloadResponse : undefined) ??
102
- (await fetch(event.request))
131
+ return await fromNetwork(event, page, page)
103
132
  } catch {
104
- const saved = await caches.match(event.request, {
105
- cacheName: SAVED,
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
- const rotated = response.headers.has(PRINCIPAL)
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
- async function keep(request, response) {
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, response)
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)}