@abide/abide 0.123.3 → 0.124.1

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 (91) hide show
  1. package/ABIDE.md +8 -5
  2. package/CHANGELOG.md +38 -0
  3. package/docs/REGISTRY.md +45 -12
  4. package/docs/RULEBOOK.md +29 -16
  5. package/docs/abide.md +999 -459
  6. package/package.json +1 -1
  7. package/src/compiler/ARRAY_MUTATORS.ts +20 -0
  8. package/src/compiler/LoweredFile.ts +609 -0
  9. package/src/compiler/Lowering.ts +46 -4
  10. package/src/compiler/SHARED_NAMES.ts +2 -0
  11. package/src/compiler/ShadowHost.ts +670 -0
  12. package/src/compiler/ShadowMap.ts +1 -1
  13. package/src/compiler/TypeSession.ts +71 -1715
  14. package/src/compiler/appLints.ts +109 -0
  15. package/src/compiler/checkedShadow.ts +1 -1
  16. package/src/compiler/classifyRead.ts +16 -1
  17. package/src/compiler/compileApp.ts +2 -2
  18. package/src/compiler/declaredExports.ts +44 -17
  19. package/src/compiler/diagnoseSources.ts +232 -0
  20. package/src/compiler/formatAbide.ts +5 -6
  21. package/src/compiler/handlersUnder.ts +16 -35
  22. package/src/compiler/projectShadow.ts +1 -0
  23. package/src/compiler/reactiveReads.ts +235 -0
  24. package/src/compiler/reactiveTypes.ts +13 -0
  25. package/src/compiler/readsIn.ts +180 -81
  26. package/src/compiler/routesDeclaration.ts +46 -0
  27. package/src/compiler/scaffold.ts +7 -2
  28. package/src/compiler/serveLanguage.ts +26 -20
  29. package/src/compiler/splitImports.ts +2 -2
  30. package/src/server/BRIDGE_VERSION.ts +1 -1
  31. package/src/server/DesktopSession.ts +213 -0
  32. package/src/server/Followers.ts +399 -0
  33. package/src/server/MODULE_QUERIES.ts +6 -2
  34. package/src/server/PageRender.ts +201 -0
  35. package/src/server/abideModules.ts +7 -7
  36. package/src/server/answerFailure.ts +3 -2
  37. package/src/server/answerRequest.ts +6 -70
  38. package/src/server/answerRpc.ts +48 -260
  39. package/src/server/answerSocket.ts +41 -463
  40. package/src/server/appKit.ts +1 -1
  41. package/src/server/argsContract.ts +1 -1
  42. package/src/server/createApp.ts +8 -2
  43. package/src/server/csp.ts +1 -1
  44. package/src/server/deferredBuilds.ts +3 -16
  45. package/src/server/faceOf.ts +1 -1
  46. package/src/server/generatedSurfaces.ts +2 -1
  47. package/src/server/handlerFileMount.ts +1 -1
  48. package/src/server/index.ts +1 -1
  49. package/src/server/installServer.ts +5 -9
  50. package/src/server/invokeHandler.ts +230 -0
  51. package/src/server/mountHandlers.ts +184 -0
  52. package/src/server/openDesktopWindow.ts +308 -0
  53. package/src/server/preload.ts +18 -0
  54. package/src/server/principal.ts +1 -1
  55. package/src/server/remote.ts +1 -1
  56. package/src/server/render.ts +31 -59
  57. package/src/server/requestScope.ts +5 -36
  58. package/src/server/runDesktop.ts +19 -447
  59. package/src/server/seedBuffer.ts +11 -11
  60. package/src/server/servedApp.ts +1 -1
  61. package/src/server/shell.ts +1 -1
  62. package/src/server/surfaceOf.ts +1 -1
  63. package/src/server/versionOf.ts +1 -1
  64. package/src/shared/RPC_SERVER.ts +27 -31
  65. package/src/shared/Schema.ts +1 -2
  66. package/src/shared/index.ts +1 -1
  67. package/src/shared/{note.ts → issueReport.ts} +22 -0
  68. package/src/shared/loadCall.ts +66 -0
  69. package/src/shared/reactive/Keyed.ts +23 -15
  70. package/src/shared/reactive/Reactive.ts +46 -1002
  71. package/src/shared/reactive/byOf.ts +2 -5
  72. package/src/shared/reactive/callLoad.ts +8 -14
  73. package/src/shared/reactive/cellShape.ts +734 -0
  74. package/src/shared/reactive/index.ts +12 -24
  75. package/src/shared/reactive/pairReactive.ts +5 -3
  76. package/src/shared/reactive/producer.ts +121 -0
  77. package/src/shared/reactive/scoped.ts +3 -2
  78. package/src/shared/reactive/selections.ts +209 -0
  79. package/src/shared/reactive/watch.ts +3 -4
  80. package/src/shared/rpc.ts +8 -5
  81. package/src/shared/validateJson.ts +1 -2
  82. package/src/shared/writePath.ts +29 -0
  83. package/src/ui/Router.ts +53 -69
  84. package/src/ui/outboxQueue.ts +2 -8
  85. package/src/ui/outcomeOf.ts +120 -0
  86. package/src/ui/socketWire.ts +3 -3
  87. package/src/ui/wire.ts +9 -10
  88. package/src/shared/LOAD_OFFLINE.ts +0 -15
  89. package/src/shared/LOAD_SIGNAL.ts +0 -10
  90. package/src/shared/issuesOf.ts +0 -18
  91. package/src/shared/reactive/ENTRY_LIVES.ts +0 -7
package/docs/abide.md CHANGED
@@ -23,6 +23,7 @@ at its address with `.md` on the end, and the links below point at those files.
23
23
 
24
24
  - Overview — `reference.md`
25
25
  - `Reactive` — `reference/types/Reactive.md`
26
+ - `isReactive` — `reference/isReactive.md`
26
27
  - `Keyed` — `reference/types/Keyed.md`
27
28
  - `reactive` — `reference/reactive.md`
28
29
  - `Transformer` — `reference/types/Transformer.md`
@@ -86,6 +87,7 @@ at its address with `.md` on the end, and the links below point at those files.
86
87
  - `ClaudeCodeConfig` — `reference/types/ClaudeCodeConfig.md`
87
88
  - `BridgeStatus` — `reference/types/BridgeStatus.md`
88
89
  - `cookies` — `reference/cookies.md`
90
+ - `Cookies` — `reference/types/Cookies.md`
89
91
  - `request` — `reference/request.md`
90
92
  - `response` — `reference/response.md`
91
93
  - `route` — `reference/route.md`
@@ -215,71 +217,26 @@ In abide a value on the server is something a page reads, and reading it loads i
215
217
 
216
218
  These docs build one app, a CRM, in ten chapters. Each chapter adds what the CRM needs next, and ends with the CRM as an app you can download and run. Every card is a file from that app, with the tests and the bench the app runs on it.
217
219
 
218
- ## 1. Start an app
220
+ The handler a page calls is also an OpenAPI operation, an MCP tool and a CLI command: one handler, four surfaces, so a model reaches the CRM through the same code a person does. See [Open it to machines](/guide/open-it-to-machines.md).
219
221
 
220
- This chapter starts the CRM the rest of the docs build: a contacts page reading a handler, a star you can click, and a note on a contact. Four commands get it running.
221
-
222
- Read on: [Start an app](/guide/start-an-app.md)
223
-
224
- ## 2. Edit a contact
225
-
226
- A contact's profile is held, edited and shown. This chapter is the whole of what a page needs for that: one value per thing held, values derived from them, and markup that follows both.
227
-
228
- Read on: [Edit a contact](/guide/edit-a-contact.md)
229
-
230
- ## 3. Talk to the server
231
-
232
- Deals live on the server. This chapter reads them, writes them, and refuses a request that is wrong, all through handlers a page calls by name.
233
-
234
- Read on: [Talk to the server](/guide/talk-to-the-server.md)
235
-
236
- ## 4. Keep it current
237
-
238
- A pipeline is read by several parts of the page and changes all day. This chapter loads it once, reloads it when it goes stale, and keeps the list current without refetching it.
239
-
240
- Read on: [Keep it current](/guide/keep-it-current.md)
241
-
242
- ## 5. Make it live
243
-
244
- Activity on an account is something every open tab should see as it happens. This chapter makes a feed anything can write to and everyone reads, and streams answers as they arrive.
245
-
246
- Read on: [Make it live](/guide/make-it-live.md)
247
-
248
- ## 6. Add pages
249
-
250
- The CRM outgrows one page. This chapter adds a page per company, the chrome they share, the links between them, and what a reader sees when one fails.
251
-
252
- Read on: [Add pages](/guide/add-pages.md)
253
-
254
- ## 7. Decide who sees what
255
-
256
- Not everyone may see every field. This chapter signs a user in, decides what each caller may read and call, and bounds what a request and a page may do.
257
-
258
- Read on: [Who sees what](/guide/decide-who-sees-what.md)
259
-
260
- ## 8. Run the CRM
261
-
262
- A CRM in production has to be configured, started, stopped and watched. This chapter does each, with the logs, traces and health checks an operator reads.
263
-
264
- Read on: [Run it](/guide/run-it.md)
222
+ ## The guide, chapter by chapter
265
223
 
266
- ## 9. Open it to machines
267
-
268
- The handlers the CRM already has are its API. This chapter publishes them as OpenAPI and MCP, keeps one off a surface, puts an assistant on the site, and calls a handler from a terminal.
269
-
270
- Read on: [Open it to machines](/guide/open-it-to-machines.md)
271
-
272
- ## 10. Test, measure and ship
273
-
274
- The CRM gets a reports page, and each report gets a test: what a click rewrote, what a change costs as the pipeline grows, and where a slow click spends its time. Then the CRM ships, as a server, a single binary or a desktop app, and keeps its pages readable offline.
275
-
276
- Read on: [Test, measure, ship](/guide/test-measure-and-ship.md)
224
+ 1. [Start an app](/guide/start-an-app.md) This chapter starts the CRM the rest of the docs build: a contacts page reading a handler, a star you can click, and a note on a contact. Four commands get it running.
225
+ 2. [Edit a contact](/guide/edit-a-contact.md) A contact's profile is held, edited and shown. This chapter is the whole of what a page needs for that: one value per thing held, values derived from them, and markup that follows both.
226
+ 3. [Talk to the server](/guide/talk-to-the-server.md) Deals live on the server. This chapter reads them, writes them, and refuses a request that is wrong, all through handlers a page calls by name.
227
+ 4. [Keep it current](/guide/keep-it-current.md) A pipeline is read by several parts of the page and changes all day. This chapter loads it once, reloads it when it goes stale, and keeps the list current without refetching it.
228
+ 5. [Make it live](/guide/make-it-live.md) Activity on an account is something every open tab should see as it happens. This chapter makes a channel anything can write to and everyone reads, and streams answers as they arrive.
229
+ 6. [Add pages](/guide/add-pages.md) The CRM outgrows one page. This chapter adds a page per company, the chrome they share, the links between them, and what a reader sees when one fails.
230
+ 7. [Who sees what](/guide/decide-who-sees-what.md) Not everyone may see every field. This chapter signs a user in, decides what each caller may read and call, and bounds what a request and a page may do.
231
+ 8. [Run it](/guide/run-it.md) A CRM in production has to be configured, started, stopped and watched. This chapter does each, with the logs, traces and health checks an operator reads.
232
+ 9. [Open it to machines](/guide/open-it-to-machines.md) The handlers the CRM already has are its API. This chapter publishes them as OpenAPI and MCP, keeps one off a surface, puts an assistant on the site, and calls a handler from a terminal.
233
+ 10. [Test, measure, ship](/guide/test-measure-and-ship.md) The CRM gets a reports page, and each report gets a test: what a click rewrote, what a change costs as the pipeline grows, and where a slow click spends its time. Then the CRM ships, as a server, a single binary or a desktop app, and keeps its pages readable offline.
277
234
 
278
235
  ## Reference
279
236
 
280
237
  A guide answers *how do I solve this*. Reference answers *what exactly does this take, and what does it hand back*: one page per name an app imports, read off the registry, and one per area for what is written in place.
281
238
 
282
- Read on: [Overview](/reference.md)
239
+ Read on: [Reference](/reference.md)
283
240
 
284
241
  ---
285
242
 
@@ -298,9 +255,21 @@ bun install
298
255
  bun run dev
299
256
  ```
300
257
 
301
- The scaffold writes a root layout, a page, a handler file the page calls, and a test of each. `bun run dev` runs `abide dev`, which serves the app on `localhost:3000` and restarts it on every save, with the open tab reloading behind it.
258
+ `bun run dev` runs `abide dev`, which serves the app on `localhost:3000` and restarts it on every save, with the open tab reloading behind it.
302
259
 
303
- Every chapter ends with the CRM it built, as an app you can download and run. Each card below is a file from that app, with the test the app runs on it.
260
+ | The scaffold writes | What it is |
261
+ | --- | --- |
262
+ | `src/layout.abide` | the root layout, around every page |
263
+ | `src/index.abide` | a page with a greeting and a click counter |
264
+ | `src/getGreeting.handler.ts` | the handler the page reads its greeting from |
265
+ | `src/getGreeting.test.ts` | a test that calls the handler |
266
+ | `src/index.test.ts` | a test that mounts the page, clicks it, and counts the work the click did |
267
+ | `src/lib/app.css` | the stylesheet the layout imports: Tailwind, built by the plugin `bunfig.toml` declares |
268
+ | `CLAUDE.md` · `AGENTS.md` | notes for a coding agent, importing the `ABIDE.md` the installed package holds: where its docs, as one markdown file, and its registry and rulebook are |
269
+ | `Dockerfile` · `.dockerignore` | an image holding the compiled server and nothing else of the app. See [Test, measure, ship](/guide/test-measure-and-ship.md) |
270
+ | `package.json` | the scripts `dev`, `build`, `start`, `test`, `check`, `format`, `bench` and `routes` |
271
+
272
+ The CRM replaces the starter's page with a contacts page, and keeps the rest. Every card on this page offers the CRM as this chapter leaves it, at `/examples/crm-01.tar.gz`.
304
273
 
305
274
  ## A file's path is the address it answers
306
275
 
@@ -323,6 +292,8 @@ Code that is not an address goes in a directory that never is, and a file's name
323
292
  | `**/lib/**` | components and modules | wherever they are imported |
324
293
  | `**/lib/**/<name>.server.<extension>` | the database, secrets, anything the browser never loads | the server alone; a page importing it is a compile error |
325
294
  | `**/<name>.test.ts` | tests, beside what they test | `bun test`, never served |
295
+ | `**/<name>.bench.ts` | benches, beside what they time. See [Test, measure, ship](/guide/test-measure-and-ship.md) | `bun run bench`, never served |
296
+ | `**/<NAME>.param.ts` | what a segment named `NAME` takes. See [Add pages](/guide/add-pages.md) | wherever an address is matched |
326
297
  | `**/<name>.d.ts` | types, beside what they type | nowhere; the checker reads them |
327
298
  | `**/public/**` | files answered as they are | nowhere; each is sent at its path |
328
299
 
@@ -363,11 +334,18 @@ export default GET(async () => {
363
334
  ```ts src/contacts/lib/ContactList.test.ts
364
335
  import { expect, test } from 'bun:test'
365
336
 
366
- import listContacts from '../listContacts.handler.ts'
337
+ import { mount } from '@abide/harness/abide'
338
+
339
+ import ContactList from './ContactList.abide'
340
+
341
+ const names = () =>
342
+ [...document.querySelectorAll('strong')].map((name) => name.textContent)
367
343
 
368
- test('the handler the list reads answers with every contact', async () => {
369
- const contacts = await listContacts()
370
- expect(contacts.map((contact) => contact.name)).toEqual([
344
+ test('the list is empty until the handler answers, then fills in', async () => {
345
+ using _list = await mount(ContactList({}))
346
+ expect(names()).toEqual([])
347
+ await Bun.sleep(500)
348
+ expect(names()).toEqual([
371
349
  'Ada Lovelace',
372
350
  'Grace Hopper',
373
351
  'Katherine Johnson',
@@ -375,9 +353,47 @@ test('the handler the list reads answers with every contact', async () => {
375
353
  })
376
354
  ```
377
355
 
356
+ ```ts src/contacts/lib/ContactList.bench.ts
357
+ import { reactive } from '@abide/abide'
358
+ import { batch, type Batched } from '@abide/harness/report'
359
+
360
+ // The list as the card reads it, once the handler answered `count` contacts.
361
+ function landed(count: number): () => void {
362
+ const answered: { id: string; name: string; company: string }[] = []
363
+ for (let at = 0; at < count; at += 1)
364
+ answered.push({
365
+ id: `c${at}`,
366
+ name: `Contact ${at}`,
367
+ company: `Company ${at}`,
368
+ })
369
+ const contacts = reactive(() => answered)
370
+ contacts()
371
+ return () => {
372
+ contacts()
373
+ }
374
+ }
375
+
376
+ // The most a read may take among 1,000 contacts, in milliseconds.
377
+ export const CEILINGS = { priced: { 'among 1,000': 1 } }
378
+
379
+ /** Reading the landed list among 100 contacts, and among 1,000. */
380
+ export function priced(): Batched {
381
+ return batch({
382
+ case: 'read the contacts the handler answered',
383
+ emulated: null,
384
+ arms: {
385
+ 'among 100': landed(100),
386
+ 'among 1,000': landed(1000),
387
+ },
388
+ })
389
+ }
390
+ ```
391
+
392
+ The bench reads the list once the handler has answered, among 100 contacts and among 1,000. Its ratio is what a read costs as the list grows: a read hands back what the handler answered, and copies nothing.
393
+
378
394
  `listContacts.handler.ts` runs on the server. The list imports it like any module, and the browser is sent a call under the same name in its place, so there is no `fetch`, no route to register, and no second copy of the contact type.
379
395
 
380
- The test imports the same handler and calls it, in process. A handler only one page calls can live in that page's `<script server>` instead. See [Talk to the server](/guide/talk-to-the-server.md).
396
+ The test mounts the list, and the handler runs in process. A handler only one file calls can live in that file's `<script server>` instead. See [Talk to the server](/guide/talk-to-the-server.md).
381
397
 
382
398
  ## The page is sent before the data lands
383
399
 
@@ -416,15 +432,34 @@ async function requestPage() {
416
432
  ```ts src/contacts/lib/PageTiming.test.ts
417
433
  import { expect, test } from 'bun:test'
418
434
 
435
+ import { flushSync } from '@abide/abide'
419
436
  import { createApp } from '@abide/abide/server'
437
+ import { mount } from '@abide/harness/abide'
438
+
439
+ import PageTiming from './PageTiming.abide'
440
+
441
+ // happy-dom's own hook: the card requests the page from where it is open.
442
+ const openAt = (address: string) =>
443
+ (
444
+ window as unknown as { happyDOM: { setURL(url: string): void } }
445
+ ).happyDOM.setURL(address)
420
446
 
421
- test('the contacts page is complete before the contacts land', async () => {
447
+ const tile = (at: number) =>
448
+ Number.parseInt(document.querySelectorAll('strong')[at]?.textContent ?? '')
449
+
450
+ test('the contacts page is complete before the contacts answer', async () => {
422
451
  await using app = await createApp()
423
- const answer = await app.fetch(new Request('http://crm.invalid/contacts'))
424
- const page = await answer.text()
425
- // The whole document, closed, with a hole where the list goes.
426
- expect(page).toEndWith('</html>')
427
- expect(page).not.toContain('Ada Lovelace')
452
+ using server = Bun.serve({
453
+ port: 0,
454
+ fetch: (request) => app.fetch(request),
455
+ })
456
+ openAt(server.url.href)
457
+ using _timing = await mount(PageTiming({}))
458
+ document.querySelector('button')?.click()
459
+ await Bun.sleep(600)
460
+ flushSync()
461
+ expect(tile(0)).toBeLessThan(tile(1))
462
+ openAt('http://localhost/')
428
463
  })
429
464
  ```
430
465
 
@@ -436,7 +471,7 @@ A page's `<script>` runs on the server to render it, then again in the browser.
436
471
 
437
472
  ## A value is read and written by name
438
473
 
439
- **`starred` — read by name, written by name** — Star a contact. The button reads the value, and the click writes it.
474
+ **`starred` — read by name, written by name** — Grace Hopper is the contact you call most. Star her, then take the star off.
440
475
 
441
476
  ```abide src/contacts/lib/StarButton.abide
442
477
  <script>
@@ -471,7 +506,7 @@ test('the click writes starred and the button follows', async () => {
471
506
  })
472
507
  ```
473
508
 
474
- Naming `starred` in the markup reads it and follows it from then on. Assigning to it writes it and wakes everything that read it. There is no store to declare and no re-render to ask for. A `.ts` file has no markup around it, so it writes the same value as `starred()` and `starred.set(true)`.
509
+ Naming `starred` in the markup reads it and follows it from then on. Assigning to it writes it and wakes everything that read it, with no re-render to ask for. [Edit a contact](/guide/edit-a-contact.md) has every spelling of it.
475
510
 
476
511
  ## Every value is a `Reactive`
477
512
 
@@ -534,6 +569,8 @@ A value the page owns, a value derived from it, a value loaded from a handler, a
534
569
 
535
570
  Swapping a local value for a loaded one changes how it is declared and nothing that reads it.
536
571
 
572
+ A `Reactive` can also be awaited, so code that takes a promise for a load asks `isReactive` first. A `Reactive` is a value, even while it loads.
573
+
537
574
  ## The `abide` command
538
575
 
539
576
  | Command | What it does for the CRM |
@@ -560,7 +597,7 @@ Swapping a local value for a loaded one changes how it is declared and nothing t
560
597
 
561
598
  A contact's profile is held, edited and shown. This chapter is the whole of what a page needs for that: one value per thing held, values derived from them, and markup that follows both.
562
599
 
563
- The profile lives in `src/profile`, at `/profile`. Each card is one of its components, with the test the app runs on it.
600
+ The profile lives in `src/profile`, at `/profile`.
564
601
 
565
602
  ## A field and its value are one copy
566
603
 
@@ -603,8 +640,47 @@ test('typing writes the value, and the value fills the field', async () => {
603
640
  })
604
641
  ```
605
642
 
643
+ ```ts src/profile/lib/ContactFields.bench.ts
644
+ import { reactive } from '@abide/abide'
645
+ import { batch, type Batched } from '@abide/harness/report'
646
+
647
+ // The field as the card keeps it, holding `length` characters.
648
+ function typing(length: number): () => void {
649
+ const typed = 'a'.repeat(length - 1)
650
+ // Two values a keystroke apart, differing in the last character.
651
+ const keystrokes = [`${typed}b`, `${typed}c`]
652
+ const email = reactive(keystrokes[0] as string)
653
+ email()
654
+ let at = 0
655
+ return () => {
656
+ at ^= 1
657
+ email.set(keystrokes[at] as string)
658
+ email()
659
+ }
660
+ }
661
+
662
+ // The most a keystroke may take in 1,000 characters, in milliseconds.
663
+ export const CEILINGS = { priced: { 'of 1,000 characters': 1 } }
664
+
665
+ /** A keystroke into a field of 100 characters, and of 1,000. */
666
+ export function priced(): Batched {
667
+ return batch({
668
+ case: 'type into the field, read what a save would send',
669
+ emulated: null,
670
+ arms: {
671
+ 'of 100 characters': typing(100),
672
+ 'of 1,000 characters': typing(1000),
673
+ },
674
+ })
675
+ }
676
+ ```
677
+
678
+ The bench types into the field once it holds 100 characters, and once it holds 1,000. Its ratio is what a keystroke costs as the value grows: a keystroke writes the value once, and copies nothing.
679
+
606
680
  `bind:value` sets the field from the value, and writes the value back on `input` or `change`. There is no form model beside it and no handler per field. A `<textarea>` and a `<select>` take the same binding.
607
681
 
682
+ A value that starts as a number binds as one, and so does an `<input type="number">` or a `type="range"`. Text that reads as a number writes the number, blank text writes `undefined`, and any other text writes nothing.
683
+
608
684
  | You write | What it binds |
609
685
  | --- | --- |
610
686
  | `bind:checked` | a checkbox, to a boolean |
@@ -614,9 +690,9 @@ test('typing writes the value, and the value fills the field', async () => {
614
690
  | `bind:value={{get, set}}` | a field to a pair you write, where the value is computed |
615
691
  | `bind:prop={state}` | a component's prop, so the child's write reaches your value |
616
692
 
617
- ## A reactive value is read and written by its name
693
+ ## Where a reactive value's name is written decides what it reaches
618
694
 
619
- `reactive` holds a value. Inside a `.abide` file its name is the value: `email` in an expression reads it, and `email = 'a@b.co'` writes it. The sugar sits over two explicit members, `s` and `s.set`, and a `.ts` file writes those out.
695
+ `reactive` holds a value, and inside a `.abide` file its name reads and writes it. The sugar sits over two explicit members, `s` and `s.set`, and a `.ts` file writes those out.
620
696
 
621
697
  | You write | You get |
622
698
  | --- | --- |
@@ -624,7 +700,7 @@ test('typing writes the value, and the value fills the field', async () => {
624
700
  | `const x: Reactive<…> = foo` / a `Reactive<…>`-typed parameter, return or `bind:` operand | the `Reactive` itself |
625
701
  | `const [a, b] = foo` / `...foo` | one read, taken apart |
626
702
  | `foo = bar` | a write |
627
- | `foo.bar` | the value's `bar` |
703
+ | `foo.bar` | the value's `bar`, or the `Reactive`'s own `bar` where it declares one the value does not |
628
704
  | `foo.bar(…)` | the value's method where it has one, and otherwise a member of the `Reactive` such as `busy` |
629
705
  | `foo()` / `foo.set(v)` | the explicit read and write |
630
706
  | `await foo` | the value, once it has settled |
@@ -735,11 +811,11 @@ test('a phone edit reruns the count, not the initials', async () => {
735
811
 
736
812
  Give `reactive` a body, `reactive(() => …)`, and what the body read on its last run is what reruns it. You list no dependencies. The test counts the work: a phone edit walks to the count and never reaches the initials, because the initials never read the phone.
737
813
 
738
- A body with a parameter, `reactive((args) => …)`, is a `Keyed`: a factory holding one value per set of arguments. It is how a handler is called per contact, in [Talk to the server](/guide/talk-to-the-server.md).
814
+ A body with a parameter, `reactive((args) => …)`, is a `Keyed`: a factory holding one value per set of arguments. See [Keep it current](/guide/keep-it-current.md), where the pipeline keeps one per stage.
739
815
 
740
816
  ## A write through a path copies down the path
741
817
 
742
- **`contact.address.city = v` — a write two levels down** — A contact's city, inside its address. Type a new city.
818
+ **`bind:value={contact.address.city}` — a write two levels down** — A contact's city, inside its address. Type a new city.
743
819
 
744
820
  ```abide src/profile/lib/AddressField.abide
745
821
  <script>
@@ -781,13 +857,13 @@ test('a write to the city reaches the record it sits in', async () => {
781
857
  })
782
858
  ```
783
859
 
784
- `foo.bar = v` copies `contact`, copies its `address`, sets `city`, and writes the new record back. The copy is what wakes the readers: a record changed in place is the object it already was, and nothing downstream has a reason to read it again.
860
+ The binding writes through the path, the way `contact.address.city = city` would: it copies `contact`, copies its `address`, sets `city`, and writes the new record back. The copy wakes the readers. A record changed in place is the object it already was, and nothing downstream has a reason to read it again.
785
861
 
786
- `foo.push(v)` is the same write reached through an array's mutator, once the compiler lifts it (31.9). Until then, write the new array: `tags = [...tags, tag]`.
862
+ `foo.bar = v` is that write wherever you spell it, and so is `foo.push(v)`: where the value is an array, `tags.push(tag)` runs on a copy and writes the copy back. The array's type decides it, so a class's own `push` stays the class's call.
787
863
 
788
- ## A hole in the markup follows what it reads
864
+ ## An expression in the markup follows what it reads
789
865
 
790
- A hole is `{}` around an expression, and it updates the one thing it names and nothing around it.
866
+ Put `{}` around an expression and it updates the one thing it lands on, and nothing around it.
791
867
 
792
868
  | You write | Where it lands |
793
869
  | --- | --- |
@@ -924,7 +1000,7 @@ const { name } = props<{ name: string }>()
924
1000
  </script>
925
1001
 
926
1002
  <span class="rounded-full border border-rule bg-raised px-2 py-1 font-semibold">
927
- { name
1003
+ {name
928
1004
  .split(' ')
929
1005
  .map((word) => word.charAt(0))
930
1006
  .join('')}
@@ -958,6 +1034,8 @@ test('the avatar follows its name prop, and is not rebuilt', async () => {
958
1034
 
959
1035
  A component is a `.abide` file, and `<Name/>` invokes it. `props` hands back each prop as a live read of what the caller passed, so the avatar follows the field and nothing is rebuilt: the test counts no node made or moved.
960
1036
 
1037
+ A prop the child only reads holds the caller's `Reactive` wherever a name would: handed on to a `bind:` or a `Reactive`-typed parameter, it passes the `Reactive` itself. Its probes answer for everything the caller's expression read, so `busy(rows)` in a table component is busy while the list its page passed loads. [Talk to the server](/guide/talk-to-the-server.md) introduces the probes.
1038
+
961
1039
  | Name | What it does for the CRM |
962
1040
  | --- | --- |
963
1041
  | `<Tag>…</Tag>` | passes the markup between the tags as `children` |
@@ -1101,7 +1179,7 @@ A `<script>` block runs each time the component is used, top to bottom, so each
1101
1179
 
1102
1180
  ## Next
1103
1181
 
1104
- - [Talk to the server](/guide/talk-to-the-server.md) — load a contact from a handler, and save it back
1182
+ - [Talk to the server](/guide/talk-to-the-server.md) — read deals from the server, change them, refuse a bad request, and say why
1105
1183
 
1106
1184
  ---
1107
1185
 
@@ -1113,7 +1191,7 @@ A `<script>` block runs each time the component is used, top to bottom, so each
1113
1191
 
1114
1192
  Deals live on the server. This chapter reads them, writes them, and refuses a request that is wrong, all through handlers a page calls by name.
1115
1193
 
1116
- Every server call in this chapter is a handler: a function wrapped in `GET`, `POST`, `PATCH` or one of their siblings. A page imports it and calls it like any function. There is no route table, no `fetch` and no client to keep in step with the server.
1194
+ Every server call in this chapter is a handler: a function wrapped in `GET`, `POST`, `PATCH` or one of their siblings. A page imports it and calls it like any function. There is no route to register, no `fetch` and no client to keep in step with the server.
1117
1195
 
1118
1196
  ## A handler file answers at its own path
1119
1197
 
@@ -1180,22 +1258,154 @@ export default GET(
1180
1258
  ```ts src/deals/lib/DealDetail.test.ts
1181
1259
  import { expect, test } from 'bun:test'
1182
1260
 
1183
- import getDeal from '../getDeal.handler.ts'
1261
+ import { flushSync } from '@abide/abide'
1262
+ import { mount } from '@abide/harness/abide'
1184
1263
 
1185
- test('the handler answers the deal its args name', async () => {
1186
- const deal = await getDeal({ id: 'd2' })
1187
- expect(deal.name).toBe('Compiler pilot')
1264
+ import DealDetail from './DealDetail.abide'
1265
+
1266
+ const shown = () => document.querySelector('strong')?.textContent
1267
+
1268
+ test('picking a deal loads the one its args name', async () => {
1269
+ using _detail = await mount(DealDetail({}))
1270
+ await Bun.sleep(400)
1271
+ expect(shown()).toBe('Engine renewal')
1272
+ for (const tab of document.querySelectorAll('button'))
1273
+ if (tab.textContent === 'd2') tab.click()
1274
+ flushSync()
1275
+ await Bun.sleep(400)
1276
+ expect(shown()).toBe('Compiler pilot')
1188
1277
  })
1189
1278
  ```
1190
1279
 
1280
+ ```ts src/deals/lib/DealDetail.bench.ts
1281
+ import { reactive } from '@abide/abide'
1282
+ import { batch, type Batched } from '@abide/harness/report'
1283
+
1284
+ // The detail as the card reads it, the handler's answers among `count` deals.
1285
+ function picking(count: number): () => void {
1286
+ const answered: Record<string, { name: string; company: string }> = {}
1287
+ for (let at = 0; at < count; at += 1)
1288
+ answered[`d${at}`] = { name: `Deal ${at}`, company: `Company ${at}` }
1289
+ const id = reactive('d0')
1290
+ const deal = reactive(() => answered[id()])
1291
+ deal()
1292
+ // Back and forth between two deals, as the card's tabs pick.
1293
+ let picked = 'd0'
1294
+ return () => {
1295
+ picked = picked === 'd0' ? 'd1' : 'd0'
1296
+ id.set(picked)
1297
+ deal()
1298
+ }
1299
+ }
1300
+
1301
+ // The most a pick may take among 1,000 deals, in milliseconds.
1302
+ export const CEILINGS = { priced: { 'among 1,000': 1 } }
1303
+
1304
+ /** Picking a deal among 100, and among 1,000. */
1305
+ export function priced(): Batched {
1306
+ return batch({
1307
+ case: 'pick a deal, read the one the handler answered',
1308
+ emulated: null,
1309
+ arms: {
1310
+ 'among 100': picking(100),
1311
+ 'among 1,000': picking(1000),
1312
+ },
1313
+ })
1314
+ }
1315
+ ```
1316
+
1317
+ The bench picks a deal once the handler has answered it, among 100 deals and among 1,000. Its ratio is what a pick costs as the pipeline grows: the page holds the one deal its arguments name, and never the rest.
1318
+
1191
1319
  The annotation `{ id: string }` is the handler's `Args`, and what it returns is its `Value`. `GET` builds the runtime schema from that annotation, so a request whose arguments do not match it is refused with 422 before the handler runs.
1192
1320
 
1193
1321
  In the browser the import is an `Rpc`: a call under the same name, with the same types, sent over http. The call hands back a reactive value rather than a promise, so the page reads `deal.name` by name and the probes are on the result.
1194
1322
 
1195
- Reading `deal` starts the load. The page renders with a hole where each field goes, and the holes fill when the deal lands. A read outside the markup, in a click handler, throws `NotLanded` until then.
1323
+ Reading `deal` starts the load. The page renders with a hole where each field goes, and the holes fill when the deal lands.
1196
1324
 
1197
1325
  The `description` is the sentence every surface shows for the handler: OpenAPI, MCP and the CLI. `refuse(404)` answers a deal that does not exist.
1198
1326
 
1327
+ ## A refresh keeps the figures on screen
1328
+
1329
+ **`busy()` and `ready()` — a first load and a refresh, told apart** — The pipeline's total. Wait for it, then press Refresh: the old figure stays until the new one lands.
1330
+
1331
+ ```abide src/deals/lib/Pipeline.abide
1332
+ <script>
1333
+ import { reactive, refresh } from '@abide/abide'
1334
+
1335
+ import listDeals from '../listDeals.handler.ts'
1336
+
1337
+ const deals = reactive(() => listDeals())
1338
+
1339
+ const total = reactive(() => {
1340
+ let sum = 0
1341
+ for (const deal of deals) if (deal.stage !== 'won') sum += deal.amount
1342
+ return sum
1343
+ })
1344
+ </script>
1345
+
1346
+ <div class="row">
1347
+ <button onclick={() => refresh(deals)}>Refresh</button>
1348
+ <span class="status">
1349
+ {!deals.ready() ? 'loading…' : deals.busy() ? 'refreshing…' : ''}
1350
+ </span>
1351
+ </div>
1352
+
1353
+ <ul class="tiles">
1354
+ <li><span>in the pipeline</span>
1355
+ <strong>${total.toLocaleString('en-US')}</strong></li>
1356
+ </ul>
1357
+ ```
1358
+
1359
+ ```ts src/deals/listDeals.handler.ts
1360
+ import { GET } from '@abide/abide/server'
1361
+
1362
+ import { DEALS } from './lib/DEALS.server.ts'
1363
+
1364
+ export default GET(
1365
+ async () => {
1366
+ // A slow database, so the wait is on screen.
1367
+ await Bun.sleep(400)
1368
+ return DEALS.map((deal) => ({ ...deal }))
1369
+ },
1370
+ { description: 'Every deal.' },
1371
+ )
1372
+ ```
1373
+
1374
+ ```ts src/deals/lib/Pipeline.test.ts
1375
+ import { expect, test } from 'bun:test'
1376
+
1377
+ import { flushSync } from '@abide/abide'
1378
+ import { mount } from '@abide/harness/abide'
1379
+
1380
+ import Pipeline from './Pipeline.abide'
1381
+
1382
+ const status = () => document.querySelector('.status')?.textContent?.trim()
1383
+ const total = () => document.querySelector('strong')?.textContent
1384
+
1385
+ test('a refresh is busy and still ready; a first load is not', async () => {
1386
+ using _pipeline = await mount(Pipeline({}))
1387
+ expect(status()).toBe('loading…')
1388
+ await Bun.sleep(500)
1389
+ expect([status(), total()]).toEqual(['', '$16,800'])
1390
+ document.querySelector('button')?.click()
1391
+ flushSync()
1392
+ // Busy and still ready: the figure on screen stays until the new one lands.
1393
+ expect([status(), total()]).toEqual(['refreshing…', '$16,800'])
1394
+ })
1395
+ ```
1396
+
1397
+ A first load is `busy()` with nothing `ready()`. A refresh is `busy()` and still `ready()`, so the tile keeps the figure it has while the new one loads. One loading flag cannot tell the two apart, and blanks the page on every refresh. `refresh(deals)` loads the value again, and [Keep it current](/guide/keep-it-current.md) is the chapter on reloading.
1398
+
1399
+ | Probe | Answers |
1400
+ | --- | --- |
1401
+ | `s.busy` | whether work is in flight for the value |
1402
+ | `s.ready` | whether there is a value to show |
1403
+ | `s.error` | the standing failure, handed back rather than thrown |
1404
+ | `busy` | whether anything in a selection is in flight: an app-wide progress bar |
1405
+ | `ready` | whether everything in a selection has a value |
1406
+
1407
+ A probe never throws and never starts a load, so it is safe to read before anything has landed. A read of the value itself outside the markup, in a click handler, throws `NotLanded` until it lands. The value is also a promise, for a handler or a test: `await deals` waits for it, and `s.then`, `s.catch` and `s.finally` reach the same settling.
1408
+
1199
1409
  ## A handler only one file calls stays in that file
1200
1410
 
1201
1411
  **`const getNotes = GET(fn)` — a handler its own file calls** — The notes on a deal, which only this card shows. The handler sits in the card's own server block.
@@ -1242,7 +1452,7 @@ test('the private handler answers below /__abide/handlers', async () => {
1242
1452
  })
1243
1453
  ```
1244
1454
 
1245
- A `<script server>` runs on the server alone. It declares `getNotes` as a `const`, and the same file's script and markup call it by name. A `.abide` exports nothing, so no other file can reach it.
1455
+ A `<script server>` runs on the server alone. It declares `getNotes` as a `const`, and the same file's script and markup call it by name. A `.abide` exports nothing, so no other file can import it.
1246
1456
 
1247
1457
  It still answers over http, below `/__abide/handlers` at its file's path and then its name. That address is listed on no surface, and the test calls it to show it is there. When a second file needs the handler, move it into a handler file: `const getNotes =` becomes `export default`.
1248
1458
 
@@ -1255,7 +1465,7 @@ It still answers over http, below `/__abide/handlers` at its file's path and the
1255
1465
  | `PUT(fn)` | a deal replaced whole | in the body |
1256
1466
  | `DELETE(fn)` | a deal removed | in the URL, flat |
1257
1467
 
1258
- `POST` / `PUT` / `PATCH` / `DELETE` each take what `GET` takes and are called the way it is. The page never names the method; the declaration carries it. A `GET` must not write, because a link on another site can reach it with your user's cookie attached.
1468
+ `POST` / `PUT` / `PATCH` / `DELETE` each take what `GET` takes and are called the way it is. The page never names the method; the handler carries it. A `GET` must not write, because a link on another site can reach it with your user's cookie attached.
1259
1469
 
1260
1470
  ## A new deal is checked before it is written
1261
1471
 
@@ -1271,12 +1481,12 @@ type NewDeal = { name: string; company: string; amount: number }
1271
1481
 
1272
1482
  let name = reactive('Ledger audit')
1273
1483
  let company = reactive('')
1274
- let amount = reactive('0')
1484
+ let amount = reactive(0)
1275
1485
  let issues = reactive<Issues<NewDeal>>({})
1276
1486
 
1277
1487
  async function add() {
1278
1488
  try {
1279
- await createDeal({ name, company, amount: Number(amount) })
1489
+ await createDeal({ name, company, amount })
1280
1490
  issues = {}
1281
1491
  } catch (error) {
1282
1492
  if (!createDeal.isError(error, 'ValidationError')) throw error
@@ -1321,6 +1531,8 @@ const NEW_DEAL = jsonSchema<NewDeal>({
1321
1531
 
1322
1532
  export default POST(
1323
1533
  ({ name, company, amount }: NewDeal) => {
1534
+ // Every caller shares one list: past ten, the newest replaces the last.
1535
+ if (DEALS.length >= 10) DEALS.pop()
1324
1536
  const deal = {
1325
1537
  id: `d${DEALS.length + 1}`,
1326
1538
  name,
@@ -1338,30 +1550,26 @@ export default POST(
1338
1550
  ```ts src/deals/lib/NewDeal.test.ts
1339
1551
  import { expect, test } from 'bun:test'
1340
1552
 
1341
- import { createApp } from '@abide/abide/server'
1553
+ import { flushSync } from '@abide/abide'
1554
+ import { mount } from '@abide/harness/abide'
1555
+
1556
+ import NewDeal from './NewDeal.abide'
1342
1557
 
1343
1558
  test('a deal that does not fit is refused, field by field', async () => {
1344
- await using app = await createApp()
1345
- const answer = await app.fetch(
1346
- new Request('http://crm.invalid/deals/createDeal', {
1347
- method: 'POST',
1348
- headers: { 'content-type': 'application/json' },
1349
- body: JSON.stringify({
1350
- name: 'Ledger audit',
1351
- company: '',
1352
- amount: 0,
1353
- }),
1354
- }),
1559
+ using _form = await mount(NewDeal({}))
1560
+ document.querySelector('button')?.click()
1561
+ await Bun.sleep(50)
1562
+ flushSync()
1563
+ const fields = [...document.querySelectorAll('li span')].map(
1564
+ (field) => field.textContent,
1355
1565
  )
1356
- expect(answer.status).toBe(422)
1357
- const { data } = await answer.json()
1358
- expect(Object.keys(data).sort()).toEqual(['amount', 'company'])
1566
+ expect(fields.sort()).toEqual(['amount', 'company'])
1359
1567
  })
1360
1568
  ```
1361
1569
 
1362
1570
  `args` gates the arguments before the handler runs. A request that fails it is refused with 422 and a `ValidationError`, whose data is `Issues<T>`: the messages, keyed by the field's path. The card lists each field with its first message, and `issues.company` is the company's.
1363
1571
 
1364
- Two presses of Add inside one flight send one request and write one deal, and both get its answer.
1572
+ `amount` starts as a number, so its field binds as one and the handler is handed a number.
1365
1573
 
1366
1574
  | Name | What it does for the CRM |
1367
1575
  | --- | --- |
@@ -1456,16 +1664,26 @@ export default PATCH(
1456
1664
  ```ts src/deals/lib/DealStage.test.ts
1457
1665
  import { expect, test } from 'bun:test'
1458
1666
 
1459
- import moveDeal from '../moveDeal.handler.ts'
1667
+ import { flushSync } from '@abide/abide'
1668
+ import { mount } from '@abide/harness/abide'
1460
1669
 
1461
- test('a closed deal is refused by name, with its stage as data', async () => {
1462
- try {
1463
- await moveDeal({ id: 'd3', stage: 'lead' })
1464
- throw new Error('expected a refusal')
1465
- } catch (error) {
1466
- if (!moveDeal.isError(error, 'DealClosed')) throw error
1467
- expect(error.data).toEqual({ stage: 'won' })
1468
- }
1670
+ import DealStage from './DealStage.abide'
1671
+
1672
+ function press(label: string) {
1673
+ for (const button of document.querySelectorAll('button'))
1674
+ if (button.textContent === label) button.click()
1675
+ flushSync()
1676
+ }
1677
+
1678
+ test('a closed deal is refused by name, and the page says why', async () => {
1679
+ using _stage = await mount(DealStage({}))
1680
+ press('d3')
1681
+ await Bun.sleep(400)
1682
+ press('lead')
1683
+ await Bun.sleep(50)
1684
+ expect(document.querySelector('.status')?.textContent).toBe(
1685
+ 'Already won: A closed deal does not move.',
1686
+ )
1469
1687
  })
1470
1688
  ```
1471
1689
 
@@ -1473,13 +1691,15 @@ test('a closed deal is refused by name, with its stage as data', async () => {
1473
1691
 
1474
1692
  Every refusal is a `Failed<Name, Data>`, in process and over the wire alike. The ones a handler can return are its `Failures`, so `rpc.isError` narrows by name and types `error.data` from the schema. `s.isError` is the same check on any reactive value, for a refusal the page made itself.
1475
1693
 
1476
- `refuse(status)` is for where the status is the whole answer. It has no name a caller can narrow to.
1694
+ `refuse(status)` is for where the status is the whole answer. Every one is named `HttpError`, so `moveDeal.isError(error, 'HttpError')` narrows to it, and the status is the only thing a caller can tell apart.
1695
+
1696
+ A move that lands writes the server's answer over `deal`, a derived value, so the page shows the new stage without loading the deal again. The write stands until the body reruns, when `id` changes.
1477
1697
 
1478
1698
  | abide's own refusal | Status | When |
1479
1699
  | --- | --- | --- |
1480
1700
  | `notFound` | 404 | nothing answers the address, with the path and method as data |
1481
1701
  | `validationError` | 422 | a schema or an `args` gate refused, with the issues as data |
1482
- | `aborted` | 499 | every caller waiting on a load passed a `signal`, and every one aborted |
1702
+ | `aborted` | 499 | every caller waiting on a load passed a `signal` and every one aborted, or the client left |
1483
1703
 
1484
1704
  ## A handler can answer with a file
1485
1705
 
@@ -1515,11 +1735,19 @@ export default GET(
1515
1735
  ```ts src/deals/lib/ExportDeals.test.ts
1516
1736
  import { expect, test } from 'bun:test'
1517
1737
 
1518
- import exportDeals from '../exportDeals.handler.ts'
1738
+ import { createApp } from '@abide/abide/server'
1739
+ import { mount } from '@abide/harness/abide'
1519
1740
 
1520
- test('the export answers a CSV file', async () => {
1521
- const file = await exportDeals()
1522
- expect(file.type).toBe('text/csv')
1741
+ import ExportDeals from './ExportDeals.abide'
1742
+
1743
+ test('the link downloads a CSV file', async () => {
1744
+ await using app = await createApp()
1745
+ using _link = await mount(ExportDeals({}))
1746
+ const href = document.querySelector('a')?.getAttribute('href') ?? ''
1747
+ const answer = await app.fetch(
1748
+ new Request(new URL(href, 'http://crm.invalid')),
1749
+ )
1750
+ expect(answer.headers.get('content-type')).toStartWith('text/csv')
1523
1751
  })
1524
1752
  ```
1525
1753
 
@@ -1550,93 +1778,21 @@ Every answer carries headers you did not write:
1550
1778
 
1551
1779
  ## `abide routes` prints every address
1552
1780
 
1553
- ```
1554
- abide routes
1555
- GET /deals deals/index.abide
1556
- POST /deals/createDeal deals/createDeal.handler.ts
1557
- GET /deals/exportDeals deals/exportDeals.handler.ts
1558
- GET /deals/getDeal deals/getDeal.handler.ts
1559
- GET /deals/listDeals deals/listDeals.handler.ts
1560
- PATCH /deals/moveDeal deals/moveDeal.handler.ts
1561
- ```
1562
-
1563
- `abide routes [--json]` prints each address, its method and the file answering it, with every private handler marked `(private)`. With `--json` it prints the same as one document.
1564
-
1565
- ## A refresh keeps the figures on screen
1566
-
1567
- **`busy()` and `ready()` — a first load and a refresh, told apart** — The pipeline's total. Wait for it, add a deal, then press Refresh: the old figure stays until the new one lands.
1568
-
1569
- ```abide src/deals/lib/Pipeline.abide
1570
- <script>
1571
- import { reactive, refresh } from '@abide/abide'
1572
-
1573
- import listDeals from '../listDeals.handler.ts'
1574
-
1575
- const deals = reactive(() => listDeals())
1576
-
1577
- const total = reactive(() => {
1578
- let sum = 0
1579
- for (const deal of deals) if (deal.stage !== 'won') sum += deal.amount
1580
- return sum
1581
- })
1582
- </script>
1781
+ `abide routes [--json]` prints each address, its method and the file answering it, with every private handler marked `(private)`. With `--json` it prints the same as one document. In the CRM as this chapter leaves it, `bun run routes` prints:
1583
1782
 
1584
- <div class="row">
1585
- <button onclick={() => refresh(deals)}>Refresh</button>
1586
- <span class="status">
1587
- {!deals.ready() ? 'loading…' : deals.busy() ? 'refreshing…' : ''}
1588
- </span>
1589
- </div>
1590
-
1591
- <ul class="tiles">
1592
- <li><span>in the pipeline</span>
1593
- <strong>${total.toLocaleString('en-US')}</strong></li>
1594
- </ul>
1595
- ```
1596
-
1597
- ```ts src/deals/listDeals.handler.ts
1598
- import { GET } from '@abide/abide/server'
1599
-
1600
- import { DEALS } from './lib/DEALS.server.ts'
1601
-
1602
- export default GET(
1603
- async () => {
1604
- // A slow database, so the wait is on screen.
1605
- await Bun.sleep(400)
1606
- return DEALS.map((deal) => ({ ...deal }))
1607
- },
1608
- { description: 'Every deal.' },
1609
- )
1610
- ```
1611
-
1612
- ```ts src/deals/lib/Pipeline.test.ts
1613
- import { expect, test } from 'bun:test'
1614
-
1615
- import { reactive, refresh } from '@abide/abide'
1616
-
1617
- import listDeals from '../listDeals.handler.ts'
1618
-
1619
- test('a refresh is busy and still ready; a first load is not', async () => {
1620
- const deals = reactive(() => listDeals())
1621
- expect([deals.busy(), deals.ready()]).toEqual([true, false])
1622
- await deals
1623
- refresh(deals)
1624
- // The figures already on screen stay there.
1625
- expect([deals.busy(), deals.ready()]).toEqual([true, true])
1626
- })
1627
- ```
1628
-
1629
- A first load is `busy()` with nothing `ready()`. A refresh is `busy()` and still `ready()`, so the tile keeps the figure it has while the new one loads. One loading flag cannot tell the two apart, and blanks the page on every refresh.
1630
-
1631
- | Probe | Answers |
1632
- | --- | --- |
1633
- | `s.busy` | whether work is in flight for the value |
1634
- | `s.ready` | whether there is a value to show |
1635
- | `s.error` | the standing failure, handed back rather than thrown |
1636
- | `busy` | whether anything in a selection is in flight: an app-wide progress bar |
1637
- | `ready` | whether everything in a selection has a value |
1638
-
1639
- A probe never throws and never starts a load, so it is safe to read before anything has landed. The value is also a promise, for a handler or a test: `await deals` waits for it, and `s.then`, `s.catch` and `s.finally` reach the same settling.
1783
+ | Method | Address | File below `src` |
1784
+ | --- | --- | --- |
1785
+ | `GET` | `/` | `index.abide` |
1786
+ | `GET` | `/__abide/handlers/deals/lib/DealNotes/getNotes` | `deals/lib/DealNotes.abide` `(private)` |
1787
+ | `GET` | `/contacts` | `contacts/index.abide` |
1788
+ | `GET` | `/contacts/listContacts` | `contacts/listContacts.handler.ts` |
1789
+ | `GET` | `/deals` | `deals/index.abide` |
1790
+ | `POST` | `/deals/createDeal` | `deals/createDeal.handler.ts` |
1791
+ | `GET` | `/deals/exportDeals` | `deals/exportDeals.handler.ts` |
1792
+ | `GET` | `/deals/getDeal` | `deals/getDeal.handler.ts` |
1793
+ | `GET` | `/deals/listDeals` | `deals/listDeals.handler.ts` |
1794
+ | `PATCH` | `/deals/moveDeal` | `deals/moveDeal.handler.ts` |
1795
+ | `GET` | `/profile` | `profile/index.abide` |
1640
1796
 
1641
1797
  ## Next
1642
1798
 
@@ -1656,10 +1812,11 @@ The pipeline lives at `src/pipeline`: deals in four stages, a forecast, a search
1656
1812
 
1657
1813
  ## A call with the same arguments is one load
1658
1814
 
1659
- **`getStage({ stage })` — one entry per set of arguments** — Two panels, each with its own stage picker. Put one on won, and then the other.
1815
+ **`getStage({ stage })` — one entry per set of arguments** — Three panels, each with its own stage picker. Put one on won, and then the others.
1660
1816
 
1661
1817
  ```abide src/pipeline/lib/StageTiles.abide
1662
1818
  <script>
1819
+ import StageAverage from './StageAverage.abide'
1663
1820
  import StageCount from './StageCount.abide'
1664
1821
  import StageValue from './StageValue.abide'
1665
1822
  </script>
@@ -1667,6 +1824,7 @@ import StageValue from './StageValue.abide'
1667
1824
  <div class="row">
1668
1825
  <StageCount />
1669
1826
  <StageValue />
1827
+ <StageAverage />
1670
1828
  </div>
1671
1829
  ```
1672
1830
 
@@ -1718,6 +1876,34 @@ const totals = reactive(() => getStage({ stage }))
1718
1876
  </ul>
1719
1877
  ```
1720
1878
 
1879
+ ```abide src/pipeline/lib/StageAverage.abide
1880
+ <script>
1881
+ import { reactive } from '@abide/abide'
1882
+
1883
+ import getStage from '../getStage.handler.ts'
1884
+ import { dollars } from './dollars.ts'
1885
+ import { STAGES } from './STAGES.ts'
1886
+
1887
+ let stage = reactive('lead')
1888
+ const totals = reactive(() => getStage({ stage }))
1889
+ const average = reactive(() =>
1890
+ totals.ready() && totals.count > 0
1891
+ ? dollars(Math.round(totals.value / totals.count))
1892
+ : '—',
1893
+ )
1894
+ </script>
1895
+
1896
+ <ul class="fields stacked">
1897
+ <li><span>stage</span>
1898
+ <select bind:value={stage}>
1899
+ {#for one of STAGES}
1900
+ <option value={one}>{one}</option>
1901
+ {/for}
1902
+ </select></li>
1903
+ <li><span>average</span><strong>{average}</strong></li>
1904
+ </ul>
1905
+ ```
1906
+
1721
1907
  ```ts src/pipeline/getStage.handler.ts
1722
1908
  import { GET } from '@abide/abide/server'
1723
1909
 
@@ -1759,20 +1945,67 @@ function pick(index: number, stage: string): void {
1759
1945
  select.dispatchEvent(new Event('change', { bubbles: true }))
1760
1946
  }
1761
1947
 
1762
- test('two readers of one stage are one load', async () => {
1948
+ test('three readers of one stage are one load', async () => {
1763
1949
  // Nothing held from an earlier test, so the first read loads.
1764
1950
  getStage.invalidate()
1765
1951
  const before = LOADS.won ?? 0
1766
1952
  using _tiles = await mount(StageTiles({}))
1767
1953
  pick(0, 'won')
1768
1954
  pick(1, 'won')
1955
+ pick(2, 'won')
1769
1956
  await Bun.sleep(600)
1770
- // Both panels on `won`: one load between them.
1957
+ // All three panels on `won`: one load between them.
1771
1958
  expect((LOADS.won ?? 0) - before).toBe(1)
1772
1959
  })
1773
1960
  ```
1774
1961
 
1775
- The two panels never speak to each other, and nothing is lifted into a parent. When both ask for the same stage they get the same entry, so the second panel paints at once, with no request. On two stages they are two entries, and each pays for its own.
1962
+ ```ts src/pipeline/lib/StageTiles.bench.ts
1963
+ import { reactive } from '@abide/abide'
1964
+ import { batch, type Batched } from '@abide/harness/report'
1965
+
1966
+ import { STAGES } from './STAGES.ts'
1967
+
1968
+ // The three panels as the card reads them, entries summed over `count` deals.
1969
+ function panels(count: number): () => void {
1970
+ const entries = new Map<string, { count: number; value: number }>()
1971
+ for (const stage of STAGES) entries.set(stage, { count: 0, value: 0 })
1972
+ for (let at = 0; at < count; at += 1) {
1973
+ const entry = entries.get(STAGES[at % STAGES.length] as string)
1974
+ if (entry) {
1975
+ entry.count += 1
1976
+ entry.value += 1000
1977
+ }
1978
+ }
1979
+ const stages = [reactive('lead'), reactive('lead'), reactive('lead')]
1980
+ const readers = stages.map((stage) => reactive(() => entries.get(stage())))
1981
+ for (const reader of readers) reader()
1982
+ let at = 0
1983
+ return () => {
1984
+ at = (at + 1) % STAGES.length
1985
+ for (const stage of stages) stage.set(STAGES[at] as string)
1986
+ for (const reader of readers) reader()
1987
+ }
1988
+ }
1989
+
1990
+ // The most a pick may take among 1,000 deals, in milliseconds.
1991
+ export const CEILINGS = { priced: { 'among 1,000': 1 } }
1992
+
1993
+ /** Three panels onto the next stage, among 100 deals and among 1,000. */
1994
+ export function priced(): Batched {
1995
+ return batch({
1996
+ case: 'put three panels on one stage, read each entry',
1997
+ emulated: null,
1998
+ arms: {
1999
+ 'among 100': panels(100),
2000
+ 'among 1,000': panels(1000),
2001
+ },
2002
+ })
2003
+ }
2004
+ ```
2005
+
2006
+ The bench puts all three panels on one stage once each entry has been answered, among 100 deals and among 1,000. Its ratio is what a pick costs as the pipeline grows: the panels read the entry the server summed, and never the deals.
2007
+
2008
+ The three panels never speak to each other, and nothing is lifted into a parent. When they ask for the same stage they get the same entry, so the later panels paint at once, with no request. On two stages they are two entries, and each pays for its own.
1776
2009
 
1777
2010
  A body with a parameter is a keyed value: one entry per set of arguments, held. That is true of a handler like `getStage`, and of `reactive((args) => …)` written in a page. The arguments are its `Args`, and the key is their JSON with the properties sorted, so argument order never splits an entry.
1778
2011
 
@@ -1884,7 +2117,7 @@ test('reload now keeps the figure up; mark stale loads nothing', async () => {
1884
2117
 
1885
2118
  `refresh()` loads now and keeps the forecast on screen while it does: the value is `busy()` and still `ready()`, and it swaps when the new one lands. `invalidate()` loads nothing. It marks what is held as wrong, and the next read fetches.
1886
2119
 
1887
- Press Mark stale and then Reload now, and the forecast blanks between the two. The reload has nothing trusted to serve, so it is a first load again. Reach for one or the other, not both.
2120
+ Reach for one or the other, not both. A reload over a value marked stale has nothing trusted to serve, so the figure blanks until it lands.
1888
2121
 
1889
2122
  | Name | What it does for the pipeline |
1890
2123
  | --- | --- |
@@ -2081,7 +2314,8 @@ Every keystroke is a new `q`, and so a new key. `debounce: 300` holds the search
2081
2314
  | `debounce` | changes once the changes stop for that many ms |
2082
2315
  | `throttle` | changes at once, then at most once per window: a live deal count that updates every second, not every message |
2083
2316
  | `identity` | what counts as the same value: a new value equal to the held one by it wakes nobody. The default is the reference |
2084
- | `structural` | `{ identity: structural }` compares by content, so a forecast that reloads to the same record does not repaint |
2317
+ | `structural` | `{ identity: structural }` compares by content, so a stage whose `{ count, value }` reloads unchanged does not repaint |
2318
+ | `transform` | `{ transform: (names) => names.toSorted() }` reshapes the results once, as they land, rather than in every reader. The function is a `Transformer` |
2085
2319
 
2086
2320
  ## `patch` changes one row of a list in place
2087
2321
 
@@ -2170,11 +2404,7 @@ test('an update rewrites one amount and builds nothing', async () => {
2170
2404
 
2171
2405
  The `by deal.id` on the loop keeps each row tied to its deal, so the row the update names is the only one that changes.
2172
2406
 
2173
- Change the held value without `patch` and nothing on the page moves, because no reader was told. The change shows up on the next patch, attached to the wrong update.
2174
-
2175
- | Name | What it does for the pipeline |
2176
- | --- | --- |
2177
- | `transform` | `{ transform: (rows) => new Map(rows.map((deal) => [deal.id, deal])) }` reshapes the list once, as it lands, so an update by id is a lookup rather than a scan. The function is a `Transformer` |
2407
+ `patch` changes the array it holds and copies nothing. A path write in a `.abide` file, `deals[0].amount = 27_500`, also wakes the readers, but copies the path it writes down first. In a `.ts` file, or where the held value is not plain data, `patch` is the write that wakes them.
2178
2408
 
2179
2409
  ## Two components far apart share one value
2180
2410
 
@@ -2260,7 +2490,7 @@ test('picking a deal in the list shows it in the detail panel', async () => {
2260
2490
 
2261
2491
  `DealPicker` creates `selectedDeal`. Every row and the detail panel ask for the same key, find that one on the way up, and never run their own build. No prop runs between them.
2262
2492
 
2263
- Use the value `reactive.share` returns, and no other name. A value lives in the component that created it and below, so two pickers on one page each get their own. Where a value is one thing for the whole app, export it from a module instead.
2493
+ A value lives in the component that created it and below, so two pickers on one page each get their own. Where a value is one thing for the whole app, export it from a module instead.
2264
2494
 
2265
2495
  `Shared` is an interface you widen with declaration merging to type each key. It is optional: without it, the type comes from the build function.
2266
2496
 
@@ -2354,7 +2584,7 @@ The draft is read in the effect, not inside the timer: a read there happens afte
2354
2584
  | `s.peek` | reads what is held without subscribing |
2355
2585
  | `flushSync` | runs every effect a write woke before it returns, for a handler that measures or scrolls the page the write left |
2356
2586
 
2357
- Where a read subscribes depends on where it runs:
2587
+ The effect is the one place in this card where a read subscribes. Its `Disposer` does not, and nor does any function abide calls without rebuilding it:
2358
2588
 
2359
2589
  | A read in | Subscribes |
2360
2590
  | --- | --- |
@@ -2362,125 +2592,114 @@ Where a read subscribes depends on where it runs:
2362
2592
  | `watch` effect, over sources | to the sources alone |
2363
2593
  | `Transformer`, `Disposer`, middleware, lifecycle hooks | to nothing: none of them is rebuilt |
2364
2594
 
2365
- ## A `store` keeps a preference between visits
2595
+ ## A `store` keeps a value between visits
2366
2596
 
2367
- **`{ store }` — where a value lives between visits** — The pipeline as a board or a list. Pick list, then reload the page.
2597
+ **`{ store }` — where a value lives between visits** — The quarter's target, which the whole team works to. Set a new one, then reload the page.
2368
2598
 
2369
- ```abide src/pipeline/lib/PipelineView.abide
2599
+ ```abide src/pipeline/lib/TeamTarget.abide
2370
2600
  <script>
2371
2601
  import { reactive } from '@abide/abide'
2372
2602
 
2373
- import { preferenceStore } from './preferenceStore.ts'
2374
- import { STAGES } from './STAGES.ts'
2375
- import StageTile from './StageTile.abide'
2376
-
2377
- const VIEWS = ['board', 'list']
2603
+ import { dollars } from './dollars.ts'
2604
+ import { targetStore } from './targetStore.ts'
2378
2605
 
2379
2606
  // One option, and the reads and writes below are untouched.
2380
- let view = reactive('board', { store: preferenceStore('view') })
2607
+ let target = reactive(500_000, { store: targetStore })
2381
2608
  </script>
2382
2609
 
2383
- <div class="tabs">
2384
- {#for one of VIEWS}
2385
- <button aria-selected={view === one} onclick={() => (view = one)}>
2386
- {one}</button>
2387
- {/for}
2388
- </div>
2610
+ <label>the team's target this quarter
2611
+ <input type="number" step="50000" bind:value={target}>
2612
+ </label>
2389
2613
 
2390
- <ul class={view === 'board' ? 'tiles' : 'fields'}>
2391
- {#for stage of STAGES}
2392
- <StageTile stage={stage} />
2393
- {/for}
2614
+ <ul class="tiles">
2615
+ <li><span>target</span><strong>{dollars(target)}</strong></li>
2394
2616
  </ul>
2395
2617
  ```
2396
2618
 
2397
- ```ts src/pipeline/lib/preferenceStore.ts
2619
+ ```ts src/pipeline/lib/targetStore.ts
2398
2620
  import type { Store } from '@abide/abide'
2399
2621
 
2400
- import getPreference from '../getPreference.handler.ts'
2401
- import setPreference from '../setPreference.handler.ts'
2402
-
2403
- /** Where a preference lives between visits: both halves, or neither. */
2404
- export function preferenceStore(name: string): Store<string> {
2405
- return {
2406
- // Never saved answers `null`, which is the miss.
2407
- get: async () => (await getPreference({ name })) ?? undefined,
2408
- set: async (value) => {
2409
- await setPreference({ name, value })
2410
- // The next read asks the server, not this tab's copy.
2411
- getPreference.invalidate({ name })
2412
- },
2413
- }
2622
+ import getTarget from '../getTarget.handler.ts'
2623
+ import setTarget from '../setTarget.handler.ts'
2624
+
2625
+ /** The team's target, kept on the server, so every visitor reads one. */
2626
+ export const targetStore: Store<number> = {
2627
+ // Never set answers `null`, which is the miss.
2628
+ get: async () => (await getTarget()) ?? undefined,
2629
+ set: async (target) => {
2630
+ await setTarget({ target })
2631
+ // The next read asks the server, not this tab's copy.
2632
+ getTarget.invalidate()
2633
+ },
2414
2634
  }
2415
2635
  ```
2416
2636
 
2417
- ```ts src/pipeline/getPreference.handler.ts
2637
+ ```ts src/pipeline/getTarget.handler.ts
2418
2638
  import { GET } from '@abide/abide/server'
2419
2639
 
2420
- import { PREFERENCES } from './lib/PREFERENCES.server.ts'
2640
+ import { TEAM } from './lib/TEAM.server.ts'
2421
2641
 
2422
- /** A saved preference, or `null` where none was saved. */
2642
+ /** The team's target, or `null` where nobody has set one. */
2423
2643
  export default GET(
2424
- async ({ name }: { name: string }) => {
2644
+ async () => {
2425
2645
  // A slow database, so the wait is on screen.
2426
2646
  await Bun.sleep(300)
2427
- return PREFERENCES[name] ?? null
2647
+ return TEAM.target ?? null
2428
2648
  },
2429
- { description: 'Reads a saved view preference.' },
2649
+ { description: "Reads the team's target for the quarter." },
2430
2650
  )
2431
2651
  ```
2432
2652
 
2433
- ```ts src/pipeline/setPreference.handler.ts
2653
+ ```ts src/pipeline/setTarget.handler.ts
2434
2654
  import { POST } from '@abide/abide/server'
2435
2655
 
2436
- import { PREFERENCES } from './lib/PREFERENCES.server.ts'
2656
+ import { TEAM } from './lib/TEAM.server.ts'
2437
2657
 
2438
- /** Saves a preference. */
2658
+ /** Sets the target the whole team sees. */
2439
2659
  export default POST(
2440
- ({ name, value }: { name: string; value: string }) => {
2441
- PREFERENCES[name] = value
2660
+ ({ target }: { target: number }) => {
2661
+ TEAM.target = target
2442
2662
  },
2443
- { description: 'Saves a view preference.' },
2663
+ { description: "Sets the team's target for the quarter." },
2444
2664
  )
2445
2665
  ```
2446
2666
 
2447
- ```ts src/pipeline/lib/PipelineView.test.ts
2667
+ ```ts src/pipeline/lib/TeamTarget.test.ts
2448
2668
  import { expect, test } from 'bun:test'
2449
2669
 
2450
2670
  import { flushSync } from '@abide/abide'
2451
2671
  import { mount } from '@abide/harness/abide'
2452
2672
 
2453
- import PipelineView from './PipelineView.abide'
2454
- import { PREFERENCES } from './PREFERENCES.server.ts'
2673
+ import { TEAM } from './TEAM.server.ts'
2674
+ import TeamTarget from './TeamTarget.abide'
2455
2675
 
2456
- function chosen(): string | undefined {
2457
- const tab = document.querySelector('[aria-selected="true"]')
2458
- return tab?.textContent?.trim()
2459
- }
2676
+ const shown = () => document.querySelector('strong')?.textContent
2460
2677
 
2461
- test('the view picked is the view the next visit opens on', async () => {
2678
+ test('a target set once is what every later visit opens on', async () => {
2462
2679
  {
2463
- using _first = await mount(PipelineView({}))
2680
+ using _first = await mount(TeamTarget({}))
2464
2681
  await Bun.sleep(500)
2465
- expect(chosen()).toBe('board')
2466
- for (const tab of document.querySelectorAll('button'))
2467
- if (tab.textContent?.trim() === 'list') tab.click()
2682
+ expect(shown()).toBe('$500,000')
2683
+ const input = document.querySelector('input')
2684
+ if (!input) throw new Error('no target field')
2685
+ input.value = '750000'
2686
+ input.dispatchEvent(new Event('input', { bubbles: true }))
2468
2687
  flushSync()
2469
2688
  await Bun.sleep(100)
2470
- expect(PREFERENCES.view).toBe('list')
2689
+ expect(TEAM.target).toBe(750_000)
2471
2690
  }
2472
- // A fresh component, as a reload builds one.
2473
- using _next = await mount(PipelineView({}))
2691
+ // A fresh component, as another visitor's page builds one.
2692
+ using _next = await mount(TeamTarget({}))
2474
2693
  await Bun.sleep(500)
2475
- expect(chosen()).toBe('list')
2694
+ expect(shown()).toBe('$750,000')
2476
2695
  })
2477
2696
  ```
2478
2697
 
2479
- A `Store` is a `get` and a `set`. The value reads the store when it is built and falls back to `'board'` on a miss, and every change is written back. The reads and writes in the markup do not change.
2698
+ A `Store` is a `get` and a `set`. The value reads the store when it is built and falls back to `500_000` on a miss, and every change is written back. The reads and writes in the markup do not change.
2480
2699
 
2481
- Where the stored value can change from outside the app, such as a file another program edits, the store adds `watch`. It is handed a function to call when that happens, and returns the function that stops watching. The value reads `get` again, and the body does not run.
2700
+ This store goes through two handlers, so the target is one value on the server: the next visit opens on it, and so does everyone else's, painted right the first time. After a write, `set` invalidates the read it made, so the next read asks the server rather than this tab's copy. A setting one visitor alone sees, such as a theme, belongs in a cookie instead.
2482
2701
 
2483
- This store goes through two handlers, so the server and the browser read the same preference and the first paint is already right. A store over `localStorage` works too, but only in a browser, so the server paints the fallback first. After a write, `set` invalidates the read it made, so the next read asks the server rather than this tab's copy.
2702
+ Where the stored value can change from outside the app, such as a file another program edits, the store adds `watch`. It is handed a function to call when that happens, and returns the function that stops watching. The value reads `get` again, and the body does not run.
2484
2703
 
2485
2704
  ## Next
2486
2705
 
@@ -2494,7 +2713,7 @@ This store goes through two handlers, so the server and the browser read the sam
2494
2713
 
2495
2714
  *An activity feed every open tab sees as it happens.*
2496
2715
 
2497
- Activity on an account is something every open tab should see as it happens. This chapter makes a feed anything can write to and everyone reads, and streams answers as they arrive.
2716
+ Activity on an account is something every open tab should see as it happens. This chapter makes a channel anything can write to and everyone reads, and streams answers as they arrive.
2498
2717
 
2499
2718
  A channel is a `reactive` holding an array. Anything that can reach it appends with `patch`, and anything that can reach it reads. Put one behind a `GET` and every open tab follows it. A handler that yields sends each value as it is produced, from the same address that answers with the whole.
2500
2719
 
@@ -2576,11 +2795,56 @@ test('a reader the logger never names follows what it logs', async () => {
2576
2795
  })
2577
2796
  ```
2578
2797
 
2798
+ ```ts src/activity/lib/QuickLog.bench.ts
2799
+ import { reactive } from '@abide/abide'
2800
+ import { batch, type Batched } from '@abide/harness/report'
2801
+
2802
+ import type { Activity } from './activity.ts'
2803
+
2804
+ // The channel as the card logs to it: `count` entries, the oldest let go.
2805
+ function logging(count: number): () => void {
2806
+ const opening: Activity[] = []
2807
+ for (let at = 0; at < count; at += 1)
2808
+ opening.push({ id: at, contact: `Contact ${at}`, kind: 'Email' })
2809
+ const activity = reactive(opening)
2810
+ const last = reactive(() => activity().at(-1))
2811
+ last()
2812
+ return () => {
2813
+ activity.patch((entries) => {
2814
+ entries.push({
2815
+ id: (entries.at(-1)?.id ?? 0) + 1,
2816
+ contact: 'Grace Hopper',
2817
+ kind: 'Call',
2818
+ })
2819
+ entries.shift()
2820
+ })
2821
+ last()
2822
+ }
2823
+ }
2824
+
2825
+ // The most a log may take in a channel of 1,000 entries, in milliseconds.
2826
+ export const CEILINGS = { priced: { 'of 1,000 entries': 1 } }
2827
+
2828
+ /** Logging a call to a channel of 100 entries, and of 1,000. */
2829
+ export function priced(): Batched {
2830
+ return batch({
2831
+ case: 'log a call, read the last touch',
2832
+ emulated: null,
2833
+ arms: {
2834
+ 'of 100 entries': logging(100),
2835
+ 'of 1,000 entries': logging(1000),
2836
+ },
2837
+ })
2838
+ }
2839
+ ```
2840
+
2841
+ The bench logs a call to a channel of 100 entries, and of 1,000, letting the oldest go each time. Its ratio is what a log costs as the channel grows: the patch changes the entries in place, and the channel is never copied.
2842
+
2579
2843
  `activity.ts` holds the channel and the one function that writes it. The bar imports the function, the readout imports the channel, and neither imports the other. No prop is threaded between them and no provider wraps them.
2580
2844
 
2581
2845
  The readout derives `last` as `activity.at(-1)`, and follows the channel the way any derived value follows what it read. Opened with an array, the channel has landed: it is `ready()` at once and never `busy()` where it is written.
2582
2846
 
2583
- ## A feed keeps its last few entries
2847
+ ## A channel keeps its last few entries
2584
2848
 
2585
2849
  **`patch` with a trim — the last three entries** — The same channel listed in full, already three entries long. Log a meeting and watch the top of the list.
2586
2850
 
@@ -2653,7 +2917,7 @@ The trim is one line in the patch that appends: `if (entries.length > 3)
2653
2917
 
2654
2918
  A value keeps no past of its own. Where you want what came before, make the value the array and append where you write, as here. An undo stack is the same: an array the app pushes into.
2655
2919
 
2656
- ## A feed on the server reaches every open tab
2920
+ ## A channel on the server reaches every open tab
2657
2921
 
2658
2922
  **`GET` + `POST` — one account's feed, every tab** — The activity feed on Hopper & Co. Open this page in a second tab, log a call in either, and both lists grow.
2659
2923
 
@@ -2731,6 +2995,22 @@ export default POST(
2731
2995
  import { expect, test } from 'bun:test'
2732
2996
 
2733
2997
  import { createApp } from '@abide/abide/server'
2998
+ import { mount } from '@abide/harness/abide'
2999
+
3000
+ import AccountFeed from './AccountFeed.abide'
3001
+
3002
+ const kinds = () =>
3003
+ [...document.querySelectorAll('strong')].map((each) => each.textContent)
3004
+
3005
+ test('the patch the button sends is what lists the call', async () => {
3006
+ using _feed = await mount(AccountFeed({}))
3007
+ await Bun.sleep(100)
3008
+ const before = kinds().length
3009
+ document.querySelector('button')?.click()
3010
+ await Bun.sleep(200)
3011
+ expect(kinds()).toHaveLength(before + 1)
3012
+ expect(kinds().at(-1)).toBe('Call')
3013
+ })
2734
3014
 
2735
3015
  // Each request is a caller of its own, as each open tab is.
2736
3016
  test('what one caller logs, another caller reads', async () => {
@@ -2752,17 +3032,17 @@ test('what one caller logs, another caller reads', async () => {
2752
3032
 
2753
3033
  | You write | What it is |
2754
3034
  | --- | --- |
2755
- | `reactive(fn, { global: true })` | the feeds, one per account, shared by every caller |
3035
+ | `reactive(fn, { global: true })` | the channels, one per account, shared by every caller |
2756
3036
  | `GET(feeds)` | its address: a tab reading it follows it |
2757
- | `POST(…)` | the way in: a handler that patches a feed |
3037
+ | `POST(…)` | the way in: a handler that patches a channel |
2758
3038
 
2759
- `global: true` puts the feeds in one cache for the whole process. Leave it out and a value on the server belongs to one request, so every caller gets a feed of their own and nobody sees anybody's calls. The body takes `account`, so there is one feed per account, and `ttl` lets one nobody has written to for an hour go.
3039
+ `global: true` puts the channels in one cache for the whole process. Leave it out and a value on the server belongs to one request, so every caller gets a channel of their own and nobody sees anybody's calls. The body takes `account`, so there is one channel per account, and `ttl` lets one nobody has written to for an hour go. `by: 'id'` tells one entry from another, and every tab's copy carries it, so `{#for entry of entries}` keys its rows by id with no key of its own.
2760
3040
 
2761
- A tab reading `feed` is subscribed to it, over one web socket that every followed `GET` on the page shares. The button does not paint its own entry. It calls `logToFeed`, the handler patches the feed, and what the patch wrote goes to every tab following it, this one included. Each write travels as a `PatchOp`, so a new entry is one `add` rather than the whole feed.
3041
+ A tab reading `feed` is subscribed to it, over one web socket that every followed `GET` on the page shares. The button does not paint its own entry. It calls `logToFeed`, the handler patches the channel, and what the patch wrote goes to every tab following it, this one included. Each write travels as a `PatchOp`, so a new entry is one `add` rather than the whole channel.
2762
3042
 
2763
3043
  The server writes each entry: its id, its kind and its time. A caller can only pick a kind the server knows, so nobody forges an entry, and who may log at all is the `POST`'s own middleware. The call hands back the same `Reactive` the handler had, its `Produced` type included, so `entries.length` and `{#for entry of entries}` read it like any array.
2764
3044
 
2765
- The feed lives in memory. A restart starts it empty, and each instance of the app holds its own. Activity anyone must scroll back past that belongs in your database, behind an ordinary handler.
3045
+ The channel lives in memory. A restart starts it empty, and each instance of the app holds its own. Activity anyone must scroll back past that belongs in your database, behind an ordinary handler.
2766
3046
 
2767
3047
  ## A handler that yields sends each entry as it is read
2768
3048
 
@@ -2770,7 +3050,7 @@ The feed lives in memory. A restart starts it empty, and each instance of the ap
2770
3050
 
2771
3051
  ```abide src/activity/lib/ActivityHistory.abide
2772
3052
  <script>
2773
- import { reactive, refresh } from '@abide/abide'
3053
+ import { reactive } from '@abide/abide'
2774
3054
 
2775
3055
  import olderActivity from '../olderActivity.handler.ts'
2776
3056
 
@@ -2783,10 +3063,7 @@ const entries = reactive(() => olderActivity())
2783
3063
  {/for}
2784
3064
  </ul>
2785
3065
 
2786
- <div class="row">
2787
- <button onclick={() => refresh(entries)}>Read again</button>
2788
- {#if entries.busy()}<span class="status">reading the archive…</span>{/if}
2789
- </div>
3066
+ {#if entries.busy()}<p class="status">reading the archive…</p>{/if}
2790
3067
  ```
2791
3068
 
2792
3069
  ```ts src/activity/olderActivity.handler.ts
@@ -2814,9 +3091,27 @@ export default GET(
2814
3091
  ```ts src/activity/lib/ActivityHistory.test.ts
2815
3092
  import { expect, test } from 'bun:test'
2816
3093
 
3094
+ import { mount } from '@abide/harness/abide'
3095
+
2817
3096
  import olderActivity from '../olderActivity.handler.ts'
3097
+ import ActivityHistory from './ActivityHistory.abide'
3098
+
3099
+ const listed = () => document.querySelectorAll('li').length
3100
+
3101
+ test('the list shows each entry as it is read', async () => {
3102
+ using _history = await mount(ActivityHistory({}))
3103
+ const reading = () => document.body.textContent?.includes('reading')
3104
+ while (listed() === 0) await Bun.sleep(20)
3105
+ // One entry in, and the archive still being read.
3106
+ expect(listed()).toBeLessThan(3)
3107
+ expect(reading()).toBe(true)
3108
+ // The rest arrive on their own; the test times out where they never do.
3109
+ while (listed() < 3) await Bun.sleep(20)
3110
+ })
2818
3111
 
2819
3112
  test('the first entry is in hand while the rest are still read', async () => {
3113
+ // Read afresh rather than the run the test above left settled.
3114
+ olderActivity.invalidate()
2820
3115
  const entries = olderActivity()
2821
3116
  const stillReading: boolean[] = []
2822
3117
  for await (const _entry of entries) stillReading.push(entries.busy())
@@ -2840,7 +3135,7 @@ A page rendered on the server is not held for an unfinished stream. What the str
2840
3135
 
2841
3136
  ## An export is written as its rows are read
2842
3137
 
2843
- **`bytes(values)` — a CSV written as the cursor reaches each row** — An account's activity as a CSV download. Each line goes into the file as its row is read.
3138
+ **`bytes(values)` — a CSV written as the cursor reaches each row** — A rep takes an account's activity into a spreadsheet. The preview above the link is the file it downloads.
2844
3139
 
2845
3140
  ```abide src/activity/lib/CsvExport.abide
2846
3141
  <script>
@@ -2889,6 +3184,22 @@ export default GET(() => bytes(csvOf()), {
2889
3184
  import { expect, test } from 'bun:test'
2890
3185
 
2891
3186
  import { createApp } from '@abide/abide/server'
3187
+ import { mount } from '@abide/harness/abide'
3188
+
3189
+ import CsvExport from './CsvExport.abide'
3190
+
3191
+ test('the preview is the file the link downloads', async () => {
3192
+ // An app in this process, which the component's call is answered by.
3193
+ await using _app = await createApp()
3194
+ using _export = await mount(CsvExport({}))
3195
+ await Bun.sleep(900)
3196
+ expect(document.querySelector('pre')?.textContent).toStartWith(
3197
+ 'date,kind,note\n2026-09-02,Call,',
3198
+ )
3199
+ expect(document.querySelector('a')?.getAttribute('href')).toEndWith(
3200
+ '/activity/exportCsv',
3201
+ )
3202
+ })
2892
3203
 
2893
3204
  test('the export is a file, written a row at a time', async () => {
2894
3205
  await using app = await createApp()
@@ -2897,6 +3208,7 @@ test('the export is a file, written a row at a time', async () => {
2897
3208
  )
2898
3209
  const reader = (answer.body as ReadableStream<Uint8Array>).getReader()
2899
3210
  const first = await reader.read()
3211
+ // The header goes out alone, before the first row is read.
2900
3212
  expect(new TextDecoder().decode(first.value)).toBe('date,kind,note\n')
2901
3213
  await reader.cancel()
2902
3214
  })
@@ -2915,7 +3227,7 @@ A stream whose first chunk is binary is sent as bytes without the helper. `bytes
2915
3227
 
2916
3228
  ## A wallboard is sent events whatever it asks for
2917
3229
 
2918
- **`sse(values)` — events, whoever asks** — A wallboard by the sales floor reads today's deal moves. It sends no Accept header, and what comes back is events anyway.
3230
+ **`sse(values)` — events, whoever asks** — A wallboard by the sales floor reads today's deal moves with the plain request its script has always sent.
2919
3231
 
2920
3232
  ```abide src/activity/lib/Wallboard.abide
2921
3233
  <script>
@@ -2956,14 +3268,19 @@ export default GET(() => sse(MOVES), {
2956
3268
  import { expect, test } from 'bun:test'
2957
3269
 
2958
3270
  import { createApp } from '@abide/abide/server'
3271
+ import { mount } from '@abide/harness/abide'
3272
+
3273
+ import Wallboard from './Wallboard.abide'
2959
3274
 
2960
3275
  test('a caller asking for nothing in particular is sent events', async () => {
2961
- await using app = await createApp()
2962
- const answer = await app.fetch(
2963
- new Request('http://crm.invalid/activity/ticker'),
3276
+ // An app in this process, which the component's call is answered by.
3277
+ await using _app = await createApp()
3278
+ using _board = await mount(Wallboard({}))
3279
+ await Bun.sleep(300)
3280
+ expect(document.querySelector('strong')?.textContent).toStartWith(
3281
+ 'text/event-stream',
2964
3282
  )
2965
- expect(answer.headers.get('content-type')).toStartWith('text/event-stream')
2966
- expect(await answer.text()).toContain(
3283
+ expect(document.querySelector('pre')?.textContent).toContain(
2967
3284
  'data: {"account":"Hopper & Co","stage":"Won"}\n',
2968
3285
  )
2969
3286
  })
@@ -2971,7 +3288,7 @@ test('a caller asking for nothing in particular is sent events', async () => {
2971
3288
 
2972
3289
  A wallboard script, like `curl`, asks for nothing in particular, and an async generator answers that caller with JSON Lines. `sse` fixes the answer as events, `data: ` and a JSON value per event, so the board reads it with the event-stream parser it already has. An `EventSource` asks for events by name, and gets them from any streaming handler without the helper.
2973
3290
 
2974
- ## Watch a feed from the terminal
3291
+ ## Watch a channel from the terminal
2975
3292
 
2976
3293
  ```
2977
3294
  abide watch activity/feed account=hopper-co --url http://localhost:3000
@@ -3029,6 +3346,24 @@ An `**/index.abide` answers its directory's own address, and any other `**/<name
3029
3346
  | `[[name]]` | one segment or none | text, absent when nothing matched |
3030
3347
  | `[...name]` | every remaining segment | a list, empty when nothing matched |
3031
3348
 
3349
+ ## A link names a route, and `url` fills it in
3350
+
3351
+ The list links each company with `url('/companies/[id=SLUG]', { id: company.id })`. You write the pattern a page is filed under, matcher and all, rather than the address, and `url` fills in the segments. Leaving out a required one is a compile error, and a pattern no page is filed under is refused by `abide check`. A query goes last: `url('/companies', {}, { city: 'London' })`.
3352
+
3353
+ | Name | What it does for the CRM |
3354
+ | --- | --- |
3355
+ | `Routes` | every pattern the CRM files a page under, and every public file's address, declared by `abide check` |
3356
+ | `RouteLiteral` | one of those patterns: what `url` takes |
3357
+ | `Query` | the query argument, a record or a `URLSearchParams` |
3358
+ | `ParamsOf<P>` | the params object pattern `P` takes |
3359
+ | `HasParams<P>` | whether `P` requires one |
3360
+ | `HasSegments<P>` | whether `P` has any bracketed segment |
3361
+ | `RequiredNames<P>` | its `[name]` segments |
3362
+ | `OptionalNames<P>` | its `[[name]]` segments |
3363
+ | `RestNames<P>` | its `[...name]` segments |
3364
+
3365
+ You never write the last six. They are how `url`'s signature is read off the pattern, and a compile error naming `RequiredNames` is naming the segment you left out.
3366
+
3032
3367
  ## `[id=SLUG].abide` answers for every company
3033
3368
 
3034
3369
  **`[id=SLUG].abide` — one file for every company** — Two companies, one page file. Pick one: the address changes, and the page loads the company the address names.
@@ -3075,7 +3410,9 @@ const company = reactive(() => getCompany({ id: route.params.id }))
3075
3410
  <CompanyTabs />
3076
3411
 
3077
3412
  <style>
3078
- ::view-transition-group(*) { animation-duration: 0.5s; }
3413
+ ::view-transition-group(*) {
3414
+ animation-duration: 0.5s;
3415
+ }
3079
3416
  </style>
3080
3417
  ```
3081
3418
 
@@ -3097,7 +3434,7 @@ test('one file answers for every company, reading its id', async () => {
3097
3434
  })
3098
3435
  ```
3099
3436
 
3100
- `route.params.id` is the segment the address matched. It is a reactive value like any other, so moving from one company to the next reloads the company rather than rebuilding the page.
3437
+ `route.params.id` is the segment the address matched, read like any reactive value, so the company the page loads is the one its address names.
3101
3438
 
3102
3439
  | Name | What it does for the CRM |
3103
3440
  | --- | --- |
@@ -3156,7 +3493,9 @@ const company = reactive(() => getCompany({ id: route.params.id }))
3156
3493
  <CompanyTabs />
3157
3494
 
3158
3495
  <style>
3159
- ::view-transition-group(*) { animation-duration: 0.5s; }
3496
+ ::view-transition-group(*) {
3497
+ animation-duration: 0.5s;
3498
+ }
3160
3499
  </style>
3161
3500
  ```
3162
3501
 
@@ -3247,30 +3586,34 @@ test('the list and a company render inside one layout', async () => {
3247
3586
  })
3248
3587
  ```
3249
3588
 
3589
+ ```ts e2e/Layout.test.ts
3590
+ import { expect, test } from '@playwright/test'
3591
+
3592
+ test('a company opens inside the layout, and the note stays', async ({
3593
+ page,
3594
+ }) => {
3595
+ // Relative, so it reaches /companies below whatever base the run serves.
3596
+ await page.goto('companies', { waitUntil: 'networkidle' })
3597
+ const note = page.getByPlaceholder('Note for the call')
3598
+ await note.fill('Grace wants a quote')
3599
+ await page.getByRole('link', { name: 'Hopper & Co', exact: true }).click()
3600
+ await expect(page).toHaveURL('companies/hopper-and-co')
3601
+ await expect(page.getByRole('heading', { name: 'Hopper & Co' })).toHaveText(
3602
+ 'Hopper & Co',
3603
+ )
3604
+ await expect(note).toHaveValue('Grace wants a quote')
3605
+ await page.getByRole('link', { name: 'All companies', exact: true }).click()
3606
+ await expect(page).toHaveURL('companies')
3607
+ await expect(note).toHaveValue('Grace wants a quote')
3608
+ })
3609
+ ```
3610
+
3250
3611
  A `**/layout.abide` wraps every page at its directory and below, and `<slot/>` is where the page renders. A navigation between two of its pages swaps what is in the slot and leaves the layout alone, so its inputs, its scroll and anything it was loading stay as they were. Layouts nest: the root layout wraps this one.
3251
3612
 
3252
3613
  A layout's `<script server>` files hooks for the same pages. The companies layout files its `onError` hook there.
3253
3614
 
3254
3615
  A package can ship pages too. `import '…' with { type: 'routes' }` in a file of your app serves every page and handler in the named directory below that file's directory, and moving the line moves them.
3255
3616
 
3256
- ## A link names a route, and `url` fills it in
3257
-
3258
- The list links each company with `url('/companies/[id=SLUG]', { id: company.id })`. You write the pattern a page is filed under, matcher and all, rather than the address, and `url` fills in the segments. Leaving out a required one is a compile error, and a pattern no page is filed under is refused by `abide check`. A query goes last: `url('/companies', {}, { city: 'London' })`.
3259
-
3260
- | Name | What it does for the CRM |
3261
- | --- | --- |
3262
- | `Routes` | every pattern the CRM files a page under, declared by `abide check` |
3263
- | `RouteLiteral` | one of those patterns: what `url` takes |
3264
- | `Query` | the query argument, a record or a `URLSearchParams` |
3265
- | `ParamsOf<P>` | the params object pattern `P` takes |
3266
- | `HasParams<P>` | whether `P` requires one |
3267
- | `HasSegments<P>` | whether `P` has any bracketed segment |
3268
- | `RequiredNames<P>` | its `[name]` segments |
3269
- | `OptionalNames<P>` | its `[[name]]` segments |
3270
- | `RestNames<P>` | its `[...name]` segments |
3271
-
3272
- You never write the last six. They are how `url`'s signature is read off the pattern, and a compile error naming `RequiredNames` is naming the segment you left out.
3273
-
3274
3617
  ## `navigate` moves the address from code
3275
3618
 
3276
3619
  **`navigate` — a new address, the same page** — A company page keeps its open tab in the address, so a link can open it. Type a draft, then switch tabs: the address moves, and the draft stays.
@@ -3316,13 +3659,30 @@ test('the open tab is read from the address', async () => {
3316
3659
  })
3317
3660
  ```
3318
3661
 
3319
- An address the page already shown matches moves `route.url` and keeps the page mounted. Another page's address mounts that page in place, without reloading the document. `replace: true` writes over the history entry, so Back leaves the company rather than stepping through every tab.
3662
+ ```ts e2e/CompanyTabs.test.ts
3663
+ import { expect, test } from '@playwright/test'
3664
+
3665
+ test('a tab moves the address, and the draft stays', async ({ page }) => {
3666
+ // Relative, so it reaches /companies below whatever base the run serves.
3667
+ await page.goto('companies/hopper-and-co', { waitUntil: 'networkidle' })
3668
+ const draft = page.getByPlaceholder('Follow up on Friday')
3669
+ await draft.fill('Ask Grace about the renewal')
3670
+ await page.getByRole('button', { name: 'notes', exact: true }).click()
3671
+ await expect(page).toHaveURL('companies/hopper-and-co?tab=notes')
3672
+ await expect(
3673
+ page.getByRole('button', { name: 'notes', exact: true }),
3674
+ ).toHaveAttribute('aria-selected', 'true')
3675
+ await expect(draft).toHaveValue('Ask Grace about the renewal')
3676
+ })
3677
+ ```
3678
+
3679
+ An address that matches the page already shown moves `route.url` and keeps the page mounted. Another page's address mounts that page in place, without reloading the document. `replace: true` writes over the history entry, so Back leaves the company rather than stepping through every tab.
3320
3680
 
3321
3681
  A plain `<a href>` click does the same. Reach for `navigate` when code decides where to go. `route.navigating` is true while a navigation is in flight, which is what a progress bar reads.
3322
3682
 
3323
3683
  ## A view transition carries the company across pages
3324
3684
 
3325
- **`view-transition-name` — a company name that moves to where it lands** — The list row and the company page give the company's name the same view-transition-name. Open a company: the name travels from its row to the heading.
3685
+ **`view-transition-name` — a company name that moves to where it lands** — A rep opens a company from the list, and the name they clicked becomes the heading they land on. Open one and watch it travel.
3326
3686
 
3327
3687
  ```abide src/companies/lib/TransitionFrame.abide
3328
3688
  <script>
@@ -3352,7 +3712,9 @@ const companies = reactive(() => listCompanies())
3352
3712
  </ul>
3353
3713
 
3354
3714
  <style>
3355
- ::view-transition-group(*) { animation-duration: 0.5s; }
3715
+ ::view-transition-group(*) {
3716
+ animation-duration: 0.5s;
3717
+ }
3356
3718
  </style>
3357
3719
  ```
3358
3720
 
@@ -3377,7 +3739,9 @@ const company = reactive(() => getCompany({ id: route.params.id }))
3377
3739
  <CompanyTabs />
3378
3740
 
3379
3741
  <style>
3380
- ::view-transition-group(*) { animation-duration: 0.5s; }
3742
+ ::view-transition-group(*) {
3743
+ animation-duration: 0.5s;
3744
+ }
3381
3745
  </style>
3382
3746
  ```
3383
3747
 
@@ -3399,58 +3763,20 @@ test("the list row and the company's heading share one name", async () => {
3399
3763
 
3400
3764
  Elements on two pages that share a `view-transition-name` are animated from one place to the other. A `<style>` rule naming a view transition turns them on. The build reads it, so an app with none never looks for one at run time. A navigation waits for the DOM to be written, not for the animation. A browser without view transitions navigates as it otherwise would.
3401
3765
 
3402
- ## `<head>` in a page replaces its layout's
3766
+ ## A page's `<head>` wins over its layout's, key by key
3403
3767
 
3404
- **`<head>` in a page and its layout — the deepest title wins** — The companies layout titles every page, and a company page titles itself. Open the list, then a company: the tab takes the company's name.
3768
+ **`<head>` in a page and its layout — the deepest title wins** — The companies layout titles every page, and a company page titles itself. Open each in a tab of its own: the company's tab takes its name.
3405
3769
 
3406
3770
  ```abide src/companies/lib/TabTitle.abide
3407
3771
  <script>
3408
- import { reactive, url } from '@abide/abide'
3409
-
3410
- const PAGES = [
3411
- { label: 'All companies', address: url('/companies') },
3412
- {
3413
- label: 'Hopper & Co',
3414
- address: url('/companies/[id=SLUG]', { id: 'hopper-and-co' }),
3415
- },
3416
- ]
3417
-
3418
- let open = reactive(PAGES[0]?.address ?? '')
3419
- let title = reactive('—')
3420
-
3421
- const ALL = { subtree: true, childList: true, characterData: true }
3422
-
3423
- // Follows the framed page's tab title as it loads and as it fills in.
3424
- function followTitle(element: Element) {
3425
- const frame = element as HTMLIFrameElement
3426
- const read = () => (title = frame.contentDocument?.title || '—')
3427
- const watcher = new MutationObserver(read)
3428
- const watch = () => {
3429
- read()
3430
- const head = frame.contentDocument?.head
3431
- if (head) watcher.observe(head, ALL)
3432
- }
3433
- frame.addEventListener('load', watch)
3434
- return () => {
3435
- frame.removeEventListener('load', watch)
3436
- watcher.disconnect()
3437
- }
3438
- }
3772
+ import { url } from '@abide/abide'
3439
3773
  </script>
3440
3774
 
3441
- <div class="tabs">
3442
- {#for page of PAGES}
3443
- <button aria-selected={open === page.address}
3444
- onclick={() => (open = page.address)}>{page.label}</button>
3445
- {/for}
3775
+ <div class="row">
3776
+ <a href={url('/companies')} target="_blank">All companies</a>
3777
+ <a href={url('/companies/[id=SLUG]', { id: 'hopper-and-co' })}
3778
+ target="_blank">Hopper & Co</a>
3446
3779
  </div>
3447
-
3448
- <ul class="fields">
3449
- <li><span>tab title</span><strong>{title}</strong></li>
3450
- </ul>
3451
-
3452
- <iframe src={open} bind:element={followTitle} title="A page and its title"
3453
- class="h-48 w-full rounded-md border border-rule"></iframe>
3454
3780
  ```
3455
3781
 
3456
3782
  ```abide src/companies/layout.abide
@@ -3499,7 +3825,9 @@ const company = reactive(() => getCompany({ id: route.params.id }))
3499
3825
  <CompanyTabs />
3500
3826
 
3501
3827
  <style>
3502
- ::view-transition-group(*) { animation-duration: 0.5s; }
3828
+ ::view-transition-group(*) {
3829
+ animation-duration: 0.5s;
3830
+ }
3503
3831
  </style>
3504
3832
  ```
3505
3833
 
@@ -3524,7 +3852,7 @@ test("a page's title replaces its layout's", async () => {
3524
3852
  })
3525
3853
  ```
3526
3854
 
3527
- A `<head>` sits at the top level of a file, never inside a block. A `<title>`, a `<meta>` by its name and a `<link>` by its `rel` and `href` each have a key, and the deepest contribution for a key wins. One with no key is appended. Leaving a page puts its layout's title back.
3855
+ A `<head>` sits at the top level of a file, never inside a block. A `<title>` and a `<base>` by their tag, a `<meta>` by its `name` or `property`, and a `<link>` by its `rel` and `href` each have a key, and the deepest contribution for a key wins. One with no key is appended. Leaving a page puts its layout's title back.
3528
3856
 
3529
3857
  ## The page goes out before the company lands
3530
3858
 
@@ -3567,7 +3895,9 @@ const company = reactive(() => getCompany({ id: route.params.id }))
3567
3895
  <CompanyTabs />
3568
3896
 
3569
3897
  <style>
3570
- ::view-transition-group(*) { animation-duration: 0.5s; }
3898
+ ::view-transition-group(*) {
3899
+ animation-duration: 0.5s;
3900
+ }
3571
3901
  </style>
3572
3902
  ```
3573
3903
 
@@ -3686,6 +4016,8 @@ An `**/error.abide` is found the way a layout is: the nearest one above the page
3686
4016
 
3687
4017
  The refusal has to come in the page's first turn to set the status. That is why `getCompany` refuses before it waits. An error page that fails itself hands off to the next one up.
3688
4018
 
4019
+ A request for a page that is neither a navigation nor a `fetch`, such as an `<img>` pointed at `/companies/initech`, gets a 404 with no body, and the page is never set up for it.
4020
+
3689
4021
  ## `onError` reports what the caller is not told
3690
4022
 
3691
4023
  **`onError(fn)` — the tracker hears what the caller is not told** — The export worker has stopped answering. Run the export: the button gets a bare 500, and the hook in the companies layout gets the reason.
@@ -3783,6 +4115,8 @@ test('the caller gets a bare 500, and onError gets the reason', async () => {
3783
4115
 
3784
4116
  `onError` runs on an unexpected error in any request below the file that called it. Send the error to a tracker there. The caller gets a 500 with nothing about the cause, so a stack trace never reaches a browser.
3785
4117
 
4118
+ The button's `catch` gets whatever was thrown. `isFailed` says whether that is a refusal, which carries a `status` and a `message`, before the button reads either.
4119
+
3786
4120
  ## `render` builds a document from a component
3787
4121
 
3788
4122
  **`render(component)` — an account sheet from the component itself** — A rep sends a customer a one-page account sheet. The handler renders the sheet component into a document of its own, and the frame shows it.
@@ -3827,7 +4161,7 @@ const { company } = props<{
3827
4161
  <body style="margin: 0; padding: 1.25rem; background: #fff; color: #1d2a28;
3828
4162
  font: 14px/1.5 system-ui, sans-serif">
3829
4163
  <h1 style="margin: 0 0 0.5rem; font-size: 1.25rem">{company.name}</h1>
3830
- <table>
4164
+ <table>
3831
4165
  <tr><th align="left">Contact</th><td>{company.contact}</td></tr>
3832
4166
  <tr><th align="left">Pipeline</th><td>{company.pipeline}</td></tr>
3833
4167
  </table>
@@ -3867,7 +4201,7 @@ APP_URL=https://example.com/crm abide start
3867
4201
 
3868
4202
  Set `APP_URL` and every page, handler and asset answers below its path. Nothing is rebuilt: `url`, a handler's address and every asset reference are resolved when they are read, never baked into the build. An address you write out by hand is not, which is one more reason to build them with `url`.
3869
4203
 
3870
- Everything abide serves for itself, from handler calls to the health check, is below `/__abide/**`, so a proxy in front of the CRM forwards one path family.
4204
+ A handler file answers at its own route, as `/companies/getCompany` does, so it moves below the path with the pages. What abide serves for itself is below `/__abide/**`: the client bundle, the health report, the OpenAPI document, the MCP endpoint, and a private handler.
3871
4205
 
3872
4206
  ## Next
3873
4207
 
@@ -4109,7 +4443,7 @@ test('the server paints the density the cookie holds', async () => {
4109
4443
  })
4110
4444
  ```
4111
4445
 
4112
- `cookies()` is the request's cookies on the server and the document's in the browser, as a `CookieMap`. Reading a cookie by name follows it, so the list changes on the click. The server render reads the same cookie off the request, so a reload paints compact rather than flashing comfortable first.
4446
+ `cookies()` is the request's cookies on the server and the document's in the browser, as a `Cookies`: a jar shaped like Bun's `CookieMap`, with `get`, `set` and `delete`. Reading a cookie by name follows it, so the list changes on the click. The server render reads the same cookie off the request, so a reload paints compact rather than flashing comfortable first.
4113
4447
 
4114
4448
  `cookies()` reads and writes any cookie the request carries: a density, a theme, or another system's session cookie your server checks. The principal cookie is `HttpOnly`, so in the browser `cookies()` never sees the seal.
4115
4449
 
@@ -4282,23 +4616,133 @@ test('sales and a signed-out caller are refused 403', async () => {
4282
4616
  })
4283
4617
  ```
4284
4618
 
4285
- A rung is a `Middleware`: it is handed `next` and the call's context, and either returns `next()` or answers in the handler's place. Returning `refuse(403, …)` refuses, and neither the handler nor any rung after it runs. A throw escapes every rung rather than unwinding through them.
4619
+ A rung is a `Middleware`: it is handed `next` and the call's context, and either returns `next()` or answers in the handler's place. Returning `refuse(403, …)` refuses, and neither the handler nor any rung after it runs. A rung outside it is handed the answer from `next()`, the `403` response the caller gets, and never the refusal itself. A throw escapes every rung rather than unwinding through them.
4286
4620
 
4287
4621
  The rung sits on the handler, so it runs on every call: from a browser, from another handler, and from a page rendering on the server. It reads arguments through `ctx.args()`, which parses the body on its first call. So a rung that never asks refuses a signed-out caller before their body is parsed or checked.
4288
4622
 
4289
- To guard a whole section, call `middleware(…)` in its `layout.abide`, such as `src/account/admin/layout.abide`. That rung runs over every page and handler below it, and it has the request and nothing else. The same `adminsOnly` fits there, and its refusal is painted by the section's `error.abide` over a page and answered as Problem Details over a handler:
4623
+ ## A layout's rung guards a whole section
4624
+
4625
+ **`middleware(rung)` in a layout — one refusal, answered in each lane** — Everything under the admin section is for admins, and its handler names no rung of its own. Open the section as Theo, then as Maya.
4626
+
4627
+ ```abide src/account/lib/AdminSection.abide
4628
+ <script>
4629
+ import { reactive, url } from '@abide/abide'
4630
+
4631
+ import getTeam from '../admin/getTeam.handler.ts'
4632
+ import SignInAs from './SignInAs.abide'
4633
+
4634
+ let page = reactive('—')
4635
+ let handler = reactive('—')
4636
+
4637
+ const answered = (answer: Response) =>
4638
+ `${answer.status} ${answer.headers.get('content-type')?.split(';')[0]}`
4639
+
4640
+ async function open() {
4641
+ page = answered(await fetch(url('/account/admin')))
4642
+ handler = answered(await getTeam.raw())
4643
+ }
4644
+ </script>
4645
+
4646
+ <SignInAs />
4647
+
4648
+ <div class="row">
4649
+ <button onclick={open}>Open the admin section</button>
4650
+ </div>
4651
+
4652
+ <ul class="fields stacked">
4653
+ <li><span>page</span><strong>{page}</strong></li>
4654
+ <li><span>getTeam</span><strong>{handler}</strong></li>
4655
+ </ul>
4656
+ ```
4290
4657
 
4291
- ```abide abide
4658
+ ```abide src/account/admin/layout.abide
4292
4659
  <script server>
4293
4660
  import { middleware } from '@abide/abide/server'
4294
- import { adminsOnly } from '#src/account/lib/adminsOnly.ts'
4295
4661
 
4662
+ import { adminsOnly } from '../lib/adminsOnly.ts'
4663
+
4664
+ // Over every page and handler under src/account/admin.
4296
4665
  middleware(adminsOnly)
4297
4666
  </script>
4298
4667
 
4299
- <slot/>
4668
+ <slot />
4669
+ ```
4670
+
4671
+ ```ts src/account/admin/getTeam.handler.ts
4672
+ import { GET } from '@abide/abide/server'
4673
+
4674
+ import { TEAM } from '../lib/TEAM.server.ts'
4675
+
4676
+ // No middleware of its own: the section's layout guards it.
4677
+ export default GET(async () => TEAM.map(({ name, role }) => ({ name, role })), {
4678
+ description: 'Every team member and their role, for an admin.',
4679
+ })
4680
+ ```
4681
+
4682
+ ```abide src/account/admin/error.abide
4683
+ <script>
4684
+ import { type Failed, props } from '@abide/abide'
4685
+
4686
+ const { failure } = props<{ failure: Failed<string, unknown> }>()
4687
+ </script>
4688
+
4689
+ <h2 class="text-heading font-semibold">Not for you</h2>
4690
+ <p>{failure.message}</p>
4691
+ ```
4692
+
4693
+ ```ts src/account/lib/AdminSection.test.ts
4694
+ import { expect, test } from 'bun:test'
4695
+
4696
+ import { createApp } from '@abide/abide/server'
4697
+
4698
+ const signedInAs = async (
4699
+ app: { fetch: (request: Request) => Promise<Response> },
4700
+ email: string,
4701
+ ) => {
4702
+ const answer = await app.fetch(
4703
+ new Request('http://crm.invalid/account/signIn', {
4704
+ method: 'POST',
4705
+ headers: { 'content-type': 'application/json' },
4706
+ body: JSON.stringify({ email }),
4707
+ }),
4708
+ )
4709
+ return answer.headers.getSetCookie()[0]?.split(';')[0] ?? ''
4710
+ }
4711
+
4712
+ test('a page is refused as a page, a handler as Problem Details', async () => {
4713
+ await using app = await createApp()
4714
+ const cookie = await signedInAs(app, 'theo@crm.example')
4715
+ const ask = (path: string) =>
4716
+ app.fetch(
4717
+ new Request(`http://crm.invalid${path}`, { headers: { cookie } }),
4718
+ )
4719
+
4720
+ const page = await ask('/account/admin')
4721
+ expect(page.status).toBe(403)
4722
+ expect(await page.text()).toContain('Not for you')
4723
+
4724
+ const handler = await ask('/account/admin/getTeam')
4725
+ expect(handler.status).toBe(403)
4726
+ expect(handler.headers.get('content-type')).toStartWith(
4727
+ 'application/problem+json',
4728
+ )
4729
+ })
4730
+
4731
+ test('an admin is let through to both', async () => {
4732
+ await using app = await createApp()
4733
+ const cookie = await signedInAs(app, 'maya@crm.example')
4734
+ const ask = (path: string) =>
4735
+ app.fetch(
4736
+ new Request(`http://crm.invalid${path}`, { headers: { cookie } }),
4737
+ )
4738
+
4739
+ expect((await ask('/account/admin')).status).toBe(200)
4740
+ expect(await (await ask('/account/admin/getTeam')).json()).toHaveLength(2)
4741
+ })
4300
4742
  ```
4301
4743
 
4744
+ The same `adminsOnly` fits a layout, and there it runs over every page and handler below it. Its refusal is answered in the lane the request was in: the section's `error.abide` paints it over a page, and a handler answers it as Problem Details. A layout's rung has the request and nothing else, so it cannot read a handler's arguments.
4745
+
4302
4746
  Filed in the root `src/layout.abide`, a rung runs before routing, over every request the app answers. `abide routes` lists each rung beside the route it is filed at.
4303
4747
 
4304
4748
  ## `crossOrigin` opens one handler to a partner
@@ -4485,7 +4929,6 @@ Without one, nothing is cut short: the server never closes a connection under a
4485
4929
  | `ABIDE_MAX_REQUEST_BODY_SIZE` | none | the `maxBodySize` of a handler that names none |
4486
4930
  | `ABIDE_MAX_SOCKET_MESSAGE_SIZE` | 1 MiB | one message a browser sends over the socket |
4487
4931
  | `ABIDE_MAX_SOCKET_SUBSCRIPTIONS` | 1024 | the values one connection may follow at once |
4488
- | `ABIDE_STOP_TIMEOUT` | 5000 ms | how long a stop waits on requests still being answered |
4489
4932
 
4490
4933
  None of these is a rate limit. A caller sending many small, fast requests passes every ceiling, and a rung keyed on `principal.caller` is where a rate limit goes.
4491
4934
 
@@ -4693,8 +5136,12 @@ function addWidget() {
4693
5136
  <script server>
4694
5137
  import { csp, middleware } from '@abide/abide/server'
4695
5138
 
4696
- // Every page: the build's own scripts and styles load, and nothing else.
4697
- middleware(csp())
5139
+ // The build's own scripts load, and only this site may frame a page.
5140
+ middleware(csp({ 'frame-ancestors': ["'self'"] }))
5141
+ </script>
5142
+
5143
+ <script>
5144
+ import '#src/lib/app.css'
4698
5145
  </script>
4699
5146
 
4700
5147
  <html lang="en">
@@ -4702,7 +5149,10 @@ middleware(csp())
4702
5149
  <meta charset="utf-8" />
4703
5150
  <meta name="viewport" content="width=device-width, initial-scale=1" />
4704
5151
  </head>
4705
- <body>
5152
+ <body
5153
+ class="flex flex-col gap-flow bg-paper p-5 font-sans text-prose/6
5154
+ text-ink antialiased"
5155
+ >
4706
5156
  <slot />
4707
5157
  </body>
4708
5158
  </html>
@@ -4724,16 +5174,17 @@ test("a page admits the app's own scripts, and no other origin's", async () => {
4724
5174
 
4725
5175
  `csp()` is a rung, filed in the root layout so it covers every page. Its policy admits the build's own scripts and styles, and the inline script the render writes by a hash taken at build time. The widget's origin is not the app's, so the browser refuses it before a request is made. The docs app serves this page under the same baseline.
4726
5176
 
4727
- Name a directive to replace it, such as `csp({ 'img-src': ["'self'", 'https://cdn.example'] })` for avatars on a CDN. The rest stay the baseline's.
5177
+ Name a directive to replace it, and the rest stay the baseline's. The companies pages frame the CRM's own pages, so the layout lets this site, and no other, frame them:
4728
5178
 
4729
- An inline script of your own reads `csp.nonce()`, the nonce this response carries, in the root layout:
5179
+ ```abide src/layout.abide — excerpt
5180
+ middleware(csp({ 'frame-ancestors': ["'self'"] }))
5181
+ ```
4730
5182
 
4731
- ```abide abide
4732
- <script>
4733
- import { csp } from '@abide/abide'
4734
- </script>
5183
+ An inline script of your own reads `csp.nonce()`, the nonce this response carries. The account page starts an analytics queue that way:
4735
5184
 
5185
+ ```abide src/account/index.abide — excerpt
4736
5186
  <head>
5187
+ <title>Account — CRM</title>
4737
5188
  <script nonce={csp.nonce()}>window.dataLayer = []</script>
4738
5189
  </head>
4739
5190
  ```
@@ -4748,7 +5199,7 @@ Without `csp()`, a page is still served `content-security-policy: frame-ancestor
4748
5199
 
4749
5200
  <!-- guide/run-it.md -->
4750
5201
 
4751
- # Run it
5202
+ # Run the CRM
4752
5203
 
4753
5204
  *Configure the app, run code at start and stop, and see what it is doing.*
4754
5205
 
@@ -4827,14 +5278,16 @@ The last layer to name a setting wins:
4827
5278
  | `APP_DATA_DIR` | the platform's per-user data directory, for a local database file |
4828
5279
  | `PORT` | the port `abide start` listens on |
4829
5280
 
4830
- A setting that has to be right gets a schema. `config`'s `schema` is checked last, over the whole document, so it can relate two settings. Keep it loose, or every setting it does not name is dropped:
5281
+ A setting that has to be right gets a schema. `config`'s `schema` is checked last, over the whole document, so it can relate two settings, and what it accepts is merged back over the document. The operations page caps the retries:
4831
5282
 
4832
- ```ts server
4833
- onConfig(() => ({ RETRIES: 3 }), {
4834
- schema: z.looseObject({
4835
- RETRIES: z.number().int().min(0).max(10),
4836
- STRIPE_KEY: z.string().min(1),
4837
- }),
5283
+ ```abide src/ops/index.abide — excerpt
5284
+ onConfig(null, {
5285
+ schema: (document) => {
5286
+ const { RETRIES } = document as { RETRIES: number }
5287
+ if (!Number.isInteger(RETRIES) || RETRIES < 0 || RETRIES > 10)
5288
+ throw new Error('RETRIES is a whole number from 0 to 10.')
5289
+ return { RETRIES }
5290
+ },
4838
5291
  })
4839
5292
  ```
4840
5293
 
@@ -4895,7 +5348,7 @@ test('the pool is connected before the first request is answered', async () => {
4895
5348
 
4896
5349
  `onStart` hands you the next step. Work before `await start()` finishes before the app answers anything; work after it knows the app is up. A hook that throws stops the server from coming up, which is the right answer to a failed migration.
4897
5350
 
4898
- `onStop` is the mirror: `await stop()` first, then close what you opened, so the pool outlives the last request that could use it. It runs on `SIGTERM`, `Ctrl-C` or a crash, and a second signal ends the process at once.
5351
+ `onStop` is the mirror: `await stop()` first, then close what you opened, so the pool outlives the last request that could use it. It runs on `SIGTERM`, `Ctrl-C` or a crash, and a second signal ends the process at once. A request still being answered after `ABIDE_STOP_TIMEOUT` milliseconds, 5000 unless you set it, has its connection closed, and the stop goes on without it.
4899
5352
 
4900
5353
  A hook is called in a page, a layout or a handler file, and returns a function that removes it. The ones for the whole app go in `src/layout.abide`, the root layout, whose server block the scaffold already starts with an `onStart`.
4901
5354
 
@@ -4955,14 +5408,6 @@ test('DEBUG writes the billing line and not the poll', async () => {
4955
5408
 
4956
5409
  `log.named('billing')` returns a `Logger` under the app's name: it writes as `crm-08:billing` in the downloaded app, and as `dogfood:billing` on this page. `DEBUG` takes the comma and wildcard list you already know: `DEBUG=crm-08:billing,abide:*`. It is read from the environment on the server, and the browser gets it from the document.
4957
5410
 
4958
- ```ts shared
4959
- log('sending')
4960
- log.debug('cache table', { entries: cache.size })
4961
- log.info('invoice paid', { id: '4310' })
4962
- log.warning('retrying', { attempt: 2 })
4963
- log.error('mailer refused', { error })
4964
- ```
4965
-
4966
5411
  `log`, `log.warning` and `log.error` always write. `log.info` and `log.debug` write when `DEBUG` names their namespace, and `log.enabled()` asks first where building the line is the expensive part.
4967
5412
 
4968
5413
  | Name | What it is for the CRM |
@@ -4991,7 +5436,7 @@ abide logs under `abide:`, so `DEBUG=abide:*` turns on the framework and nothing
4991
5436
 
4992
5437
  ## One trace id covers everything one request did
4993
5438
 
4994
- **`trace()` — one id for everything one request did** — Syncing a contact logs in the handler and again in the billing module it calls. Sync, and both lines carry the request's trace; sync again for a new one.
5439
+ **`trace()` — the id on every line a request writes** — Syncing a contact logs in the handler, and the billing module it calls times its work and logs again. Sync, and every line carries the request's trace; sync again for a new one.
4995
5440
 
4996
5441
  ```abide src/ops/lib/Sync.abide
4997
5442
  <script>
@@ -5041,11 +5486,13 @@ export default POST(
5041
5486
 
5042
5487
  ```ts src/ops/lib/billing.ts
5043
5488
  import { log } from '@abide/abide'
5489
+ import { trace } from '@abide/abide/server'
5044
5490
 
5045
5491
  /** The billing service: handed a contact id, and nothing else. */
5046
5492
  export const billing = {
5047
5493
  async notify(contact: string): Promise<void> {
5048
- await Bun.sleep(150)
5494
+ // A line with its duration, and `billing` in the server-timing header.
5495
+ await trace.span('billing', () => Bun.sleep(150), { timing: true })
5049
5496
  log(`billing told about contact ${contact}`)
5050
5497
  },
5051
5498
  }
@@ -5069,8 +5516,11 @@ test('both modules log under the one trace of the request', async () => {
5069
5516
  const { trace, lines } = await answer.json()
5070
5517
  expect(lines).toEqual([
5071
5518
  'contact c2 loaded',
5519
+ expect.stringMatching(/^billing [\d.]+ms$/),
5072
5520
  'billing told about contact c2',
5073
5521
  ])
5522
+ // The span joins the answer's server-timing under its name.
5523
+ expect(answer.headers.get('server-timing')).toContain('billing')
5074
5524
  // A second request is a second trace.
5075
5525
  const again = await (await app.fetch(sync())).json()
5076
5526
  expect(again.trace).not.toBe(trace)
@@ -5087,14 +5537,13 @@ Nothing starts a trace and nothing passes it down. The billing module was handed
5087
5537
  | `traceresponse` | the response header naming the trace that answered |
5088
5538
  | `server-timing` | the response header with the handler's own time, read by the browser's network panel |
5089
5539
 
5090
- ```ts server
5091
- const rows = await trace.span('search', () => search(query), {
5092
- timing: true,
5093
- })
5094
- const answer = await fetch(billingUrl, { headers: trace.headers() })
5540
+ The billing module's span is the line with a duration in it:
5541
+
5542
+ ```ts src/ops/lib/billing.ts — excerpt
5543
+ await trace.span('billing', () => Bun.sleep(150), { timing: true })
5095
5544
  ```
5096
5545
 
5097
- With `timing`, a span joins `server-timing` under its name, so the name has to be a plain token.
5546
+ With `timing`, a span joins `server-timing` under its name, so the name has to be a plain token. A call to another service carries the trace on with `fetch(url, { headers: trace.headers() })`.
5098
5547
 
5099
5548
  ## A load balancer reads the health check
5100
5549
 
@@ -5199,14 +5648,12 @@ test('the banner shows while the browser is offline, and goes', async () => {
5199
5648
 
5200
5649
  Read `online` by name. There is no listener to attach and none to remove. In the browser it follows `navigator.onLine`; on the server it is `true`, since a caller being served has reached the app.
5201
5650
 
5202
- `false` is certain and `true` is a guess: a dead server reads as online. Use it to explain a failed load, not to decide whether to try one. To catch up when the connection returns, refresh what is on screen from the root layout:
5203
-
5204
- ```abide abide
5205
- <script>
5206
- import { online, refresh, watch } from '@abide/abide'
5651
+ `false` is certain and `true` is a guess: a dead server reads as online. Use it to explain a failed load, not to decide whether to try one. To catch up when the connection returns, refresh what is on screen. The operations page does:
5207
5652
 
5208
- watch(() => { if (online) refresh() })
5209
- </script>
5653
+ ```abide src/ops/index.abide — excerpt
5654
+ watch(() => {
5655
+ if (online) refresh()
5656
+ })
5210
5657
  ```
5211
5658
 
5212
5659
  ## A call logged offline is sent when the network is back
@@ -5750,7 +6197,7 @@ test('the model calls the CRM search, through the app', async () => {
5750
6197
  | `Toolbox` | What a provider is handed: `list` of the tools, and `call` to run one as the asker. |
5751
6198
  | `ToolAnswer` | What `call` answers: its `content`, the same pieces as a turn — the tool's text, or the image a handler drew, which the model is shown as a picture — and whether it failed. |
5752
6199
  | `ToolDefinition` | One tool in `list`: its name, description and input schema. |
5753
- | `abide:assistant` | The log namespace that writes one `debug` line per conversation once it ends: the stop reason, the transcript's length, the tool calls, the tokens and the time. |
6200
+ | `abide:assistant` | The log namespace that writes one `debug` line per conversation once it ends: the stop reason, the transcript's length, the tool calls, the tokens and the time. A conversation that ended without `done` is `unfinished`, then why: `aborted` when the request was, `failed` with the provider's message when it threw, or `stopped` when the page stopped reading. |
5754
6201
 
5755
6202
  ## `claudeApi` sends the request body you wrote
5756
6203
 
@@ -6055,6 +6502,8 @@ test('unpaired: refused, with a command and a link', async () => {
6055
6502
  bunx @abide/bridge <site> <code> -- --tools WebSearch,WebFetch
6056
6503
  ```
6057
6504
 
6505
+ `bunx @abide/bridge` runs `abide-bridge <site> <code>`. `--claude <path>` names the Claude Code to run where `claude` is not on the `PATH`, and each `--env NAME=VALUE` sets a variable for it, such as a proxy or an API key of the visitor's own.
6506
+
6058
6507
  Pass the same `tools` to `claudeCode.bridge` or `claudeCode.bridgeCommand` and the command it mints carries that grant, so a visitor who runs it as shown gives the tools. One minted without them grants none.
6059
6508
 
6060
6509
  The visitor pairs once per visit by running a command, which dials out to the site. Asking with nothing paired is refused 409.
@@ -6065,7 +6514,9 @@ On a Mac the visitor can pair with a click instead. Each status carries a `link`
6065
6514
  bunx @abide/bridge install
6066
6515
  ```
6067
6516
 
6068
- That builds a small app under their own Application Support folder, nowhere in Applications, and registers it for the scheme. Opening a link asks them every time, naming the site, the tools and the bridge release it asks for, and then runs that release, as the command would. A link only proves the site minted its code, never that this visitor asked for it, so no answer is kept for a site. `bunx @abide/bridge uninstall` takes it away.
6517
+ That builds a small app under their own Application Support folder, nowhere in Applications, and registers it for the scheme. `abide-bridge install` takes the same `--claude`, `--env` and options after `--` as the command, and keeps them: every link it opens runs Claude Code with them, and no link can set either. Opening a link asks them every time, naming the site, the tools and the bridge release it asks for, and the options and variable names install kept, and then runs that release, as the command would. A link only proves the site minted its code, never that this visitor asked for it, so no answer is kept for a site. `abide-bridge uninstall` takes away everything install wrote.
6518
+
6519
+ A bridge that loses the site says so once, and not again until the site takes a report. A turn whose report the site did not take is ended, and reported failed.
6069
6520
 
6070
6521
  The tools the conversation calls run as the visitor, on the sign-in their ask carried, so a tool reads `principal` as any handler does. The pairing belongs to that sign-in too. If someone else signs in at the same browser, their asks are refused 409 and the status offers them a command of their own, so nobody's turn runs on another member's Claude Code.
6071
6522
 
@@ -6179,7 +6630,7 @@ test('the summary answers in process as it does on the wire', async () => {
6179
6630
  | `App.fetch` | one request, through the whole app, in this process |
6180
6631
  | `App.run` | runs a function inside one request's scope, so two reads of one key load once, signed in as the `principal` claims you give it |
6181
6632
  | `App.listen` | binds the socket, for a test that needs a real `WebSocket` |
6182
- | `App.stop` | drains what is in flight, then runs `onStop` |
6633
+ | `App.stop` | stops the app: every `onStop`, wrapped around draining what is in flight |
6183
6634
  | `App[Symbol.asyncDispose]` | `stop`, under the name `await using` calls |
6184
6635
 
6185
6636
  Hold the app with `await using` and it stops when the test ends, a failed one included. An app nobody stops leaves open whatever `onStart` opened, and the suite hangs with no report at all.
@@ -6270,13 +6721,11 @@ test('a range switch rewrites two numbers and builds nothing', async () => {
6270
6721
 
6271
6722
  A DOM member the harness does not count throws while a case is open, rather than reading as zero. `patchSet()` lists every member it does count.
6272
6723
 
6273
- The DOM under `bun test` comes from one line in `bunfig.toml`, which the scaffold writes:
6724
+ The DOM under `bun test` comes from the `preload` line in `bunfig.toml`, which the scaffold writes:
6274
6725
 
6275
- ```toml bunfig.toml
6276
- # A DOM to mount into and measure, and `.abide` files as components.
6726
+ ```toml bunfig.toml — excerpt
6277
6727
  [test]
6278
- # Bun's tests are under src; e2e holds the browser's, which playwright runs.
6279
- root = "src"
6728
+ …
6280
6729
  preload = ["@abide/harness/abide/preload"]
6281
6730
  ```
6282
6731
 
@@ -6779,14 +7228,21 @@ test('every deal adds the same bytes at any length', async () => {
6779
7228
 
6780
7229
  The last three read the engine's heap, and throw where a DOM is preloaded, because the DOM's own objects would be counted with yours. Run them through `inAFreshProcess`.
6781
7230
 
6782
- ## Test and bench results on a page
7231
+ ## Report the CRM's test results one file at a time
7232
+
7233
+ ```ts scripts/testRows.ts — excerpt
7234
+ await testRows(
7235
+ [{ section: 'tests', root: 'src', runner: 'bun' }],
7236
+ Bun.fileURLToPath(new URL('..', import.meta.url)),
7237
+ )
7238
+ ```
6783
7239
 
6784
7240
  ```
6785
7241
  bun scripts/testRows.ts --list
6786
7242
  bun scripts/testRows.ts --scope all --filter reports
6787
7243
  ```
6788
7244
 
6789
- `testRows` runs each test file of a `TestSuite` in a process of its own, and prints one `TestRow` per line as each file finishes, with a `TestCase` per test. The cards in this chapter read their results from those rows.
7245
+ `testRows` runs each test file of a `TestSuite` in a process of its own, and prints one `TestRow` per line as each file finishes, with a `TestCase` per test. A dashboard reads the rows as they land, rather than waiting on the whole run. The cards in this chapter read their results from those rows.
6790
7246
 
6791
7247
  | Name | What it does for the CRM |
6792
7248
  | --- | --- |
@@ -6801,13 +7257,21 @@ A file whose runner wrote no report is `broken`, which is different from a red r
6801
7257
  ## Check, build and start the CRM
6802
7258
 
6803
7259
  ```
6804
- abide check && abide build && bun test
7260
+ abide check && abide format --check && abide build && bun test
6805
7261
  abide start --port 8080
6806
7262
  ```
6807
7263
 
6808
7264
  `abide check [dir…]` type-checks the `.abide` files as well as the TypeScript, and reports on the line you wrote. `abide lsp` is the same check in your editor. Run it first in CI, because it is the cheapest failure to report.
6809
7265
 
6810
- `abide format [--check] [dir…]` formats every `.abide` the way `bun fmt` formats everything else: its scripts, styles and expressions under the `[fmt]` options in `bunfig.toml`, and its markup indented by its nesting. `--check` writes nothing and fails where a file would change, and `abide lsp` formats on save the same way.
7266
+ `abide format [--check] [dir…]` formats every `.abide` the way `bun fmt` formats everything else: its scripts, styles and expressions under the `[fmt]` options in `bunfig.toml`, and its markup indented by its nesting. `--check` writes nothing and fails where a file would change, and `abide lsp` formats on save the same way. The scaffold's options:
7267
+
7268
+ ```toml bunfig.toml — excerpt
7269
+ [fmt]
7270
+ tabWidth = 4
7271
+ semi = false
7272
+ singleQuote = true
7273
+ sortImports = true
7274
+ ```
6811
7275
 
6812
7276
  `abide build` writes content-hashed chunks and a manifest naming them. `abide start [--port <n>]` serves what was built, and builds first where nothing was. Configuration is checked as it starts, so a misconfigured deployment fails to come up.
6813
7277
 
@@ -6830,6 +7294,30 @@ abide compile --platforms linux-x64,darwin-arm64
6830
7294
 
6831
7295
  `abide compile [--target] [--out] [--platforms]` writes one executable with the built client, the server and the runtime inside it. `--target` picks one platform, `--platforms` builds several in one run, and `--out` names the file. The executable is `abide` for this one app: `./crm-10 start` serves it, and `config()` still reads the environment.
6832
7296
 
7297
+ ```dockerfile Dockerfile
7298
+ FROM oven/bun:1.4.4 AS build
7299
+ WORKDIR /app
7300
+ COPY package.json bun.lock bunfig.toml ./
7301
+ RUN bun install --frozen-lockfile
7302
+ COPY . .
7303
+ RUN bun x abide compile --out /app/server
7304
+
7305
+ FROM gcr.io/distroless/cc-debian12
7306
+ COPY --from=build /app/server /server
7307
+ ENV NODE_ENV=production
7308
+ EXPOSE 3000
7309
+ CMD ["/server", "start"]
7310
+ ```
7311
+
7312
+ The scaffold writes this `Dockerfile`, and the CRM carries it. Only the executable reaches the second stage, so the image holds no Bun, no source and no `node_modules`.
7313
+
7314
+ ```
7315
+ docker build -t crm .
7316
+ docker run -p 3000:3000 -e ABIDE_PRINCIPAL_SECRET crm
7317
+ ```
7318
+
7319
+ `.dockerignore` keeps `.env` out of the build, so the deployment sets the principal secret and every other setting itself.
7320
+
6833
7321
  ## Ship the CRM as a desktop app
6834
7322
 
6835
7323
  ```
@@ -6866,7 +7354,7 @@ onMenu(() => [
6866
7354
  watch(() => printReport.on(() => print()))
6867
7355
  ```
6868
7356
 
6869
- The reports page adds a Reports menu while it is showing. `onMenu` is read the way markup is, so the menus follow the page's state. Each entry is a `MenuItem`: `'separator'`, an item with an `href`, one with a `MenuRole` the platform performs, or a `MenuEntry` made by `menuItem`. `MenuEntry.on` files what runs when it is picked, and hands back what stops it, which is what `watch` takes. In a browser nothing picks an item, and the page runs unchanged.
7357
+ The reports page adds a Reports menu while it is showing. `onMenu` is read the way markup is, so the menus follow the page's values. Each entry is a `MenuItem`: `'separator'`, an item with an `href`, one with a `MenuRole` the platform performs, or a `MenuEntry` made by `menuItem`. `MenuEntry.on` files what runs when it is picked, and hands back what stops it, which is what `watch` takes. In a browser nothing picks an item, and the page runs unchanged.
6870
7358
 
6871
7359
  ## Keep the CRM readable offline
6872
7360
 
@@ -6910,6 +7398,7 @@ Arrive here knowing which name you want. Each page is read off the registry: the
6910
7398
  | Page | Imported from |
6911
7399
  | --- | --- |
6912
7400
  | [`Reactive`](/reference/types/Reactive.md) | `@abide/abide` |
7401
+ | [`isReactive`](/reference/isReactive.md) | `@abide/abide` |
6913
7402
  | [`Keyed`](/reference/types/Keyed.md) | `@abide/abide` |
6914
7403
  | [`reactive`](/reference/reactive.md) | `@abide/abide` |
6915
7404
  | [`Transformer`](/reference/types/Transformer.md) | `@abide/abide` |
@@ -6993,6 +7482,7 @@ Arrive here knowing which name you want. Each page is read off the registry: the
6993
7482
  | Page | Imported from |
6994
7483
  | --- | --- |
6995
7484
  | [`cookies`](/reference/cookies.md) | `@abide/abide` |
7485
+ | [`Cookies`](/reference/types/Cookies.md) | `@abide/abide` |
6996
7486
  | [`request`](/reference/request.md) | `@abide/abide/server` |
6997
7487
  | [`response`](/reference/response.md) | `@abide/abide/server` |
6998
7488
  | [`route`](/reference/route.md) | `@abide/abide` |
@@ -7219,6 +7709,24 @@ Arrive here knowing which name you want. Each page is read off the registry: the
7219
7709
 
7220
7710
  ---
7221
7711
 
7712
+ <!-- reference/isReactive.md -->
7713
+
7714
+ # `isReactive`
7715
+
7716
+ `import { isReactive } from '@abide/abide'`
7717
+
7718
+ ## The value
7719
+
7720
+ | Name | Signature | Meaning | Rules |
7721
+ | --- | --- | --- | --- |
7722
+ | `isReactive` | `(value: unknown) => value is Reactive<unknown>` | Whether a value is a `Reactive`. | [1.6](/rulebook#1.6) |
7723
+
7724
+ ## Taught in
7725
+
7726
+ - [Start an app](/guide/start-an-app.md)
7727
+
7728
+ ---
7729
+
7222
7730
  <!-- reference/types/Keyed.md -->
7223
7731
 
7224
7732
  # `Keyed`
@@ -7735,7 +8243,7 @@ Reactive primitives: what is written in place rather than imported.
7735
8243
 
7736
8244
  | Name | Signature | Meaning | Rules |
7737
8245
  | --- | --- | --- | --- |
7738
- | `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) |
8246
+ | `Rpc` | `<Value, Args, Failures = never, Produced = Value>(args?: Args, options?: { signal?: AbortSignal; offline?: (args: Args) => Value }) => Reactive<Value, Value, Failures \| Failed<'HttpError', undefined> \| Annotated<Args>, 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) |
7739
8247
  | `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) |
7740
8248
  | `Value` | `unknown` | What the addressed `Reactive` holds. | [16.3](/rulebook#16.3), [16.30](/rulebook#16.30) |
7741
8249
  | `Produced` | `unknown` | What it yields, per the unit rule. | [1.2](/rulebook#1.2), [16.3](/rulebook#16.3) |
@@ -7745,7 +8253,7 @@ Reactive primitives: what is written in place rather than imported.
7745
8253
  | `rpc.synced` | `(pattern?: Partial<Args>) => boolean` | `synced` narrowed to the handler's matching entries. | [13.12](/rulebook#13.12), [13.18](/rulebook#13.18), [13.19](/rulebook#13.19) |
7746
8254
  | `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), [13.19](/rulebook#13.19) |
7747
8255
  | `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), [13.19](/rulebook#13.19) |
7748
- | `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) |
8256
+ | `rpc.isError` | `<Name extends string>(error: unknown, name: Name) => error is Extract<Failures \| Failed<'HttpError', undefined> \| Annotated<Args>, { name: Name }>` | Whether a caught failure is the one named. | [16.8](/rulebook#16.8) |
7749
8257
  | `rpc.raw` | `(args: Args, init?: RequestInit) => Promise<Response>` | The same call handed back as the raw response. | [16.11](/rulebook#16.11), [16.42](/rulebook#16.42) |
7750
8258
  | `rpc.method` | `string` | The HTTP method. | [16.2](/rulebook#16.2) |
7751
8259
  | `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) |
@@ -7794,9 +8302,15 @@ Reactive primitives: what is written in place rather than imported.
7794
8302
 
7795
8303
  | Name | Signature | Meaning | Rules |
7796
8304
  | --- | --- | --- | --- |
7797
- | `GET` | `<Value, Args, Failures, RungFailures, Produced = Value>(handler: Reactive<Value, Value, Failures, Produced> \| Keyed<Value, Args, Value, Failures, Produced>, options?: RpcOptions<Args, RungFailures>) => Rpc<Value, Args, Failures \| RungFailures, Produced>` | Declares a read any surface may call. | [16.1](/rulebook#16.1), [16.2](/rulebook#16.2), [16.5](/rulebook#16.5), [16.6](/rulebook#16.6), [16.30](/rulebook#16.30), [16.31](/rulebook#16.31), [16.32](/rulebook#16.32), [16.68](/rulebook#16.68), [16.76](/rulebook#16.76), [16.34](/rulebook#16.34), [16.35](/rulebook#16.35), [16.36](/rulebook#16.36), [16.37](/rulebook#16.37), [16.39](/rulebook#16.39), [16.44](/rulebook#16.44), [16.45](/rulebook#16.45), [16.46](/rulebook#16.46), [16.47](/rulebook#16.47), [16.75](/rulebook#16.75), [16.50](/rulebook#16.50), [16.51](/rulebook#16.51), [16.52](/rulebook#16.52), [16.64](/rulebook#16.64), [18.1](/rulebook#18.1), [18.3](/rulebook#18.3), [18.6](/rulebook#18.6), [18.8](/rulebook#18.8), [18.10](/rulebook#18.10), [18.12](/rulebook#18.12), [18.21](/rulebook#18.21), [18.24](/rulebook#18.24), [18.27](/rulebook#18.27) |
7798
- | `GET` | `<Value, Args, Failures, RungFailures, Produced = Value>(handler: (args?: Args) => Value, options?: RpcOptions<Args, RungFailures> & ReactiveOptions<Value, Value, Failures, Args>) => Rpc<Value, Args, Failures \| RungFailures, Produced>` | The plain-function overload, whose `ReactiveOptions` (`ttl`, `global`, `debounce` and the rest) build the value it becomes beside its address. | [16.1](/rulebook#16.1), [16.6](/rulebook#16.6), [16.7](/rulebook#16.7) |
7799
- | `GET` | `<Value, Failures, RungFailures>(handler: Value \| PromiseLike<Value>, options?: RpcOptions<undefined, RungFailures> & ReactiveOptions<Value, Value, Failures>) => Rpc<Value, undefined, Failures \| RungFailures>` | The value overload, for what the handler owns outright, whose `ReactiveOptions` build the value it becomes. | [16.1](/rulebook#16.1), [16.6](/rulebook#16.6), [16.7](/rulebook#16.7) |
8305
+ | `GET` | `<Stored, Accepted, Failures, Produced>(handler: Reactive<Stored, Accepted, Failures, Produced>, options?: RpcOptions<undefined>) => Rpc<Stored, undefined, Failures, Produced>` | Declares a read any surface may call. | [16.1](/rulebook#16.1), [16.2](/rulebook#16.2), [16.5](/rulebook#16.5), [16.6](/rulebook#16.6), [16.30](/rulebook#16.30), [16.31](/rulebook#16.31), [16.32](/rulebook#16.32), [16.68](/rulebook#16.68), [16.76](/rulebook#16.76), [16.34](/rulebook#16.34), [16.35](/rulebook#16.35), [16.36](/rulebook#16.36), [16.37](/rulebook#16.37), [16.39](/rulebook#16.39), [16.44](/rulebook#16.44), [16.45](/rulebook#16.45), [16.46](/rulebook#16.46), [16.47](/rulebook#16.47), [16.75](/rulebook#16.75), [16.50](/rulebook#16.50), [16.51](/rulebook#16.51), [16.52](/rulebook#16.52), [16.64](/rulebook#16.64), [18.1](/rulebook#18.1), [18.3](/rulebook#18.3), [18.6](/rulebook#18.6), [18.8](/rulebook#18.8), [18.10](/rulebook#18.10), [18.12](/rulebook#18.12), [18.21](/rulebook#18.21), [18.24](/rulebook#18.24), [18.27](/rulebook#18.27) |
8306
+ | `GET` | `<Stored, Args, Accepted, Failures, Produced>(handler: Keyed<Stored, Args, Accepted, Failures, Produced>, options?: RpcOptions<Args>) => Rpc<Stored, Args, Failures, Produced>` | The keyed overload, for a `Keyed` given an address. | [11.2](/rulebook#11.2), [16.1](/rulebook#16.1), [16.3](/rulebook#16.3), [16.6](/rulebook#16.6) |
8307
+ | `GET` | `<Chunk, Shaped = Chunk[], Gate = Schema<Chunk[]>>(handler: () => AsyncGenerator<Chunk> \| Promise<AsyncGenerator<Chunk>>, options?: Shaping<Chunk[], Shaped, never, Gate> & RpcOptions<undefined>) => Rpc<Kept<Shaped>, undefined, Refusals<Shaped, Gate, Chunk[]>, Chunk>` | The streaming overload, whose options build the value it becomes beside its address. | [16.1](/rulebook#16.1), [16.6](/rulebook#16.6), [16.7](/rulebook#16.7), [16.44](/rulebook#16.44), [16.45](/rulebook#16.45), [16.46](/rulebook#16.46), [16.47](/rulebook#16.47) |
8308
+ | `GET` | `<Chunk, Args, Shaped = Chunk[], Gate = Schema<Chunk[]>, ArgsGate = Schema<Args>>(handler: (args: Args) => AsyncGenerator<Chunk> \| Promise<AsyncGenerator<Chunk>>, options?: Shaping<Chunk[], Shaped, Args, Gate, ArgsGate> & RpcOptions<Args>) => Rpc<Kept<Shaped>, Args, Refusals<Shaped, Gate, Chunk[], ArgsGate, Args>, Chunk>` | The keyed streaming overload. | [11.2](/rulebook#11.2), [16.1](/rulebook#16.1), [16.6](/rulebook#16.6), [16.7](/rulebook#16.7) |
8309
+ | `GET` | `<Chunk, Args, Shaped = Chunk[], Gate = Schema<Chunk[]>, ArgsGate = Schema<Args \| undefined>>(handler: (args?: Args) => AsyncGenerator<Chunk> \| Promise<AsyncGenerator<Chunk>>, options?: Shaping<Chunk[], Shaped, Args \| undefined, Gate, ArgsGate> & RpcOptions<Args \| undefined>) => Rpc<Kept<Shaped>, Args \| undefined, Refusals<Shaped, Gate, Chunk[], ArgsGate, Args \| undefined>, Chunk>` | The keyed streaming overload for a body whose parameter is optional. | [11.2](/rulebook#11.2) |
8310
+ | `GET` | `<Computed, Shaped = Awaited<Computed>, Gate = Schema<Awaited<Computed>>>(handler: () => Computed, options?: Shaping<Awaited<Computed>, Shaped, never, Gate> & RpcOptions<undefined>) => Rpc<Kept<Shaped>, undefined, AdoptedFailures<Computed> \| Refusals<Shaped, Gate, Awaited<Computed>>, AdoptedProduced<Computed, Kept<Shaped>>>` | The plain-function overload, whose options (`ttl`, `global`, `debounce` and the rest) build the value it becomes beside its address. | [16.1](/rulebook#16.1), [16.6](/rulebook#16.6), [16.7](/rulebook#16.7) |
8311
+ | `GET` | `<Computed, Args, Shaped = Awaited<Computed>, Gate = Schema<Awaited<Computed>>, ArgsGate = Schema<Args>>(handler: (args: Args) => Computed, options?: Shaping<Awaited<Computed>, Shaped, Args, Gate, ArgsGate> & RpcOptions<Args>) => Rpc<Kept<Shaped>, Args, AdoptedFailures<Computed> \| Refusals<Shaped, Gate, Awaited<Computed>, ArgsGate, Args>, AdoptedProduced<Computed, Kept<Shaped>>>` | The keyed plain-function overload. | [11.2](/rulebook#11.2), [16.1](/rulebook#16.1), [16.6](/rulebook#16.6), [16.7](/rulebook#16.7) |
8312
+ | `GET` | `<Computed, Args, Shaped = Awaited<Computed>, Gate = Schema<Awaited<Computed>>, ArgsGate = Schema<Args \| undefined>>(handler: (args?: Args) => Computed, options?: Shaping<Awaited<Computed>, Shaped, Args \| undefined, Gate, ArgsGate> & RpcOptions<Args \| undefined>) => Rpc<Kept<Shaped>, Args \| undefined, AdoptedFailures<Computed> \| Refusals<Shaped, Gate, Awaited<Computed>, ArgsGate, Args \| undefined>, AdoptedProduced<Computed, Kept<Shaped>>>` | The keyed plain-function overload for a body whose parameter is optional. | [11.2](/rulebook#11.2) |
8313
+ | `GET` | `<Accepted, Shaped = Accepted, Gate = Schema<Accepted>>(handler: Accepted \| PromiseLike<Accepted>, options?: Shaping<Accepted, Shaped, never, Gate> & RpcOptions<undefined>) => Rpc<Kept<Shaped>, undefined, Refusals<Shaped, Gate, Accepted>>` | The value overload, for what the handler owns outright, whose options build the value it becomes. | [16.1](/rulebook#16.1), [16.6](/rulebook#16.6), [16.7](/rulebook#16.7) |
7800
8314
 
7801
8315
  ## Taught in
7802
8316
 
@@ -8418,8 +8932,8 @@ Generated surfaces: what is written in place rather than imported.
8418
8932
 
8419
8933
  | Name | Signature | Meaning | Rules |
8420
8934
  | --- | --- | --- | --- |
8421
- | `claudeCode` | `(config?: ClaudeCodeConfig) => Provider` | The visitor's own Claude Code, reached through the bridge paired with their browser, as a provider. | [44.5](/rulebook#44.5), [44.13](/rulebook#44.13), [44.17](/rulebook#44.17), [44.18](/rulebook#44.18), [44.19](/rulebook#44.19), [44.24](/rulebook#44.24), [44.25](/rulebook#44.25), [44.27](/rulebook#44.27), [44.30](/rulebook#44.30), [44.33](/rulebook#44.33), [44.45](/rulebook#44.45), [44.46](/rulebook#44.46) |
8422
- | `claudeCode.bridgeCommand` | `(options?: { tools?: string[] }) => { command: string; link: string; expires: number }` | The command and the link that pair a bridge with this browser, granting the built-in tools it is given. | [44.14](/rulebook#44.14), [44.32](/rulebook#44.32), [44.33](/rulebook#44.33), [44.40](/rulebook#44.40), [44.41](/rulebook#44.41), [44.42](/rulebook#44.42), [44.43](/rulebook#44.43), [44.44](/rulebook#44.44) |
8935
+ | `claudeCode` | `(config?: ClaudeCodeConfig) => Provider` | The visitor's own Claude Code, reached through the bridge paired with their browser, as a provider. | [44.5](/rulebook#44.5), [44.13](/rulebook#44.13), [44.17](/rulebook#44.17), [44.18](/rulebook#44.18), [44.19](/rulebook#44.19), [44.33](/rulebook#44.33) |
8936
+ | `claudeCode.bridgeCommand` | `(options?: { tools?: string[] }) => { command: string; link: string; expires: number }` | The command and the link that pair a bridge with this browser, granting the built-in tools it is given. | [44.14](/rulebook#44.14), [44.32](/rulebook#44.32), [44.33](/rulebook#44.33), [44.40](/rulebook#44.40) |
8423
8937
  | `claudeCode.bridge` | `(options?: { tools?: string[] }) => AsyncGenerator<BridgeStatus>` | This browser's pairing, as it changes. | [44.20](/rulebook#44.20), [44.22](/rulebook#44.22), [44.32](/rulebook#44.32), [44.33](/rulebook#44.33), [44.34](/rulebook#44.34), [44.40](/rulebook#44.40) |
8424
8938
  | `/__abide/bridge` | `GET`, `POST` | Where a bridge pairs, and is relayed its turns. | [36.7](/rulebook#36.7), [44.14](/rulebook#44.14), [44.15](/rulebook#44.15), [44.19](/rulebook#44.19), [44.23](/rulebook#44.23), [44.26](/rulebook#44.26), [16.57](/rulebook#16.57), [16.78](/rulebook#16.78) |
8425
8939
 
@@ -8475,7 +8989,25 @@ Generated surfaces: what is written in place rather than imported.
8475
8989
 
8476
8990
  | Name | Signature | Meaning | Rules |
8477
8991
  | --- | --- | --- | --- |
8478
- | `cookies` | `() => CookieMap` | The cookies of the request being served, live and mutable. | [22.2](/rulebook#22.2), [22.3](/rulebook#22.3), [22.4](/rulebook#22.4), [22.5](/rulebook#22.5), [22.6](/rulebook#22.6), [22.7](/rulebook#22.7), [22.8](/rulebook#22.8), [22.10](/rulebook#22.10) |
8992
+ | `cookies` | `() => Cookies` | The cookies of the request being served, live and mutable. | [22.2](/rulebook#22.2), [22.3](/rulebook#22.3), [22.4](/rulebook#22.4), [22.5](/rulebook#22.5), [22.6](/rulebook#22.6), [22.7](/rulebook#22.7), [22.8](/rulebook#22.8), [22.10](/rulebook#22.10) |
8993
+
8994
+ ## Taught in
8995
+
8996
+ - [Who sees what](/guide/decide-who-sees-what.md)
8997
+
8998
+ ---
8999
+
9000
+ <!-- reference/types/Cookies.md -->
9001
+
9002
+ # `Cookies`
9003
+
9004
+ `import type { Cookies } from '@abide/abide'`
9005
+
9006
+ ## `cookies`
9007
+
9008
+ | Name | Signature | Meaning | Rules |
9009
+ | --- | --- | --- | --- |
9010
+ | `Cookies` | `class Cookies implements Iterable<[string, string]> { get(name: string): string \| null; has(name: string): boolean; set(name: string, value: string, options?: CookieInit): void; set(options: CookieInit): void; delete(name: string, options?: Omit<CookieStoreDeleteOptions, 'name'>): void; delete(options: CookieStoreDeleteOptions): void; size: number; toJSON(): Record<string, string>; toSetCookieHeaders(): string[] }` | A cookie jar shaped like Bun's `CookieMap`. | [22.4](/rulebook#22.4), [22.5](/rulebook#22.5), [22.6](/rulebook#22.6) |
8479
9011
 
8480
9012
  ## Taught in
8481
9013
 
@@ -8977,7 +9509,7 @@ Generated surfaces: what is written in place rather than imported.
8977
9509
 
8978
9510
  | Name | Signature | Meaning | Rules |
8979
9511
  | --- | --- | --- | --- |
8980
- | `csp` | `(sources?: Record<string, string[]>) => (next: (ctx?: { request: Request }) => Promise<Response>, ctx: { request: Request }) => Promise<Response>` | The rung that sets `content-security-policy`. | [29.1](/rulebook#29.1), [29.2](/rulebook#29.2), [29.5](/rulebook#29.5), [29.6](/rulebook#29.6), [29.7](/rulebook#29.7), [29.8](/rulebook#29.8) |
9512
+ | `csp` | `(sources?: Record<string, string[]>) => (next: (ctx?: { request: Request }) => Promise<Response>, ctx: { request: Request }) => Response \| Promise<Response>` | The rung that sets `content-security-policy`. | [29.1](/rulebook#29.1), [29.2](/rulebook#29.2), [29.5](/rulebook#29.5), [29.6](/rulebook#29.6), [29.7](/rulebook#29.7), [29.8](/rulebook#29.8) |
8981
9513
  | `csp.nonce` | `() => string` | This response's nonce, for the app's own inline script. | [29.3](/rulebook#29.3), [29.4](/rulebook#29.4), [29.9](/rulebook#29.9), [29.11](/rulebook#29.11) |
8982
9514
 
8983
9515
  ## Taught in
@@ -9221,7 +9753,7 @@ Generated surfaces: what is written in place rather than imported.
9221
9753
  | `foo.push(v)` | the same | [31.8](/rulebook#31.8), [31.9](/rulebook#31.9) |
9222
9754
  | `const [a, b] = foo` / `...foo` | one read of the value | [31.12](/rulebook#31.12) |
9223
9755
  | `foo.bar` | the value's `bar`, or the `Reactive`'s own `bar` where it declares one the value does not | [31.5](/rulebook#31.5) |
9224
- | `foo.bar(…)` | the `Reactive` API where `bar` is a member | [31.6](/rulebook#31.6) |
9756
+ | `foo.bar(…)` | a call of the value's `bar`, or of the `Reactive`'s own `bar` where it declares one the value does not | [31.6](/rulebook#31.6) |
9225
9757
  | `foo()` / `foo.set(v)` | the explicit forms | [31.7](/rulebook#31.7), [31.11](/rulebook#31.11) |
9226
9758
 
9227
9759
  ## Expressions
@@ -9979,7 +10511,7 @@ The measurement harness: what is written in place rather than imported.
9979
10511
 
9980
10512
  | Name | Meaning | Rules |
9981
10513
  | --- | --- | --- |
9982
- | `preload = ["@abide/harness/abide/preload"]` | The `[test]` preload that registers a DOM a test mounts into and measures, then abide's own. | [38.109](/rulebook#38.109), [38.119](/rulebook#38.119) |
10514
+ | `preload = ["@abide/harness/abide/preload"]` | The `[test]` preload that registers a DOM a test mounts into and measures, then abide's own. | [38.109](/rulebook#38.109), [38.119](/rulebook#38.119), [38.133](/rulebook#38.133) |
9983
10515
 
9984
10516
  ## Taught in
9985
10517
 
@@ -10736,7 +11268,7 @@ CLI: what is written in place rather than imported.
10736
11268
 
10737
11269
  | Command | Meaning | Rules |
10738
11270
  | --- | --- | --- |
10739
- | `abide scaffold <name>` | Writes a starter project. | [38.1](/rulebook#38.1), [38.42](/rulebook#38.42), [38.103](/rulebook#38.103), [38.104](/rulebook#38.104), [38.106](/rulebook#38.106), [38.112](/rulebook#38.112), [38.122](/rulebook#38.122), [38.124](/rulebook#38.124), [38.131](/rulebook#38.131) |
11271
+ | `abide scaffold <name>` | Writes a starter project. | [38.1](/rulebook#38.1), [38.42](/rulebook#38.42), [38.103](/rulebook#38.103), [38.104](/rulebook#38.104), [38.106](/rulebook#38.106), [38.112](/rulebook#38.112), [38.122](/rulebook#38.122), [38.124](/rulebook#38.124), [38.131](/rulebook#38.131), [38.132](/rulebook#38.132) |
10740
11272
  | `abide run <file> [args…]` | Runs a script under the abide runtime. | [38.2](/rulebook#38.2), [38.57](/rulebook#38.57) |
10741
11273
  | `abide check [dir…]` | The type-checker over a `.abide` tree. | [38.3](/rulebook#38.3), [38.31](/rulebook#38.31), [38.32](/rulebook#38.32), [38.33](/rulebook#38.33), [38.40](/rulebook#38.40), [38.105](/rulebook#38.105), [38.123](/rulebook#38.123) |
10742
11274
  | `abide format [--check] [dir…]` | The formatter over a `.abide` tree. | [38.25](/rulebook#38.25), [38.125](/rulebook#38.125), [38.126](/rulebook#38.126), [38.127](/rulebook#38.127), [38.128](/rulebook#38.128), [38.129](/rulebook#38.129) |
@@ -10755,6 +11287,14 @@ CLI: what is written in place rather than imported.
10755
11287
  | `--help` · `-h` · `abide help` | Usage, generated from the command list. | [38.12](/rulebook#38.12), [38.113](/rulebook#38.113), [38.114](/rulebook#38.114) |
10756
11288
  | `--version` · `-v` | The version of abide, or of the app a compiled executable holds. | [38.115](/rulebook#38.115) |
10757
11289
 
11290
+ ## `abide-bridge` — the bridge
11291
+
11292
+ | Command | Meaning | Rules |
11293
+ | --- | --- | --- |
11294
+ | `abide-bridge <site> <code> [--claude <path>] [--env NAME=VALUE]… [-- <options>]` | This machine's Claude Code, paired with the browser a site minted the code for. | [44.15](/rulebook#44.15), [44.16](/rulebook#44.16), [44.24](/rulebook#44.24), [44.25](/rulebook#44.25), [44.27](/rulebook#44.27), [44.30](/rulebook#44.30), [44.44](/rulebook#44.44), [44.45](/rulebook#44.45), [44.46](/rulebook#44.46), [44.47](/rulebook#44.47) |
11295
+ | `abide-bridge install [--claude <path>] [--env NAME=VALUE]… [-- <options>]` | The app that opens a site's `abide-bridge` links on this machine, registered. | [44.41](/rulebook#44.41), [44.42](/rulebook#44.42), [44.43](/rulebook#44.43), [44.44](/rulebook#44.44) |
11296
+ | `abide-bridge uninstall` | `abide-bridge install`, undone. | [44.43](/rulebook#44.43) |
11297
+
10758
11298
  ## Taught in
10759
11299
 
10760
11300
  - [Start an app](/guide/start-an-app.md)