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:

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

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

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:

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.