Package Exports
- @abide/abide/build
- @abide/abide/bundle/BundleMenu
- @abide/abide/bundle/BundleMenuItem
- @abide/abide/bundle/BundleWindow
- @abide/abide/bundle/bundled
- @abide/abide/bundle/onMenu
- @abide/abide/compile
- @abide/abide/mcp/createMcpServer
- @abide/abide/preload
- @abide/abide/resolver-plugin
- @abide/abide/server/AppModule
- @abide/abide/server/DELETE
- @abide/abide/server/GET
- @abide/abide/server/HEAD
- @abide/abide/server/InspectorContext
- @abide/abide/server/PATCH
- @abide/abide/server/POST
- @abide/abide/server/PUT
- @abide/abide/server/agent
- @abide/abide/server/appDataDir
- @abide/abide/server/cookies
- @abide/abide/server/env
- @abide/abide/server/error
- @abide/abide/server/json
- @abide/abide/server/jsonl
- @abide/abide/server/prompts/definePrompt
- @abide/abide/server/prompts/renderPromptTemplate
- @abide/abide/server/reachable
- @abide/abide/server/redirect
- @abide/abide/server/request
- @abide/abide/server/rpc/defineRpc
- @abide/abide/server/server
- @abide/abide/server/socket
- @abide/abide/server/sockets/defineSocket
- @abide/abide/server/sse
- @abide/abide/shared/HttpError
- @abide/abide/shared/ValidationErrorData
- @abide/abide/shared/cache
- @abide/abide/shared/createSubscriber
- @abide/abide/shared/health
- @abide/abide/shared/log
- @abide/abide/shared/online
- @abide/abide/shared/page
- @abide/abide/shared/pending
- @abide/abide/shared/refreshing
- @abide/abide/shared/snippet
- @abide/abide/shared/trace
- @abide/abide/shared/url
- @abide/abide/shared/withJsonSchema
- @abide/abide/test/assertAgentFrameConformance
- @abide/abide/test/createScriptedSurface
- @abide/abide/test/createTestApp
- @abide/abide/tsconfig
- @abide/abide/ui-plugin
- @abide/abide/ui/dom/anchorCursor
- @abide/abide/ui/dom/appendSnippet
- @abide/abide/ui/dom/appendStatic
- @abide/abide/ui/dom/appendText
- @abide/abide/ui/dom/appendTextAt
- @abide/abide/ui/dom/applyResolved
- @abide/abide/ui/dom/attach
- @abide/abide/ui/dom/attr
- @abide/abide/ui/dom/awaitBlock
- @abide/abide/ui/dom/cloneStatic
- @abide/abide/ui/dom/each
- @abide/abide/ui/dom/eachAsync
- @abide/abide/ui/dom/hydrate
- @abide/abide/ui/dom/mergeProps
- @abide/abide/ui/dom/mount
- @abide/abide/ui/dom/mountChild
- @abide/abide/ui/dom/mountSlot
- @abide/abide/ui/dom/on
- @abide/abide/ui/dom/outlet
- @abide/abide/ui/dom/readCall
- @abide/abide/ui/dom/restProps
- @abide/abide/ui/dom/skeleton
- @abide/abide/ui/dom/spreadAttrs
- @abide/abide/ui/dom/spreadProps
- @abide/abide/ui/dom/switchBlock
- @abide/abide/ui/dom/text
- @abide/abide/ui/dom/tryBlock
- @abide/abide/ui/dom/when
- @abide/abide/ui/effect
- @abide/abide/ui/enterScope
- @abide/abide/ui/exitScope
- @abide/abide/ui/html
- @abide/abide/ui/navigate
- @abide/abide/ui/outbox
- @abide/abide/ui/remoteProxy
- @abide/abide/ui/renderToStream
- @abide/abide/ui/router
- @abide/abide/ui/runtime/enterRenderPass
- @abide/abide/ui/runtime/escapeKey
- @abide/abide/ui/runtime/exitRenderPass
- @abide/abide/ui/runtime/nextBlockId
- @abide/abide/ui/scope
- @abide/abide/ui/socketProxy
- @abide/abide/ui/startClient
- @abide/abide/ui/tail
Readme
abide
One typed declaration fans out to HTTP, a CLI, an MCP tool, and an OpenAPI operation — and the same callable runs on the server and in the browser.
abide is an isomorphic, multimodal HTTP framework for a single Bun runtime: you declare a typed RPC once and it becomes a browser fetch, an in-process SSR call, an MCP tool, a CLI subcommand, and an OpenAPI operation; the bundler swaps the runtime per side so the same name behaves the same on both. Built for humans and machines.
- One direct dependency (TypeScript). One runtime (Bun ≥ 1.3).
.abidesingle-file components render on the server and hydrate in place.
Quick start
bunx abide scaffold my-app # scaffolds, installs, and starts the dev serverOr clone the kitchen-sink example:
git clone https://github.com/briancray/abide
cd abide/examples/kitchen-sink
bun install
bun run devRPCs
An RPC is one export under src/server/rpc/. The file path is the URL; the
export's schema validates the args and projects the MCP tool, the CLI flags, and
the OpenAPI operation. The schema contract is Standard Schema — pass a zod,
valibot, or arktype schema directly, unadapted.
// src/server/rpc/getMessages.ts
import { GET } from '@abide/abide/server/GET'
import { json } from '@abide/abide/server/json'
import { z } from 'zod'
export const getMessages = GET(
async ({ channel }) => json(await db.recentMessages(channel)),
{ inputSchema: z.object({ channel: z.coerce.string() }) },
)One declaration, every surface:
export const getMessages = GET(fn, { inputSchema })
│
┌───────────────┬─────────────┼─────────────┬───────────────┐
▼ ▼ ▼ ▼ ▼
SSR call browser fetch MCP tool CLI command OpenAPI op
cache(fn)() typed proxy (read-only) getMessages … /openapi.json
(in-process) (swapped fn())A schema unlocks the CLI for any method and auto-exposes read-only methods
(GET/HEAD) as MCP tools; a mutating method (POST/PUT/PATCH/DELETE)
never auto-exposes to MCP — it needs an explicit clients: { mcp: true }.
Consume the RPC four ways: cache(getMessages)({ channel }) in-process during
SSR, the swapped fetch proxy in the browser, getMessages.raw(args) for the
underlying Response, and getMessages.stream(args) for a frame iterable.
Query args travel as strings — validate with
z.coerce.*. A per-RPCtimeout(504 on every surface) is distinct from the client-sideABIDE_CLIENT_TIMEOUT.
Sockets
A socket is one broadcast topic per file under src/server/sockets/. A
Socket<T> is an isomorphic AsyncIterable<T> — for await (const m of chat)
is the live stream — and every socket multiplexes onto one WebSocket at
/__abide/sockets.
// src/server/sockets/chat.ts
import { socket } from '@abide/abide/server/socket'
import { z } from 'zod'
export const chat = socket({
schema: z.object({ id: z.string(), author: z.string(), text: z.string() }),
tail: 50, // retain the last 50 frames for replay
ttl: 60_000, // evict retained frames older than 60s
})Each socket also has an HTTP face at /__abide/sockets/<name>: GET returns the
retained tail, POST publishes a frame (gated by clientPublish).
Components
A .abide component renders on the server and hydrates in the browser. The page
below imports the RPC and the socket above and exercises the whole template
grammar. Reactive state is reached through scope() — the documented default is
to destructure it once at the top of the <script> and call the primitives bare.
<script>
import { cache } from '@abide/abide/shared/cache'
import { tail } from '@abide/abide/ui/tail'
import { html } from '@abide/abide/ui/html'
import { getMessages } from '$server/rpc/getMessages'
import { sendMessage } from '$server/rpc/sendMessage'
import { chat } from '$server/sockets/chat'
import Card from '$ui/Card.abide'
import Message from '$ui/Message.abide'
import Risky from '$ui/Risky.abide'
// props() is the ambient prop reader — no import; destructure with defaults + rest
const { channel = 'general', ...rest } = props()
// destructure-once: reach every reactive primitive through scope(), then call bare
const { state, computed, linked, effect } = scope()
let draft = state('')
let count = state(0)
let muted = state(false)
let topic = state('all')
const doubled = computed(() => count * 2)
const banner = linked(() => `# ${channel}`)
// live reads off the §3 socket: latest frame, plus a window of the last 20
const latest = tail(chat)
const recent = tail(chat, { last: 20 })
// effect runs client-only (stripped from SSR)
effect(() => {
document.title = `${channel} (${count})`
})
// an event handler calling a mutating RPC
async function send() {
await sendMessage({ channel, text: draft })
draft = ''
count = count + 1
}
// the derived two-way binding's accessors
const get = () => count
const set = (value) => (count = Number(value))
// an attachment: runs against the element at mount
const focus = (node) => node.focus()
</script>
<main {...rest}>
<h1 class:muted={muted} style:opacity={muted ? 0.5 : 1}>{banner}</h1>
<p>seen {count} — doubled {doubled}</p>
<p>{html`<em>latest:</em> ${latest?.text ?? '—'}`}</p>
<form onsubmit={send}>
<input bind:value={draft} attach={focus} placeholder="message" />
<label><input type="checkbox" bind:checked={muted} /> mute</label>
<label><input type="radio" bind:group={topic} value="all" /> all</label>
<label><input type="radio" bind:group={topic} value="mine" /> mine</label>
<input type="range" min="0" max="50" bind:value={{ get, set }} />
<button>send</button>
</form>
{#snippet row(message)}
<li>{html`<strong>${message.author}</strong>`}: {message.text}</li>
{/snippet}
{#if recent.length === 0}
<p>No messages yet.</p>
{:else if muted}
<p>Muted — {recent.length} hidden.</p>
<script>
// a nested script scoped to this branch — its own scope(), no imports
const { computed } = scope()
const note = computed(() => `${recent.length} while muted`)
</script>
<small>{note}</small>
<style>
small { color: gray; }
</style>
{:else}
<ul>
{#for message, i of recent by message.id}
<Card title={`#${i}`}>{row(message)}</Card>
{/for}
</ul>
{/if}
<h2>filter</h2>
{#switch topic}
{:case 'all'}<p>showing everything</p>
{:case 'mine'}<p>showing mine</p>
{:default}<p>unknown filter</p>
{/switch}
<h2>history</h2>
{#await cache(getMessages)({ channel })}
<p>loading…</p>
{:then list}
<ul>
{#for entry of list by entry.id}
<Message {...entry} />
{/for}
</ul>
{:catch error}
<p>failed: {error.message}</p>
{:finally}
<hr />
{/await}
<h2>live</h2>
<ul>
{#for await frame of chat}
<li>{frame.text}</li>
{/for}
</ul>
{#try}
<Risky />
{:catch error}
<p>widget crashed: {error.message}</p>
{:finally}
<span>boundary settled</span>
{/try}
</main>
<style>
main { font-family: system-ui; }
.muted { text-decoration: line-through; }
</style>A capitalised tag is a child component; the content nested inside it renders
where the child calls {children()} (the <slot> element was removed — there
are no named slots). {#if children}…{:else}…{/if} is the fallback form:
<script>
const { title = 'Card' } = props()
</script>
<section class="card">
<h3>{title}</h3>
{#if children}{children()}{:else}<p>empty</p>{/if}
</section>
<style>
.card { border: 1px solid #ddd; }
</style>MIT