JSPM

  • Created
  • Published
  • Downloads 757
  • Score
    100M100P100Q116069F
  • License MIT

Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime

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/deferResume
  • @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 spec — on a single Bun runtime.

abide is a type-safe isomorphic framework: you declare an RPC, a socket, and a .abide component once, and the bundler swaps the runtime per side — the same callable is a direct in-process call on the server and a typed fetch in the browser. Built for humans and machines: every schema-carrying RPC is simultaneously a browser endpoint, a CLI subcommand, an MCP tool, and an OpenAPI operation.

One direct dependency (TypeScript). One runtime (Bun ≥ 1.3.0).

Quick start

# Scaffold a project, install it, and start the dev server (in a TTY).
bunx abide scaffold my-app

# Or explore the full feature set in the kitchen-sink example.
git clone https://github.com/briancray/abide
cd abide/examples/kitchen-sink
bun install
bun run dev

RPCs

An RPC is one export per file under src/server/rpc/. The file path is the URL (under /rpc/), the import (GET/POST/PUT/PATCH/DELETE/HEAD) picks the HTTP method, and an optional inputSchema — any Standard Schema (zod, valibot, arktype, unadapted) — validates the args and projects the MCP tool, the CLI flags, and the OpenAPI operation.

// src/server/rpc/getMessages.ts
import { GET } from '@abide/abide/server/GET'
import { json } from '@abide/abide/server/json'
import { z } from 'zod'

const rooms: Record<string, { id: string; text: string }[]> = {}

// GET /rpc/getMessages?room=lobby
export const getMessages = GET(
    (args: { room: string }) => json(rooms[args.room] ?? []),
    { inputSchema: z.object({ room: z.coerce.string() }) },
)
// src/server/rpc/postMessage.ts
import { POST } from '@abide/abide/server/POST'
import { json } from '@abide/abide/server/json'
import { error } from '@abide/abide/server/error'
import { z } from 'zod'

const rateLimited = error.typed('rateLimited', 429)

// A mutating method never auto-exposes to MCP — opt in explicitly.
export const postMessage = POST(
    (args: { room: string; text: string }) => {
        if (args.text.length > 500) return rateLimited()
        return json({ ok: true })
    },
    { inputSchema: z.object({ room: z.string(), text: z.string() }), clients: { mcp: true } },
)

One declaration, five surfaces:

              export const getMessages = GET(fn, { inputSchema })
                                  │
      ┌────────────┬─────────────┼─────────────┬────────────┐
      ▼            ▼             ▼             ▼            ▼
 cache(fn)()   typed fetch    MCP tool     CLI subcmd   OpenAPI op
  (SSR, warm    (browser,    (read-only   (any schema-  (/openapi
   hydration)    swapped)     GET/HEAD)    carrying)     .json)

A schema unlocks the CLI for any RPC and MCP for read-only methods (GET/HEAD); a mutating method (POST/PUT/PATCH/DELETE) reaches MCP only with an explicit clients: { mcp: true }. Consume an RPC four ways: cache(fn)() in-process (warm SSR + hydration), the swapped fetch in the browser, fn.raw(args) for the underlying Response, and fn.stream(args) to iterate jsonl/sse frames.

Query args travel as strings — coerce them (z.coerce.number()). A per-RPC timeout returns 504 on every surface; it is distinct from the client-wide ABIDE_CLIENT_TIMEOUT.

Sockets

A socket is one broadcast topic per file under src/server/sockets/. A Socket<T> is an isomorphic AsyncIterable<T> — iterate for the live stream, .tail(count) to seed from the retained tail. Every socket multiplexes onto one WebSocket at /__abide/sockets; the server publishes in-process and fans out, the browser proxy sends a pub frame.

// src/server/sockets/messages.ts
import { socket } from '@abide/abide/server/socket'
import { z } from 'zod'

export const messages = socket({
    schema: z.object({ room: z.string(), text: z.string() }),
    tail: 50, // retain the last 50 frames for late joiners / reconnects
    ttl: 60_000, // evict retained frames older than 60s before replay
})

The HTTP face lives at /__abide/sockets/<name>: GET returns the retained tail, POST publishes (gated by clientPublish).

Components

A .abide component is a single file: a leading <script> (module imports plus reactive setup), the template, and an optional component-scoped <style>. This page imports the RPC from §RPCs and the socket from §Sockets and drives them through the whole template grammar.

Reactive state is reached only through scope(). The authoring default is the destructure-once idiom — pull the primitives off scope() at the top of the <script>, then call them bare. computed is read-only; effect is client-only (stripped from SSR). A bare state/computed/linked/effect with no scope() destructure is a compile error.

<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.ts'
import { postMessage } from '$server/rpc/postMessage.ts'
import { messages } from '$server/sockets/messages.ts'
import MessageCard from '$ui/MessageCard.abide'

const { state, computed, linked, effect } = scope()
const { room = 'lobby' } = props()

let draft = state('')
let showRaw = state(false)
let filter = state('all')
const trimmed = computed(() => draft.trim())
const outgoing = linked(() => room + ': ' + trimmed)
const banner = html`<strong>#${room}</strong>`

// client-only reaction — never runs during SSR
effect(() => console.log('draft is now', draft))

// the derived two-way binding target, read down / written up
const get = () => outgoing
const set = (value) => (draft = value)
// mutating RPC from an event handler
const send = () => {
    postMessage({ room, text: trimmed })
    draft = ''
}
</script>

{#snippet chip(label)}
    <span class="rounded bg-slate-100 px-2 text-xs">{label}</span>
{/snippet}

<section class="p-4" attach={(node) => node.focus()} {...{ id: 'chat' }}>
    <h1 class:live={showRaw} style:opacity={showRaw ? 1 : 0.6}>{banner}</h1>

    {#await cache(getMessages)({ room })}
        <p>loading…</p>
    {:then history}
        {#if history.length > 0}
            {#for message, i of history by message.id}
                <MessageCard {...message}>
                    {chip(i)}
                    <p>{message.text}</p>
                </MessageCard>
            {/for}
        {:else if showRaw}
            <p>no history yet</p>
        {:else}
            <p>say hello</p>
        {/if}
    {:catch error}
        <p>failed to load: {error.message}</p>
    {:finally}
        <hr />
    {/await}

    {#for await frame of messages.tail(10)}
        <p class="live-frame">{frame.text}</p>
    {/for}

    {#switch filter}
        {:case 'all'}<p>showing all rooms</p>
        {:case 'mine'}<p>showing my rooms</p>
        {:default}<p></p>
    {/switch}

    {#try}
        {#if showRaw}
            <script>const { computed } = scope()
const label = computed(() => 'raw view active')</script>
            <pre>{label}</pre>
        {/if}
    {:catch error}
        <p>render error: {error.message}</p>
    {:finally}
        <small>rendered</small>
    {/try}

    <form onsubmit={send}>
        <input bind:value={draft} placeholder="message" />
        <label><input type="checkbox" bind:checked={showRaw} /> raw</label>
        <label><input type="radio" bind:group={filter} value="all" /> all</label>
        <label><input type="radio" bind:group={filter} value="mine" /> mine</label>
        <input bind:value={{ get, set }} />
        <button type="submit">send</button>
    </form>
</section>

<style>
    .live { color: teal; }
</style>

Content nested inside a capitalised child component renders where the child calls {children()} — the single slot, with {#if children}…{:else}…{/if} as its fallback (there is no <slot> element and no named slots):

<script>
const { text = '' } = props()
</script>

<article class="card rounded border p-2">
    <p class="font-medium">{text}</p>
    {#if children}{children()}{:else}<em>no body</em>{/if}
</article>

MIT