Serve over HTTP
Start a server node with serve, then talk to it with the typed client or the raw RPC client.
Why serve over HTTP
Serve when several services or languages need to share the same boards, when you want horizontal scale-out through the router, or when callers should hold a capability token instead of catalog credentials. Skip it when one service owns its boards outright: embed the engine directly, and keep any write that needs to read first (a transaction) embedded too, since that combination isn’t available over HTTP. See the transactions guide for why.
Starting a node
serve from lakefront/server opens an Engine and puts an HTTP front end
on it. ServeOptions extends OpenOptions (see the
full knob table) with three
additions: port, nodeId, and auth.
import { serve } from "lakefront/server";
const node = await serve({
postgres: "postgres://lakefront:lakefront@127.0.0.1:5432/lakefront",
data: "./data",
port: 8080,
});
console.log(`listening on ${node.url}`);
port: 0 asks the OS to pick a free port. Read the assigned port and URL from the returned server:
const node = await serve({ postgres, data: "./data", port: 0 });
node.port; // the OS-assigned port
node.url; // http://127.0.0.1:<port>
nodeId defaults to node-${process.pid}; set it explicitly in anything with
more than one process, since it is what shows up in node.stats() and in a
query’s servedBy field.
auth is covered on its own page. See
the auth guide. Leave it unset and every request
is permitted, which is the zero-config default the tour, the benchmarks, and
local development all run under.
The returned Server carries the Engine instance directly
(node.engine), so operations like flush, maintain,
stats, and evict are reachable without a second connection to the lake.
node.stop() closes the HTTP server, waits for in-flight replica work to
settle, and closes the engine.
Endpoints
A node exposes three routes:
| Route | Purpose |
|---|---|
GET /health |
Liveness check: no DuckDB work, so a cold node still answers |
POST /rpc/:procedure |
Every read and write in the wire contract |
GET /watch |
Server-sent-events change stream |
See the wire protocol reference for request and response formats, headers, and SSE events.
Using the typed client
connect from lakefront/client wraps the wire contract in a Kysely query
builder and a set of mutation methods, scoped to one board at a time:
import { connect } from "lakefront/client";
const board = connect({ url: node.url }).board("acme_roadmap");
const counted = (await board.query("SELECT count(*) AS n FROM {{table}}")).unwrap("query");
const rows = await board.select(["id", "state"]).execute();
const written = await board.insert({ id: "i1", state: "active", title: "first item" });
written.unwrap("insert failed"); // -> the watermark it committed at
query is the engine’s own read surface over the wire: same SQL, same outcome
shape. select is the query builder rooted at the board; both go through
query.execute. The mutation methods (insert, insertMany, update,
updateMany, delete, deleteMany) go through the corresponding mutate.*
procedures and each return Result<number, WireError>, where the number is the
watermark the write committed at.
Unwrap it with .unwrap() for a happy path, or check isErr() and branch
on error._tag when a caller needs to handle the failure.
connect(...).board(id).atLeast(watermark) returns a new client whose reads
refuse a replica older than that watermark: this is the read-your-write
mechanism. See the consistency model for what that
guarantees.
The board client also exposes watch(), following the change feed over this
same connection. See the watch guide for the
consumption pattern.
Procedures BoardClient doesn’t expose
BoardClient covers queries, the three mutation procedures (insert, update,
delete, each with a singular and a Many form), stats(), and watch().
It does not expose changes.read. Reach for createRpcClient from
lakefront/contract directly when you need to page the change feed manually,
or any procedure the board client doesn’t wrap:
import { createRpcClient, orThrow } from "lakefront/contract";
const rpc = createRpcClient({ url: node.url, boardId: "acme_roadmap" });
const page = await rpc["changes.read"]({ after: 0 });
// orThrow() unwraps every call instead of returning a Result -- useful in
// scripts and demos where a server-side failure is a bug, not a case to handle.
const stats = await orThrow(rpc)["node.stats"]({});
createRpcClient is also what BoardClient is built on, so its options
(url, boardId, token, timeoutMs, retry) are the same ones connect
accepts. boardId is optional when the token pins exactly one board; see
the auth guide for that.
For the full watch-feed contract, including tail-only retention, the resync
event, and how to recover from one, see watch().