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/InspectorContext
- @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/reachable
- @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/health
- @belte/belte/shared/log
- @belte/belte/shared/online
- @belte/belte/shared/pending
- @belte/belte/shared/refreshing
- @belte/belte/shared/trace
- @belte/belte/shared/url
- @belte/belte/shared/withJsonSchema
- @belte/belte/svelte-plugin
- @belte/belte/test/assertAgentFrameConformance
- @belte/belte/test/createScriptedSurface
- @belte/belte/test/createTestApp
- @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 SSR + SPA framework for Bun and Svelte: declare a function once and it serves an SSR/browser call, an HTTP + OpenAPI operation, an MCP tool, and a CLI subcommand. The bundler swaps the runtime per build target — the same name is a direct call on the server and a network fetch on the client.
- Zero runtime dependencies.
- One runtime: Bun (
engines.bun >= 1.3), Svelte the only required peer.
# start a project — scaffolds, installs, and starts dev
bunx belte scaffold myapp
# or see everything live — the kitchen-sink exercises every surface
git clone https://github.com/briancray/belte
cd belte && bun install
cd examples/kitchen-sink && bun run devDefine behaviour once
One named function is the whole unit of work:
// src/server/rpc/getWeather.ts — the filename is the rpc's identity and its URL
import { GET } from '@belte/belte/server/GET'
import { json } from '@belte/belte/server/json'
import { z } from 'zod'
export const getWeather = GET(({ city }) => json(forecast(city)), {
inputSchema: z.object({ city: z.string() }),
})It fans out to every surface:
getWeather ─┬─ await cache(getWeather)({ city }) SSR + browser call
├─ GET /rpc/getWeather?city=… HTTP + OpenAPI op
├─ getWeather MCP tool (read-only)
└─ app getWeather --city=… CLI subcommandOn boot, the exposure is a map, not a guess:
pages:
page layout error
/ layout ·
/about layout ·
sockets:
socket schema browser mcp cli publish
chat ✓ ✓ ✓ ✓ ✓
rpcs:
http schema browser mcp cli
GET /rpc/getWeather ✓ ✓ ✓ ✓
POST /rpc/createPost ✓ ✓ · ✓A declared schema is what gates the machine surfaces (MCP/CLI/OpenAPI); a
mutating verb never auto-exposes to MCP — it needs an explicit clients: { mcp: true }.
rpc
The verb you import (GET / POST / PUT / PATCH / DELETE / HEAD) sets the HTTP method.
| option | default | effect |
|---|---|---|
inputSchema |
— | validates args (422 on failure); gates + describes the machine surfaces |
outputSchema |
— | types the 200 body for OpenAPI + the MCP tool's outputSchema |
filesSchema |
— | validates multipart File parts (kept off the JSON-Schema projection) |
clients |
browser always; cli when a schema is present; mcp when read-only and schema |
which surfaces expose the verb |
crossOrigin |
false |
exempt a mutating verb from the same-origin gate |
maxBodySize |
— | cap actual received body bytes (413 past it); else Bun's server-wide ceiling |
timeout |
— | bound the handler's run (ms) on every surface (SSR/MCP/CLI/network); 504 once exceeded, and aborts request().signal |
Every rpc is callable three ways:
| form | returns | |
|---|---|---|
fn(args) |
Promise<Return> |
Content-Type-decoded body; throws HttpError on non-2xx |
fn.raw(args) |
Promise<Response> |
the raw Response — status, headers, body streaming |
fn.stream(args) |
Subscribable<Return> |
jsonl/sse frames, consumed with tail() |
GET/DELETE/HEAD args travel as a query string, so every value arrives as a string — coerce in the schema (
z.coerce.number()), don't expect a number.
Schemas whose library lacks a native toJSONSchema() (needed for OpenAPI / MCP /
CLI) wrap once at declaration with withJsonSchema(schema, toJsonSchema).
Response helpers
| helper | response |
|---|---|
json(data, init?) |
application/json (204 when data is undefined) |
jsonl(iterable, init?) |
application/jsonl stream — one JSON value per line |
sse(iterable, init?) |
text/event-stream with a 15s keepalive |
error(status, message?, init?) |
text/plain; the client HttpError carries the message |
redirect(url, status=302, init?) |
3xx; accepts relative URLs |
All default to Cache-Control: no-store (rpc replies shouldn't sit in shared
caches); the positional status and any explicit header override the default.
Request scope
Inside an SSR render or rpc handler the inbound request is reachable by call,
backed by an AsyncLocalStorage scope (each throws outside one):
| call | returns |
|---|---|
request() |
the inbound Request |
cookies() |
Bun CookieMap — reads parse Cookie, writes flush as Set-Cookie on return |
server() |
the live Bun.serve instance (.publish, .requestIP, …) |
In-process calls (SSR, MCP, CLI) forward only an allowlist —
cookie,authorization,traceparent,tracestate,x-forwarded-*. A handler reading any other inbound header sees nothing; add the names you rely on viaforwardHeadersinsrc/app.ts.
Build the web app
Turn those functions into a UI.
Pages
- Every
page.svelteundersrc/browser/pages/mounts at its folder's URL;[id]/[...rest]segments become params. layout.svelteis nearest-only — the deepest match wraps the page; layouts don't stack.error.svelteis the boundary for throws during render, server and client.
<script lang="ts">
import { page } from '@belte/belte/browser/page'
</script>
<p>route: {page.route} — {page.params.id}</p>page is reactive route/params/url state; page.url is browser-space on both
sides (under a mount base, compare against url() output).
navigate
<script lang="ts">
import { navigate } from '@belte/belte/browser/navigate'
</script>
<a href="/about" onclick={(e) => { e.preventDefault(); navigate('/about') }}>About</a>navigate(href, { replace?, scroll? }) does SPA navigation — it resolves the
target view before touching history, then writes the entry and refreshes
page state so $derived consumers re-run. A non-SPA or cross-origin target
falls back to a clean hard load.
cache
cache(fn, options?) returns an invoker; calling it dedupes identical in-flight
calls (always) and retains the result per ttl. Read it inside a $derived /
$effect and the read is reactive — cache.invalidate re-runs that scope. Keys
derive from method+url+args (remote) or producer-reference+args (plain function).
const post = await cache(getPost)({ id }) // one-shot: dedupe + retain
const live = $derived(cache(getPost)({ id })) // reactive: re-runs on cache.invalidate
cache(createPost, { ttl: 0 })({ title }) // mutation idiom: coalesce, retain nothing| option | default | effect |
|---|---|---|
ttl |
forever | ms after resolve to keep the entry; 0 = dedupe only, nothing retained |
scope |
— | free-form tag(s) grouping calls for one cache.invalidate({ scope }) |
global |
request-scoped | put the entry in the process store, reused across requests |
invalidate |
drop-and-reload | { throttle } / { debounce } ms — stale-while-revalidate on invalidate hits |
cache.invalidate(selector?, args?) drops matching entries (or coalesces a refetch under a policy) and re-runs readers. cache.on(source, handler) binds a socket/stream to event-driven cache maintenance; the handler's context carries a scoped invalidate (drop, then refetch) and patch(selector, updater) (fold the frame's authoritative delta into matching entries with no refetch), and replays its coverage on reconnect.
During SSR the consumption form decides inline vs streaming (Svelte's {#await} rule): a top-level await cache(fn)() blocks render and bakes the value into the HTML; {#await cache(fn)()} flushes the shell and streams the value in.
- Warm SSR keys return synchronously (
Promise<Return> | Return), matching the hydrated DOM — consume viaawait/{#await}, never.then/.catch. - A top-level
awaitsweeps every sibling{#await}in the component into await-everything mode — isolate blocking reads in child components to keep siblings streaming. - Producers key on reference identity — hoist to a named binding so calls coalesce (an inline arrow never dedupes, and warns once).
pending / refreshing / online
Reactive probes over the cache, stream, and connectivity. They report, never
act — reading one opens no fetch and no stream — and the cache pair takes
cache.invalidate's selector grammar.
const loading = $derived(pending(getPost, { id })) // no value yet
const stale = $derived(refreshing(getPost, { id })) // value held, fresher in flight
const connected = $derived(online()) // navigator.onLine, same probe familySockets & tail
One named export per file under src/server/sockets/. A Socket<T> is a
bidirectional named broadcast primitive — the same import is a server fan-out and
a client ws proxy by build target — and tail() is its reactive consumer (also
for any fn.stream(args)).
// src/server/sockets/chat.ts
import { socket } from '@belte/belte/server/socket'
export const chat = socket<ChatMessage>({ tail: 50, clientPublish: true, schema })| option | default | effect |
|---|---|---|
tail |
— | retain the last N frames so late joiners / .tail() seed from them |
ttl |
— | evict retained frames older than N ms (lazy, no timer) |
clientPublish |
false |
accept publish frames from browser / CLI clients |
schema |
— | validate publish payloads (sync); gate + describe MCP/CLI |
clients |
browser; mcp/cli when a schema is present | which surfaces expose the socket |
publish(message) is isomorphic — server-side it notifies in-process iterators and fans out over Bun's native server.publish; client-side it sends a pub frame. Iterating the socket (for await … of chat) is the live stream; .tail(count) replays retained frames before going live.
const latest = $derived(tail(chat)) // T | undefined, latest-wins
const recent = $derived(tail(chat, { last: 20 })) // T[], live windowtail.status(x) is pending | open | done | error; tail.error(x) surfaces the
error without throwing. A transport loss retains the window, flags refreshing,
and reconnects on backoff — never an error. tail is a no-op during SSR — seed
the value with cache() against an rpc handler, then layer tail() on top.
url
url(path, …) resolves any in-app path to its base-correct, typed form so a
project mounted under APP_URL's subpath keeps every link, asset ref, and rpc
href within the mount.
url('/product/[id]', { id }, { ref }) // page route: params, then query
url('/rpc/search', { q }) // rpc: the verb's args, serialised to queryReach it beyond the browser
The same functions, through every non-browser front door.
CLI
A thin remote client with the rpc manifest baked in, shipping the server beside
it — consumed by humans at a terminal and by scripts. belte cli builds the
binary; invoke as <app> <command> --flags, served for install at /__belte/cli.
MCP & agent
Schema-bearing read verbs (plus clients.mcp mutations) and sockets — together
with src/mcp/prompts/*.md and src/mcp/resources/ — are served as JSON-RPC at
/__belte/mcp. agent(engine, messages) runs a model engine against that same
gated surface and yields a provider-neutral AgentFrame stream; the handler
picks the transport.
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>); they see the surface in and
emit AgentFrames out, so swapping providers never touches the verb or the UI.
bundle
A movable desktop app — server + launcher + webview, with a connect screen.
belte bundle builds it for the current platform; configure the window and menu
via src/bundle/window.ts, onMenu(), bundled(), and appDataDir().
Configure, test, ship
Lock it down, verify it, get it running.
Configuration
Typed environment — validated at boot, every issue reported at once:
// src/server/config.ts
import { env } from '@belte/belte/server/env'
export const config = env(z.object({ DATABASE_URL: z.string(), PORT: z.coerce.number() }))src/app.ts exports optional hooks: handle, init, handleError, health,
forwardHeaders (all optional; see AppModule).
Security defaults
- A browser request whose
Origindoesn't match the app's host is 403 on every mutating verb (CSRF / CSWSH); native clients send no Origin and pass.crossOrigin: trueopts a verb out. The/__belte/mcpmount and socket publishes get the same check; GET reads stay open cross-origin. - Boot warns when MCP tools are exposed with no
app.handleto authenticate them.
app.handle is the auth seam — one middleware wrapping every request:
// src/app.ts
import type { AppModule } from '@belte/belte/server/AppModule'
export const handle: AppModule['handle'] = async (request, next) => {
if (!(await authorized(request))) return new Response('unauthorized', { status: 401 })
return next(request)
}The Origin gate compares against the request's own host — behind a TLS-terminating proxy, preserve the original
Hostso same-origin posts aren't read as cross-site.
Testing
createTestApp() boots the real app on an ephemeral port (no fixtures, no
manifests — it reads your project's own routes) and hands back the whole surface:
fetch for pages and raw HTTP, a typed rpc for verbs over the full pipeline
(CSRF, cookies, base path), sockets for live ws streams, and health. The
rpc/sockets types are generated from your verbs/sockets, so app.rpc.getWeather
exists and is typed with nothing imported.
# bunfig.toml
[test]
preload = ["@belte/belte/preload"]import { createTestApp } from '@belte/belte/test/createTestApp'
await using app = await createTestApp() // disposed (server + slots) at scope end
const html = await (await app.fetch('/')).text()
expect(html).toContain('</html>')
expect(await app.rpc.getWeather({ city: 'NYC' })).toEqual(expected)
const ticks = app.sockets.feed[Symbol.asyncIterator]()
expect((await ticks.next()).value).toMatchObject({ tick: 1 })Deploy
belte runs as a single Bun process: the global cache, socket retention, and fan-out are all process memory — two replicas share neither, so run one process and scale through an external store, or pin clients to it.
belte compile builds a self-contained binary — runtime and zstd-packed assets embedded — so the runtime image needs neither Bun nor node_modules:
FROM oven/bun:1 AS build
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run compile # → dist/app
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=build /app/dist/app ./app
ENV PORT=3000
EXPOSE 3000
CMD ["./app"]Cross-compile for another target with belte compile --target=bun-linux-arm64. PORT binds exactly (a collision fails loudly); unset, the listener scans up from 3000. BELTE_IDLE_TIMEOUT raises Bun's per-connection idle cap (streams opt out).
Observability — health, reachable, trace, log
const { reachable, authenticated } = $derived(health()) // app.health() fields merge inhealth() polls /__belte/health; the app.health(request) hook adds public
fields to the payload. Server-side, reachable() checks an outbound dependency
so a handler can fail fast when it's down:
import { reachable } from '@belte/belte/server/reachable'
if (!(await reachable('api.example.com'))) return error(503) // HEADs the origin; 3s timeout, 30s TTL-polledimport { log } from '@belte/belte/shared/log'
import { trace } from '@belte/belte/shared/trace'
log('order placed', { id }) // tsv (or JSON under BELTE_LOG_FORMAT=json), request-scoped
const traceparent = trace() // the request's W3C traceparent, isomorphicReference
Imports name the side they run on: @belte/belte/server/* is server-only,
@belte/belte/browser/* is client-only, @belte/belte/shared/* is isomorphic
(same callable, same behaviour both sides — e.g. shared/cache, shared/HttpError,
shared/url, shared/health). shared is an import namespace, not a project
directory. A project:
src/
app.ts optional app hooks (see Configuration)
server/
config.ts $server/config — env(schema), validated at boot
rpc/<name>.ts one verb export per file → /rpc/<name>
sockets/<name>.ts one socket export per file
browser/
pages/ page.svelte + nearest layout.svelte + error.svelte
public/ static files served at the site root
mcp/
prompts/<name>.md MCP prompt templates
resources/ MCP resources
bundle/window.ts desktop-bundle window + menu config (optional)
cli/ banner.txt / footer.txt for the CLI binary| command | does |
|---|---|
belte scaffold <name> |
scaffold a project, install, start dev |
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 binary (--target, --out) |
belte cli |
build the CLI binary (--platforms to cross-compile) |
belte bundle |
build a movable desktop bundle for this platform |
| route | serves |
|---|---|
/__belte/health |
liveness + identity payload (/__belte/identity is a compatibility alias) |
/__belte/mcp |
MCP JSON-RPC endpoint |
/__belte/sockets/<name> |
HTTP face of a socket — tail (SSE/JSON) and publish |
/__belte/cli |
CLI install script + per-platform binary download |
/openapi.json |
OpenAPI 3.1 document of the /rpc/* surface |
| env | effect |
|---|---|
PORT |
bind port (exact; unset scans from 3000) |
APP_URL |
public URL — its pathname becomes the mount base |
BELTE_IDLE_TIMEOUT |
Bun per-connection idle seconds (default 10) |
BELTE_MAX_REQUEST_BODY_SIZE |
server-wide body ceiling |
BELTE_CLIENT_TIMEOUT |
client-side fetch wait before an rpc gives up (ms; opt-in, unset = unbounded) — distinct from a verb's server-side timeout |
BELTE_REACHABLE_TIMEOUT |
reachable() probe timeout ms (default 3000) |
BELTE_REACHABLE_TTL |
reachable() re-probe interval ms (default 30000) |
BELTE_LOG_FORMAT |
json for one JSON object per log line (default tsv) |
DEBUG |
boot map + request logs print by default; -belte silences them, belte:* adds diagnostic channels (cache, svelte, build) |
BELTE_DATA_DIR |
override the bundle's per-user data dir |
BELTE_APP_URL / BELTE_APP_TOKEN |
CLI client's default server + bearer token |
MIT