Embedded with Bun (HTTP)
The smallest possible integration: Bun.serve calling the shared engine handle directly. No framework, no abstraction — just the bare mechanics, so you can see exactly what every other framework page is really doing underneath.
What we're building
A tiny notes API with two endpoints — GET /notes (list) and POST /notes (create) — backed by an embedded file:// database. Prerequisites: bun add @twilldb/bun and the shared db.ts module from the overview.
Step 1 — the shared handle
The database is opened once in its own module so every part of the app imports the same handle. Opening per request would be wrong: it's a long-lived resource, and one process should hold one handle.
// db.ts
import { open, type Database } from "@twilldb/bun";
export const db: Database = open(process.env.TWILLDB_URL ?? "file://./app.db");
db.exec(`CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY,
body TEXT NOT NULL,
ts TEXT NOT NULL
)`);
Breakdown: open() picks the backend from the URL scheme (file:// here). db.exec() runs a statement that returns no rows; CREATE TABLE runs in autocommit, so the schema exists before the first request. IF NOT EXISTS makes boot idempotent across restarts.
Step 2 — the server
Bun.serve takes a single fetch(req) function. We import the shared handle and branch on method + path.
// server.ts
import { db } from "./db";
Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
// GET /notes — list the 50 most recent
if (url.pathname === "/notes" && req.method === "GET") {
const rows = db.query("SELECT * FROM notes ORDER BY id DESC LIMIT 50");
return Response.json(rows);
}
// POST /notes — create one
if (url.pathname === "/notes" && req.method === "POST") {
const { body } = (await req.json()) as { body: string };
if (!body) return new Response("body required", { status: 400 });
db.query("INSERT INTO notes (body, ts) VALUES (?, ?)", [body, new Date().toISOString()]);
return Response.json({ ok: true }, { status: 201 });
}
return new Response("not found", { status: 404 });
},
});
console.log("listening on http://localhost:3000");
Breakdown:
db.query(sql)with no params buffers and returns all rows as an array of plain objects (column → value).db.query(sql, params)binds positional?placeholders to the array — this is how you pass user input safely. Never string-concatenate it into SQL.- Values come back as strings (or
null); cast in your app if you need numbers/dates.Response.json(rows)serializes the array directly.
Step 3 — run it
bun run ./server.ts
# in another shell
curl localhost:3000/notes
curl -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"body":"hello"}'
Step 4 — transactions & error handling
For multi-statement writes that must be atomic, wrap them in db.transaction(). It commits durably on normal return and rolls back + rethrows on error — so you translate the throw into an HTTP status.
import { EngineError } from "@twilldb/bun";
try {
db.transaction((tx) => {
tx.query("UPDATE accounts SET balance = balance - ? WHERE id = ?", [100, 1]);
tx.query("UPDATE accounts SET balance = balance + ? WHERE id = ?", [100, 2]);
});
} catch (e) {
if (e instanceof EngineError && e.retryable) {
// write conflict or transient storage error — safe to retry
return new Response("conflict, retry", { status: 409 });
}
throw e;
}
Breakdown: EngineError carries the numeric status and a retryable flag (true for write-conflict / transient storage errors). A conflict means another writer committed first — retrying the whole transaction is the correct response.
Clean shutdown
process.on("beforeExit", () => db.close());