@abide/abide 0.32.2 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/AGENTS.md +3 -3
  2. package/CHANGELOG.md +79 -63
  3. package/package.json +6 -2
  4. package/src/lib/server/runtime/buildCacheSnapshot.ts +5 -4
  5. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
  6. package/src/lib/shared/cache.ts +43 -29
  7. package/src/lib/shared/types/CacheEntry.ts +12 -12
  8. package/src/lib/shared/types/CacheOptions.ts +17 -13
  9. package/src/lib/ui/README.md +1 -1
  10. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +4 -1
  11. package/src/lib/ui/compile/componentWrapperTag.ts +1 -1
  12. package/src/lib/ui/compile/generateBuild.ts +265 -121
  13. package/src/lib/ui/compile/generateSSR.ts +78 -37
  14. package/src/lib/ui/compile/parseTemplate.ts +34 -0
  15. package/src/lib/ui/compile/skeletonable.ts +80 -0
  16. package/src/lib/ui/dom/MATHML_NAMESPACE.ts +6 -0
  17. package/src/lib/ui/dom/SVG_NAMESPACE.ts +7 -0
  18. package/src/lib/ui/dom/anchorCursor.ts +24 -0
  19. package/src/lib/ui/dom/appendSnippet.ts +1 -1
  20. package/src/lib/ui/dom/appendTextAt.ts +70 -0
  21. package/src/lib/ui/dom/awaitBlock.ts +27 -7
  22. package/src/lib/ui/dom/cloneStatic.ts +14 -7
  23. package/src/lib/ui/dom/each.ts +44 -25
  24. package/src/lib/ui/dom/eachAsync.ts +6 -2
  25. package/src/lib/ui/dom/effectiveChildNamespace.ts +13 -0
  26. package/src/lib/ui/dom/enterNamespace.ts +20 -0
  27. package/src/lib/ui/dom/fillBefore.ts +20 -3
  28. package/src/lib/ui/dom/foreignWrapperTag.ts +22 -0
  29. package/src/lib/ui/dom/hydrate.ts +1 -1
  30. package/src/lib/ui/dom/inheritedNamespace.ts +19 -0
  31. package/src/lib/ui/dom/mountSlot.ts +32 -0
  32. package/src/lib/ui/dom/openMarker.ts +4 -2
  33. package/src/lib/ui/dom/skeleton.ts +202 -0
  34. package/src/lib/ui/dom/switchBlock.ts +10 -3
  35. package/src/lib/ui/dom/tryBlock.ts +7 -5
  36. package/src/lib/ui/dom/types/SkeletonHoles.ts +8 -0
  37. package/src/lib/ui/dom/when.ts +6 -2
  38. package/src/lib/ui/installHotBridge.ts +8 -2
  39. package/src/lib/ui/runtime/HOLE_ATTRIBUTE.ts +9 -0
  40. package/src/lib/ui/runtime/RENDER.ts +7 -0
  41. package/src/lib/ui/runtime/types/UiProps.ts +3 -4
  42. package/template/src/ui/pages/about/page.abide +4 -6
  43. package/template/src/ui/pages/layout.abide +21 -0
  44. package/template/src/ui/pages/page.abide +5 -8
  45. package/src/lib/ui/compile/partitionSlots.ts +0 -36
  46. package/src/lib/ui/dom/openChild.ts +0 -22
  47. package/template/src/ui/Layout.abide +0 -19
package/AGENTS.md CHANGED
@@ -134,7 +134,7 @@ Resolve only during an in-flight SSR render or RPC handler (throw outside one):
134
134
  ## Isomorphic surface — `abide/shared/*`
135
135
 
136
136
  ### Cache — `@readme cache`
137
- - `abide/shared/cache(fnOrProducer)` → a memoised callable. Key auto-derives (method+url+args for a remote fn; producer-reference+args for a producer). Options `{ ttl?, scope?, global?, invalidate? }`: `ttl` ms-past-resolve (omit = forever, `0` = dedupe-only / the **mutation idiom**); `scope` tags for group invalidation; `global` = process-level store (server); `invalidate: { throttle | debounce }` = stale-while-revalidate refetch policy (GET-only, throws on a write).
137
+ - `abide/shared/cache(fnOrProducer)` → a memoised callable. Key auto-derives (method+url+args for a remote fn; producer-reference+args for a producer). Options `{ ttl?, scope?, global?, swr? }`: `ttl` ms-past-resolve (omit = forever, `0` = dedupe-only / the **mutation idiom**); `scope` tags for group invalidation; `global` = process-level store (server); `swr` = stale-while-revalidate — on invalidate, keep the stale value (reader sees `refreshing()`, not `pending()`) and refetch in the background. `swr: true` refetches immediately; `swr: { throttle | debounce }` adds a coalescing window. GET-only, throws on a write.
138
138
  - `cache.invalidate(selector?, args?)` — end retention early (by fn, fn+args, `{ scope }`, or all).
139
139
  - `cache.patch(selector, updater, args?)` — optimistic local fold over matching entries.
140
140
  - `cache.on(subscribable, (frame, ctx) => …)` — event-driven cache maintenance: run a handler per socket/stream frame; `ctx.invalidate` / `ctx.patch` are coverage-tracked and resync on reconnect. Client-only (no-op on server).
@@ -174,13 +174,13 @@ Same selector grammar as `cache.invalidate`; also accept a `Subscribable`. See C
174
174
  - `abide/ui/router(...)`, `abide/ui/startClient(...)`, `abide/ui/renderToStream(render)` — bootstrap/render runtime (compiler/launcher uses these).
175
175
 
176
176
  ### DOM + render runtime — `@readme plumbing` (compiler-emitted; you don't hand-write these)
177
- `abide/ui/dom/{mount,mountChild,hydrate,text,openChild,appendText,appendSnippet,appendStatic,cloneStatic,attr,on,attach,each,eachAsync,when,awaitBlock,tryBlock,switchBlock,applyResolved}` and `abide/ui/runtime/{nextBlockId,enterRenderPass,exitRenderPass}`. These are what `analyzeComponent → generateBuild/generateSSR` lower a `.abide` file into. Read them only to understand compiler output.
177
+ `abide/ui/dom/{mount,mountChild,hydrate,text,appendText,appendTextAt,appendSnippet,appendStatic,cloneStatic,skeleton,anchorCursor,mountSlot,attr,on,attach,each,eachAsync,when,awaitBlock,tryBlock,switchBlock,applyResolved}` and `abide/ui/runtime/{nextBlockId,enterRenderPass,exitRenderPass}`. These are what `analyzeComponent → generateBuild/generateSSR` lower a `.abide` file into — every bound element builds through the parser-backed `skeleton` (one clone + located holes / `<!--a-->` anchors); there is no imperative element builder. Read them only to understand compiler output.
178
178
  - `abide/ui/remoteProxy`, `abide/ui/socketProxy` — the browser-side implementations the bundler swaps in for `GET(...)` / `socket(...)`.
179
179
 
180
180
  ### `.abide` component format (see `src/lib/ui/README.md`)
181
181
  Valid HTML with `<script>` + native `<template>` control flow + scoped `<style>`.
182
182
  - **Bindings:** `{expr}` text, `name={expr}` attr, `onclick={fn}`, `bind:value={…}` / `bind:checked` / `bind:group`, `attach={fn}` (node-lifetime attachment — the dual of `on`; the `use:`-action / `{@attach}` equivalent, lowered to `ui/dom/attach`).
183
- - **Control flow (native `<template>`):** `if`/`else`, `each={list} as="x" key="x.id"`, `await={p}`/`then`/`catch`, `switch`/`case`/`default`.
183
+ - **Control flow (native `<template>`):** `if` with a nested `else` (the `<template else>` is a CHILD of its `<template if>`, not a sibling), `each={list} as="x" key="x.id"`, `await={p}`/`then`/`catch`, `switch`/`case`/`default`.
184
184
  - **A branch is a lexical scope:** any control-flow branch may host its own nested `<script>` and `<style>`. The nested `<script>` declares branch-local **plain** signals (`state`/`derived`/`linked`/`prop`) — owned by the branch's render scope, re-seeded each mount (not the serializable top-level `doc`) — with the branch's binding in scope (`then`/`catch`'s `as` value, the `each` `as`/`key` row), so it can derive from the awaited/iterated value; its bindings cover the branch subtree and later siblings auto-deref them. The nested `<style>` is scoped to that branch alone (its own `data-a-<hash>`), not the whole component.
185
185
  - **Components:** capitalised tags (`<Layout title=…>`); children fill `<slot/>`; props are reactive (passed as thunks). A component has no directives — every attribute is a prop under its written name (so `onclick=`/`bind:open=`/`attach=` pass through as props, e.g. callbacks, not the DOM-element directives those are on a lowercase tag) and is type-checked against the child's declared props. `prop('name')` reads a typed component prop (the parent-supplied thunk, reactive + read-only); route params come from the `page` proxy (`page.params.name`), not `prop()`.
186
186
  - **Snippets / named slots:** `<template name="x" args={…}>` declares a reusable named builder (the `snippet()` form), rendered like a function — covers named slots / `{@render}`.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # abide
2
2
 
3
+ ## 0.33.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`8af031e`](https://github.com/briancray/abide/commit/8af031e74ff3f3ab9cef05df75c0bbc22bd370d6) - feat(cache): rename the cache invalidation-policy option from `invalidate` to `swr` (stale-while-revalidate). `swr: true` keeps the entry and refetches in the background on a `cache.invalidate` hit — the stale value stays visible and `refreshing()` reports the in-flight reload — instead of dropping the entry to `pending()`. An optional window coalesces a burst of invalidations: `swr: { throttle: N }` refetches on the leading edge then at most once per N ms, `swr: { debounce: N }` refetches only after N ms of quiet. `cache()` still throws at wrap time on throttle+debounce set together, on `ttl: 0`, and on a non-replayable remote method. Replaces the former `invalidate: { throttle, debounce }` option (same coalescing semantics, clearer name).
8
+
9
+ ### Patch Changes
10
+
11
+ - [`9d4d899`](https://github.com/briancray/abide/commit/9d4d8992499bc7ef6e9cfc963dafc819725adabe) - fix: keyed `each`, `when`, and `switch` no longer crash when a synchronous write rebuilds them mid-hydrate. A page seeding shared reactive state during the hydrate pass (e.g. `breadcrumbs.crumbs = [...]`, or flipping an `if`/`switch` condition) re-ran the block's effect while `RENDER.hydration` was still active, so its fresh build claimed SSR nodes that were never adopted — surfacing as `null is not an object (element.setAttribute)` in `attr`/`openChild`. The reactive rebuild now runs with the global claim cursor cleared (restored after, mirroring `awaitBlock`/`tryBlock`): `each` clears it around its reconcile body, and `when`/`switch` get it for free via `fillBefore`, their single fresh-build path — adopt happens in place, so `fillBefore` is only ever create mode.
12
+
13
+ - [`9d4d899`](https://github.com/briancray/abide/commit/9d4d8992499bc7ef6e9cfc963dafc819725adabe) - fix: SVG and MathML now render correctly on the client. The imperative element builder used `document.createElement`, so a foreign element built on the client (an `<svg>` with a binding, a bound `<path>` inside an svg, foreign content with reactive children, or foreign elements generated by `if`/`each`/`await`) landed in the HTML namespace and rendered as nothing — SSR was unaffected, the divergence appeared only on the client-create path. Three changes close it: a static foreign subtree carrying bindings builds through a new parser-backed `skeleton` (one clone of a `<template>`-parsed skeleton, holes located and wired by index — the browser parser namespaces foreign content for free); the imperative path (`openChild`/`cloneStatic`) now namespaces elements off their parent via `createElementNS`/a foreign parse wrapper; and control-flow blocks (`when`/`switch`/`each`/`eachAsync`/`await`/`try`) carry an ambient foreign-namespace context (`RENDER.namespace`) across the detached fragment they build into, which `skeleton`/`openChild`/`cloneStatic` consult. Hydration was already correct (it adopts server-namespaced nodes).
14
+
15
+ - [`9d4d899`](https://github.com/briancray/abide/commit/9d4d8992499bc7ef6e9cfc963dafc819725adabe) - refactor(ui): unify the client render backend on the parser-backed `skeleton` and delete the imperative `openChild` element builder. Every bound element now builds through one clone-with-located-holes path — element holes by element-only path, and reactive text / control-flow blocks / `<slot>` outlets positioned by `<!--a-->` anchors (`anchorCursor`), so a block or slot can sit anywhere among static siblings (not just in a `[prefix][contiguous blocks][suffix]` shape). Nested `<script>`/snippet children build through the skeleton too. Foreign content (SVG/MathML) keeps its namespace in every case (the browser parser is the sole tree-builder). New plumbing: `abide/ui/dom/anchorCursor`, `abide/ui/dom/mountSlot`. Removed: `abide/ui/dom/openChild`, `abide/ui/dom/cursorAfterElements` (and the internal `childNamespace`). SSR emits the matching `<!--a-->` anchors, so the SSR == client DOM invariant holds. No public `.abide` behaviour change.
16
+
17
+ Also fixes `<template await>` hydration when the block is genuinely pending (no resume value, not warm-sync): it discards the SSR boundary and rebuilds, and now (a) inserts at the node _after_ the discarded boundary rather than the captured insertion ref — which for a skeleton-anchored block was the await's own open boundary, removed by the discard, so reusing it threw `NotFoundError` in a strict DOM (WebKit) — and (b) clears the claim cursor while rebuilding the pending branch, so its static content actually builds instead of trying to claim discarded nodes. The test mini-DOM's `insertBefore` is now strict (throws on a non-child reference) so this class of bug surfaces in tests, not only the browser.
18
+
3
19
  ## 0.32.2
4
20
 
5
21
  ### Patch Changes
@@ -104,16 +120,16 @@
104
120
 
105
121
  - [`0cf889b`](https://github.com/briancray/abide/commit/0cf889b859b9169be2abd35c2624ff762bd08e91) - Add `@abide/inspector` — an opt-in inspector activated by `ABIDE_ENABLE_INSPECTOR=true`. Install it (`bun add -d @abide/inspector`) and the flag mounts a UI at `/__abide/inspector`.
106
122
 
107
- Three tabs over one event stream:
123
+ Three tabs over one event stream:
108
124
 
109
- - **Logs** (default) — a live tail of request traffic, app/diagnostic channels, cache tallies, and published socket frames, with filters by channel, trace id, and free text. Click a trace id to pivot the feed to that trace.
110
- - **Traces** — records grouped by trace id. Because abide propagates W3C trace context (a page session is one trace — the SSR render plus every RPC its interactions fire), a trace holds many requests; the tab splits each trace into nested per-request lanes (each on its own time axis, children indented under the request they descend from) with a span waterfall per request. Records now carry `requestSpan` / `parentSpan` (from the request's `TraceContext`) so this split is possible.
111
- - **Cache** — the persistent (`global: true`) cache store: each entry's key, lifecycle state (settled / in-flight / refreshing), kind, ttl, time-to-expiry, scope tags, invalidate policy, and a value preview. Refreshes on open and on demand. (Request-scoped caches are ephemeral, so they surface as per-request tallies in Logs/Traces instead.)
112
- - **Surface** — the static machine catalog: RPC verbs (with their declared `timeout` / `maxBodySize` / `crossOrigin` / `files` options as columns, and input/output JSON Schemas on expand) and sockets.
125
+ - **Logs** (default) — a live tail of request traffic, app/diagnostic channels, cache tallies, and published socket frames, with filters by channel, trace id, and free text. Click a trace id to pivot the feed to that trace.
126
+ - **Traces** — records grouped by trace id. Because abide propagates W3C trace context (a page session is one trace — the SSR render plus every RPC its interactions fire), a trace holds many requests; the tab splits each trace into nested per-request lanes (each on its own time axis, children indented under the request they descend from) with a span waterfall per request. Records now carry `requestSpan` / `parentSpan` (from the request's `TraceContext`) so this split is possible.
127
+ - **Cache** — the persistent (`global: true`) cache store: each entry's key, lifecycle state (settled / in-flight / refreshing), kind, ttl, time-to-expiry, scope tags, invalidate policy, and a value preview. Refreshes on open and on demand. (Request-scoped caches are ephemeral, so they surface as per-request tallies in Logs/Traces instead.)
128
+ - **Surface** — the static machine catalog: RPC verbs (with their declared `timeout` / `maxBodySize` / `crossOrigin` / `files` options as columns, and input/output JSON Schemas on expand) and sockets.
113
129
 
114
- The framework now self-instruments the request lifecycle with `log.trace` spans on DEBUG-gated diagnostic channels — `abide:render` (SSR render), `abide:view` (view/module resolution), `abide:rpc` (verb dispatch + validation; reveals in-process RPC→RPC calls as nested spans in the same trace), `abide:cache` (producer run on a miss + coalesced wait), `abide:mcp` (tool dispatch), and `abide:sockets` (REST tail/publish). They're zero-cost when their channel is off and fill the Traces waterfall when enabled (`DEBUG=abide:*`). The verb registry now records `timeout` / `maxBodySize` / `crossOrigin` so introspection can report them.
130
+ The framework now self-instruments the request lifecycle with `log.trace` spans on DEBUG-gated diagnostic channels — `abide:render` (SSR render), `abide:view` (view/module resolution), `abide:rpc` (verb dispatch + validation; reveals in-process RPC→RPC calls as nested spans in the same trace), `abide:cache` (producer run on a miss + coalesced wait), `abide:mcp` (tool dispatch), and `abide:sockets` (REST tail/publish). They're zero-cost when their channel is off and fill the Traces waterfall when enabled (`DEBUG=abide:*`). The verb registry now records `timeout` / `maxBodySize` / `crossOrigin` so introspection can report them.
115
131
 
116
- Core stays clean: a guarded, non-literal dynamic import keeps the package out of the compiled binary, and core injects an `InspectorContext` so the package imports no abide internals. Two passive observation seams feed the inspector — the existing log tap plus a new socket-frame tap in `defineSocket`'s `publish()` — both no-ops when no inspector is mounted. The inspector is privileged operator tooling: it answers ahead of `app.handle` and warns loudly on mount, so enable it only in trusted/dev environments.
132
+ Core stays clean: a guarded, non-literal dynamic import keeps the package out of the compiled binary, and core injects an `InspectorContext` so the package imports no abide internals. Two passive observation seams feed the inspector — the existing log tap plus a new socket-frame tap in `defineSocket`'s `publish()` — both no-ops when no inspector is mounted. The inspector is privileged operator tooling: it answers ahead of `app.handle` and warns loudly on mount, so enable it only in trusted/dev environments.
117
133
 
118
134
  ### Patch Changes
119
135
 
@@ -185,13 +201,13 @@
185
201
 
186
202
  - [`f9cd269`](https://github.com/briancray/abide/commit/f9cd269b44b065702275422ac710019117f69a2c) - fix(abide): `json(undefined)` returns 204 No Content instead of throwing
187
203
 
188
- `Response.json(undefined)` throws TypeError because JSON has no encoding for
189
- `undefined`, so any handler returning `json(undefined)` — e.g. a
190
- `Shape | undefined` route signalling "not found" — 500ed instead of degrading.
191
- `json()` now emits 204 No Content for `undefined`, which `decodeResponse`
192
- already maps back to `undefined` on both the fetch and in-process paths, so
193
- the `Shape | undefined` RPC contract round-trips the wire. The helper owns
194
- the 204; it wins over any `init.status`.
204
+ `Response.json(undefined)` throws TypeError because JSON has no encoding for
205
+ `undefined`, so any handler returning `json(undefined)` — e.g. a
206
+ `Shape | undefined` route signalling "not found" — 500ed instead of degrading.
207
+ `json()` now emits 204 No Content for `undefined`, which `decodeResponse`
208
+ already maps back to `undefined` on both the fetch and in-process paths, so
209
+ the `Shape | undefined` RPC contract round-trips the wire. The helper owns
210
+ the 204; it wins over any `init.status`.
195
211
 
196
212
  ## 0.25.1
197
213
 
@@ -263,7 +279,7 @@
263
279
 
264
280
  - [`d8fe8fc`](https://github.com/briancray/abide/commit/d8fe8fcae7863407d7b55774e19762715d311450) - **Fixed**
265
281
 
266
- - The lifecycle channel defers its notify to a microtask, coalescing marks within a tick. With a live `pending()`/`refreshing()` probe armed, a cold cache read inside a `$derived` — the documented `$derived(await cache(fn)())` idiom — registered its entry mid-derived and wrote the subscriber's version source synchronously, throwing `state_unsafe_mutation` and killing the flush (seen as an unhandled rejection plus a follow-on `active_reaction` TypeError, with UI updates in that batch silently dropped). Probes scan the registry at re-derive time, so the deferred ping reads state that is already current.
282
+ - The lifecycle channel defers its notify to a microtask, coalescing marks within a tick. With a live `pending()`/`refreshing()` probe armed, a cold cache read inside a `$derived` — the documented `$derived(await cache(fn)())` idiom — registered its entry mid-derived and wrote the subscriber's version source synchronously, throwing `state_unsafe_mutation` and killing the flush (seen as an unhandled rejection plus a follow-on `active_reaction` TypeError, with UI updates in that batch silently dropped). Probes scan the registry at re-derive time, so the deferred ping reads state that is already current.
267
283
 
268
284
  ## 0.23.0
269
285
 
@@ -271,52 +287,52 @@
271
287
 
272
288
  - [`13f841c`](https://github.com/briancray/abide/commit/13f841c4f3ec92727414715c1bb5eeabc12b60a7) - **Breaking**
273
289
 
274
- - `bundled()` moved to the bundle namespace alongside `onMenu`: `import { bundled } from '@abide/abide/bundle/bundled'` (was `abide/shared/bundled`).
290
+ - `bundled()` moved to the bundle namespace alongside `onMenu`: `import { bundled } from '@abide/abide/bundle/bundled'` (was `abide/shared/bundled`).
275
291
 
276
292
  - [`bbc82a1`](https://github.com/briancray/abide/commit/bbc82a12c948acbb2f82fe1dfc46165678fafb04) - Registries act, probes report: standalone `pending`/`refreshing` spanning cache and streams, the `ttl: 0` mutation idiom made sound, and tail reconnect-with-retained-value.
277
293
 
278
- **Breaking**
294
+ **Breaking**
279
295
 
280
- - `cache.pending` / `cache.refreshing` moved to their own modules: `import { pending } from '@abide/abide/shared/pending'`, `import { refreshing } from '@abide/abide/shared/refreshing'`. `cache.invalidate` stays on `cache`.
281
- - SSR snapshots ship GET entries only (`REPLAYABLE_METHODS`): DELETE is idempotent but still a write and no longer replays from hydration.
282
- - `cache()` now throws at wrap time on invalid policy combinations: `invalidate` policy on a non-GET remote, policy with `ttl: 0`, or `throttle` and `debounce` together.
296
+ - `cache.pending` / `cache.refreshing` moved to their own modules: `import { pending } from '@abide/abide/shared/pending'`, `import { refreshing } from '@abide/abide/shared/refreshing'`. `cache.invalidate` stays on `cache`.
297
+ - SSR snapshots ship GET entries only (`REPLAYABLE_METHODS`): DELETE is idempotent but still a write and no longer replays from hydration.
298
+ - `cache()` now throws at wrap time on invalid policy combinations: `invalidate` policy on a non-GET remote, policy with `ttl: 0`, or `throttle` and `debounce` together.
283
299
 
284
- **Fixed**
300
+ **Fixed**
285
301
 
286
- - Invalidate policies attach on read to entries that lack one (like scope tags), so a snapshot-hydrated entry revalidates stale-in-place from its first invalidate instead of hard-dropping to a pending flash, and a policy-less first read no longer permanently wins.
287
- - Hydrated snapshot entries adopt the first reading call site's `ttl` (the snapshot ships no wrap options): omitted keeps the entry as before, `ttl > 0` starts the expiry clock at that read, and `ttl: 0` serves the hydration pass only, evicting a macrotask later — previously any ttl was ignored, so a `ttl: 0` key warm-hit forever and never refetched.
288
- - Eviction clears armed policy timers — a TTL-expired or rejected key can no longer ghost-refetch.
302
+ - Invalidate policies attach on read to entries that lack one (like scope tags), so a snapshot-hydrated entry revalidates stale-in-place from its first invalidate instead of hard-dropping to a pending flash, and a policy-less first read no longer permanently wins.
303
+ - Hydrated snapshot entries adopt the first reading call site's `ttl` (the snapshot ships no wrap options): omitted keeps the entry as before, `ttl > 0` starts the expiry clock at that read, and `ttl: 0` serves the hydration pass only, evicting a macrotask later — previously any ttl was ignored, so a `ttl: 0` key warm-hit forever and never refetched.
304
+ - Eviction clears armed policy timers — a TTL-expired or rejected key can no longer ghost-refetch.
289
305
 
290
- **Added**
306
+ **Added**
291
307
 
292
- - `pending(x)` / `refreshing(x)` probe both registries: cache selectors (bare / fn / `{ scope }`) plus Subscribables — `pending(chat)` is "awaiting first frame", `refreshing(chat)` is "reconnecting with last value retained" (never merely open). Bare forms span registries. Probes report, never act: they open no fetch and no stream.
293
- - `tail()` (né `subscribe()` — renamed in this release) self-heals transport loss: on the typed `SocketDisconnectedError` it keeps its value, flags `refreshing`, and reopens under the channel's backoff (retained-tail replay converges the value — correct for a latest-wins/window consumer). Server `err` frames stay terminal; raw `for await` consumers keep the explicit-disconnect contract.
294
- - `cache()` warns once per call site when handed an anonymous producer (fresh identity per call — it can never coalesce and probes can never match it).
308
+ - `pending(x)` / `refreshing(x)` probe both registries: cache selectors (bare / fn / `{ scope }`) plus Subscribables — `pending(chat)` is "awaiting first frame", `refreshing(chat)` is "reconnecting with last value retained" (never merely open). Bare forms span registries. Probes report, never act: they open no fetch and no stream.
309
+ - `tail()` (né `subscribe()` — renamed in this release) self-heals transport loss: on the typed `SocketDisconnectedError` it keeps its value, flags `refreshing`, and reopens under the channel's backoff (retained-tail replay converges the value — correct for a latest-wins/window consumer). Server `err` frames stay terminal; raw `for await` consumers keep the explicit-disconnect contract.
310
+ - `cache()` warns once per call site when handed an anonymous producer (fresh identity per call — it can never coalesce and probes can never match it).
295
311
 
296
312
  - [`c77fc89`](https://github.com/briancray/abide/commit/c77fc89a12f07b9356b5ad68cd4a92f4764e52b8) - Replay is demarcated on the wire: a sub's seed now arrives as one per-sub `{ type: 'replay', sub, messages }` frame (sent even when empty) instead of socket-keyed `msg` frames.
297
313
 
298
- - `tail()` windows commit their seed atomically at the boundary — no more shrink-regrow rebuild on reconnect or first paint — and an **empty** replay keeps the held window across a gap (nothing replayed = nothing to duplicate; live frames append). Previously the first post-gap frame wiped the window even on non-retaining sockets.
299
- - Per-sub addressing fixes cross-sub replay leakage: two subs on the same socket no longer receive each other's replay.
300
- - `Subscribable.tail(count, hooks?)` gains optional `TailHooks`: retaining sources must signal `hooks.replayed` in-band once the seed is delivered (sockets do; a source that ends without signalling commits at `done`).
301
- - Internal: `createPushIterator` gains `control(run)` — in-band signal slots, strictly ordered against pushed values, invisible to consumers. Raw `for await` iteration is unchanged.
314
+ - `tail()` windows commit their seed atomically at the boundary — no more shrink-regrow rebuild on reconnect or first paint — and an **empty** replay keeps the held window across a gap (nothing replayed = nothing to duplicate; live frames append). Previously the first post-gap frame wiped the window even on non-retaining sockets.
315
+ - Per-sub addressing fixes cross-sub replay leakage: two subs on the same socket no longer receive each other's replay.
316
+ - `Subscribable.tail(count, hooks?)` gains optional `TailHooks`: retaining sources must signal `hooks.replayed` in-band once the seed is delivered (sockets do; a source that ends without signalling commits at `done`).
317
+ - Internal: `createPushIterator` gains `control(run)` — in-band signal slots, strictly ordered against pushed values, invisible to consumers. Raw `for await` iteration is unchanged.
302
318
 
303
319
  - [`c77fc89`](https://github.com/briancray/abide/commit/c77fc89a12f07b9356b5ad68cd4a92f4764e52b8) - `subscribe()` is now `tail()`, and "history" is dead: one word for reading the retained end of a stream at every altitude — declaration (`socket<T>({ tail: n })`), raw iteration (`chat.tail(count)`), and the reactive consumer (`tail(x)` / `tail(x, { last: n })`).
304
320
 
305
- **Breaking**
321
+ **Breaking**
306
322
 
307
- - `import { subscribe } from '@abide/abide/browser/subscribe'` → `import { tail } from '@abide/abide/browser/tail'`; `subscribe.status`/`subscribe.error` → `tail.status`/`tail.error` (both accept the same `{ last }` options to address a window entry).
308
- - Socket declaration option `history` → `tail` (`socket<T>({ tail: 100 })`). Retention is opt-in: an undeclared socket is a pure live pipe and storage is the consumer's concern.
309
- - Bare socket iteration (`for await (const m of chat)`) is now live-only — replay is exclusively `.tail`'s job. `chat.tail()` no-arg replays the whole retained tail (the old bare behavior); `chat.tail(n)` the last n. The ws wire is unchanged; only the local defaults flipped.
310
- - The reactive bare form seeds a socket via `tail(1)` instead of full replay — retained frames no longer churn through `latest` on open.
323
+ - `import { subscribe } from '@abide/abide/browser/subscribe'` → `import { tail } from '@abide/abide/browser/tail'`; `subscribe.status`/`subscribe.error` → `tail.status`/`tail.error` (both accept the same `{ last }` options to address a window entry).
324
+ - Socket declaration option `history` → `tail` (`socket<T>({ tail: 100 })`). Retention is opt-in: an undeclared socket is a pure live pipe and storage is the consumer's concern.
325
+ - Bare socket iteration (`for await (const m of chat)`) is now live-only — replay is exclusively `.tail`'s job. `chat.tail()` no-arg replays the whole retained tail (the old bare behavior); `chat.tail(n)` the last n. The ws wire is unchanged; only the local defaults flipped.
326
+ - The reactive bare form seeds a socket via `tail(1)` instead of full replay — retained frames no longer churn through `latest` on open.
311
327
 
312
- **Added**
328
+ **Added**
313
329
 
314
- - `tail(x, { last: n })` → `T[]`: a live rolling window of the last ≤n frames, however they arrived ([] while pending and on SSR, never undefined). Retaining sockets seed it by replaying up to `last` (clamped to the declared `tail`); rpc streams and undeclared sockets fill it from live frames. The bare form and each window size are independent subscriptions (`last` is registry-keyed). On reconnect the replay replaces the window, so a gap can't duplicate frames.
315
- - `Subscribable.tail(count)` is the optional retention capability: sources that keep recent frames implement it (sockets do verbatim) and the consumer bounds replay to what the reader keeps — no consumer special-casing per source type.
330
+ - `tail(x, { last: n })` → `T[]`: a live rolling window of the last ≤n frames, however they arrived ([] while pending and on SSR, never undefined). Retaining sockets seed it by replaying up to `last` (clamped to the declared `tail`); rpc streams and undeclared sockets fill it from live frames. The bare form and each window size are independent subscriptions (`last` is registry-keyed). On reconnect the replay replaces the window, so a gap can't duplicate frames.
331
+ - `Subscribable.tail(count)` is the optional retention capability: sources that keep recent frames implement it (sockets do verbatim) and the consumer bounds replay to what the reader keeps — no consumer special-casing per source type.
316
332
 
317
- **Fixed**
333
+ **Fixed**
318
334
 
319
- - An untracked `tail.status()`/`tail.error()` read (outside `$derived`/`$effect`) no longer leaks a permanently-pending registry entry that held the bare `pending()` probe true forever.
335
+ - An untracked `tail.status()`/`tail.error()` read (outside `$derived`/`$effect`) no longer leaks a permanently-pending registry entry that held the bare `pending()` probe true forever.
320
336
 
321
337
  ## 0.22.0
322
338
 
@@ -350,7 +366,7 @@
350
366
 
351
367
  - [`9cbca7d`](https://github.com/briancray/abide/commit/9cbca7d28c258d4574ac450811628f107e502711) - Bundle apps auto-start the local assistant. When a bundled abide app connects (embedded or remote) and the app ships `@abide/claude-code` (its UI uses `browser/assistant`) with `claude` on PATH, the bundle launcher runs the loopback bridge for you and hands the page its port+token via the URL fragment — no copy-paste command. The bridge is loopback-only and dies with the connection. abide takes **no dependency** on `@abide/claude-code`: it's a guarded optional import that no-ops (and compiles fine) when the app doesn't ship it.
352
368
 
353
- `assistant()` gains a `status` — `'ready' | 'starting' | 'manual' | 'unavailable'` — so the same UI works in a browser (`manual` → show `command`) and a bundle (`starting`/`ready` auto-managed, or `unavailable` when `claude` isn't installed → show an install hint). `command` is now `string | undefined` (undefined whenever a host manages the bridge).
369
+ `assistant()` gains a `status` — `'ready' | 'starting' | 'manual' | 'unavailable'` — so the same UI works in a browser (`manual` → show `command`) and a bundle (`starting`/`ready` auto-managed, or `unavailable` when `claude` isn't installed → show an install hint). `command` is now `string | undefined` (undefined whenever a host manages the bridge).
354
370
 
355
371
  ## 0.19.4
356
372
 
@@ -474,15 +490,15 @@
474
490
 
475
491
  - [#40](https://github.com/briancray/abide/pull/40) [`26ba6fe`](https://github.com/briancray/abide/commit/26ba6fe710002f84b99947f7b198fa3b3f235d53) Thanks [@briancray](https://github.com/briancray)! - Namespace the CLI's baked env under `ABIDE_` and add data-dir controls.
476
492
 
477
- **Breaking:** `APP_URL` → `ABIDE_APP_URL` and `APP_TOKEN` → `ABIDE_APP_TOKEN`. These are the values baked into a downloaded CLI's `.env` (the hosted server URL, derived from the request origin, plus the bearer token when the download was authenticated) and read by the thin client to resolve its connection target. `ABIDE_APP_URL` is now public, documented surface — app code can read it to refer to the app's hosted location. Existing baked binaries and any shell `APP_URL`/`APP_TOKEN` overrides must switch to the prefixed names.
493
+ **Breaking:** `APP_URL` → `ABIDE_APP_URL` and `APP_TOKEN` → `ABIDE_APP_TOKEN`. These are the values baked into a downloaded CLI's `.env` (the hosted server URL, derived from the request origin, plus the bearer token when the download was authenticated) and read by the thin client to resolve its connection target. `ABIDE_APP_URL` is now public, documented surface — app code can read it to refer to the app's hosted location. Existing baked binaries and any shell `APP_URL`/`APP_TOKEN` overrides must switch to the prefixed names.
478
494
 
479
- **Added:** `abide/server/appDataDir` — a zero-arg accessor returning the running bundle's per-user data dir, keyed to the same program name abide uses for the user's `.env`/`last-connection.json`, so an app's DB/cache lands beside abide's own config rather than a drifted sibling directory.
495
+ **Added:** `abide/server/appDataDir` — a zero-arg accessor returning the running bundle's per-user data dir, keyed to the same program name abide uses for the user's `.env`/`last-connection.json`, so an app's DB/cache lands beside abide's own config rather than a drifted sibling directory.
480
496
 
481
- **Added:** `ABIDE_DATA_DIR` — overrides the data dir on every platform, used as-is. A cross-platform `XDG_DATA_HOME` (which the helper otherwise honours on Linux only), letting dev point at a throwaway dir without touching app code. Must come from a layer above the data-dir `.env` (shell, CWD `.env`, or binary-dir `.env`), since it decides where that file lives.
497
+ **Added:** `ABIDE_DATA_DIR` — overrides the data dir on every platform, used as-is. A cross-platform `XDG_DATA_HOME` (which the helper otherwise honours on Linux only), letting dev point at a throwaway dir without touching app code. Must come from a layer above the data-dir `.env` (shell, CWD `.env`, or binary-dir `.env`), since it decides where that file lives.
482
498
 
483
499
  - [#40](https://github.com/briancray/abide/pull/40) [`310eceb`](https://github.com/briancray/abide/commit/310ecebebd018df62155d18ac01a376f5a0f42ba) Thanks [@briancray](https://github.com/briancray)! - Remove the `key` option from `cache()`.
484
500
 
485
- **Breaking:** `cache(fn, { key })` and the `{ key }` selector form of `cache.invalidate` / `cache.pending` are gone. Cache keys are always auto-derived — method+url+args for a remote function, producer-reference+args for a plain producer. To share an entry across calls, hoist the producer to a stable reference (an inline arrow gets a fresh identity every call and never dedupes). To target a set of unrelated calls with one `cache.invalidate`, tag them with a `scope`; a unique tag (e.g. a uuid) gives a set of calls their own private invalidation group.
501
+ **Breaking:** `cache(fn, { key })` and the `{ key }` selector form of `cache.invalidate` / `cache.pending` are gone. Cache keys are always auto-derived — method+url+args for a remote function, producer-reference+args for a plain producer. To share an entry across calls, hoist the producer to a stable reference (an inline arrow gets a fresh identity every call and never dedupes). To target a set of unrelated calls with one `cache.invalidate`, tag them with a `scope`; a unique tag (e.g. a uuid) gives a set of calls their own private invalidation group.
486
502
 
487
503
  ## 0.11.1
488
504
 
@@ -496,7 +512,7 @@
496
512
 
497
513
  - [`a1d1d56`](https://github.com/briancray/abide/commit/a1d1d56efe4887bebb74dd6707cf7cb38d8b4771) - `cache` and `HttpError` move from the `browser`/`server` namespaces to `shared`, which now denotes the isomorphic surface — names that are the same callable with the same behaviour on both sides. `cache()` runs in SSR and MCP request scope just as it does on the client, so importing it as a "browser" module misrepresented it; its client-only streaming/hydration helpers stay in `browser/` and its server-only snapshot helpers stay in `server/runtime/`. Update imports: `abide/browser/cache` → `abide/shared/cache`, and `abide/browser/HttpError` (or `abide/server/HttpError`) → `abide/shared/HttpError`.
498
514
 
499
- The package `exports` map is now an explicit allowlist of the public API instead of per-directory `*` globs, so internal modules (machinery under `shared/`, runtime/registry internals under `server/`, launcher internals under `bundle/`, and all `types/` subtrees) are no longer reachable via the package specifier. Only documented names — the verb/response/context helpers, `cache`, `HttpError`, `page`/`navigate`/`subscribe`, the `bundle` window config, the test client, and the build/plugin entries — resolve. Importing an unlisted internal path now fails; use the public name instead.
515
+ The package `exports` map is now an explicit allowlist of the public API instead of per-directory `*` globs, so internal modules (machinery under `shared/`, runtime/registry internals under `server/`, launcher internals under `bundle/`, and all `types/` subtrees) are no longer reachable via the package specifier. Only documented names — the verb/response/context helpers, `cache`, `HttpError`, `page`/`navigate`/`subscribe`, the `bundle` window config, the test client, and the build/plugin entries — resolve. Importing an unlisted internal path now fails; use the public name instead.
500
516
 
501
517
  - [`a1d1d56`](https://github.com/briancray/abide/commit/a1d1d56efe4887bebb74dd6707cf7cb38d8b4771) - `cache()` now memoises plain producers, not just rpc verb helpers — pass any `() => Promise<T>` to dedupe and retain external calls (e.g. a third-party `fetch` the server makes). Producers key on the function's reference plus args (so hoist the function, or pass an explicit `key`; an inline arrow is a fresh reference every call and never dedupes), and the value promise is stored as-is — no Response decode and no SSR snapshot. A new `global: true` option puts the entry in a process-level store instead of the request-scoped one, so a value computed in one request is reused by later ones; omit it to keep per-request data from leaking across requests, and note it is a no-op on the client (one tab store). `cache.invalidate` / `cache.pending` accept a producer reference and span both stores.
502
518
 
@@ -506,13 +522,13 @@
506
522
 
507
523
  - [`a1d1d56`](https://github.com/briancray/abide/commit/a1d1d56efe4887bebb74dd6707cf7cb38d8b4771) - Run in-process rpc dispatch inside the request scope for the MCP tool dispatcher and the in-process CLI client. Previously both invoked handlers without a per-request scope, so `cache()` silently shared one process-wide store across calls (leaking state between unrelated tool/CLI invocations) and `cookies()`/`request()` threw. Both now cross the same `runWithRequestScope` seam the HTTP router uses, giving per-call cache isolation and resolving the scope-bound helpers.
508
524
 
509
- Behavior change for MCP: a tool handler that throws is now caught by the scope and returned as a tool result with `isError: true` (framed from the 500 response), instead of surfacing as a JSON-RPC `-32603` error on the envelope. The JSON-RPC call itself succeeds; the failure is reported at the tool-result level, which is the correct MCP shape.
525
+ Behavior change for MCP: a tool handler that throws is now caught by the scope and returned as a tool result with `isError: true` (framed from the 500 response), instead of surfacing as a JSON-RPC `-32603` error on the envelope. The JSON-RPC call itself succeeds; the failure is reported at the tool-result level, which is the correct MCP shape.
510
526
 
511
527
  - [`a1d1d56`](https://github.com/briancray/abide/commit/a1d1d56efe4887bebb74dd6707cf7cb38d8b4771) - Project JSON Schema from a schema's own `toJSONSchema()` everywhere it's needed (OpenAPI, MCP tools, CLI flags, the bundle setup form). Drop the `inputJsonSchema` / `outputJsonSchema` / `filesJsonSchema` verb opts and the socket `jsonSchema` opt — a schema whose library doesn't expose a method wraps once with the new `abide/shared/withJsonSchema` helper. Multipart file parts are now advertised generically as binary in OpenAPI rather than named per field.
512
528
 
513
- Add `src/server/config.ts` as the home for typed env: `export const config = env(schema)`, imported as `$server/config` and eager-imported at boot so validation fails fast. The file is optional and scaffolded — when absent you read `Bun.env` directly.
529
+ Add `src/server/config.ts` as the home for typed env: `export const config = env(schema)`, imported as `$server/config` and eager-imported at boot so validation fails fast. The file is optional and scaffolded — when absent you read `Bun.env` directly.
514
530
 
515
- The bundle's first-run setup form is now derived from that same env schema by default, so one declaration drives boot validation and the form. `BundleWindow.config` still works but now _replaces_ the derived schema (for a form that should differ from the env schema) rather than being the only source.
531
+ The bundle's first-run setup form is now derived from that same env schema by default, so one declaration drives boot validation and the form. `BundleWindow.config` still works but now _replaces_ the derived schema (for a form that should differ from the env schema) rather than being the only source.
516
532
 
517
533
  - [`a1d1d56`](https://github.com/briancray/abide/commit/a1d1d56efe4887bebb74dd6707cf7cb38d8b4771) - Add two server primitives. `abide/server/env` validates the process environment against a Standard Schema at module load, returning typed config and failing the boot with every issue listed when a variable is missing or malformed. `abide/server/cookies` exposes the request's cookie jar — Bun's native `CookieMap` parsed from the inbound `Cookie` header, with `set`/`delete` writes flushed to `Set-Cookie` on the outgoing response when the handler returns. `cookies` resolves from the request scope like `request()`, materialized lazily so a request that never touches them parses and emits nothing; `env` reads `Bun.env` once at module load, independent of any request.
518
534
 
@@ -610,12 +626,12 @@
610
626
 
611
627
  - [#10](https://github.com/briancray/abide/pull/10) [`6ceb71b`](https://github.com/briancray/abide/commit/6ceb71b28e3b1a4c9726483d2c7dd3f40be3be59) Thanks [@briancray](https://github.com/briancray)! - Bundles now resolve config from a cwd-independent source instead of relying on Bun's cwd-based `.env` autoload (which a launched `.app`, whose cwd is `/`, silently misses). Config flows entirely through `process.env`, so app code keeps reading `Bun.env.*` and never learns where a value came from.
612
628
 
613
- - The compiled server loads two `.env` layers into `process.env` at boot, before anything reads it: the per-user data dir first (user config), then the binary dir (shipped default). Both back-fill only what a shell export or Bun's CWD `.env` didn't already set, so the precedence is `shell > CWD .env > data-dir .env > binary-dir .env > code default`.
614
- - Add `abide/shared/appDataDir` — the platform-standard per-user data directory keyed by program name, where the data-dir `.env` lives.
615
- - `abide bundle` ships an optional project `.env.bundle` as the binary-dir `.env` (the shipped default layer). Skipped when absent; use a dedicated file, never the working `.env`, since a compiled bundle is extractable.
616
- - Start now races server readiness against the child's exit, so a misconfigured bundle reports the crash immediately instead of stalling for the full readiness timeout.
617
- - A bundle resolves its last connection before the window opens: the launcher records the choice (embedded, or a remote URL) in the data dir, and on relaunch boots/probes it first, opening the window straight at the live server — so the connect screen never flashes. A boot that fails or exceeds a short ceiling, an unconfigured embedded resume, a dead saved server, or no saved choice falls back to opening the connect screen.
618
- - A bundle can declare `config` on its `BundleWindow` as a Standard Schema (the same kind abide accepts for RPC/MCP). Its JSON Schema drives a first-run settings modal on the connect screen — `title` → label, `description` → hint, `format: 'password'` → masked input, `default` → prefill — and answers persist to the data-dir `.env`. An explicit Start (button or File-menu click) always opens the modal prefilled with the last-used values, so re-running Start after a disconnect is how you reconfigure; an auto-start on relaunch never opens the modal — it boots a fully-configured app, or stays on the connect screen when a required key is still unset. Apps with no schema always boot straight through.
629
+ - The compiled server loads two `.env` layers into `process.env` at boot, before anything reads it: the per-user data dir first (user config), then the binary dir (shipped default). Both back-fill only what a shell export or Bun's CWD `.env` didn't already set, so the precedence is `shell > CWD .env > data-dir .env > binary-dir .env > code default`.
630
+ - Add `abide/shared/appDataDir` — the platform-standard per-user data directory keyed by program name, where the data-dir `.env` lives.
631
+ - `abide bundle` ships an optional project `.env.bundle` as the binary-dir `.env` (the shipped default layer). Skipped when absent; use a dedicated file, never the working `.env`, since a compiled bundle is extractable.
632
+ - Start now races server readiness against the child's exit, so a misconfigured bundle reports the crash immediately instead of stalling for the full readiness timeout.
633
+ - A bundle resolves its last connection before the window opens: the launcher records the choice (embedded, or a remote URL) in the data dir, and on relaunch boots/probes it first, opening the window straight at the live server — so the connect screen never flashes. A boot that fails or exceeds a short ceiling, an unconfigured embedded resume, a dead saved server, or no saved choice falls back to opening the connect screen.
634
+ - A bundle can declare `config` on its `BundleWindow` as a Standard Schema (the same kind abide accepts for RPC/MCP). Its JSON Schema drives a first-run settings modal on the connect screen — `title` → label, `description` → hint, `format: 'password'` → masked input, `default` → prefill — and answers persist to the data-dir `.env`. An explicit Start (button or File-menu click) always opens the modal prefilled with the last-used values, so re-running Start after a disconnect is how you reconfigure; an auto-start on relaunch never opens the modal — it boots a fully-configured app, or stays on the connect screen when a required key is still unset. Apps with no schema always boot straight through.
619
635
 
620
636
  ### Patch Changes
621
637
 
@@ -681,12 +697,12 @@
681
697
 
682
698
  - [`cf136c7`](https://github.com/briancray/abide/commit/cf136c7b763283570ef431b3aad269626bea7824) - Add a `abide bundle` desktop app and make the CLI a thin remote-only client.
683
699
 
684
- - `abide bundle` assembles a movable, self-contained desktop app (a `.app` on macOS, a flat dir elsewhere) that boots into a connect screen — start the embedded server or connect to a remote one by URL.
685
- - **Breaking:** the CLI binary is now always a thin remote client (talks to a running server over HTTP, `APP_URL` required). Dropped the `--thin`/full split and in-process fallback — use `abide bundle` for the embedded-backend case.
686
- - **Breaking:** MCP prompts are now markdown files (`src/mcp/prompts/**.md`) with YAML frontmatter, replacing the `.ts` prompt modules.
687
- - **Breaking:** handlers read the inbound request via `request()` and the live server via `server()` rather than `RequestStore` fields.
688
- - `json` / `jsonl` / `sse` / `error` / `redirect` accept a trailing `ResponseInit`.
689
- - Static-asset header caching is shared across asset servers, and zstd decompression moved to the async API.
700
+ - `abide bundle` assembles a movable, self-contained desktop app (a `.app` on macOS, a flat dir elsewhere) that boots into a connect screen — start the embedded server or connect to a remote one by URL.
701
+ - **Breaking:** the CLI binary is now always a thin remote client (talks to a running server over HTTP, `APP_URL` required). Dropped the `--thin`/full split and in-process fallback — use `abide bundle` for the embedded-backend case.
702
+ - **Breaking:** MCP prompts are now markdown files (`src/mcp/prompts/**.md`) with YAML frontmatter, replacing the `.ts` prompt modules.
703
+ - **Breaking:** handlers read the inbound request via `request()` and the live server via `server()` rather than `RequestStore` fields.
704
+ - `json` / `jsonl` / `sse` / `error` / `redirect` accept a trailing `ResponseInit`.
705
+ - Static-asset header caching is shared across asset servers, and zstd decompression moved to the async API.
690
706
 
691
707
  ## 0.1.0
692
708
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.32.2",
3
+ "version": "0.33.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
@@ -81,11 +81,14 @@
81
81
  "./ui/dom/mountChild": "./src/lib/ui/dom/mountChild.ts",
82
82
  "./ui/dom/hydrate": "./src/lib/ui/dom/hydrate.ts",
83
83
  "./ui/dom/text": "./src/lib/ui/dom/text.ts",
84
- "./ui/dom/openChild": "./src/lib/ui/dom/openChild.ts",
85
84
  "./ui/dom/appendText": "./src/lib/ui/dom/appendText.ts",
85
+ "./ui/dom/appendTextAt": "./src/lib/ui/dom/appendTextAt.ts",
86
86
  "./ui/dom/appendSnippet": "./src/lib/ui/dom/appendSnippet.ts",
87
87
  "./ui/dom/appendStatic": "./src/lib/ui/dom/appendStatic.ts",
88
88
  "./ui/dom/cloneStatic": "./src/lib/ui/dom/cloneStatic.ts",
89
+ "./ui/dom/skeleton": "./src/lib/ui/dom/skeleton.ts",
90
+ "./ui/dom/anchorCursor": "./src/lib/ui/dom/anchorCursor.ts",
91
+ "./ui/dom/mountSlot": "./src/lib/ui/dom/mountSlot.ts",
89
92
  "./ui/dom/attr": "./src/lib/ui/dom/attr.ts",
90
93
  "./ui/dom/on": "./src/lib/ui/dom/on.ts",
91
94
  "./ui/dom/attach": "./src/lib/ui/dom/attach.ts",
@@ -151,6 +154,7 @@
151
154
  "devDependencies": {
152
155
  "@abide/inspector": "workspace:*",
153
156
  "bun-plugin-tailwind": "^0.1.2",
157
+ "happy-dom": "^20.10.6",
154
158
  "tailwindcss": "^4.0.0"
155
159
  }
156
160
  }
@@ -18,19 +18,20 @@ function preview(value: unknown): string | undefined {
18
18
  }
19
19
  }
20
20
 
21
- /* The entry's armed invalidate policy as a label, if it declared one. */
21
+ /* The entry's armed swr policy as a label, if it declared one. A bare `swr: true`
22
+ has no window, so it labels as plain `swr`. */
22
23
  function policyLabel(entry: CacheEntry): string | undefined {
23
24
  const policy = entry.invalidation
24
25
  if (!policy) {
25
26
  return undefined
26
27
  }
27
28
  if (policy.debounce !== undefined) {
28
- return `debounce ${policy.debounce}ms`
29
+ return `swr debounce ${policy.debounce}ms`
29
30
  }
30
31
  if (policy.throttle !== undefined) {
31
- return `throttle ${policy.throttle}ms`
32
+ return `swr throttle ${policy.throttle}ms`
32
33
  }
33
- return undefined
34
+ return 'swr'
34
35
  }
35
36
 
36
37
  function projectEntry(entry: CacheEntry, now: number): InspectorCacheEntry {
@@ -19,6 +19,6 @@ export type InspectorCacheEntry = {
19
19
  scope: string[]
20
20
  /* A short JSON preview of the decoded warm value, when the entry holds one. */
21
21
  value: string | undefined
22
- /* An armed invalidate policy (throttle/debounce + ms), if declared. */
22
+ /* An armed swr policy (`swr`, optionally + throttle/debounce + ms), if declared. */
23
23
  policy: string | undefined
24
24
  }
@@ -215,31 +215,48 @@ export function cache<Args, Return>(
215
215
  return read
216
216
  }
217
217
 
218
+ /*
219
+ Normalises the `swr` option to its window, or undefined when off. `true` (or
220
+ `{}`) is stale-while-revalidate with no window — refetch immediately on every
221
+ invalidate; an object carries the throttle/debounce window. `false`/omitted is
222
+ off. Collapsing the boolean here lets every downstream site treat "is SWR on"
223
+ as a single defined/undefined check.
224
+ */
225
+ function swrWindow(
226
+ options: CacheOptions | undefined,
227
+ ): { throttle?: number; debounce?: number } | undefined {
228
+ const swr = options?.swr
229
+ if (swr === undefined || swr === false) {
230
+ return undefined
231
+ }
232
+ return swr === true ? {} : swr
233
+ }
234
+
218
235
  /*
219
236
  Guards impossible option combinations at wrap time, where the call site is on
220
- the stack. A policy declares "this call is safe to re-run unprompted", so a
221
- non-replayable remote method (a write) must never carry one — replaying a write
237
+ the stack. `swr` declares "this call is safe to re-run unprompted", so a
238
+ non-replayable remote method (a write) must never carry it — replaying a write
222
239
  through the invalidation grammar would be a state change disguised as a
223
240
  refresh. Producers are opaque (no method to check); the same contract is on
224
- the caller there. ttl: 0 retains nothing, so there is nothing for a policy to
225
- revalidate; and the two coalescing strategies are exclusive by construction.
241
+ the caller there. ttl: 0 retains nothing, so there is nothing to revalidate;
242
+ and the two coalescing windows are exclusive by construction.
226
243
  */
227
244
  function validatePolicy(options: CacheOptions | undefined, method: string | undefined): void {
228
- const policy = options?.invalidate
229
- if (!policy || (policy.throttle === undefined && policy.debounce === undefined)) {
245
+ const policy = swrWindow(options)
246
+ if (!policy) {
230
247
  return
231
248
  }
232
249
  if (policy.throttle !== undefined && policy.debounce !== undefined) {
233
- throw new Error('[abide] cache(): set invalidate.throttle or invalidate.debounce, not both')
250
+ throw new Error('[abide] cache(): set swr.throttle or swr.debounce, not both')
234
251
  }
235
252
  if (options?.ttl === 0) {
236
253
  throw new Error(
237
- '[abide] cache(): an invalidate policy requires retention — ttl: 0 keeps nothing to revalidate',
254
+ '[abide] cache(): swr requires retention — ttl: 0 keeps nothing to revalidate',
238
255
  )
239
256
  }
240
257
  if (method !== undefined && !REPLAYABLE_METHODS.has(method.toUpperCase())) {
241
258
  throw new Error(
242
- `[abide] cache(): an invalidate policy re-runs the call unprompted — ${method.toUpperCase()} is a write and must not be replayed`,
259
+ `[abide] cache(): swr re-runs the call unprompted — ${method.toUpperCase()} is a write and must not be replayed`,
243
260
  )
244
261
  }
245
262
  }
@@ -334,12 +351,11 @@ function registerEntry(
334
351
  refetch: () => Promise<unknown>,
335
352
  ): CacheEntry {
336
353
  const ttl = options?.ttl
337
- /* Capture the refetch thunk + policy only when an invalidate window was asked for. */
338
- const policy = options?.invalidate
339
- const invalidation =
340
- policy?.throttle !== undefined || policy?.debounce !== undefined
341
- ? { refetch, throttle: policy.throttle, debounce: policy.debounce }
342
- : undefined
354
+ /* Capture the refetch thunk + window only when swr was asked for. */
355
+ const policy = swrWindow(options)
356
+ const invalidation = policy
357
+ ? { refetch, throttle: policy.throttle, debounce: policy.debounce }
358
+ : undefined
343
359
  /*
344
360
  A prior entry for this key was dropped by invalidate() and is awaiting its
345
361
  next read — consume the marker so this replacement read reports as a reload
@@ -477,7 +493,7 @@ Invalidates every entry matching the selector (see selectorMatcher) across both
477
493
  the request/tab store and the process-level store, and notifies readers.
478
494
  `args` narrows a fn selector to exactly that call's entry — derived through
479
495
  the same encoders the read path uses, so other args variants stay warm. An entry
480
- with an invalidate throttle/debounce policy is kept and its refetch coalesced (stale served
496
+ with an swr policy is kept and its refetch coalesced (stale served
481
497
  until it resolves); every other match is dropped so the next read refetches —
482
498
  its key recorded in pendingRefresh so that read reports as a reload (refreshing())
483
499
  rather than a first-ever load. An empty or unmatched selector is a no-op on the
@@ -722,9 +738,9 @@ cache.on = on
722
738
  Optimistic write: applies `updater` as a prediction now — the reactive read
723
739
  shows it immediately — runs `call`, then reconciles. On resolve the server is
724
740
  the truth: the prediction is dropped and the selector invalidated, so the value
725
- refetches authoritatively, coalesced per the read's own invalidate policy
726
- (cache(fn, { invalidate: { throttle } }) bounds an optimistic-write storm with no
727
- extra knob here; without a policy it is a plain drop-and-refetch). On reject the
741
+ refetches authoritatively, coalesced per the read's own swr window
742
+ (cache(fn, { swr: { throttle } }) bounds an optimistic-write storm with no
743
+ extra knob here; without swr it is a plain drop-and-refetch). On reject the
728
744
  prediction rolls back. The returned promise is transparent over `call` —
729
745
  resolves to `call`'s value (the mutation result, e.g. a created id), rejects
730
746
  with its error, settling only after the cache reflects the reconciled state: an
@@ -769,10 +785,12 @@ function patch<Args, Return, Result>(
769
785
  cache.patch = patch
770
786
 
771
787
  /*
772
- Schedules a coalesced refetch per the entry's invalidate policy. debounce: (re)arm
773
- a timer that fires after N ms of quiet. throttle: fire on the leading edge when a
774
- full window has elapsed since the last fire, else arm a single trailing timer for
775
- the remainder so a continuous invalidation stream refetches at most once per window.
788
+ Schedules a coalesced refetch per the entry's swr policy. No window (swr: true):
789
+ fire immediately (throttle defaults to 0, so the leading-edge branch always
790
+ takes). debounce: (re)arm a timer that fires after N ms of quiet. throttle: fire
791
+ on the leading edge when a full window has elapsed since the last fire, else arm
792
+ a single trailing timer for the remainder — so a continuous invalidation stream
793
+ refetches at most once per window.
776
794
  */
777
795
  function scheduleInvalidationRefetch(store: CacheStore, entry: CacheEntry): void {
778
796
  const policy = entry.invalidation
@@ -932,12 +950,8 @@ function attachPolicy(
932
950
  options: CacheOptions | undefined,
933
951
  refetch: () => Promise<unknown>,
934
952
  ): void {
935
- const policy = options?.invalidate
936
- if (
937
- entry.invalidation ||
938
- !policy ||
939
- (policy.throttle === undefined && policy.debounce === undefined)
940
- ) {
953
+ const policy = swrWindow(options)
954
+ if (entry.invalidation || !policy) {
941
955
  return
942
956
  }
943
957
  entry.invalidation = { refetch, throttle: policy.throttle, debounce: policy.debounce }
@@ -39,20 +39,20 @@ into `__SSR__`; ones still pending were consumed via `{#await}` (render emitted
39
39
  the pending branch without blocking) and stream a resolve chunk instead.
40
40
 
41
41
  `refreshing` flips true while this entry is reloading data it already held —
42
- either a policy stale-while-revalidate refetch (value still visible) or the
43
- default drop-then-reload (the prior entry was invalidated and dropped, this is
44
- its replacement read). It backs refreshing(), distinguishing a reload from a
42
+ either an `swr` refetch (stale value still visible) or the default
43
+ drop-then-reload (the prior entry was invalidated and dropped, this is its
44
+ replacement read). It backs refreshing(), distinguishing a reload from a
45
45
  first-ever load; cleared when the read settles.
46
46
 
47
- `invalidation` holds an `invalidate` throttle/debounce policy: the refetch
48
- thunk (the call captured with its args) plus the policy and its runtime timer
49
- state, so invalidate() can rate-limit refetches of this key instead of dropping
50
- the entry and refetching on every invalidation. Set at registration when the
51
- creating read declared a policy, or attached by a later read declaring one on
52
- an entry that lacks it (hydrated snapshot entries always start without one) —
53
- first policy wins. An armed `timer` is cleared if the entry is evicted, so a
54
- dead key never refetches. Wrap-time validation guarantees a policy never
55
- coexists with ttl: 0 and never sits on a non-replayable remote method.
47
+ `invalidation` holds the entry's `swr` policy: the refetch thunk (the call
48
+ captured with its args) plus its optional throttle/debounce window and runtime
49
+ timer state, so invalidate() keeps the stale value and revalidates this key
50
+ rate-limited by the window instead of dropping the entry. Set at registration
51
+ when the creating read declared `swr`, or attached by a later read declaring it
52
+ on an entry that lacks it (hydrated snapshot entries always start without one) —
53
+ first wins. An armed `timer` is cleared if the entry is evicted, so a dead key
54
+ never refetches. Wrap-time validation guarantees `swr` never coexists with
55
+ ttl: 0 and never sits on a non-replayable remote method.
56
56
  */
57
57
  export type CacheEntry = {
58
58
  key: string
@@ -18,22 +18,26 @@ for per-request data: the default keeps a per-user response from leaking across
18
18
  requests. Write only `global: true`; there is no `false` form. On the client
19
19
  there is a single tab store, so the flag is a no-op there.
20
20
 
21
- `invalidate` controls how a `cache.invalidate` hit on this key is applied, in ms.
22
- `{ throttle: N }` refetches on the leading edge then at most once per N ms while
23
- invalidations keep arriving; `{ debounce: N }` refetches only after N ms of
24
- quiet. Both coalesce a burst of invalidations (e.g. a socket spraying
25
- `cache.invalidate`) into far fewer calls and keep serving the existing (stale)
26
- value until the refetch resolves stale-while-revalidate. They affect only the
27
- refetch-after-invalidate; the first fetch and arg-change fetches stay immediate.
28
- A policy declares the call safe to re-run unprompted: cache() throws at wrap
29
- time on both set at once, on ttl: 0 (nothing retained, nothing to revalidate),
30
- and on a non-replayable remote method (replaying a write is a state change
31
- disguised as a refresh). Producers are uncheckable declare a policy only on
32
- a producer that is a pure read.
21
+ `swr` is stale-while-revalidate: it changes what a `cache.invalidate` hit does
22
+ to this key. Without it, an invalidate drops the entry and the next read shows
23
+ `pending()`. With it, the entry is kept and refetched in the background — the
24
+ existing (stale) value stays visible and `refreshing()` reports the in-flight
25
+ reload so the reader never blanks. It governs only the refetch-after-invalidate;
26
+ the first fetch and arg-change fetches stay immediate regardless.
27
+
28
+ `swr: true` refetches immediately on every invalidate. An optional window
29
+ coalesces a burst (e.g. a socket spraying `cache.invalidate`) into far fewer
30
+ calls: `swr: { throttle: N }` refetches on the leading edge then at most once
31
+ per N ms while invalidations keep arriving; `swr: { debounce: N }` refetches
32
+ only after N ms of quiet. `swr` declares the call safe to re-run unprompted:
33
+ cache() throws at wrap time on throttle+debounce set at once, on ttl: 0 (nothing
34
+ retained, nothing to revalidate), and on a non-replayable remote method
35
+ (replaying a write is a state change disguised as a refresh). Producers are
36
+ uncheckable — set `swr` only on a producer that is a pure read.
33
37
  */
34
38
  export type CacheOptions = {
35
39
  ttl?: number
36
40
  scope?: string | string[]
37
41
  global?: boolean
38
- invalidate?: { throttle?: number; debounce?: number }
42
+ swr?: boolean | { throttle?: number; debounce?: number }
39
43
  }