@abide/abide 0.114.1 → 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/docs/abide.md CHANGED
@@ -38,6 +38,7 @@ at its address with `.md` on the end, and the links below point at those files.
38
38
  - `Selection` — `reference/types/Selection.md`
39
39
  - `busy` — `reference/busy.md`
40
40
  - `ready` — `reference/ready.md`
41
+ - `synced` — `reference/synced.md`
41
42
  - `refresh` — `reference/refresh.md`
42
43
  - `invalidate` — `reference/invalidate.md`
43
44
  - `refuse` — `reference/refuse.md`
@@ -87,6 +88,8 @@ at its address with `.md` on the end, and the links below point at those files.
87
88
  - `route` — `reference/route.md`
88
89
  - `Params` — `reference/types/Params.md`
89
90
  - `online` — `reference/online.md`
91
+ - `outbox` — `reference/outbox.md`
92
+ - `QueuedCall` — `reference/types/QueuedCall.md`
90
93
  - `reachable` — `reference/reachable.md`
91
94
  - `trace` — `reference/trace.md`
92
95
  - `server` — `reference/server.md`
@@ -4889,7 +4892,7 @@ test('the health check carries the database beside the baseline', async () => {
4889
4892
 
4890
4893
  ## The browser going offline is a reactive value
4891
4894
 
4892
- **`online` — the connection, read by name** — A contact edit made on a train is not saved. Take the browser offline in DevTools' Network panel and the banner says so.
4895
+ **`online` — the connection, read by name** — On a train the network comes and goes. Take the browser offline in DevTools' Network panel and the banner says so.
4893
4896
 
4894
4897
  ```abide src/ops/lib/OfflineBanner.abide
4895
4898
  <script>
@@ -4897,7 +4900,9 @@ import { online } from '@abide/abide'
4897
4900
  </script>
4898
4901
 
4899
4902
  {#if !online}
4900
- <p class="status">You are offline. Edits to contacts are not saved.</p>
4903
+ <p class="status">
4904
+ You are offline. Calls you log are sent once you are back.
4905
+ </p>
4901
4906
  {/if}
4902
4907
 
4903
4908
  <ul class="fields">
@@ -4938,6 +4943,107 @@ watch(() => { if (online) refresh() })
4938
4943
  </script>
4939
4944
  ```
4940
4945
 
4946
+ ## A call logged offline is sent when the network is back
4947
+
4948
+ **`offline` — what a call settles to with no network** — A call logged on a train should not be lost in a tunnel. Take the browser offline in DevTools' Network panel, log a call, then go back online.
4949
+
4950
+ ```abide src/ops/lib/CallLog.abide
4951
+ <script>
4952
+ import { outbox, reactive } from '@abide/abide'
4953
+ import listCalls from '../listCalls.ts'
4954
+ import logCall from '../logCall.ts'
4955
+
4956
+ const calls = listCalls()
4957
+ let note = reactive('')
4958
+
4959
+ function log() {
4960
+ const call = { id: crypto.randomUUID(), note }
4961
+ note = ''
4962
+ // With no network: shown at once, and sent from the outbox later.
4963
+ logCall(call, {
4964
+ offline: () => calls.patch((held) => held.push(call)),
4965
+ })
4966
+ }
4967
+ </script>
4968
+
4969
+ <div class="row">
4970
+ <input bind:value={note} placeholder="What Grace said">
4971
+ <button onclick={log}>Log call</button>
4972
+ </div>
4973
+
4974
+ <ul class="fields stacked">
4975
+ {#for call of calls.ready() ? calls : [] by call.id}
4976
+ <li><span>call</span><strong>{call.note}</strong></li>
4977
+ {/for}
4978
+ </ul>
4979
+
4980
+ <ul class="tiles">
4981
+ <li><span>waiting</span>
4982
+ <strong>{outbox(logCall).length}</strong></li>
4983
+ <li><span>saved</span>
4984
+ <strong>{calls.synced() ? 'yes' : 'not yet'}</strong></li>
4985
+ </ul>
4986
+ ```
4987
+
4988
+ ```ts src/ops/logCall.ts
4989
+ import { POST } from '@abide/abide/server'
4990
+ import { type Call, calls } from './lib/calls.ts'
4991
+
4992
+ /** Logs a call once, however often it is sent: the browser picks its id. */
4993
+ export default POST(
4994
+ ({ id, note }: Call) => {
4995
+ calls.patch((held) => {
4996
+ if (!held.some((call) => call.id === id)) held.push({ id, note })
4997
+ })
4998
+ },
4999
+ { description: 'Logs a call against a contact.' },
5000
+ )
5001
+ ```
5002
+
5003
+ ```ts src/ops/lib/CallLog.test.ts
5004
+ import { expect, test } from 'bun:test'
5005
+ import { flushSync } from '@abide/abide'
5006
+ import { mount } from '@abide/harness/abide'
5007
+ import { calls } from './calls.ts'
5008
+ import CallLog from './CallLog.abide'
5009
+
5010
+ test('a call logged with the network is saved, and nothing waits', async () => {
5011
+ using _log = await mount(CallLog({}))
5012
+ const input = document.querySelector('input') as HTMLInputElement
5013
+ input.value = 'Sent the quote'
5014
+ input.dispatchEvent(new Event('input', { bubbles: true }))
5015
+ document.querySelector('button')?.click()
5016
+ await Bun.sleep(10)
5017
+ flushSync()
5018
+ expect(calls().map((call) => call.note)).toContain('Sent the quote')
5019
+ const tiles = [...document.querySelectorAll('.tiles strong')]
5020
+ expect(tiles.map((tile) => tile.textContent)).toEqual(['0', 'yes'])
5021
+ })
5022
+
5023
+ test('a call sent twice under one id is logged once', async () => {
5024
+ const { default: logCall } = await import('../logCall.ts')
5025
+ const call = { id: 'again', note: 'Asked twice' }
5026
+ await logCall(call)
5027
+ await logCall(call)
5028
+ expect(calls().filter((each) => each.id === 'again').length).toBe(1)
5029
+ })
5030
+ ```
5031
+
5032
+ A call given `offline` that gets no answer settles to what `offline` returns. A mutation then waits in the outbox, kept on the device through a reload, and is sent once the network answers: oldest first, one at a time, by one tab for all of them. Here `offline` also adds the call to the list, so it shows at once. `outbox` is asked of a selection as `busy` is, and answers a list: `outbox(logCall)` is the calls waiting for that handler.
5033
+
5034
+ What is shown before the server has it says so: `synced()` is false on a value written in the browser rather than made by its producer, and on anything read from one. The list keeps its added call until the outbox is empty, then reads the server's again. A call is sent with the same `idempotency-key` every time, and `logCall` takes the browser's id, so a send whose answer was lost and is sent again logs the call once.
5035
+
5036
+ | Name | What it does for the CRM |
5037
+ | --- | --- |
5038
+ | `outbox` · `rpc.outbox` | the calls waiting to be sent, and the ones the server refused, over a selection |
5039
+ | `QueuedCall` | one call in the outbox: its key, its address, its args, and how often it was sent |
5040
+ | `call.value` | what the call settled to: its stand-in, read as unsynced, until the server answers, and its refusal as `error()`, after a reload too |
5041
+ | `call.retry` | sends a refused call again |
5042
+ | `call.discard` | drops a call, sent or not |
5043
+ | `s.synced` | whether a value holds what its producer made |
5044
+ | `k.synced` · `rpc.synced` | the same, over a keyed value's or a handler's entries |
5045
+ | `synced` | the same, over a selection |
5046
+
4941
5047
  ## Next
4942
5048
 
4943
5049
  - [Open it to machines](/guide/open-it-to-machines.md) — the CRM's handlers, described to other programs
@@ -6422,7 +6528,15 @@ abide start
6422
6528
  ABIDE_OFFLINE=0 abide start
6423
6529
  ```
6424
6530
 
6425
- Each page the CRM opens is kept on the device, with the answers its handlers gave, the public files it loaded and the bundle it runs, until `ABIDE_OFFLINE=0` turns that off where the CRM is served. With the network gone, a page opened before loads again with its data and works. One never opened says the device is offline, and a link to it leaves the page you are on standing.
6531
+ Each page the CRM opens is kept on the device, with the answers its handlers gave, the public files it loaded and the bundle it runs, until `ABIDE_OFFLINE=0` turns that off where the CRM is served. A kept page opens at once, however weak the signal, and the network is asked for the next copy behind it. With the network gone, a page opened in the last week loads again with its data and works. One never opened says the device is offline, and a link to it leaves the page you are on standing.
6532
+
6533
+ | Name | What it does for the CRM |
6534
+ | --- | --- |
6535
+ | `cache-control: private, stale-while-revalidate=604800, stale-if-error=604800` | what a page and a handler's answer carry, so the device shows what it kept while it asks again |
6536
+ | `cache-control: public, max-age=0, stale-while-revalidate=604800, stale-if-error=604800` | the same, for a public file |
6537
+ | `clear-site-data: "cache"` | what a sign-in or a sign-out answers, so the browser forgets what it kept for the last person |
6538
+
6539
+ What is kept is what those headers allow, and nothing else decides it. A handler that writes `cache-control: no-store` through `response` is never kept, and an answer with a `ttl` is shown from the device until its `max-age` runs out. A refresh always asks the network.
6426
6540
 
6427
6541
  With `src/public/icon.png` beside it, each page also links a manifest, so a phone can add the CRM to its home screen, which is what keeps what was saved there for good.
6428
6542
 
@@ -6464,6 +6578,7 @@ Arrive here knowing which name you want. Each page is read off the registry: the
6464
6578
  | [`Selection`](/reference/types/Selection.md) | `@abide/abide` |
6465
6579
  | [`busy`](/reference/busy.md) | `@abide/abide` |
6466
6580
  | [`ready`](/reference/ready.md) | `@abide/abide` |
6581
+ | [`synced`](/reference/synced.md) | `@abide/abide` |
6467
6582
  | [`refresh`](/reference/refresh.md) | `@abide/abide` |
6468
6583
  | [`invalidate`](/reference/invalidate.md) | `@abide/abide` |
6469
6584
  | [`refuse`](/reference/refuse.md) | `@abide/abide` |
@@ -6533,6 +6648,8 @@ Arrive here knowing which name you want. Each page is read off the registry: the
6533
6648
  | [`route`](/reference/route.md) | `@abide/abide` |
6534
6649
  | [`Params`](/reference/types/Params.md) | `@abide/abide` |
6535
6650
  | [`online`](/reference/online.md) | `@abide/abide` |
6651
+ | [`outbox`](/reference/outbox.md) | `@abide/abide` |
6652
+ | [`QueuedCall`](/reference/types/QueuedCall.md) | `@abide/abide` |
6536
6653
  | [`reachable`](/reference/reachable.md) | `@abide/abide/server` |
6537
6654
  | [`trace`](/reference/trace.md) | `@abide/abide/server` |
6538
6655
  | [`server`](/reference/server.md) | `@abide/abide/server` |
@@ -6724,6 +6841,7 @@ Arrive here knowing which name you want. Each page is read off the registry: the
6724
6841
  | `s.busy` | `() => boolean` | Work is in flight for this value. | [3.1](/rulebook#3.1), [3.2](/rulebook#3.2), [3.3](/rulebook#3.3), [3.4](/rulebook#3.4), [3.7](/rulebook#3.7), [3.9](/rulebook#3.9), [5.15](/rulebook#5.15), [7.5](/rulebook#7.5), [7.8](/rulebook#7.8), [11.19](/rulebook#11.19), [11.20](/rulebook#11.20), [11.21](/rulebook#11.21), [11.22](/rulebook#11.22), [11.50](/rulebook#11.50) |
6725
6842
  | `s.ready` | `() => boolean` | There is a value to serve. | [3.1](/rulebook#3.1), [3.2](/rulebook#3.2), [3.3](/rulebook#3.3), [3.5](/rulebook#3.5), [3.6](/rulebook#3.6), [3.8](/rulebook#3.8), [7.8](/rulebook#7.8), [11.20](/rulebook#11.20), [11.54](/rulebook#11.54), [11.58](/rulebook#11.58) |
6726
6843
  | `s.error` | `() => Failures \| undefined` | The standing refusal. | [3.1](/rulebook#3.1), [3.2](/rulebook#3.2), [3.3](/rulebook#3.3), [3.5](/rulebook#3.5), [4.10](/rulebook#4.10), [4.11](/rulebook#4.11), [4.12](/rulebook#4.12) |
6844
+ | `s.synced` | `() => boolean` | What is held is what the producer made. | [3.1](/rulebook#3.1), [3.2](/rulebook#3.2), [3.3](/rulebook#3.3), [3.10](/rulebook#3.10), [3.11](/rulebook#3.11), [3.12](/rulebook#3.12), [3.13](/rulebook#3.13) |
6727
6845
  | `s.isError` | `<Name extends Failures['name']>(error: unknown, name: Name) => error is Extract<Failures, { name: Name }>` | Whether a caught failure is the one named. | [3.1](/rulebook#3.1) |
6728
6846
 
6729
6847
  ## Triggers
@@ -6746,6 +6864,7 @@ Arrive here knowing which name you want. Each page is read off the registry: the
6746
6864
  - [Talk to the server](/guide/talk-to-the-server.md)
6747
6865
  - [Keep it current](/guide/keep-it-current.md)
6748
6866
  - [Make it live](/guide/make-it-live.md)
6867
+ - [Run it](/guide/run-it.md)
6749
6868
 
6750
6869
  ---
6751
6870
 
@@ -6768,6 +6887,7 @@ Arrive here knowing which name you want. Each page is read off the registry: the
6768
6887
  | --- | --- | --- | --- |
6769
6888
  | `k.busy` | `(pattern?: Partial<Args>) => boolean` | `busy` narrowed to matching entries. | [13.2](/rulebook#13.2), [13.3](/rulebook#13.3), [13.12](/rulebook#13.12) |
6770
6889
  | `k.ready` | `(pattern?: Partial<Args>) => boolean` | `ready` narrowed to matching entries. | [13.11](/rulebook#13.11), [13.12](/rulebook#13.12) |
6890
+ | `k.synced` | `(pattern?: Partial<Args>) => boolean` | `synced` narrowed to matching entries. | [13.12](/rulebook#13.12), [13.18](/rulebook#13.18) |
6771
6891
  | `k.invalidate` | `(pattern?: Partial<Args>) => void` | `invalidate` narrowed to matching entries. | [7.2](/rulebook#7.2), [7.3](/rulebook#7.3), [13.4](/rulebook#13.4), [13.12](/rulebook#13.12) |
6772
6892
  | `k.refresh` | `(pattern?: Partial<Args>) => void` | `refresh` narrowed to matching entries. | [7.4](/rulebook#7.4), [7.5](/rulebook#7.5), [13.4](/rulebook#13.4), [13.12](/rulebook#13.12) |
6773
6893
 
@@ -6775,6 +6895,7 @@ Arrive here knowing which name you want. Each page is read off the registry: the
6775
6895
 
6776
6896
  - [Edit a contact](/guide/edit-a-contact.md)
6777
6897
  - [Keep it current](/guide/keep-it-current.md)
6898
+ - [Run it](/guide/run-it.md)
6778
6899
 
6779
6900
  ---
6780
6901
 
@@ -7028,7 +7149,7 @@ Arrive here knowing which name you want. Each page is read off the registry: the
7028
7149
 
7029
7150
  | Name | Signature | Meaning | Rules |
7030
7151
  | --- | --- | --- | --- |
7031
- | `busy` | `(selection?: Selection, pattern?: Partial<Args>) => boolean` | Whether any entry in the selection has work in flight. | [13.2](/rulebook#13.2), [13.3](/rulebook#13.3), [13.5](/rulebook#13.5), [13.12](/rulebook#13.12) |
7152
+ | `busy` | `(selection?: Selection, pattern?: Partial<Args>) => boolean` | Whether any entry in the selection has work in flight. | [13.2](/rulebook#13.2), [13.3](/rulebook#13.3), [13.5](/rulebook#13.5), [13.12](/rulebook#13.12), [46.11](/rulebook#46.11) |
7032
7153
 
7033
7154
  ## Taught in
7034
7155
 
@@ -7054,6 +7175,24 @@ Arrive here knowing which name you want. Each page is read off the registry: the
7054
7175
 
7055
7176
  ---
7056
7177
 
7178
+ <!-- reference/synced.md -->
7179
+
7180
+ # `synced`
7181
+
7182
+ `import { synced } from '@abide/abide'`
7183
+
7184
+ ## Selections — over many entries
7185
+
7186
+ | Name | Signature | Meaning | Rules |
7187
+ | --- | --- | --- | --- |
7188
+ | `synced` | `(selection?: Selection, pattern?: Partial<Args>) => boolean` | Whether every entry in the selection holds what its producer made. | [13.12](/rulebook#13.12), [13.18](/rulebook#13.18) |
7189
+
7190
+ ## Taught in
7191
+
7192
+ - [Run it](/guide/run-it.md)
7193
+
7194
+ ---
7195
+
7057
7196
  <!-- reference/refresh.md -->
7058
7197
 
7059
7198
  # `refresh`
@@ -7227,12 +7366,14 @@ Reactive primitives: what is written in place rather than imported.
7227
7366
 
7228
7367
  | Name | Signature | Meaning | Rules |
7229
7368
  | --- | --- | --- | --- |
7230
- | `Rpc` | `<Value, Args, Failures, Produced = Value>(args?: Args, options?: { signal?: AbortSignal }) => Reactive<Value, Value, Failures \| Failed<'HttpError', undefined>, Produced>` | A handler reached over http, or in process on a server. | [16.3](/rulebook#16.3), [16.4](/rulebook#16.4), [16.8](/rulebook#16.8), [16.11](/rulebook#16.11), [16.25](/rulebook#16.25), [16.26](/rulebook#16.26), [16.27](/rulebook#16.27), [16.28](/rulebook#16.28), [16.29](/rulebook#16.29), [16.33](/rulebook#16.33), [16.43](/rulebook#16.43), [16.48](/rulebook#16.48), [16.49](/rulebook#16.49), [16.62](/rulebook#16.62), [41.5](/rulebook#41.5), [41.7](/rulebook#41.7), [41.12](/rulebook#41.12), [41.21](/rulebook#41.21), [41.22](/rulebook#41.22), [41.25](/rulebook#41.25), [41.26](/rulebook#41.26), [41.27](/rulebook#41.27), [16.79](/rulebook#16.79) |
7369
+ | `Rpc` | `<Value, Args, Failures, Produced = Value>(args?: Args, options?: { signal?: AbortSignal; offline?: (args: Args) => Value }) => Reactive<Value, Value, Failures \| Failed<'HttpError', undefined>, Produced>` | A handler reached over http, or in process on a server. | [16.3](/rulebook#16.3), [16.4](/rulebook#16.4), [16.8](/rulebook#16.8), [16.11](/rulebook#16.11), [16.25](/rulebook#16.25), [16.26](/rulebook#16.26), [16.27](/rulebook#16.27), [16.28](/rulebook#16.28), [16.29](/rulebook#16.29), [16.33](/rulebook#16.33), [16.43](/rulebook#16.43), [16.48](/rulebook#16.48), [16.49](/rulebook#16.49), [16.62](/rulebook#16.62), [41.5](/rulebook#41.5), [41.7](/rulebook#41.7), [41.12](/rulebook#41.12), [41.21](/rulebook#41.21), [41.22](/rulebook#41.22), [41.25](/rulebook#41.25), [41.26](/rulebook#41.26), [41.27](/rulebook#41.27), [16.79](/rulebook#16.79), [16.80](/rulebook#16.80), [16.85](/rulebook#16.85) |
7370
+ | `offline` | `(args: Args) => Value` | What a call settles to where a browser could not get its answer. | [16.81](/rulebook#16.81), [16.82](/rulebook#16.82), [16.83](/rulebook#16.83), [16.84](/rulebook#16.84) |
7231
7371
  | `Value` | `unknown` | What the addressed `Reactive` holds. | [16.3](/rulebook#16.3), [16.30](/rulebook#16.30) |
7232
7372
  | `Produced` | `unknown` | What it yields, per the unit rule. | [1.2](/rulebook#1.2), [16.3](/rulebook#16.3) |
7233
7373
  | `Args` | `Record<string, JsonValue> \| undefined` | The arguments, in the body on a mutation and in the URL on a `GET` or `DELETE`. | [11.24](/rulebook#11.24), [16.18](/rulebook#16.18), [19.15](/rulebook#19.15) |
7234
- | `rpc.busy` | `(pattern?: Partial<Args>) => boolean` | `busy` narrowed to the handler's matching entries. | [13.2](/rulebook#13.2), [13.3](/rulebook#13.3), [13.12](/rulebook#13.12) |
7374
+ | `rpc.busy` | `(pattern?: Partial<Args>) => boolean` | `busy` narrowed to the handler's matching entries. | [13.2](/rulebook#13.2), [13.3](/rulebook#13.3), [13.12](/rulebook#13.12), [46.11](/rulebook#46.11) |
7235
7375
  | `rpc.ready` | `(pattern?: Partial<Args>) => boolean` | `ready` narrowed to the handler's matching entries. | [13.11](/rulebook#13.11), [13.12](/rulebook#13.12) |
7376
+ | `rpc.synced` | `(pattern?: Partial<Args>) => boolean` | `synced` narrowed to the handler's matching entries. | [13.12](/rulebook#13.12), [13.18](/rulebook#13.18) |
7236
7377
  | `rpc.invalidate` | `(pattern?: Partial<Args>) => void` | `invalidate` narrowed to the handler's matching entries. | [7.2](/rulebook#7.2), [7.3](/rulebook#7.3), [13.4](/rulebook#13.4), [13.12](/rulebook#13.12) |
7237
7378
  | `rpc.refresh` | `(pattern?: Partial<Args>) => void` | `refresh` narrowed to the handler's matching entries. | [7.4](/rulebook#7.4), [7.5](/rulebook#7.5), [13.4](/rulebook#13.4), [13.12](/rulebook#13.12) |
7238
7379
  | `rpc.isError` | `<Name extends Failures['name']>(error: unknown, name: Name) => error is Extract<Failures, { name: Name }>` | Whether a caught failure is the one named. | [16.8](/rulebook#16.8) |
@@ -7241,11 +7382,18 @@ Reactive primitives: what is written in place rather than imported.
7241
7382
  | `rpc.url` | `(args?: Args) => string` | The address, resolved against the mount. | [16.9](/rulebook#16.9), [16.10](/rulebook#16.10), [30.28](/rulebook#30.28) |
7242
7383
  | `rpc.description` | `string \| undefined` | The human description. | [16.28](/rulebook#16.28) |
7243
7384
 
7385
+ ## `outbox`
7386
+
7387
+ | Name | Signature | Meaning | Rules |
7388
+ | --- | --- | --- | --- |
7389
+ | `rpc.outbox` | `(pattern?: Partial<Args>) => QueuedCall[]` | `outbox` narrowed to the handler's calls. | [46.1](/rulebook#46.1), [13.12](/rulebook#13.12) |
7390
+
7244
7391
  ## Taught in
7245
7392
 
7246
7393
  - [Talk to the server](/guide/talk-to-the-server.md)
7247
7394
  - [Keep it current](/guide/keep-it-current.md)
7248
7395
  - [Make it live](/guide/make-it-live.md)
7396
+ - [Run it](/guide/run-it.md)
7249
7397
 
7250
7398
  ---
7251
7399
 
@@ -7518,6 +7666,9 @@ Transports: what is written in place rather than imported.
7518
7666
  | `cache-control: no-store` | `/__abide/openapi.json`, `/__abide/mcp` | Refuses caching for an answer about the app's surfaces. | [21.4](/rulebook#21.4) |
7519
7667
  | `cache-control: public, max-age=31536000, immutable` | the built bundle | A chunk addressed by its own content hash. | [21.5](/rulebook#21.5) |
7520
7668
  | `cache-control: public, max-age=0, must-revalidate` | a file under a `public` directory | A file at an address the build never chose. | [21.12](/rulebook#21.12) |
7669
+ | `cache-control: private, stale-while-revalidate=604800, stale-if-error=604800` | a page, a `GET`'s answer, where `ABIDE_OFFLINE` registers its worker | The default that lets a copy kept on the device stand in for the network. | [21.22](/rulebook#21.22), [21.11](/rulebook#21.11), [21.17](/rulebook#21.17) |
7670
+ | `cache-control: public, max-age=0, stale-while-revalidate=604800, stale-if-error=604800` | a file under a `public` directory, where `ABIDE_OFFLINE` registers its worker | The same, for a file at an address the build never chose. | [21.23](/rulebook#21.23) |
7671
+ | `clear-site-data: "cache"` | an answer carrying `abide-principal`, where pages are kept offline | Drops what the browser's own cache kept for the caller signed in before. | [21.24](/rulebook#21.24) |
7521
7672
  | `etag` | a file under a `public` directory | The answered bytes, addressed by their content. | [21.13](/rulebook#21.13), [21.14](/rulebook#21.14) |
7522
7673
  | `referrer-policy: strict-origin-when-cross-origin` | a page | The browsers' own default, written down. | [21.6](/rulebook#21.6) |
7523
7674
  | `vary` | wherever an answer depends on a request header | Names the header the answer varied on. | [21.7](/rulebook#21.7) |
@@ -8020,6 +8171,45 @@ Generated surfaces: what is written in place rather than imported.
8020
8171
 
8021
8172
  ---
8022
8173
 
8174
+ <!-- reference/outbox.md -->
8175
+
8176
+ # `outbox`
8177
+
8178
+ `import { outbox } from '@abide/abide'`
8179
+
8180
+ ## `outbox`
8181
+
8182
+ | Name | Signature | Meaning | Rules |
8183
+ | --- | --- | --- | --- |
8184
+ | `outbox` | `<Chosen extends Selection>(selection?: Chosen, pattern?: Partial<Args>) => QueuedCall[]` | The calls a browser has queued and not delivered, the refused ones included. | [46.1](/rulebook#46.1), [46.2](/rulebook#46.2), [46.3](/rulebook#46.3), [46.4](/rulebook#46.4), [46.5](/rulebook#46.5), [46.6](/rulebook#46.6), [46.7](/rulebook#46.7), [46.8](/rulebook#46.8), [46.9](/rulebook#46.9), [46.10](/rulebook#46.10), [46.14](/rulebook#46.14), [46.15](/rulebook#46.15), [46.16](/rulebook#46.16), [46.17](/rulebook#46.17), [46.20](/rulebook#46.20), [18.35](/rulebook#18.35) |
8185
+ | `call.value` | `Reactive<Value, Value, Failed<string, unknown>>` | The value a queued call settled. | [46.5](/rulebook#46.5), [46.7](/rulebook#46.7), [46.18](/rulebook#46.18), [46.19](/rulebook#46.19) |
8186
+ | `call.retry` | `() => void` | Sends a queued call again. | [46.6](/rulebook#46.6), [46.12](/rulebook#46.12) |
8187
+ | `call.discard` | `() => void` | Drops a queued call. | [46.13](/rulebook#46.13) |
8188
+
8189
+ ## Taught in
8190
+
8191
+ - [Run it](/guide/run-it.md)
8192
+
8193
+ ---
8194
+
8195
+ <!-- reference/types/QueuedCall.md -->
8196
+
8197
+ # `QueuedCall`
8198
+
8199
+ `import type { QueuedCall } from '@abide/abide'`
8200
+
8201
+ ## `outbox`
8202
+
8203
+ | Name | Signature | Meaning | Rules |
8204
+ | --- | --- | --- | --- |
8205
+ | `QueuedCall` | `{ id: string; method: 'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'; url: string; args: Record<string, JsonValue> \| undefined; queuedAt: number; attempts: number; value: Reactive<Value, Value, Failed<string, unknown>>; retry: () => void; discard: () => void }` | One call `outbox` holds. | [46.1](/rulebook#46.1), [46.10](/rulebook#46.10) |
8206
+
8207
+ ## Taught in
8208
+
8209
+ - [Run it](/guide/run-it.md)
8210
+
8211
+ ---
8212
+
8023
8213
  <!-- reference/reachable.md -->
8024
8214
 
8025
8215
  # `reachable`
@@ -8821,7 +9011,7 @@ Pages and routing: what is written in place rather than imported.
8821
9011
 
8822
9012
  | Name | Signature | Meaning | Rules |
8823
9013
  | --- | --- | --- | --- |
8824
- | `render` | `(component: Component) => Promise<Uint8Array>` | What produces a document. | [29.10](/rulebook#29.10), [35.4](/rulebook#35.4), [35.5](/rulebook#35.5), [35.6](/rulebook#35.6), [35.15](/rulebook#35.15), [35.16](/rulebook#35.16), [41.1](/rulebook#41.1), [41.2](/rulebook#41.2), [41.3](/rulebook#41.3), [41.4](/rulebook#41.4), [41.9](/rulebook#41.9), [41.13](/rulebook#41.13), [41.16](/rulebook#41.16), [41.17](/rulebook#41.17), [41.18](/rulebook#41.18), [41.19](/rulebook#41.19), [41.20](/rulebook#41.20), [41.28](/rulebook#41.28), [35.11](/rulebook#35.11), [41.29](/rulebook#41.29) |
9014
+ | `render` | `(component: Component) => Promise<Uint8Array>` | What produces a document. | [29.10](/rulebook#29.10), [35.4](/rulebook#35.4), [35.5](/rulebook#35.5), [35.6](/rulebook#35.6), [35.15](/rulebook#35.15), [35.16](/rulebook#35.16), [41.1](/rulebook#41.1), [41.2](/rulebook#41.2), [41.3](/rulebook#41.3), [41.4](/rulebook#41.4), [41.9](/rulebook#41.9), [41.13](/rulebook#41.13), [41.16](/rulebook#41.16), [41.17](/rulebook#41.17), [41.18](/rulebook#41.18), [41.19](/rulebook#41.19), [41.20](/rulebook#41.20), [41.28](/rulebook#41.28), [35.11](/rulebook#35.11), [41.29](/rulebook#41.29), [41.30](/rulebook#41.30) |
8825
9015
 
8826
9016
  ## Taught in
8827
9017
 
@@ -8853,7 +9043,7 @@ Configuration: what is written in place rather than imported.
8853
9043
  | `ABIDE_MAX_REQUEST_BODY_SIZE` | `number` | The default ceiling on a mutation's body. | [16.13](/rulebook#16.13), [16.54](/rulebook#16.54), [16.55](/rulebook#16.55), [16.78](/rulebook#16.78) |
8854
9044
  | `ABIDE_MAX_SOCKET_MESSAGE_SIZE` | `number` | The ceiling on a frame received over the socket mux, in bytes. | [18.28](/rulebook#18.28) |
8855
9045
  | `ABIDE_MAX_SOCKET_SUBSCRIPTIONS` | `number` | How many values one connection to the socket mux may follow. | [18.31](/rulebook#18.31), [18.32](/rulebook#18.32) |
8856
- | `ABIDE_OFFLINE` | `boolean` | Whether the app keeps its pages and its bundle on the device, for when the network is gone. | [45.1](/rulebook#45.1), [45.2](/rulebook#45.2), [45.3](/rulebook#45.3), [45.4](/rulebook#45.4), [45.5](/rulebook#45.5), [45.6](/rulebook#45.6), [45.7](/rulebook#45.7), [45.8](/rulebook#45.8), [45.9](/rulebook#45.9), [45.10](/rulebook#45.10), [45.11](/rulebook#45.11), [45.12](/rulebook#45.12), [45.13](/rulebook#45.13), [45.14](/rulebook#45.14), [45.15](/rulebook#45.15), [45.16](/rulebook#45.16) |
9046
+ | `ABIDE_OFFLINE` | `boolean` | Whether the app keeps its pages and its bundle on the device, for when the network is gone. | [45.1](/rulebook#45.1), [45.2](/rulebook#45.2), [45.3](/rulebook#45.3), [45.4](/rulebook#45.4), [45.5](/rulebook#45.5), [45.6](/rulebook#45.6), [45.7](/rulebook#45.7), [45.8](/rulebook#45.8), [45.9](/rulebook#45.9), [45.10](/rulebook#45.10), [45.11](/rulebook#45.11), [45.12](/rulebook#45.12), [45.13](/rulebook#45.13), [45.14](/rulebook#45.14), [45.15](/rulebook#45.15), [45.16](/rulebook#45.16), [45.17](/rulebook#45.17), [45.18](/rulebook#45.18), [45.19](/rulebook#45.19), [45.20](/rulebook#45.20), [45.21](/rulebook#45.21), [45.22](/rulebook#45.22), [45.23](/rulebook#45.23) |
8857
9047
  | `ABIDE_STOP_TIMEOUT` | `number` | How long `App.stop` drains, in ms. | [42.17](/rulebook#42.17), [42.18](/rulebook#42.18) |
8858
9048
  | `ABIDE_MAX_STREAM_BUFFER_SIZE` | `number` | The cap on a stream transcript held in memory. | [36.18](/rulebook#36.18) |
8859
9049
  | `ABIDE_MAX_LOG_BUFFER_COUNT` | `number` | How many lines `log.records` is trimmed back to. | [28.1](/rulebook#28.1) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.114.1",
3
+ "version": "0.115.0",
4
4
  "type": "module",
5
5
  "description": "Isomorphic type-safe framework for reactive async interfaces for humans and machines",
6
6
  "license": "MIT",
@@ -57,7 +57,9 @@
57
57
  "sideEffects": [
58
58
  "./src/server/**",
59
59
  "./src/shared/WORK.ts",
60
- "./src/shared/reactive/Reactive.ts"
60
+ "./src/shared/outbox.ts",
61
+ "./src/shared/reactive/Reactive.ts",
62
+ "./src/ui/outboxQueue.ts"
61
63
  ],
62
64
  "dependencies": {
63
65
  "typescript": "^7.0.2"
@@ -28,6 +28,7 @@ export const REACTIVE_MEMBERS: ReadonlySet<string> = new Set([
28
28
  'ready',
29
29
  'refresh',
30
30
  'set',
31
+ 'synced',
31
32
  'watch',
32
33
  // A `Reactive` is thenable (1.4), and `catch`/`finally` derive from `then` (1.5).
33
34
  'then',
@@ -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 (