@abide/abide 0.50.0 → 0.50.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 (4) hide show
  1. package/AGENTS.md +564 -455
  2. package/CHANGELOG.md +6 -0
  3. package/README.md +161 -203
  4. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # abide
2
2
 
3
+ ## 0.50.1
4
+
5
+ ### Patch Changes
6
+
7
+ - ef143f5: regenerate README + AGENTS surface map ([`8665efa`](https://github.com/briancray/abide/commit/8665efa7eb9f4106882cf246738c08e465d7e8d7))
8
+
3
9
  ## 0.50.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -1,103 +1,89 @@
1
1
  # abide
2
2
 
3
- **One typed declaration, every surface: an RPC you write once fans out to HTTP,
4
- a CLI, an MCP tool, and an OpenAPI operation — and the same callable runs
5
- in-process on the server and over fetch in the browser.**
3
+ **One typed declaration, every surface: SSR, browser fetch, MCP, CLI, and OpenAPI from a single Bun runtime.**
6
4
 
7
- abide is an isomorphic multimodal HTTP framework built for humans and machines
8
- in a single Bun runtime: file-path routing, Standard Schema validation, and a
9
- compiled `.abide` UI layer that server-renders, streams, and hydrates the same
10
- components. The bundler swaps each callable's runtime per side — same name,
11
- same behavior, no client/server forks in app code.
5
+ abide is an isomorphic HTTP framework where a typed RPC you declare once fans out to an in-process SSR call, a browser fetch, an MCP tool, a CLI subcommand, and an OpenAPI operation — the bundler swaps the runtime per side, so the same callable behaves the same in-process on the server and over `fetch` in the browser. It is built for humans and machines: the same schema that types your code projects the tool, the flag, and the spec.
12
6
 
13
- - One direct dependency (TypeScript). One runtime (Bun ≥ 1.3).
7
+ - One direct dependency (TypeScript), one runtime (Bun ≥ 1.3.0).
8
+ - No barrels — every public name is its own module path (`@abide/abide/server/GET`, `@abide/abide/ui/state`, …). The namespace marks the side: `server/*` runs server-side, `ui/*` client-side, `shared/*` isomorphic.
14
9
 
15
10
  ## Quick start
16
11
 
17
12
  ```sh
18
- bunx abide scaffold my-app # scaffolds, installs, and starts the dev server
13
+ # Scaffold a project from the bundled template, install it, and start dev.
14
+ bunx abide scaffold my-app
19
15
  ```
20
16
 
21
- Or run the kitchen-sink example, which demos the full surface:
17
+ Or clone the kitchen-sink example, which exercises the whole surface:
22
18
 
23
19
  ```sh
24
20
  git clone https://github.com/briancray/abide
25
- cd abide && bun install
26
- cd examples/kitchen-sink && bun run dev
21
+ cd abide/examples/kitchen-sink
22
+ bun install
23
+ bun run dev
27
24
  ```
28
25
 
29
26
  ## RPCs
30
27
 
31
- An RPC is one exported handler per file under `src/server/rpc/` — the file path
32
- is the URL. The export wraps the handler in an HTTP-method helper (`GET`,
33
- `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`); a Standard Schema `schemas.input`
34
- (zod, valibot, arktype — no adapter) validates the args and projects the CLI
35
- flags, the MCP tool, and the OpenAPI operation from the one declaration.
28
+ An RPC is one export per file under `src/server/rpc/`. The file path is the URL; the declared method helper (`GET`/`POST`/`PUT`/`PATCH`/`DELETE`/`HEAD`) picks the verb. Attach a `schemas.input` (any Standard Schema zod, valibot, or arktype, unadapted) and the same schema validates the args and projects the MCP tool, the CLI flags, and the OpenAPI operation.
36
29
 
37
30
  ```ts
38
31
  // src/server/rpc/getMessages.ts
39
32
  import { GET } from '@abide/abide/server/GET'
40
33
  import { json } from '@abide/abide/server/json'
41
34
  import { z } from 'zod'
42
- import { listMessages } from '$server/store'
35
+ import { load } from '$server/db.ts'
43
36
 
44
37
  export const getMessages = GET(
45
- async ({ room, limit }) => json(await listMessages(room, limit)),
38
+ (args) => json(load(args.channel, args.limit)),
46
39
  {
47
40
  schemas: {
48
41
  input: z.object({
49
- room: z.string(),
50
- limit: z.coerce.number().default(50),
42
+ channel: z.string().default('general'),
43
+ limit: z.number().max(100).default(20),
51
44
  }),
52
45
  },
53
46
  },
54
47
  )
55
48
  ```
56
49
 
57
- That one file fans out:
50
+ One declaration, five surfaces:
58
51
 
59
52
  ```text
60
- src/server/rpc/getMessages.ts
61
-
62
- ├─ SSR / server await getMessages({ room }) in-process, no HTTP
63
- ├─ browser await getMessages({ room }) typed fetch proxy
64
- ├─ HTTP GET /rpc/getMessages?room=…
65
- ├─ CLI my-app get-messages --room …
66
- ├─ MCP tool: get-messages
67
- └─ OpenAPI operation in /openapi.json
53
+ export const getMessages = GET(fn, { schemas })
54
+
55
+ ┌───────────┬──────────────┼──────────────┬─────────────┐
56
+ ▼ ▼ ▼ ▼ ▼
57
+ SSR call browser fetch MCP tool CLI subcmd OpenAPI op
58
+ (bare, (same call, (read-only abide-cli /openapi.json
59
+ in-proc) swap to fetch) from schema) getMessages
68
60
  ```
69
61
 
70
- A schema is the gate: it unlocks the CLI, and for read-only methods
71
- (GET/HEAD) — the MCP tool. A mutating method (POST/PUT/PATCH/DELETE) never
72
- auto-exposes to MCP; it needs an explicit `clients: { mcp: true }`.
73
-
74
- The bare call **is** the smart read — cached, coalesced, reactive,
75
- stale-while-revalidate and it is isomorphic: the same callable resolves
76
- in-process during SSR (its value baked into the HTML for warm hydration) and
77
- over `fetch` in the browser. Around it:
78
-
79
- - `getMessages.raw(args, init?)` the raw `Response`; per-call transport
80
- options (`signal`, `headers`, …) live here
81
- - `getMessages.refresh(args?)` — refetch, keeping the stale value visible
82
- - `getMessages.patch(args?, updater)` mutate the retained value locally,
83
- no network
84
- - `getMessages.peek(args?)` — read the retained value synchronously
85
- - `getMessages.pending(args?)` / `.refreshing(args?)` / `.error(args?)` —
86
- reactive probes
87
- - a handler that returns `jsonl()`/`sse()` makes the bare call return a
88
- `NamedAsyncIterable` you `for await` detected at build time, nothing to declare
89
-
90
- > Query args travel as stringsuse `z.coerce.*` for numbers and booleans on
91
- > GET/HEAD. The per-RPC `timeout` option is a server-side handler deadline
92
- > (504, enforced on every surface); `ABIDE_CLIENT_TIMEOUT` bounds browser calls
93
- > and is separate.
62
+ A schema unlocks the CLI on any RPC, and MCP for read-only methods (`GET`/`HEAD`). A mutating method never auto-exposes to MCP — it opts in explicitly:
63
+
64
+ ```ts
65
+ // src/server/rpc/sendMessage.ts
66
+ import { POST } from '@abide/abide/server/POST'
67
+ import { json } from '@abide/abide/server/json'
68
+ import { z } from 'zod'
69
+ import { append } from '$server/db.ts'
70
+
71
+ export const sendMessage = POST(
72
+ (args) => json(append(args.channel, args.body)),
73
+ {
74
+ schemas: { input: z.object({ channel: z.string(), body: z.string() }) },
75
+ clients: { mcp: true }, // a mutating RPC must opt into MCP by hand
76
+ },
77
+ )
78
+ ```
79
+
80
+ Consume forms are isomorphic. The **bare call `fn(args)` is the smart read** — cached, coalesced, reactive, resolved in-process during SSR and over `fetch` in the browser (there is no `cache()` wrapper; the bare call carries the caching). Alongside it: `fn.raw(args, init?)` for the raw `Response`, and the mutators/probes `fn.refresh()`, `fn.patch(...)`, `fn.peek()`, `fn.pending()`, `fn.refreshing()`, and `fn.error()`. A streaming handler (`jsonl`/`sse`) makes the bare call return a `Subscribable` you iterate.
81
+
82
+ > Query and path args auto-coerce from the endpoint's typed shape a numeric field arrives as a number, no `z.coerce` needed. The per-RPC `timeout` (a 504 on every surface) is distinct from the client-wide `ABIDE_CLIENT_TIMEOUT`.
94
83
 
95
84
  ## Sockets
96
85
 
97
- A socket is one broadcast topic per file under `src/server/sockets/` — the
98
- export name is the topic. A `Socket<T>` is an isomorphic `AsyncIterable<T>`:
99
- server code iterates and broadcasts in-process; browser code gets the same
100
- shape over one multiplexed WebSocket at `/__abide/sockets`.
86
+ A socket is one broadcast topic per file under `src/server/sockets/`. A `Socket<T>` is an isomorphic `AsyncIterable<T>` — the same value you `for await` on both sides — and every socket multiplexes onto one WebSocket at `/__abide/sockets`.
101
87
 
102
88
  ```ts
103
89
  // src/server/sockets/chat.ts
@@ -105,184 +91,156 @@ import { socket } from '@abide/abide/server/socket'
105
91
  import { z } from 'zod'
106
92
 
107
93
  export const chat = socket({
108
- schema: z.object({ author: z.string(), text: z.string() }),
109
- tail: 50, // retain the last 50 frames for late joiners (default 1)
110
- ttl: 60_000, // retained frames expire after a minute
111
- clientPublish: true, // allow browser publishes (off by default)
94
+ schema: z.object({ author: z.string(), body: z.string() }),
95
+ tail: 20, // retain the last 20 frames for late joiners and reconnects
96
+ ttl: 60_000, // evict retained frames older than 60s
112
97
  })
113
98
  ```
114
99
 
115
- `chat.publish(msg)` publishes from either side schema-validated on the
116
- server, and client publishes are gated by `clientPublish`. `for await (const
117
- msg of chat)` is the live stream; `chat.peek()` reads the latest retained
118
- frame; `chat.refresh()` re-pulls the server tail after a reconnect. A schema
119
- also advertises the topic to MCP and the CLI.
120
-
121
- Every exposed socket has an HTTP face at `/__abide/sockets/<name>`: `GET`
122
- returns the retained tail as JSON (or a live SSE stream under
123
- `Accept: text/event-stream`), `POST` publishes — gated by `clientPublish`.
100
+ Publish with `chat.publish(frame)`; seed a late reader with `chat.tail(count)`. Each socket also has an HTTP face at `/__abide/sockets/<name>`: `GET` returns the retained tail, and `POST` publishes when `clientPublish` is set.
124
101
 
125
102
  ## Components
126
103
 
127
- Pages are `.abide` single-file components under `src/ui/pages/` (`page.abide` /
128
- `layout.abide`; a `[id]` folder becomes a route param read via `props()`). A
129
- component is HTML plus `{expr}` bindings and `{#…}` control-flow blocks.
130
- Reactive state comes from imported primitives the compiler lowers, so inside a
131
- component you read and write state as plain variables. The same file renders on
132
- the server — blocking awaits inline, streaming awaits out of order, cached
133
- reads seeded warm — and hydrates in the browser.
134
-
135
- Nested content becomes the component's `children` prop — an ordinary declared
136
- prop of type `Snippet` — and renders wherever the component calls
137
- `{children()}`:
104
+ A `.abide` component is HTML with a leading `<script>`. The example below is one page that imports the RPC and socket above and exercises the whole template grammar. Reactive primitives are imported by their own module paths and called bare: `state(0)` is a writable cell (read/write via `.value`), `state.computed(fn)` is read-only derived, `state.linked(fn)` is writable and reseeded from a thunk. `watch(source, handler)` is the single reaction primitive — over a cell, a socket, or an RPC. `props()` is the ambient prop reader (no import).
138
105
 
139
106
  ```html
140
107
  <script>
141
- import { props } from '@abide/abide/ui/props'
142
- import type { Snippet } from '@abide/abide/shared/snippet'
108
+ import { getMessages } from '$server/rpc/getMessages.ts'
109
+ import { sendMessage } from '$server/rpc/sendMessage.ts'
110
+ import { chat } from '$server/sockets/chat.ts'
111
+ import MessageCard from '$ui/components/MessageCard.abide'
112
+ import { state } from '@abide/abide/ui/state'
113
+ import { watch } from '@abide/abide/ui/watch'
114
+ import { html } from '@abide/abide/ui/html'
143
115
 
144
- const { title, children } = props<{ title: string; children?: Snippet }>()
145
- </script>
116
+ const { title = 'Chat', ...rest } = props()
146
117
 
147
- <section>
148
- <h2>{title}</h2>
149
- {#if children}{children()}{:else}<p>Nothing here yet.</p>{/if}
150
- </section>
118
+ let channel = state('general')
119
+ let draft = state('')
120
+ let pinned = state(false)
121
+ let limit = state(20)
151
122
 
152
- <style>
153
- section {
154
- border: 1px solid gray;
155
- }
156
- </style>
157
- ```
123
+ let trimmed = state.computed(() => draft.value.trim())
124
+ let live = state.linked(() => limit.value)
158
125
 
159
- And one page tying together the RPC from above, the socket, and the whole
160
- template grammar:
126
+ const badge = html`<sup class="ml-1 text-xs text-emerald-600">live</sup>`
161
127
 
162
- ```html
163
- <script>
164
- import { getMessages } from '$server/rpc/getMessages'
165
- import { sendMessage } from '$server/rpc/sendMessage' // POST, declared like getMessages
166
- import { chat } from '$server/sockets/chat'
167
- import { state } from '@abide/abide/ui/state'
168
- import { watch } from '@abide/abide/ui/watch'
169
- import { html } from '@abide/abide/ui/html'
170
- import { props } from '@abide/abide/ui/props'
171
- import Panel from '$ui/components/Panel.abide'
172
-
173
- const { room = 'lobby' } = props<{ room?: string }>()
174
-
175
- let draft = state('')
176
- let author = state('anon')
177
- let tone = state('friendly')
178
- let notify = state(true)
179
- let volume = state(5, (next, previous) => (Number.isFinite(next) ? next : previous))
180
- let shout = state.computed(() => draft.toUpperCase())
181
- let roomDraft = state.linked(() => room)
182
-
183
- // watch is the one reaction primitive: a socket, an rpc, or a tracked thunk.
184
- watch(chat, () => getMessages.refresh({ room }))
185
- watch(() => console.log(author, tone))
186
-
187
- // a writable computed lives at the binding: bind:value={{ get, set }}
188
- const get = () => draft.trim()
189
- const set = (next) => (draft = next)
190
-
191
- const focus = (element) => element.focus()
192
- const panelProps = { title: 'Live feed' }
193
- const draftAttrs = { placeholder: 'say something', autocomplete: 'off' }
194
-
195
- async function send() {
196
- await sendMessage({ room: roomDraft, author, text: draft })
197
- draft = ''
198
- }
199
- </script>
128
+ watch(trimmed, (value) => console.log('draft is now', value))
129
+ watch(chat, (frame) => {
130
+ live.value = live.value + 1
131
+ })
132
+
133
+ async function submit(event) {
134
+ event.preventDefault()
135
+ if (trimmed.value === '') return
136
+ await sendMessage({ channel: channel.value, body: trimmed.value })
137
+ draft.value = ''
138
+ }
139
+
140
+ function autofocus(node) {
141
+ node.focus()
142
+ return () => {}
143
+ }
200
144
 
201
- {#snippet line(message)}
202
- <li>{message.author}: {message.text}</li>
203
- {/snippet}
145
+ const rowProps = { class: 'flex gap-2' }
204
146
 
205
- <Panel {...panelProps}>
206
- <h1 class:muted={!notify} style:opacity={notify ? '1' : '0.6'}>
207
- {room} {shout}
208
- </h1>
147
+ // Derived two-way binding: read a string, coerce writes back into the numeric cell.
148
+ const get = () => String(limit.value)
149
+ const set = (next) => (limit.value = Number(next))
150
+ </script>
151
+
152
+ <section {...rest} class:pinned={pinned.value} style:opacity={pinned.value ? '1' : '0.85'}>
153
+ <h1>{title} {badge}</h1>
209
154
 
210
- <!-- {await expr} awaits inline (blocking); a Promise-typed {expr} would stream -->
211
- <p>total messages: {await getMessages({ room }).then((all) => all.length)}</p>
155
+ <form onsubmit={submit}>
156
+ <input bind:value={draft} attach={autofocus} placeholder="Say something" />
157
+ <label><input type="checkbox" bind:checked={pinned} /> pin</label>
158
+ <label><input type="radio" bind:group={channel} value="general" /> general</label>
159
+ <label><input type="radio" bind:group={channel} value="random" /> random</label>
160
+ <input bind:value={{ get, set }} />
161
+ <button type="submit">Send</button>
162
+ </form>
212
163
 
213
- {#await getMessages({ room })}
214
- <p>loading…</p>
164
+ {#snippet row(message, index)}
165
+ <MessageCard {...rowProps} name={message.author} onclick={() => console.log(index)}>
166
+ <p>{message.body}</p>
167
+ </MessageCard>
168
+ {/snippet}
169
+
170
+ {#if live.value > 100}
171
+ <p>Busy channel</p>
172
+ {:else if live.value > 0}
173
+ <p>{live.value} updates</p>
174
+ {:else}
175
+ <script>
176
+ let seenAt = state(Date.now())
177
+ let ageLabel = state.computed(() => `waiting since ${seenAt.value}`)
178
+ </script>
179
+ <style>
180
+ p { color: gray; }
181
+ </style>
182
+ <p>{ageLabel.value}</p>
183
+ {/if}
184
+
185
+ {#await getMessages({ limit: limit.value })}
186
+ <p>Loading…</p>
215
187
  {:then messages}
216
- {#if messages.length === 0}
217
- <p>No messages yet.</p>
218
- {:else if messages.length < 100}
219
- <ul>
220
- {#for message, i of messages by message.id}
221
- {line(message)}
222
- {/for}
223
- </ul>
224
- {:else}
225
- <script>
226
- // branch-local state, re-seeded each time this branch mounts
227
- let offset = state(0)
228
- </script>
229
- <p>huge room — showing from {offset}</p>
230
- <button onclick={() => (offset = offset + 100)}>next</button>
231
- <style>
232
- p {
233
- font-variant-numeric: tabular-nums;
234
- }
235
- </style>
236
- {/if}
237
- {:catch error}
238
- <p>failed to load: {error.message}</p>
188
+ {#for message, i of messages by message.id}
189
+ {row(message, i)}
190
+ {/for}
191
+ {:catch problem}
192
+ <p>Failed: {problem.message}</p>
239
193
  {:finally}
240
- <p>checked just now</p>
194
+ <hr />
241
195
  {/await}
242
196
 
243
- <ol>
244
- {#for await message of chat}
245
- {line(message)}
246
- {/for}
247
- </ol>
197
+ {#for await frame of chat}
198
+ <p class="text-sm opacity-70">{frame.author}: {frame.body}</p>
199
+ {/for}
248
200
 
249
- {#switch tone}
250
- {:case 'friendly'}
251
- <p>{html`<em>be&nbsp;kind</em>`}</p>
201
+ {#switch channel.value}
202
+ {:case 'general'}
203
+ <span>General channel</span>
204
+ {:case 'random'}
205
+ <span>Random channel</span>
252
206
  {:default}
253
- <p>say anything</p>
207
+ <span>Unknown channel</span>
254
208
  {/switch}
255
209
 
256
210
  {#try}
257
- <p>{JSON.parse(draft).summary}</p>
211
+ <MessageCard name="system">
212
+ <p>System notice</p>
213
+ </MessageCard>
258
214
  {:catch}
259
- <p>draft isn't JSON yet</p>
215
+ <p>Card crashed</p>
216
+ {:finally}
217
+ <span class="sr-only">done</span>
260
218
  {/try}
261
-
262
- <form onsubmit={(event) => { event.preventDefault(); send() }}>
263
- <input attach={focus} bind:value={draft} {...draftAttrs} />
264
- <input bind:value={{ get, set }} />
265
- <label><input type="checkbox" bind:checked={notify} /> notify</label>
266
- <label><input type="radio" bind:group={tone} value="friendly" /> friendly</label>
267
- <label><input type="radio" bind:group={tone} value="blunt" /> blunt</label>
268
- <input type="range" bind:value={volume} min="0" max="10" />
269
- <select bind:value={author}>
270
- <option>anon</option>
271
- <option>me</option>
272
- </select>
273
- <button disabled={draft === ''}>send</button>
274
- </form>
275
- </Panel>
219
+ </section>
276
220
 
277
221
  <style>
278
- form {
279
- display: flex;
222
+ section {
223
+ display: grid;
280
224
  gap: 0.5rem;
281
225
  }
282
- .muted {
283
- color: gray;
284
- }
285
226
  </style>
286
227
  ```
287
228
 
229
+ The capitalised `MessageCard` renders its passed content where it calls `{children()}`. The `<slot>` element was removed — `{children()}` is the single fill point, and `{#if children}{children()}{:else}…{/if}` is the fallback form.
230
+
231
+ ```html
232
+ <script>
233
+ const { name, ...rest } = props()
234
+ </script>
235
+
236
+ <article {...rest}>
237
+ <strong>{name}</strong>
238
+ {#if children}
239
+ {children()}
240
+ {:else}
241
+ <em>No content</em>
242
+ {/if}
243
+ </article>
244
+ ```
245
+
288
246
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.50.0",
3
+ "version": "0.50.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",