Package Exports
- @belte/belte/browser/navigate
- @belte/belte/browser/page
- @belte/belte/browser/remoteProxy
- @belte/belte/browser/socketProxy
- @belte/belte/browser/tail
- @belte/belte/build
- @belte/belte/bundle/BundleMenu
- @belte/belte/bundle/BundleMenuItem
- @belte/belte/bundle/BundleWindow
- @belte/belte/bundle/bundled
- @belte/belte/bundle/onMenu
- @belte/belte/compile
- @belte/belte/mcp/createMcpServer
- @belte/belte/preload
- @belte/belte/resolver-plugin
- @belte/belte/server/AppModule
- @belte/belte/server/DELETE
- @belte/belte/server/GET
- @belte/belte/server/HEAD
- @belte/belte/server/PATCH
- @belte/belte/server/POST
- @belte/belte/server/PUT
- @belte/belte/server/agent
- @belte/belte/server/appDataDir
- @belte/belte/server/cookies
- @belte/belte/server/env
- @belte/belte/server/error
- @belte/belte/server/json
- @belte/belte/server/jsonl
- @belte/belte/server/prompts/definePrompt
- @belte/belte/server/prompts/renderPromptTemplate
- @belte/belte/server/redirect
- @belte/belte/server/request
- @belte/belte/server/rpc/defineVerb
- @belte/belte/server/server
- @belte/belte/server/socket
- @belte/belte/server/sockets/defineSocket
- @belte/belte/server/sse
- @belte/belte/shared/HttpError
- @belte/belte/shared/cache
- @belte/belte/shared/log
- @belte/belte/shared/pending
- @belte/belte/shared/refreshing
- @belte/belte/shared/url
- @belte/belte/shared/withJsonSchema
- @belte/belte/svelte-plugin
- @belte/belte/test/assertAgentFrameConformance
- @belte/belte/test/clearVerbRegistry
- @belte/belte/test/createScriptedSurface
- @belte/belte/test/createTestClient
- @belte/belte/tsconfig
Readme
belte
Write one function. Get a web app, a CLI, and an AI tool — from the same line of code.
belte is an isomorphic, multimodal HTTP framework for Bun and Svelte. You declare a function once; the bundler swaps the runtime per target, so the same callable renders on the server, fetches from the browser, and exposes itself to machines.
// src/server/rpc/getProduct.ts — file path is the URL, export is the verb
import { GET } from '@belte/belte/server/GET'
import { json } from '@belte/belte/server/json'
import { z } from 'zod'
export const getProduct = GET(({ id }) => json(products.find(id)), {
inputSchema: z.object({ id: z.string() }),
})That one declaration fans out across every surface — each line a real consume form:
getProduct
├─ cache(getProduct)({ id }) SSR render + browser fetch (one call)
├─ GET /rpc/getProduct?id=42 HTTP endpoint + OpenAPI operation
├─ mcp__app__getProduct { id } MCP tool (read-only schema → auto)
└─ app getProduct --id 42 CLI subcommandDEBUG=belte prints the boot surface map — every page, socket, and rpc with the surfaces it reaches:
pages:
page layout error
/ / ·
/product/[id] / /
sockets:
socket schema browser mcp cli publish
chat ✓ ✓ ✓ ✓ ·
rpcs:
http schema browser mcp cli
GET /rpc/getProduct ✓ ✓ ✓ ✓
POST /rpc/createOrder ✓ ✓ · ✓A schema gates the machine surfaces: without one a verb is browser-only. A read-only verb with a schema auto-exposes to MCP and CLI; a mutation never auto-exposes to MCP — it needs explicit clients: { mcp: true }.
- Zero runtime dependencies.
- One runtime — Bun (
>=1.3.0); Svelte 5 is the only required peer.
bunx @belte/belte scaffold my-app # scaffolds, installs, and starts the dev serverLayout
Imports come from three namespaces, each marking the side a name runs on: @belte/belte/server/* (server-only — GET, json, socket, request, env, agent, appDataDir), @belte/belte/browser/* (client — page, navigate, tail), and @belte/belte/shared/* (isomorphic — cache, pending, refreshing, HttpError, url, withJsonSchema, log). There is no umbrella entry point; every name has its own path. shared/* is an import namespace, not a project directory.
A project:
src/
app.ts lifecycle hooks (all optional — see Reference)
server/
config.ts env(schema) — typed Bun.env, validated at boot
rpc/getProduct.ts one verb per file; file path = URL, export = method
sockets/chat.ts one socket per file
browser/
pages/ page.svelte / layout.svelte / error.svelte
public/ static files, served at the site root
mcp/
prompts/greet.md MCP prompts (frontmatter + template body)
resources/ MCP resources (served from disk or embedded)
shared/ your isomorphic code (the $shared/* alias)
cli/ bundle/ optional CLI help chrome + desktop bundle configrpc
One named export per file under src/server/rpc/, bound to an HTTP verb: GET, POST, PUT, PATCH, DELETE, HEAD. The export name is the function; the file path (under /rpc/) is the URL.
import { POST } from '@belte/belte/server/POST'
import { json } from '@belte/belte/server/json'
export const createOrder = POST(({ items }) => json(db.orders.create(items)), {
inputSchema: orderSchema,
})| option | type | default | meaning |
|---|---|---|---|
inputSchema |
Standard Schema | — | validates args (422 on failure); feeds OpenAPI / MCP / CLI |
outputSchema |
Standard Schema | — | success-body schema for the OpenAPI 200 + MCP outputSchema |
filesSchema |
Standard Schema | — | validates multipart File parts (kept off the JSON-Schema projection) |
clients |
{ browser, mcp, cli } |
see below | which surfaces expose the verb |
crossOrigin |
boolean | false |
exempt a mutating verb from the same-origin gate |
clients defaults per declaration: browser: true always; cli: true when a schema is present; mcp: true only when a schema is present and the method is read-only (GET/HEAD). Explicit values always win.
| consume | result |
|---|---|
getProduct(args) |
decoded body; throws HttpError on non-2xx |
getProduct.raw(args) |
the underlying Response (status / headers / streaming) |
getProduct.stream(args) |
a Subscribable for tail() to read frame-by-frame |
Query args ride the URL as strings — a
GET/DELETEverb that expects numbers or booleans must coerce in its schema (e.g.z.coerce.number()); a barez.number()rejects the string.
Any Standard Schema library works (zod, valibot, arktype). JSON Schema is projected from each schema's own toJSONSchema(); wrap a schema whose library lacks one with withJsonSchema(schema, toJsonSchema).
Response helpers
@belte/belte/server/* — each returns a TypedResponse<T> so the verb infers its return type from the handler body.
| helper | returns |
|---|---|
json(data, init?) |
JSON body |
error(status, message?, init?) |
text/plain error; message defaults to the status reason phrase |
redirect(url, status=302, init?) |
3xx with Location; accepts relative URLs |
jsonl(asyncIterable, init?) |
newline-delimited JSON stream (tail(fn.stream()) reads it) |
sse(asyncIterable, init?) |
Server-Sent Events stream with keep-alive |
All default Cache-Control: no-store (override via init.headers); the streaming pair also sets X-Content-Type-Options: nosniff.
Request scope
Server-only, resolved through an AsyncLocalStorage store; each throws if called outside a request (SSR render or rpc handler).
| call | from @belte/belte/server/* |
gives |
|---|---|---|
request() |
request |
the inbound Request |
cookies() |
cookies |
Bun CookieMap — read inbound, set/delete flush as Set-Cookie |
server() |
server |
the live Bun.serve instance (no-op shim for in-process CLI/MCP/test calls) |
When SSR or an MCP call invokes a verb in-process, only an allowlist is forwarded onto the synthesized request —
cookie,authorization, and thex-forwarded-*hints. A handler that reads any other inbound header (e.g.accept-language, a trace id) sees nothing unless you list it viaforwardHeadersinsrc/app.ts.
Security defaults
- A browser request whose
Origindoesn't match the app's host is refused 403 on every mutating verb (anything butGET/HEAD);crossOrigin: trueopts a verb out. Origin-less native clients (curl, CLI, MCP) always pass. - The
/__belte/mcpmount and the socket publish face get the same Origin check. - Boot prints a warning when MCP tools are exposed with no
app.handleto authenticate machine clients.
// src/app.ts — single middleware; auth, rewrite, or branch before the handler runs
export async function handle(request: Request, next: (req: Request) => Promise<Response>) {
if (!authorized(request)) return new Response('Unauthorized', { status: 401 })
return next(request)
}The Origin check compares against the request's own host, so a TLS-terminating proxy must forward the original
Hostheader (e.g. nginxproxy_set_header Host $host).
Sockets
One export per file under src/server/sockets/. The socket is the live AsyncIterable; publish is isomorphic (server-side it fans out; client-side it sends a frame the server validates).
import { socket } from '@belte/belte/server/socket'
export const chat = socket<ChatMessage>({ tail: 50, schema: chatSchema })| option | type | default | meaning |
|---|---|---|---|
tail |
number | 0 |
retain the last N frames so late joiners / reconnects can seed |
ttl |
number | — | evict retained frames older than ttl ms before replay |
clientPublish |
boolean | false |
accept publishes from clients over the wire |
schema |
Standard Schema | — | validate payloads; unlocks the MCP/CLI surfaces |
clients |
{ browser, mcp, cli } |
browser-only, or all when a schema is present | which surfaces expose it |
chat.publish(msg) broadcasts; for await (const msg of chat) iterates live. On the client, read it through tail() (below). A schema-bearing socket exposes a <name>-tail MCP/CLI read tool, plus <name>-publish when clientPublish is set.
cache
cache(fn, options?) returns an invoker that dedupes identical in-flight calls and (with ttl) retains the result. Reactive: a read inside a $derived/$effect re-runs when its key is invalidated.
import { cache } from '@belte/belte/shared/cache'
const product = await cache(getProduct)({ id }) // cached read
await cache(createOrder, { ttl: 0 })({ items }) // dedupe-only mutation idiom| option | type | meaning |
|---|---|---|
ttl |
number | undefined → forever; 0 → dedupe only; > 0 → expire N ms after resolve |
global |
boolean | store process-wide instead of per-request (memoise an external endpoint) |
scope |
string | string[] | tag entries so cache.invalidate({ scope }) can target a group |
invalidate |
{ throttle } | { debounce } |
stale-while-revalidate refetch policy (rejected on a write method) |
cache.invalidate(selector?) drops matching entries (or coalesces a refetch under a policy); a selector is a remote/producer function, { scope }, or nothing for all. On the server, how you consume the call decides the SSR mode: a top-level await inlines the value into the first HTML chunk; a {#await} block streams it in on the same response when it resolves.
cache(fn)returnsPromise<Return> | Return— a warm SSR value comes back synchronously, so consume only viaawait/{#await}, never.then/.catch.- A top-level
awaitflips Svelte into await-everything mode and inlines every read in that component instance; isolate a blocking read in its own child to keep siblings streaming. - A producer (
cache(fetchRates)) keys on the function reference — hoist it to a named binding; an inline arrow mints a fresh identity each call and never dedupes (it warns once).
pending / refreshing
@belte/belte/shared/* — reactive probes over both the cache and the tail registry. They report, never act: reading one opens no fetch and no stream.
import { pending } from '@belte/belte/shared/pending'
import { refreshing } from '@belte/belte/shared/refreshing'
const loading = $derived(pending(getProduct)) // no value yet
const updating = $derived(refreshing(getProduct)) // held value being supersededpending(arg?) is true when there's no value yet (any call/stream, a function's calls, a { scope }, or a stream). refreshing(arg?) is true when a held value is being revalidated. Both take the same selector grammar as cache.invalidate.
Pages
- Routes are folders under
src/browser/pages/:page.svelterenders,[id]/[...rest]are dynamic segments. layout.svelteanderror.sveltewrap the nearest matching prefix — nearest only, no stacking.- A throw during render (server or client) swaps in the nearest
error.sveltewith{ status, message, stack }.
<script>
import { page } from '@belte/belte/browser/page'
import { navigate } from '@belte/belte/browser/navigate'
</script>
<a href="/product/{id}" onclick={(e) => (e.preventDefault(), navigate(`/product/${id}`))}>
{page.params.id}
</a>page exposes reactive route / params / url / navigating. navigate(href, { replace?, scroll? }) does SPA navigation, resolving the target view before touching history and hard-navigating any non-SPA URL.
url
url(path, …) builds a typed, base-correct in-app URL — links, asset refs, and rpc hrefs through one helper. It resolves three path kinds off the path itself: an rpc path (/rpc/*) takes the verb's own args serialised to a query, a page route takes its [id] params then an optional query, and a bare asset/path takes an optional query. Scheme-qualified or protocol-relative URLs pass through untouched.
import { url } from '@belte/belte/shared/url'
url('/product/[id]', { id: 42 }) // /product/42
url('/rpc/search', { q: 'shoes' }) // /rpc/search?q=shoes
url('/logo.svg') // /logo.svgWhen the app is mounted under a subpath via APP_URL (e.g. https://app.com/v2), url() and the shell's /_app/ asset refs carry that base so every rooted internal link stays inside the mount; the server still routes at root, so pair it with a proxy that strips the prefix.
tail
tail(subscribable) is the reactive client reader for a Socket<T> or an rpc fn.stream(args).
<script>
import { tail } from '@belte/belte/browser/tail'
const latest = $derived(tail(chat)) // T | undefined — latest frame
const recent = $derived(tail(chat, { last: 20 })) // T[] — live window of ≤20
</script>tail.status(x) is pending | open | done | error; tail.error(x) surfaces the error (never thrown, so a read can't crash the component). A socket disconnect retains the window, flags refreshing, and reconnects with backoff. tail is a no-op on the server — seed the initial HTML with cache() against an HTTP verb, then layer tail() on top for live updates after hydration.
agent
agent(engine, messages) runs a model engine against the current request's MCP surface and yields a provider-neutral frame stream; the handler picks the transport with jsonl() or sse().
import { agent } from '@belte/belte/server/agent'
import { jsonl } from '@belte/belte/server/jsonl'
import { engine } from '@belte/anthropic'
const chatEngine = engine({ model: 'claude-opus-4-8', apiKey: config.ANTHROPIC_API_KEY })
export const chat = POST(({ messages }) => jsonl(agent(chatEngine, messages)), { inputSchema })Engines are provider packages (@belte/<provider>) that map neutral messages to a provider's wire shape — swapping providers never touches the verb or the UI. The surface is already gated by each verb's clients.mcp plus its own handler auth.
MCP / CLI / bundle
Every machine surface is generated from the same declarations — no second definition.
| surface | what it is | build |
|---|---|---|
| MCP | /__belte/mcp — tools from MCP-exposed verbs/sockets, prompts, resources |
always mounted |
| CLI | a standalone binary; subcommands from CLI-exposed verbs | belte cli |
| bundle | a movable desktop app (server + launcher + webview) | belte bundle |
Desktop bundle config lives in src/bundle/: BundleWindow / BundleMenu / BundleMenuItem shape the native window and menus, onMenu handles menu events, bundled() detects the bundled context, and appDataDir() returns the app's per-user data dir (DB, cache) so its storage lands beside its config.
Deploy
belte is single-process: the global cache store, socket retention, and live fan-out are all process memory. One Bun.serve instance owns them, so scale vertically — a second process shares none of that state. PORT pins the listener (else it scans up from 3000); BELTE_IDLE_TIMEOUT sets the per-connection idle seconds (default 10); APP_URL pins the public origin for absolute URLs, and its pathname mounts the app under a subpath (e.g. /v2).
FROM oven/bun:1
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
ENV PORT=3000
EXPOSE 3000
CMD ["bun", "run", "start"]Or ship a single file: belte compile produces a standalone executable (--target=<bun triple> cross-compiles, e.g. bun-linux-arm64).
Reference
| command | does |
|---|---|
belte dev |
build the client + run the server with hot reload |
belte build |
build the client into dist/_app/ |
belte start |
run the production server against dist/ |
belte run <file> |
run a script under the belte preload (same runtime as the server) |
belte compile |
build a standalone server executable |
belte cli |
build the CLI binary (ships the server beside it) |
belte bundle |
build a movable desktop app bundle for this platform |
| route | surface |
|---|---|
/* |
pages (SSR + SPA) |
/rpc/* |
rpc verbs |
/__belte/sockets |
socket multiplex (one ws per client) |
/__belte/mcp |
MCP endpoint |
/__belte/cli |
CLI install / download |
/__belte/identity |
server identity probe |
/openapi.json |
OpenAPI document |
/_app/* |
built client assets |
// src/server/config.ts — eager-imported at boot; import `config` from $server/config anywhere
import { env } from '@belte/belte/server/env'
import { z } from 'zod'
export const config = env(z.object({ DATABASE_URL: z.string(), PORT: z.coerce.number() }))src/app.ts exports optional hooks: init (returns a cleanup run on SIGINT/SIGTERM), handle, handleError, forwardHeaders.
Tests run in-process — no server, no network. Add preload = ["@belte/belte/preload"] under [test] in bunfig.toml, then:
import { createTestClient } from '@belte/belte/test/createTestClient'
const api = createTestClient({ headers: { cookie: 'session=abc' } })
const order = await api.createOrder({ items }) // runs in a real request scopeMIT