Skip to content
Lakefront
Esc
↑↓navigate↵open⌘Jpreview
On this page

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().

Last updated on September 10, 2026

Was this page helpful?