Server SDK
cloudbed/server is how a capsule declares its data and behavior. The whole server is one default export:
import { boolean, capsule, mutation, query, string, table } from "cloudbed/server";
const schema = {
todos: table({
text: string(),
done: boolean().default(false),
ownerId: string(),
}),
};
export default capsule({
name: "todo",
page: {
title: "Todo",
description: "A tiny todo app",
favicon: "✅",
},
ssr: true,
schema,
queries: {
todos: query((ctx) =>
ctx.db.todos.where("ownerId", ctx.auth.userId).orderBy("createdAt", "desc").all()),
},
mutations: {
addTodo: mutation((ctx, text: string) => {
if (!text) throw new Error("Text required");
return ctx.db.todos.insert({ text, ownerId: ctx.auth.userId });
}),
},
});
The server is authoritative. Queries decide what each user may read; mutations validate their arguments and re-check ownership before writing. The platform independently re-validates every write against the declared schema on commit, but per-row authorization (which user may touch which row) is your capsule's job — as the ownerId checks above show.
Capsule definition
capsule({...}) accepts the app contract:
name— optional capsule name. If omitted, the CLI uses the app directory name.page— optional static shell metadata baked into the served HTML:titlesets<title>and defaults to the capsule name.descriptionemits<meta name="description">.faviconemits<link rel="icon">; use an emoji, anhttps://URL, adata:image/URL, or an absolute/path.iconsets the install manifest icon and<link rel="apple-touch-icon">; use anhttps://URL, adata:image/URL, or an absolute/path. A square PNG of at least 512x512 is recommended because iOS ignores SVG here.
ssr— optional boolean. Setssr: trueto pre-render HTML shell requests on the server.schema,queries,mutations,endpoints— the data and handler surface described below.
Every capsule serves a web app manifest at /__cloudbed/app.webmanifest. If page.icon is omitted, Cloudbed uses page.favicon for the manifest when it can: emoji favicons become a platform-served SVG icon, while URL and path favicons are referenced directly.
Page metadata is validated at build and deploy time. title is capped at 256 UTF-8 bytes, description at 1,024 bytes, and favicon and icon at 2,048 bytes each.
Server-side rendering
SSR is opt-in with capsule({ ssr: true }). The client entry must use
mount(<App />) from cloudbed/client; the CLI builds a separate SSR bundle
from that same client code.
When a browser requests / or another HTML shell route, Cloudbed runs a
read-only render as a guest identity. In local dev, ?guest=<name> selects the
guest used for that render, matching the WebSocket dev convention. The runner
pre-runs all declared queries without arguments, embeds their results in
__cloudbed_state__, renders the app into <div id="app" data-cloudbed-ssr>,
then the browser hydrates and connects live data.
Render passes are enforced read-only. Any database write or file write during a query pre-run or render fails SSR for that request. The page still returns HTTP 200 with the normal CSR shell, and the fallback reason is written to the capsule log ring for inspection. Missing SSR bundles, render throws, and timeouts fall back the same way.
Keep render paths deterministic. Values such as Date.now(), Math.random(),
locale-sensitive formatting, or browser-only state can make the server markup
differ from the browser's first render and cause hydration mismatches.
Schema
table(fields)— declare a table.string(),number(),boolean()— field types, each chainable with.default(value).
Every row automatically carries three server-managed fields you never write yourself:
| Field | Type | |
|---|---|---|
id |
string |
unique row id |
createdAt |
string |
ISO timestamp, set on insert |
updatedAt |
string |
ISO timestamp, maintained on update |
Handlers
query(fn)— a read. Runs on subscription and automatically re-runs (and pushes to subscribed clients) after every mutation.mutation(fn)— a write. Receivesctxplus whatever arguments the client passed. Throw anErrorto reject; the client's mutation promise rejects with{ code, message }.endpoint({ method, path }, fn)— a plain HTTP handler for webhooks and non-browser clients, registered underendpoints:. Return a response descriptor built with:json(value, { status?, headers? })— JSON, default 200text(value, options?)— plain textempty(options?)— default 204redirect(url, options?)— default 302
Paths may contain
:nameparam segments —endpoint({ method: "POST", path: "/hooks/:source" }, (ctx, req) => ...)captures the matching segment (URL-decoded) asreq.params.source, and the params object is typed from the path literal. Exact paths win over patterns; among patterns, first declaration wins. Reserved prefixes (/__cloudbed,/auth) and duplicate param names are rejected at deploy time.cron({ schedule }, fn)— a scheduled job, registered undercrons:. It receivesCronCtx, which is the normal server context withoutctx.auth: scheduled jobs have no visitor or signed-in user.import { capsule, cron } from "cloudbed/server"; export default capsule({ crons: { digest: cron({ schedule: "0 9 * * *" }, (ctx) => { const count = ctx.db.entries.all().length; ctx.log.info("daily digest", { count }); return { count }; }), }, });Cron schedules use five-field UTC syntax:
minute hour day-of-month month day-of-week. Supported forms are*, numbers, ranges (1-5), lists (1,2,5), and steps on*or ranges (*/15,9-17/2). Day of week uses0or7for Sunday. When both day-of-month and day-of-week are restricted, Cloudbed uses Vixie cron's OR semantics: either field may match.A capsule may define up to 5 crons (
MAX_CRONS_PER_CAPSULE). Each run has a 30 second wall-clock budget. Scheduled firing only happens after the deploy is claimed; anonymous deploys still build and deploy, but the schedule stays idle untilcloudbed claim. If a deploy was asleep or delayed, Cloudbed fires at most one catch-up run for a due schedule, then computes the next occurrence. Failed runs are recorded in run history; they are not retried automatically. Incloudbed dev, crons fire locally while the dev server is running.
Each query, mutation, and endpoint invocation is bounded at 10 seconds of wall-clock time.
The context
Every handler receives ctx:
| Property | What it is |
|---|---|
ctx.db |
Typed database handle, one property per schema table (below) |
ctx.auth |
The calling user: userId, displayName, provider ("guest" | "google"), isGuest, isAuthenticated, and email / emailVerified / picture when signed in |
ctx.files |
File storage: put / get / list / delete (below) |
ctx.push |
Web Push notifications: send(payload, opts?) (below) |
ctx.env |
Server-only env vars from .env.cloudbed.server (claimed deploys only) |
ctx.log |
info / warn / error loggers; entries are kept by the runtime (see log retention) and exposed on the deploy's inspect API |
Before sign-in, users are guests: ctx.auth.userId is guest:local by default, and opening the app with ?guest=<name> acts as the named guest guest:<name> (persisted per tab — useful for testing multi-user behavior). Ownership checks work the same whether or not the user has signed in.
Web Push
ctx.push.send(payload, opts?) queues a browser notification:
ctx.push.send(
{ title: "New comment", body: "Maya replied", url: "/comments" },
{ user: ctx.auth.userId }
);
The payload fields are:
title— required string, 1..200 chars.body— optional string, up to 1,000 chars.url— optional absolute app path or absolute URL, up to 1,024 chars.tag— optional browser notification tag, up to 100 chars.icon— optional absolute app path or absolute URL, up to 1,024 chars.data— optional JSON-serializable value passed to the service worker.
Sends are deferred: the handler records the request during the run, then
Cloudbed delivers it after the handler completes and commits. The handler gets
no delivery result. ctx.push.send() is available in mutations, endpoints, and
crons; calling it from queries or SSR render paths throws.
Each run may emit at most 10 sends (MAX_PUSH_SENDS_PER_RUN). The serialized
payload JSON may be at most 3,500 bytes (MAX_PUSH_PAYLOAD_BYTES).
Delivery runs only for claimed capsules and local cloudbed dev; anonymous
unclaimed deploys can build code that calls ctx.push.send(), but hosted
delivery stays disabled until claim. Omit opts.user to broadcast to every
subscription for the capsule. Pass { user: ctx.auth.userId } to target
subscriptions registered by that auth subject.
Reading and writing
ctx.db.<table> exposes a small chainable query builder:
ctx.db.todos.where("ownerId", ctx.auth.userId).orderBy("createdAt", "desc").limit(50).all();
ctx.db.todos.get(id); // one row by id, or null
ctx.db.todos.insert({ text, ownerId }); // returns the full row (with id, timestamps)
ctx.db.todos.update(id, { done: true }); // partial patch, returns the updated row
ctx.db.todos.delete(id);
where filters by equality, orderBy defaults to "asc", and a query may return at most 1,000 rows — see limits.
Files
ctx.files stores binary files (images, attachments, generated exports) alongside your data:
const meta = ctx.files.put(bytes, { name: "avatar.png", contentType: "image/png" });
// → { id, name, contentType, size, createdAt, path }
ctx.files.list(); // all stored files' metadata
ctx.files.get(id); // one file's metadata, or null
ctx.files.delete(id);
put accepts a Uint8Array or a string and returns the file's metadata; the bytes themselves are stored by the platform, not in your database. meta.path (/__cloudbed/files/<id>) serves the file publicly on the app's own origin with the stored content type — put it straight into an <img src> or persist it on a row. The id is unguessable, but anyone who has the path can fetch it, so don't store secrets. Deleting frees the quota; a file's contents never change (store a new file and delete the old one to "replace").
The typical upload flow is an endpoint receiving a browser form:
endpoints: {
upload: endpoint({ method: "POST", path: "/upload" }, async (ctx, req) => {
const form = await req.formData(); // multipart/form-data or urlencoded
const f = form.file("file"); // { field, filename, contentType, bytes, size }
if (!f) return json({ error: "no file" }, { status: 400 });
const meta = ctx.files.put(f.bytes, { name: f.filename, contentType: f.contentType });
return json({ path: meta.path, caption: form.field("caption") });
}),
}
Conservative caps apply for now (1 MB per file, 64 files / 16 MB per deploy, 8 file writes per handler run) — see limits.