@abide/abide 0.39.0 → 0.40.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 (71) hide show
  1. package/AGENTS.md +318 -146
  2. package/CHANGELOG.md +89 -0
  3. package/README.md +77 -79
  4. package/package.json +1 -1
  5. package/src/lib/server/rpc/parseArgs.ts +10 -1
  6. package/src/lib/server/rpc/runWithVerbTimeout.ts +7 -9
  7. package/src/lib/server/rpc/types/VerbHelper.ts +18 -21
  8. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +8 -7
  9. package/src/lib/server/sockets/createSocketDispatcher.ts +12 -3
  10. package/src/lib/server/sockets/defineSocket.ts +3 -1
  11. package/src/lib/shared/REF_JSON_HEADER.ts +9 -0
  12. package/src/lib/shared/REF_JSON_TAGS.ts +31 -0
  13. package/src/lib/shared/assertExhaustive.ts +14 -0
  14. package/src/lib/shared/buildRpcRequest.ts +8 -1
  15. package/src/lib/shared/decodeRefJson.ts +110 -0
  16. package/src/lib/shared/encodeRefJson.ts +106 -0
  17. package/src/lib/test/createTestSocketChannel.ts +6 -2
  18. package/src/lib/ui/compile/TS_PRINTER.ts +7 -0
  19. package/src/lib/ui/compile/analyzeComponent.ts +15 -16
  20. package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +66 -0
  21. package/src/lib/ui/compile/assertTranspiles.ts +19 -0
  22. package/src/lib/ui/compile/compileModule.ts +56 -34
  23. package/src/lib/ui/compile/compileSSR.ts +1 -3
  24. package/src/lib/ui/compile/compileShadow.ts +14 -7
  25. package/src/lib/ui/compile/desugarSignals.ts +168 -107
  26. package/src/lib/ui/compile/generateBuild.ts +49 -59
  27. package/src/lib/ui/compile/generateSSR.ts +30 -21
  28. package/src/lib/ui/compile/hoistCells.ts +2 -2
  29. package/src/lib/ui/compile/isWhitespaceText.ts +11 -0
  30. package/src/lib/ui/compile/lowerContext.ts +23 -10
  31. package/src/lib/ui/compile/lowerDocAccess.ts +29 -3
  32. package/src/lib/ui/compile/lowerScript.ts +64 -0
  33. package/src/lib/ui/compile/parseTemplate.ts +74 -0
  34. package/src/lib/ui/compile/renameSignalRefs.ts +160 -90
  35. package/src/lib/ui/compile/resolveBranches.ts +21 -0
  36. package/src/lib/ui/compile/stripEffects.ts +22 -17
  37. package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
  38. package/src/lib/ui/compile/types/TemplateNode.ts +12 -2
  39. package/src/lib/ui/createScope.ts +14 -0
  40. package/src/lib/ui/dom/appendText.ts +2 -3
  41. package/src/lib/ui/dom/appendTextAt.ts +2 -3
  42. package/src/lib/ui/dom/applyResolved.ts +5 -8
  43. package/src/lib/ui/dom/awaitBlock.ts +22 -2
  44. package/src/lib/ui/dom/each.ts +4 -0
  45. package/src/lib/ui/dom/on.ts +7 -0
  46. package/src/lib/ui/dom/parseRawNodes.ts +17 -0
  47. package/src/lib/ui/dom/switchBlock.ts +4 -0
  48. package/src/lib/ui/dom/when.ts +4 -0
  49. package/src/lib/ui/installInspectorBridge.ts +3 -1
  50. package/src/lib/ui/navigate.ts +28 -3
  51. package/src/lib/ui/renderToStream.ts +17 -9
  52. package/src/lib/ui/resumeSeedScript.ts +16 -6
  53. package/src/lib/ui/router.ts +108 -15
  54. package/src/lib/ui/runtime/NODE_STATE.ts +22 -0
  55. package/src/lib/ui/runtime/RESUME.ts +13 -6
  56. package/src/lib/ui/runtime/clientPage.ts +114 -9
  57. package/src/lib/ui/runtime/createComputedNode.ts +5 -3
  58. package/src/lib/ui/runtime/createEffectNode.ts +3 -1
  59. package/src/lib/ui/runtime/createSignalNode.ts +4 -2
  60. package/src/lib/ui/runtime/flushEffects.ts +8 -5
  61. package/src/lib/ui/runtime/historyEntries.ts +39 -1
  62. package/src/lib/ui/runtime/localStoragePersistence.ts +14 -8
  63. package/src/lib/ui/runtime/readNode.ts +8 -7
  64. package/src/lib/ui/runtime/runNode.ts +18 -2
  65. package/src/lib/ui/runtime/scope.ts +12 -1
  66. package/src/lib/ui/runtime/trigger.ts +40 -24
  67. package/src/lib/ui/runtime/types/ReactiveNode.ts +4 -1
  68. package/src/lib/ui/runtime/updateIfNecessary.ts +40 -0
  69. package/src/lib/ui/socketChannel.ts +5 -2
  70. package/src/lib/ui/tryEncodeResume.ts +20 -0
  71. package/src/lib/ui/types/Scope.ts +9 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,94 @@
1
1
  # abide
2
2
 
3
+ ## 0.40.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - share a single TS printer across compile passes ([`0626e68`](https://github.com/briancray/abide/commit/0626e68500b6cc8e7e2c770184ec4aa0fd06106d))
8
+
9
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - bind-check generated modules and collect identifiers via AST ([`1e9d350`](https://github.com/briancray/abide/commit/1e9d3506873121ffd978039653f920edbed6c9ce))
10
+
11
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - value-memoised push-pull reactivity (CLEAN/CHECK/DIRTY) ([`2ae8f14`](https://github.com/briancray/abide/commit/2ae8f141da5873bea0eb31a9a59d81182ca5a12a))
12
+
13
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - syntax fuzz corpus for the identifier-rewrite passes ([`2e7d604`](https://github.com/briancray/abide/commit/2e7d6043aeeb9ca894bdb45f3f27893fbc5bf2f8))
14
+
15
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - build control-flow subtrees untracked ([`33c0d8e`](https://github.com/briancray/abide/commit/33c0d8e834e5145a84d872ff496df122bbb0da95))
16
+
17
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - lower the component script in a single parse ([`53098f8`](https://github.com/briancray/abide/commit/53098f8c2fe36e46e61b41d8a617aba6ac75e96f))
18
+
19
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - regenerate README and AGENTS surface map ([`6bb6a39`](https://github.com/briancray/abide/commit/6bb6a3906d5aba522a4fe33bffae0911adf0b0fe))
20
+
21
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - spread reactive page.params before postMessage in inspector bridge ([`7c99395`](https://github.com/briancray/abide/commit/7c993959e9dfc0c7f7038f694f95b59093a10dfc))
22
+
23
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - make signal-rewrite parent-independent ([`868717a`](https://github.com/briancray/abide/commit/868717a6f7ff824b24ef8a96e5e5f59add5ceed1))
24
+
25
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - pick runtime imports by tokenizing the output, not substring grep ([`a102986`](https://github.com/briancray/abide/commit/a102986bef90609297246ea001528a32e111be53))
26
+
27
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - re-apply saved scroll offset until async content grows the page ([`a813b47`](https://github.com/briancray/abide/commit/a813b47387289c0049e63997a72a6805e461b18c))
28
+
29
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - aliased import mangled when its name collides with a signal ([`abfe25d`](https://github.com/briancray/abide/commit/abfe25dcf7c00e61156b5d36fe82f290fc6c4cfb))
30
+
31
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - granular reactive page snapshot + in-place same-route navigation ([`b561eb5`](https://github.com/briancray/abide/commit/b561eb52439577d3a75a7872befee4b9f0e98267))
32
+
33
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - lower the component script in a literal single parse ([`b72b238`](https://github.com/briancray/abide/commit/b72b238377ff6545de549589d36b49ad4ecebb2d))
34
+
35
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - chain per-expression lowering over a single parse ([`d808317`](https://github.com/briancray/abide/commit/d808317d817ae8a414695b7bfb037b8aff7017c3))
36
+
37
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - classify signal-rewrite positions at visit time ([`d99577e`](https://github.com/briancray/abide/commit/d99577e2ac77d8742088608fba554ff5c2e799a4))
38
+
39
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - make template-node generators exhaustive ([`e0f246d`](https://github.com/briancray/abide/commit/e0f246dfbbf4ea4f2dc481fc543a6e9f0a6fa43b))
40
+
41
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - lower ++/-- on a doc path; guard the whole generated module ([`f49c122`](https://github.com/briancray/abide/commit/f49c122354fc4ef65f4c44db820d703078dd57f4))
42
+
43
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - fold SSR effect-stripping into the single-parse chain ([`fa4515d`](https://github.com/briancray/abide/commit/fa4515d07164765ea0ee609cf1847535a41f95ed))
44
+
45
+ ## 0.40.0
46
+
47
+ ### Minor Changes
48
+
49
+ - [`05ea7e2`](https://github.com/briancray/abide/commit/05ea7e235b1e7c035d3b4313e12fc83733fa814b) - <template elseif> conditional chains ([`c3237cc`](https://github.com/briancray/abide/commit/c3237ccc0d222c36e8b00ce68595eb92609ef856))
50
+
51
+ - [`15c0ad3`](https://github.com/briancray/abide/commit/15c0ad334f698dfb49a9dcd0e0361925afc4f6c8) - `navigate` takes an options object — `navigate(path, { replace?, keepScroll? })` — replacing the old `navigate(path, replace?)` boolean. **Breaking:** a caller passing a boolean (`navigate(path, true)`) must migrate to `navigate(path, { replace: true })`; a bare `navigate(path)` is unchanged. The new `keepScroll` flag carries the live scroll offset onto the destination entry so an in-page URL swap — e.g. selecting another episode on the same detail page — restores to the current position instead of jumping to top. A push re-buckets the offset under the freshly minted entry id; a `replace` keeps the entry's bucket instead of discarding it.
52
+
53
+ - [`ba56bb2`](https://github.com/briancray/abide/commit/ba56bb28be06fa0a11ab57fb10fa2d6b75ab8656) - Add `share`/`shared` context to `scope()`. The reactive doc does not inherit down the scope tree (each scope owns a separate document), so passing a value from an ancestor to a descendant previously meant threading it through every layer as props. `scope().share(key, value)` now puts a named value on a scope's own side-map, and `scope().shared(key)` reads the closest ancestor (self included) that has the key — an existence-checked, non-tracking upward walk, returning `undefined` when no scope provides it. The value is held by reference and the lookup never subscribes, so reactive context is expressed by sharing a `cell` (or a scope) rather than a plain object — reactivity rides what you share, not the share itself. A shared `undefined` shadows an ancestor (the walk stops on `has`, not truthiness). The shared map is released on `dispose`.
54
+
55
+ ### Patch Changes
56
+
57
+ - [`a5a37f9`](https://github.com/briancray/abide/commit/a5a37f94258a56c4f24eb2b5539a280ce877bdfc) - extract resolveBranches for branch lookup ([`19a28a1`](https://github.com/briancray/abide/commit/19a28a1236e38a78c53a2adff0a981c8ad20b698))
58
+
59
+ - [`a5a37f9`](https://github.com/briancray/abide/commit/a5a37f94258a56c4f24eb2b5539a280ce877bdfc) - sync AGENTS.md surface map for scope share/shared and navigate options ([`3e41255`](https://github.com/briancray/abide/commit/3e4125516164c94cfe9c9e6baa35f9ff9ec9c3e0))
60
+
61
+ - [`a5a37f9`](https://github.com/briancray/abide/commit/a5a37f94258a56c4f24eb2b5539a280ce877bdfc) - use Promise.withResolvers in runWithVerbTimeout ([`504bdca`](https://github.com/briancray/abide/commit/504bdcac285cfd3762c8390fd1b6d640e62029bc))
62
+
63
+ - [`a5a37f9`](https://github.com/briancray/abide/commit/a5a37f94258a56c4f24eb2b5539a280ce877bdfc) - extract VerbBaseOpts for shared verb options ([`d7414ca`](https://github.com/briancray/abide/commit/d7414cab7a557b54395321026d818b9fba82ba34))
64
+
65
+ - [`ccc7f87`](https://github.com/briancray/abide/commit/ccc7f879d0506af084335785026200aeb38fdde4) - `on()` no longer attaches a non-function event handler. A component that omits an optional `on*` prop forwards it as `undefined`, yet the compiler still emits the `on()` call — so the listener fired `undefined(event)` and threw "handler is not a function" on every matching event (a keystroke or input on a search box, a click, …). `on()` now skips attaching when the handler is not a function, so an omitted optional handler is a no-op rather than a per-event crash.
66
+
67
+ - [`c29dcd2`](https://github.com/briancray/abide/commit/c29dcd2796b5caa3ee41857aa612322b1e4220fa) - Parse `html`-branded raw markup in the parent's namespace. The client-side raw-HTML
68
+ binding parsed markup by setting `innerHTML` on a throwaway `<div>`, so the fragment
69
+ parser always used the HTML namespace — markup bound inside an `<svg>` produced
70
+ HTML-namespaced `<path>`/`<circle>` elements that exist in the DOM but never render.
71
+ This only surfaced on client-created or re-rendered nodes; the initial SSR/hydration
72
+ path adopts the server markup verbatim and kept the correct namespace, so an icon
73
+ component (`{html(svgInnerMarkup)}` inside `<svg>`) rendered on first paint but
74
+ vanished the moment its subtree was rebuilt on the client (e.g. a live cache delta).
75
+ A new `parseRawNodes` helper picks an SVG holder when the parent is SVG-namespaced,
76
+ shared by both `appendText` and `appendTextAt`.
77
+
78
+ - [`9a74da6`](https://github.com/briancray/abide/commit/9a74da67c1f65cdb37e7a47af2e7b104cfe7620f) - Stop the signal-ref rewrite from descending into type space. `renameSignalRefs` rewrote every value-position identifier matching a signal name, but never skipped TypeScript type aliases, interfaces, or type annotations — so a component that destructures a prop (`const { option, ...rest } = props<Props>()`) and names that prop in its own type (`type Props = { option?: (value: T) => unknown; … }`) had the type member rewritten into a call (`option(): …`). This only surfaced once a `...rest` binding forced the named prop type to be emitted into the build (a no-rest `props<{…}>()` strips its type argument); with simple property types the mangle landed as accidentally-valid method-signature syntax, but optional/function/union members (`option?: (v) => unknown`, `value?: T | T[] | string`) became invalid TS and failed the page build. The visitor now leaves type aliases, interfaces, and any type node untouched — type space never holds a runtime read, and types erase at build regardless.
79
+
80
+ - [`92597f0`](https://github.com/briancray/abide/commit/92597f0cbabc1e44449c0f0f812048481c38c579) - RPC bodies and socket frames now ship as ref-json (cycles, shared references, and
81
+ the types JSON drops/coerces — `undefined`, `bigint`, `Date`, `Map`, `Set`, `NaN`,
82
+ `±Infinity`, `-0` — survive the wire), and the server still accepts ordinary JSON from
83
+ non-abide clients. abide's own client flags a ref-json body with the `abide-ref-json`
84
+ header so the server decodes it with the matching codec; a request without the header
85
+ (curl, an OpenAPI-generated SDK, a webhook) is read with plain `JSON.parse`, so the
86
+ documented HTTP/OpenAPI body contract keeps working. The header is the discriminator
87
+ rather than the payload shape because ref-json's `[root, slots]` envelope is ambiguous
88
+ with a legitimate plain-JSON two-element array body. Socket frames need no header — a
89
+ frame is always an object, never the envelope array, so a plain-JSON frame falls back
90
+ to `JSON.parse` unambiguously.
91
+
3
92
  ## 0.39.0
4
93
 
5
94
  ### Minor Changes
package/README.md CHANGED
@@ -1,159 +1,157 @@
1
1
  # abide
2
2
 
3
- **One typed RPC declaration fans out to an HTTP route, a typed browser proxy, a CLI subcommand, an MCP tool, and an OpenAPI operation — built for humans and machines.**
3
+ **One typed declaration fans out to HTTP, a CLI, an MCP tool, and an OpenAPI
4
+ spec — and the bundler swaps the runtime per side.**
4
5
 
5
- abide is an isomorphic framework on Bun: the same callable has the same name and behaviour on both sides, and the bundler swaps the runtime per side — a server handler in `src/server`, a `fetch` proxy in the browser. Server-render and hydrate the same `.abide` components, broadcast over multiplexed sockets, and expose every read to an agent — all in a single Bun runtime.
6
+ abide is an isomorphic framework on Bun and web standards: you declare a verb,
7
+ a socket, or a component once, and the same callable runs server-side, in the
8
+ browser, from the terminal, and from an agent — the bundler decides which
9
+ runtime each side gets. Built for humans *and* machines.
6
10
 
7
- - One dependency (`typescript`, for the type-checking shadow); `tailwindcss` + `bun-plugin-tailwind` are optional peers.
8
- - Runs on Bun `>= 1.3.0`. No bundler config, no server framework, no client router to wire up.
11
+ - One direct dependency the TypeScript compiler, used by `abide check`.
12
+ Tailwind is an optional peer. Everything runs on a single Bun runtime
13
+ (≥ 1.3.0); no second toolchain.
9
14
 
10
15
  ## Quick start
11
16
 
12
17
  ```sh
13
- bunx abide scaffold my-app # scaffolds, installs deps, and (in a TTY) starts dev
18
+ bunx abide scaffold my-app # copies the template, installs, starts dev
14
19
  ```
15
20
 
16
- Or read the exhaustive demo:
21
+ Or clone the kitchen-sink and run it:
17
22
 
18
23
  ```sh
19
24
  git clone https://github.com/briancray/abide
20
- cd abide && bun install
21
- cd examples/kitchen-sink && bun dev
25
+ cd abide/examples/kitchen-sink
26
+ bun install
27
+ bun run dev
22
28
  ```
23
29
 
24
- The whole public surface — every export, CLI command, route, env var, and the `.abide` grammar — is mapped in [`AGENTS.md`](./AGENTS.md).
25
-
26
30
  ## RPCs
27
31
 
28
- An RPC is one export per file under `src/server/rpc/`. The file path is the URL (`getMessages.ts` → `/rpc/getMessages`), and a [Standard Schema](https://standardschema.dev) (zod / valibot / arktype, unadapted) validates the args and projects every other surface.
32
+ An RPC is one export per file under `src/server/rpc/`. **The file path is the
33
+ URL.** The schema validates arguments and projects the MCP tool, the CLI flags,
34
+ and the OpenAPI operation. The contract is Standard Schema — zod, valibot, or
35
+ arktype, unadapted.
29
36
 
30
37
  ```ts
31
- // src/server/rpc/getMessages.ts
38
+ // src/server/rpc/getMessages.ts — file path is the route: GET /getMessages
32
39
  import { GET } from '@abide/abide/server/GET'
33
40
  import { json } from '@abide/abide/server/json'
34
41
  import { z } from 'zod'
35
- import { history } from '../../store.ts'
36
42
 
37
43
  const inputSchema = z.object({ room: z.string() })
38
44
 
39
- export const getMessages = GET(({ room }) => json({ messages: history(room) }), { inputSchema })
45
+ export const getMessages = GET(({ room }) => json(board(room)), { inputSchema })
40
46
  ```
41
47
 
42
- That one declaration fans out:
48
+ One declaration, five surfaces:
43
49
 
44
50
  ```text
45
- getMessages (one declaration)
46
-
47
- ┌──────────┬─────────┼─────────┬──────────────┐
48
- ▼ ▼ ▼ ▼ ▼
49
- SSR call browser MCP tool CLI sub- OpenAPI
50
- cache(fn)() fetch (read-only) command operation
51
+ getMessages = GET(fn, { inputSchema })
52
+
53
+ ┌───────────┬─────────────┼────────────┬──────────────┐
54
+ SSR call browser fetch MCP tool CLI command OpenAPI op
55
+ cache(fn)() typed proxy() (read-only) abide ... cli /openapi.json
51
56
  ```
52
57
 
53
- The `inputSchema` unlocks the CLI and because a `GET`/`HEAD` is read-only — an MCP tool. A mutating verb (`POST`/`PUT`/`PATCH`/`DELETE`) never auto-exposes to an agent; opt in with `clients: { mcp: true }`. Consume the verb four ways, all typed against the declaration: `cache(getMessages)({ room })` for an SSR-memoized in-process read, the bundler-swapped `getMessages({ room })` `fetch` proxy in the browser, `.raw(args)` for the untouched `Response`, and `.stream(args)` for a frame-by-frame `jsonl`/`sse` body.
58
+ A schema unlocks the CLI everywhere and MCP for read-only verbs; a mutating
59
+ verb never auto-exposes to MCP — it needs explicit `clients: { mcp: true }`.
60
+ Consume the verb four ways: `cache(getMessages)({ room })` resolves in-process
61
+ during SSR, the same call hits a swapped `fetch` in the browser,
62
+ `getMessages.raw(args)` returns the undecoded `Response`, and
63
+ `getMessages.stream(args)` is an iterable view of the body.
54
64
 
55
- > Query args travel as strings — use `z.coerce.*` for numbers and booleans. The per-verb `timeout` option fires a `504` on every surface and is distinct from the client-wide `ABIDE_CLIENT_TIMEOUT`.
65
+ > Query args travel as strings — validate with `z.coerce.*`. The per-verb
66
+ > `timeout` (504 on every surface) is distinct from `ABIDE_CLIENT_TIMEOUT`.
56
67
 
57
68
  ## Sockets
58
69
 
59
- A socket is one broadcast topic per file under `src/server/sockets/`. A `Socket<T>` is an isomorphic `AsyncIterable<T>` — `for await` it on the server, or read it reactively with `tail()` in a component — and every socket multiplexes onto one WebSocket at `/__abide/sockets`.
70
+ A socket is one broadcast topic per file under `src/server/sockets/`. A
71
+ `Socket<T>` is an isomorphic `AsyncIterable<T>`, and every socket multiplexes
72
+ onto one websocket at `/__abide/sockets`.
60
73
 
61
74
  ```ts
62
- // src/server/sockets/messages.ts
75
+ // src/server/sockets/messages.ts — topic name = file name: `messages`
63
76
  import { socket } from '@abide/abide/server/socket'
64
77
  import { z } from 'zod'
65
78
 
66
- const schema = z.object({
67
- id: z.string(),
68
- room: z.string(),
69
- from: z.string(),
70
- text: z.string(),
71
- at: z.number(),
72
- })
79
+ const schema = z.object({ id: z.string(), room: z.string(), from: z.string(), text: z.string() })
73
80
 
74
- // retain the last 100 frames; lazily evict any older than an hour
75
- export const messages = socket({ schema, tail: 100, ttl: 3_600_000 })
81
+ // retain the last 50 frames; evict any older than an hour
82
+ export const messages = socket({ schema, tail: 50, ttl: 3_600_000 })
76
83
  export type Message = z.infer<typeof schema>
77
84
  ```
78
85
 
79
- The schema validates every publish and flips on the read surfaces (a `messages-tail` MCP tool / CLI command). For clients that can't speak the ws multiplex, each socket has an HTTP face at `/__abide/sockets/messages`: `GET` returns the retained tail, `POST` publishes — gated by `clientPublish` (default `false`, so browsers publish through a validating verb instead).
86
+ The socket also has an HTTP face at `/__abide/sockets/messages`, for clients
87
+ that can't speak the ws multiplex: `GET` returns the retained tail, `POST`
88
+ publishes — gated by `clientPublish` (off by default, so the POST 403s).
80
89
 
81
90
  ## Components
82
91
 
83
- A component is an `.abide` file: valid HTML with a `<script>`, native `<template>` control flow, `{expr}` bindings, and a component-scoped `<style>`. `scope()` is the sole reactive surface; `props`, `effect`, and `html` are in scope without import. This one imports the verb and socket above:
92
+ A `.abide` component is the payoff: it imports the verb and the socket above
93
+ and ties them together with the native `<template>` grammar. Reactive state is
94
+ reached only through `scope()` — `scope().state()` is writable,
95
+ `scope().computed()` is read-only. `props()` and `effect()` are in-scope, no
96
+ import.
84
97
 
85
98
  ```html
86
99
  <script>
87
100
  import { cache } from '@abide/abide/shared/cache'
88
101
  import { tail } from '@abide/abide/ui/tail'
89
102
  import { getMessages } from '$server/rpc/getMessages.ts'
90
- import { sendMessage } from '$server/rpc/sendMessage.ts'
103
+ import { postMessage } from '$server/rpc/postMessage.ts'
91
104
  import { messages } from '$server/sockets/messages.ts'
92
105
  import Avatar from '$ui/Avatar.abide'
93
106
 
94
- const { room } = props<{ room: string }>()
107
+ const { room = 'lobby' } = props<{ room?: string }>()
108
+
109
+ // SSR-warm history; live frames then stream over the ws
110
+ const seed = cache(getMessages)({ room })
111
+ const live = scope().computed(() => tail(messages, { last: 50 }))
95
112
 
96
- let from = scope().state('alice') // a writable cell — read/assign as a plain var
97
- let draft = scope().state('')
113
+ let from = scope().state('alice')
114
+ let text = scope().state('')
98
115
  let pinned = scope().state(false)
99
- let filter = scope().state('all')
100
- const live = scope().computed(() => tail(messages)) // re-renders on every new frame
101
-
102
- async function send(event: SubmitEvent) {
103
- event.preventDefault()
104
- if (draft.trim() === '') return
105
- await sendMessage({ room, from, text: draft }) // a mutating verb
106
- draft = ''
116
+ let sort = scope().state('newest')
117
+
118
+ async function send() {
119
+ await postMessage({ room, from, text })
120
+ text = ''
107
121
  }
108
122
  </script>
109
123
 
110
- <!-- a named snippet: a reusable builder, rendered like a function -->
111
124
  <template name="bubble" args={msg}>
112
- <li><Avatar name={msg.from} /> <b>{msg.from}</b>: {msg.text}</li>
125
+ <li class="flex gap-2"><Avatar name={msg.from} /> <b>{msg.from}</b> {msg.text}</li>
113
126
  </template>
114
127
 
115
- <h1>#{room}</h1>
116
-
117
- <form onsubmit={send}>
118
- <input bind:value={from} />
119
- <input bind:value={draft} placeholder="message" />
128
+ <form onsubmit={send} class="flex gap-2">
129
+ <input bind:value={from} class="border px-2" />
130
+ <input bind:value={text} placeholder="message" class="flex-1 border px-2" />
120
131
  <label><input type="checkbox" bind:checked={pinned} /> pin</label>
121
- <label><input type="radio" bind:group={filter} value="all" /> all</label>
122
- <label><input type="radio" bind:group={filter} value="mine" /> mine</label>
123
- <button type="submit" disabled={draft.trim() === ''}>send</button>
132
+ <label><input type="radio" bind:group={sort} value="newest" /> newest</label>
133
+ <button disabled={!text} class="border px-3">send</button>
124
134
  </form>
125
135
 
126
- <template if={live}>
127
- <p class="live">latest: {live.text}</p>
128
- <template else>
129
- <p>no messages yet</p>
130
- </template>
131
- </template>
132
-
133
- <template switch={filter}>
134
- <template case={'all'}><p>showing everyone</p></template>
135
- <template case={'mine'}><p>showing {from}</p></template>
136
- <template default><p>—</p></template>
136
+ <template switch={sort}>
137
+ <template case={'newest'}><p class="text-xs">newest first</p></template>
138
+ <template default><p class="text-xs">oldest first</p></template>
137
139
  </template>
138
140
 
139
- <template await={cache(getMessages)({ room })}>
140
- <p>loading…</p>
141
+ <template await={seed}>
142
+ <p class="text-xs text-slate-500">loading…</p>
141
143
  <template then="history">
142
- <ul>
143
- <template each={history.messages} as="msg" key="msg.id">
144
+ <ul class="mt-3 space-y-1">
145
+ <template each={live ?? history} as="msg" key="msg.id">
144
146
  {bubble(msg)}
145
147
  </template>
146
148
  </ul>
147
149
  </template>
148
- <template catch="error">
149
- <p>failed: {error.message}</p>
150
- </template>
150
+ <template catch="err"><p class="text-rose-700">{err.message}</p></template>
151
151
  </template>
152
152
 
153
153
  <style>
154
- .live {
155
- font-weight: 600;
156
- }
154
+ li { font-size: 0.875rem; }
157
155
  </style>
158
156
  ```
159
157
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.39.0",
3
+ "version": "0.40.1",
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",
@@ -1,6 +1,8 @@
1
1
  import { carriesBodyArgs } from '../../shared/carriesBodyArgs.ts'
2
2
  import { contentTypeOf } from '../../shared/contentTypeOf.ts'
3
+ import { decodeRefJson } from '../../shared/decodeRefJson.ts'
3
4
  import { HttpError } from '../../shared/HttpError.ts'
5
+ import { REF_JSON_HEADER } from '../../shared/REF_JSON_HEADER.ts'
4
6
  import type { HttpVerb } from '../../shared/types/HttpVerb.ts'
5
7
  import { error } from '../error.ts'
6
8
  import { requestContext } from '../runtime/requestContext.ts'
@@ -92,7 +94,14 @@ export async function parseArgs(
92
94
  if (contentType.includes('application/json')) {
93
95
  const text = await bounded.text()
94
96
  if (text !== '') {
95
- body = JSON.parse(text)
97
+ /* abide's own client flags ref-json with REF_JSON_HEADER (restores
98
+ cycles/shared refs JSON can't carry); a non-abide client (curl, an
99
+ OpenAPI SDK) omits it and sends ordinary JSON — read with plain
100
+ JSON.parse, since the ref-json envelope is ambiguous with a 2-element
101
+ array body. */
102
+ body = bounded.headers.has(REF_JSON_HEADER)
103
+ ? decodeRefJson(text)
104
+ : JSON.parse(text)
96
105
  }
97
106
  } else if (
98
107
  contentType.includes('application/x-www-form-urlencoded') ||
@@ -19,18 +19,16 @@ export function runWithVerbTimeout(
19
19
  ms: number,
20
20
  onTimeout: () => void,
21
21
  ): Promise<Response> {
22
- let timer: ReturnType<typeof setTimeout> | undefined
23
22
  let timedOut = false
24
- const deadline = new Promise<Response>((resolve) => {
25
- timer = setTimeout(() => {
26
- timedOut = true
27
- onTimeout()
28
- resolve(error(504, 'handler timeout'))
29
- }, ms)
30
- })
23
+ const deadline = Promise.withResolvers<Response>()
24
+ const timer = setTimeout(() => {
25
+ timedOut = true
26
+ onTimeout()
27
+ deadline.resolve(error(504, 'handler timeout'))
28
+ }, ms)
31
29
  return (async () => {
32
30
  try {
33
- return await Promise.race([work, deadline])
31
+ return await Promise.race([work, deadline.promise])
34
32
  } finally {
35
33
  clearTimeout(timer)
36
34
  if (timedOut) {
@@ -3,6 +3,21 @@ import type { RemoteFunction } from '../../../shared/types/RemoteFunction.ts'
3
3
  import type { StandardSchemaV1 } from '../../../shared/types/StandardSchemaV1.ts'
4
4
  import type { RemoteHandler } from './RemoteHandler.ts'
5
5
 
6
+ /*
7
+ Options every verb overload accepts: the OpenAPI 200 `outputSchema`, the
8
+ `clients` surface flags, the same-origin CSRF exemption (`crossOrigin`), the
9
+ pre-parse body-byte ceiling (`maxBodySize`), and the per-surface handler
10
+ `timeout` (ms). The schema-bearing overloads intersect this with their own
11
+ `inputSchema`/`filesSchema` members.
12
+ */
13
+ type VerbBaseOpts = {
14
+ outputSchema?: StandardSchemaV1
15
+ clients?: Partial<ClientFlags>
16
+ crossOrigin?: boolean
17
+ maxBodySize?: number
18
+ timeout?: number
19
+ }
20
+
6
21
  /*
7
22
  Shared signature for every verb helper (GET / POST / …). Three overloads:
8
23
 
@@ -50,36 +65,18 @@ export type VerbHelper = {
50
65
  StandardSchemaV1.InferOutput<InputSchema> & StandardSchemaV1.InferOutput<FilesSchema>,
51
66
  Return
52
67
  >,
53
- opts: {
68
+ opts: VerbBaseOpts & {
54
69
  inputSchema: InputSchema
55
70
  filesSchema: FilesSchema
56
- outputSchema?: StandardSchemaV1
57
- clients?: Partial<ClientFlags>
58
- crossOrigin?: boolean
59
- maxBodySize?: number
60
- timeout?: number
61
71
  },
62
72
  ): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, Return>
63
73
  <Return = unknown, InputSchema extends StandardSchemaV1 = StandardSchemaV1>(
64
74
  fn: RemoteHandler<StandardSchemaV1.InferOutput<InputSchema>, Return>,
65
- opts: {
66
- inputSchema: InputSchema
67
- outputSchema?: StandardSchemaV1
68
- clients?: Partial<ClientFlags>
69
- crossOrigin?: boolean
70
- maxBodySize?: number
71
- timeout?: number
72
- },
75
+ opts: VerbBaseOpts & { inputSchema: InputSchema },
73
76
  ): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, Return>
74
77
  <Args = undefined, Return = unknown>(
75
78
  fn: RemoteHandler<Args, Return>,
76
- opts: {
77
- outputSchema?: StandardSchemaV1
78
- clients?: Partial<ClientFlags>
79
- crossOrigin?: boolean
80
- maxBodySize?: number
81
- timeout?: number
82
- },
79
+ opts: VerbBaseOpts,
83
80
  ): RemoteFunction<Args, Return>
84
81
  <Args = undefined, Return = unknown>(
85
82
  fn: RemoteHandler<Args, Return>,
@@ -1,17 +1,18 @@
1
1
  /*
2
2
  The tiny inline script the abide-ui SSR stream ships in <head>. For each streamed
3
3
  `<abide-resolve data-id>` frame it reads the leading `<script type=application/json>`,
4
- registers the resolved value into
5
- `window.__abideResume` (the resume manifest hydration reads) and swaps the resolved
6
- markup into the matching `<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary —
7
- so the pending shell paints instantly and each value lands as it arrives, before
8
- the client bundle even loads. Vanilla and self-contained (no framework runtime),
9
- minified to one line so it inlines cheaply ahead of the document body.
4
+ stores its raw ref-json text into `window.__abideResume` (the resume manifest hydration
5
+ reads, decoding each entry at read time) and swaps the resolved markup into the matching
6
+ `<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary — so the pending shell paints
7
+ instantly and each value lands as it arrives, before the client bundle even loads. It
8
+ stores the text undecoded precisely because it's vanilla and self-contained no framework
9
+ runtime, hence no ref-json decoder; the bundle decodes on read. Minified to one line so it
10
+ inlines cheaply ahead of the document body.
10
11
  */
11
12
  export const SSR_SWAP_SCRIPT =
12
13
  "function __abideSwap(){var f=document.querySelector('abide-resolve');while(f){" +
13
14
  "var id=f.getAttribute('data-id'),p=f.firstChild,w=document.createTreeWalker(document.body,NodeFilter.SHOW_COMMENT),o=null,c;" +
14
- "if(p&&p.nodeName==='SCRIPT'){try{(window.__abideResume=window.__abideResume||{})[id]=JSON.parse(p.textContent||'null');}catch(e){}p.remove();}" +
15
+ "if(p&&p.nodeName==='SCRIPT'){(window.__abideResume=window.__abideResume||{})[id]=p.textContent||'';p.remove();}" +
15
16
  "while((c=w.nextNode())){if(c.data==='abide:await:'+id){o=c;break;}}" +
16
17
  "if(o){var n=o.nextSibling;while(n&&!(n.nodeType===8&&n.data==='/abide:await:'+id)){var x=n.nextSibling;n.remove();n=x;}" +
17
18
  "while(f.firstChild){o.parentNode.insertBefore(f.firstChild,n);}}f.remove();f=document.querySelector('abide-resolve');}}"
@@ -1,5 +1,7 @@
1
1
  import type { ServerWebSocket } from 'bun'
2
2
  import { abideLog } from '../../shared/abideLog.ts'
3
+ import { decodeRefJson } from '../../shared/decodeRefJson.ts'
4
+ import { encodeRefJson } from '../../shared/encodeRefJson.ts'
3
5
  import { memoizeByKey } from '../../shared/memoizeByKey.ts'
4
6
  import { messageFromError } from '../../shared/messageFromError.ts'
5
7
  import { error } from '../error.ts'
@@ -104,7 +106,7 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
104
106
  if (ws.readyState !== WebSocket.OPEN) {
105
107
  return
106
108
  }
107
- ws.send(JSON.stringify(frame))
109
+ ws.send(encodeRefJson(frame))
108
110
  }
109
111
 
110
112
  function addSub(state: ConnectionState, name: string, sub: string): boolean {
@@ -312,11 +314,18 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
312
314
  const text = typeof data === 'string' ? data : textDecoder.decode(data)
313
315
  let frame: SocketClientFrame
314
316
  try {
315
- frame = JSON.parse(text) as SocketClientFrame
317
+ /* ref-json from abide's own client; a non-abide client sends a plain-JSON
318
+ frame, which decodeRefJson rejects (a frame is always an object, never the
319
+ `[root, slots]` envelope) — fall back to JSON.parse so raw WS clients work. */
320
+ try {
321
+ frame = decodeRefJson(text) as SocketClientFrame
322
+ } catch {
323
+ frame = JSON.parse(text) as SocketClientFrame
324
+ }
316
325
  } catch {
317
326
  return
318
327
  }
319
- // JSON.parse succeeds on `null`/`42`/`true`; ignore anything that isn't a frame object.
328
+ // both codecs yield a value for `null`/`42`/`true` too; ignore anything that isn't a frame object.
320
329
  if (typeof frame !== 'object' || frame === null) {
321
330
  return
322
331
  }
@@ -1,4 +1,5 @@
1
1
  import { createPushIterator } from '../../shared/createPushIterator.ts'
2
+ import { encodeRefJson } from '../../shared/encodeRefJson.ts'
2
3
  import { resolveClientFlags } from '../../shared/resolveClientFlags.ts'
3
4
  import { socketTapSlot } from '../../shared/socketTapSlot.ts'
4
5
  import type { TailHooks } from '../../shared/types/TailHooks.ts'
@@ -123,7 +124,8 @@ export function defineSocket<T>(name: string, opts: SocketOptions = {}): Socket<
123
124
  silently drift from this construction site (the dispatcher's `send` is already
124
125
  typed; this was the last `msg` frame built through an unchecked JSON.stringify). */
125
126
  const frame: SocketServerFrame = { type: 'msg', socket: name, message: validated }
126
- server.publish(topic, JSON.stringify(frame))
127
+ // ref-json (matching the dispatcher + client) so a message graph with cycles/shared refs fans out intact.
128
+ server.publish(topic, encodeRefJson(frame))
127
129
  }
128
130
  }
129
131
 
@@ -0,0 +1,9 @@
1
+ /*
2
+ Request header the RPC client stamps (value '1') when an `application/json` body is
3
+ ref-json-encoded (encodeRefJson), so the server decodes with the matching codec.
4
+ Its presence is the unambiguous discriminator: ref-json's `[rootValue, slots]`
5
+ envelope collides with a legitimate plain-JSON 2-element array body, so the shape
6
+ alone can't be trusted. Absence means an ordinary JSON body from a non-abide client
7
+ (curl, an OpenAPI-generated SDK, a webhook) — parseArgs reads it with plain JSON.parse.
8
+ */
9
+ export const REF_JSON_HEADER = 'abide-ref-json'
@@ -0,0 +1,31 @@
1
+ /*
2
+ Value tags for the ref-json codec (encodeRefJson / decodeRefJson). The output is
3
+ a `[rootValue, slots]` pair: `slots` holds only the hoisted CONTAINERS (objects,
4
+ arrays, Maps, Sets) addressed by index; every other value is encoded INLINE at its
5
+ position — a bare JSON primitive, or one of these tagged arrays. Primitives never
6
+ get their own slot, so a primitive-heavy payload stays close to plain-JSON size and
7
+ speed. A tag is always the first element of a JSON array at a value position; bare
8
+ JSON values are literal primitives, and plain objects only ever appear AS slots
9
+ (hoisted), so user data — even an object keyed `~r` or an array shaped like a tag —
10
+ can't collide with these. Shared between encoder and decoder so the tokens can't drift.
11
+ */
12
+ export const REF_JSON_TAGS = {
13
+ // ['~r', slotIndex] — reference to a hoisted container; breaks cycles, preserves shared identity.
14
+ REF: '~r',
15
+ // ['~a', ...values]
16
+ ARRAY: '~a',
17
+ // ['~m', [[keyValue, valValue], …]]
18
+ MAP: '~m',
19
+ // ['~s', [value, …]]
20
+ SET: '~s',
21
+ // ['~d', epochMs]
22
+ DATE: '~d',
23
+ // ['~x', source, flags]
24
+ REGEXP: '~x',
25
+ // ['~g', decimalString] — BigInt can't go through JSON natively.
26
+ BIGINT: '~g',
27
+ // ['~u'] — undefined (and functions/symbols, folded to it).
28
+ UNDEFINED: '~u',
29
+ // ['~n', token] — the numbers JSON flattens to null: 'NaN' | 'Infinity' | '-Infinity' | '-0'.
30
+ NUMBER: '~n',
31
+ } as const
@@ -0,0 +1,14 @@
1
+ /*
2
+ Exhaustiveness guard for a discriminated-union dispatch. When every variant is
3
+ handled, the fall-through value narrows to `never`, so passing it here type-checks;
4
+ adding a new variant without a branch makes this a COMPILE error AND, if it slips
5
+ past types, throws at runtime naming the variant — converting a silent mis-dispatch
6
+ (a new template-node kind routed to the wrong branch) into a loud failure. The
7
+ enumeration's completeness becomes the type checker's job instead of inspection's.
8
+ */
9
+ export function assertExhaustive(value: never, context = 'variant'): never {
10
+ const kind = (value as { kind?: unknown })?.kind
11
+ throw new Error(
12
+ `[abide] non-exhaustive ${context}${kind === undefined ? '' : `: ${String(kind)}`}`,
13
+ )
14
+ }
@@ -1,5 +1,7 @@
1
1
  import { carriesBodyArgs } from './carriesBodyArgs.ts'
2
+ import { encodeRefJson } from './encodeRefJson.ts'
2
3
  import { queryStringFromArgs } from './queryStringFromArgs.ts'
4
+ import { REF_JSON_HEADER } from './REF_JSON_HEADER.ts'
3
5
  import type { HttpVerb } from './types/HttpVerb.ts'
4
6
 
5
7
  /*
@@ -49,11 +51,16 @@ export function buildRpcRequest({
49
51
  body: args,
50
52
  })
51
53
  }
54
+ /* ref-json (still valid JSON, so the content-type holds) so an arg graph with
55
+ cycles or shared back-references reaches the handler instead of throwing here.
56
+ The REF_JSON_HEADER flags the encoding so parseArgs decodes it with the same
57
+ codec; a non-abide client omits it and parseArgs reads plain JSON.parse. */
52
58
  requestHeaders.set('content-type', 'application/json')
59
+ requestHeaders.set(REF_JSON_HEADER, '1')
53
60
  return new Request(new URL(url, baseUrl).href, {
54
61
  method,
55
62
  headers: requestHeaders,
56
- body: JSON.stringify(args),
63
+ body: encodeRefJson(args),
57
64
  })
58
65
  }
59
66