@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.
- package/AGENTS.md +564 -455
- package/CHANGELOG.md +6 -0
- package/README.md +161 -203
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -1,103 +1,89 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
-
**One typed declaration, every surface:
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
26
|
-
|
|
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
|
|
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 {
|
|
35
|
+
import { load } from '$server/db.ts'
|
|
43
36
|
|
|
44
37
|
export const getMessages = GET(
|
|
45
|
-
|
|
38
|
+
(args) => json(load(args.channel, args.limit)),
|
|
46
39
|
{
|
|
47
40
|
schemas: {
|
|
48
41
|
input: z.object({
|
|
49
|
-
|
|
50
|
-
limit: z.
|
|
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
|
-
|
|
50
|
+
One declaration, five surfaces:
|
|
58
51
|
|
|
59
52
|
```text
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
> Query args
|
|
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
|
|
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(),
|
|
109
|
-
tail:
|
|
110
|
-
ttl: 60_000, // retained frames
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
</script>
|
|
116
|
+
const { title = 'Chat', ...rest } = props()
|
|
146
117
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
118
|
+
let channel = state('general')
|
|
119
|
+
let draft = state('')
|
|
120
|
+
let pinned = state(false)
|
|
121
|
+
let limit = state(20)
|
|
151
122
|
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
160
|
-
template grammar:
|
|
126
|
+
const badge = html`<sup class="ml-1 text-xs text-emerald-600">live</sup>`
|
|
161
127
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
{
|
|
202
|
-
<li>{message.author}: {message.text}</li>
|
|
203
|
-
{/snippet}
|
|
145
|
+
const rowProps = { class: 'flex gap-2' }
|
|
204
146
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
{#
|
|
214
|
-
<
|
|
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
|
-
{#
|
|
217
|
-
|
|
218
|
-
{
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
<
|
|
194
|
+
<hr />
|
|
241
195
|
{/await}
|
|
242
196
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
250
|
-
{:case '
|
|
251
|
-
<
|
|
201
|
+
{#switch channel.value}
|
|
202
|
+
{:case 'general'}
|
|
203
|
+
<span>General channel</span>
|
|
204
|
+
{:case 'random'}
|
|
205
|
+
<span>Random channel</span>
|
|
252
206
|
{:default}
|
|
253
|
-
<
|
|
207
|
+
<span>Unknown channel</span>
|
|
254
208
|
{/switch}
|
|
255
209
|
|
|
256
210
|
{#try}
|
|
257
|
-
<
|
|
211
|
+
<MessageCard name="system">
|
|
212
|
+
<p>System notice</p>
|
|
213
|
+
</MessageCard>
|
|
258
214
|
{:catch}
|
|
259
|
-
<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
|
-
|
|
279
|
-
display:
|
|
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
|