# Build a Sited Capsule Sited runs small full-stack TypeScript apps called capsules. A capsule has two required entrypoints: - `server/index.ts` default-exports `app(...)` from `sited/server`. - `client/index.tsx` exports `App`. Sited provides the HTML shell, bundling, Tailwind processing, RPC, auth gate, routing, and deployment. Do not add app-level HTML, Vite, Docker, PostCSS, Tailwind configuration, or generated CSS. ## Create and Run ```sh npx @lucaspll/sited new SLUG cd SLUG npm install npm run dev ``` The scaffold creates the entrypoints, TypeScript configuration, and `dev`, `build`, `deploy`, and `typecheck` scripts. Prefer editing it over assembling a capsule manually. Capsule imports are `sited/server` and `sited/client`. Deploy to this runtime: ```sh SITED_API=https://d.ellep.dev SITED_ADMIN_TOKEN=... npm run deploy ``` Never commit `SITED_ADMIN_TOKEN`. The deployed app URL is `https://SLUG.d.ellep.dev`. ## Minimal App `server/index.ts`: ```ts import { app, mutation, query, string, table } from "sited/server"; export default app({ name: "Notes", access: "public", schema: { notes: table({ body: string() }), }, queries: { notes: query((ctx) => ctx.db.transaction((tx) => tx.notes.orderBy("createdAt", "desc").all())), }, mutations: { addNote: mutation((ctx, body: string) => { if (typeof body !== "string" || body.trim().length === 0 || body.length > 2_000) { return { error: "Note must contain 1–2,000 characters." }; } const note = ctx.db.transaction((tx) => tx.notes.insert({ body: body.trim() })); return { note }; }), }, }); ``` `client/index.tsx`: ```tsx import { useMutation, useQuery } from "sited/client"; type Note = { id: string; body: string; createdAt: string; updatedAt: string; }; type AddNoteResult = { note?: Note; error?: string }; export function App() { const notes = useQuery("notes"); const addNote = useMutation<[body: string], AddNoteResult>("addNote"); async function handleAdd() { const result = await addNote.mutate("hello"); if (result.note) await notes.refetch(); } return (
{JSON.stringify(notes.data ?? [], null, 2)}
); } ``` Use Preact and browser APIs in client code. Do not import from `react` or `react-dom`. ## Choose the Right Mechanism | Need | Use | | -------------------------------------------- | --------------------------------------------- | | Persistent structured data | A schema table inside `ctx.db.transaction()` | | Read server data | A `query` | | Write data or cause an external side effect | A `mutation` | | Use a secret | `ctx.env` in a server handler | | Ship images, fonts, or browser files | `assets/` | | Ship browser-safe static datasets | `data/` | | Restrict the whole app to the instance owner | `access: "private"` | | Support guests and signed-in users | `access: "public"` plus handler authorization | | Expose an HTTP route or runtime file store | Not supported; use RPC and bundled files | ## Server Rules Queries and mutations are the public server interface. Handlers receive `ctx.auth`, `ctx.db`, `ctx.env`, `ctx.fetch`, and `ctx.log`. - Use `query` for read-only, idempotent work. Clients may repeat queries. - Use `mutation` for database writes, state changes, and external side effects. - TypeScript types do not validate RPC input at runtime. Check type, shape, allowed values, and size at the start of each handler. - Return structured results for expected validation errors. Throwing returns a server error and writes to app logs. - For user-owned operations in public capsules, require a non-null `ctx.auth.userId` and use it to authorize every read and write. - Read secrets only from `ctx.env`. - Do not use module-level mutable values as durable or shared state. - Server bundles cannot import Node built-ins such as `node:fs` or `node:child_process`. ### Database Transactions All table access belongs inside `ctx.db.transaction((tx) => ...)`. - The callback is synchronous and serialized per app. Do not `await`, fetch, sleep, or nest a transaction inside it. - One callback is one atomic transaction. Separate calls are not atomic together. - Keep callbacks short and perform an atomic read-modify-write in one callback. - Handlers may run concurrently. After external work, re-read or verify state before writing. - A later handler failure does not roll back committed transactions. Queries receive a read-only transaction API. Mutations can also call `insert`, `update`, and `delete`. Schema fields use `string()`, `number()`, `boolean()`, or `json()`, with optional `.default(value)`, `.index()`, and `.nullable()`. Sited adds `id`, `createdAt`, and `updatedAt` to every row; do not declare them. ## Client Rules Use `useQuery(name, args?)` for reactive reads and `useMutation(name)` for UI-triggered writes. Arguments are positional: ```ts const note = useQuery("note", [noteId]); const updateNote = useMutation<[id: string, body: string], Note>("updateNote"); ``` `useQuery` returns `data`, `error`, `isLoading`, and `refetch`. `useMutation` returns `mutate`, `error`, and `isLoading`. For typed imperative calls outside hooks, use `createClient()`, then call `api.query.NAME(...args)` or `api.mutation.NAME(...args)`. ## Styling Use Tailwind classes directly in JSX and keep class names statically visible: ```tsx const tones = { danger: "bg-red-500 text-white", success: "bg-emerald-500 text-white", }; ; ``` Do not generate names such as `bg-${color}-500`. Use `client/style.css` only for Tailwind v4 CSS-first customization such as `@theme` and `@layer`. Sited processes and includes it automatically; do not import it or add a stylesheet link. Do not install Tailwind, run its CLI, or add Tailwind, PostCSS, or Vite configuration. ## Auth and Secrets Set `access` to `"public"` or `"private"`. Private capsules use Sited's Google OAuth gate before client code loads and are limited to the configured instance owner. Public capsules support guests and, when the runtime has Google OAuth configured, may offer sign-in with `useAuth()`, `signInWithGoogle()`, and `signOut()`. Sited owns the OAuth credentials, callback, and session; capsule authors do not add OAuth routes. Client-side auth controls presentation only. Enforce authorization in every protected server handler. `ctx.auth.userId` is `null` for guests and a stable, provider-qualified string for signed-in users. Use the non-null user ID as the ownership key; do not use email or display name, which may change. Tables are app-wide. On insert, store the user ID in an indexed `ownerId: string().index()` field, and filter every user-owned read and write: ```ts const myNotes = query((ctx) => { const ownerId = ctx.auth.userId; if (ownerId === null) return []; return ctx.db.transaction((tx) => tx.notes.where("ownerId", ownerId).all()); }); const deleteNote = mutation((ctx, id: unknown) => { const ownerId = ctx.auth.userId; if (ownerId === null) return { error: "Sign in required." }; if (typeof id !== "string") return { error: "Invalid note ID." }; const deleted = ctx.db.transaction((tx) => tx.notes.where("ownerId", ownerId).delete(id)); return deleted ? { deleted: true } : { error: "Note not found." }; }); ``` Manage server environment values with the CLI: ```sh export SITED_API=https://d.ellep.dev export SITED_ADMIN_TOKEN=... printf %s "$OPENAI_API_KEY" | npx @lucaspll/sited env set SLUG OPENAI_API_KEY --stdin npx @lucaspll/sited env list SLUG npx @lucaspll/sited env unset SLUG OPENAI_API_KEY ``` Read them only from server handlers, for example `ctx.env.OPENAI_API_KEY`. ## Data and Files - `shared/` contains pure TypeScript shared by client and server. Do not use secrets, DOM, Node, or Sited runtime APIs there. - `assets/` serves bundled files at `/assets/...`. - `data/` serves bundled files at `/data/...` and can be imported by server or shared code. - `.sitedignore` excludes packaged asset and data files using gitignore-style patterns. Use `/data/...` only for static data safe for browsers. Use RPC when the server must enforce auth, use secrets, access the database, compute or filter results, hide raw data, or return user-specific values. Private capsules gate HTML, client assets, data files, and RPC routes. Capsules cannot define HTTP routes or use a general runtime object-upload API. Store small uploads only after validating their type and size. ## Deploy and Inspect With `SITED_API=https://d.ellep.dev` and `SITED_ADMIN_TOKEN` set: ```sh npm run build npm run deploy npx @lucaspll/sited logs SLUG npx @lucaspll/sited db SLUG npx @lucaspll/sited assets SLUG ``` ## Limits - Entrypoints are `server/index.ts` and `client/index.tsx`. - Use relative imports, `sited/server`, `sited/client`, Preact, browser APIs, and bundled pure TypeScript dependencies. - Unsupported: Node built-ins, native Node modules, runtime dynamic imports, Vite, PostCSS, Tailwind configuration, and Tailwind plugins. - Deploy assets are limited to 10 MiB each, 16 MiB total raw file payload, and 4096 files. - Rollback means reverting the source and deploying again.