Serving
The native build can answer HTTP itself, so a database and an app file are a running site:
LOAD duckweb;
ATTACH 'toyapp.duckdb' AS app (READ_ONLY);
-- anything the app's render macro reads must exist before the first request
CREATE TABLE notes(ts TIMESTAMP, body VARCHAR);
SELECT duckweb_serve('127.0.0.1', 8080, '/path/to/assets', 'memory.main,app.main');
-- ...
SELECT duckweb_stop();Responses are streamed: rows go to the socket as render produces them, so the head arrives while a slow part of the page is still being computed. It binds loopback with no authentication; any other interface has to be asked for.
The fourth argument is a search path. Settings are per connection and the server has its own, so it inherits nothing from the session that started it.
/_duckweb/ is reserved: client.js and host.js come from the extension, everything else from the asset directory.
Files at a URL the app chose
/_duckweb/ is answered before render ever runs, so it fixes the URL of every file. duckweb_asset(path) gives that choice back to the app, without moving any bytes through SQL:
UNION ALL SELECT duckweb_asset('v2.0.0/linux_amd64/duckweb.duckdb_extension.gz')
FROM duckweb_match(route, '/v2.0.0/linux_amd64/duckweb.duckdb_extension.gz')The route is matched the ordinary way; what comes back is not the file but a sentinel naming it, in the spirit of nginx’s X-Accel-Redirect. The server recognises it and serves the file itself — so Range, ETag, 304 and Content-Length behave exactly as they do under the reserved prefix, which is the same code path, and a binary never has to survive a VARCHAR. Ranges are what make this usable at all: DuckDB reads remote files by range.
Like duckweb_redirect it is the whole response, so the branch emits this one row and nothing else — no head, no terminator. A second argument adds header lines, the same convention as duckweb_head’s third:
SELECT duckweb_asset(m.path, duckweb_header('Cache-Control', 'max-age=60'))The path is confined to the asset directory. Empty, containing .., or starting with / is a 404, before anything touches the filesystem — an app is free to build the path out of the request, and naming an absolute one must not read a file the process merely happens to be able to open.
In the wasm and service-worker tiers there is no filesystem and no server, so there is nothing to hand a file off to and the function answers 404. It is still registered there: an app’s render is one query, so a name missing from a tier stops every route binding, not the route that used it. A route serving a file therefore works natively and 404s in the browser, rather than the app failing to bind at all.
The payoff is that an extension repository can be published at the root of a host — INSTALL duckweb FROM 'https://host', with no /_duckweb in it, from a server that is also a duckweb app.
HTTPS
A fifth argument names domains, and the same call becomes an HTTPS server, with a certificate ordered from an ACME provider — Let’s Encrypt unless told otherwise — and renewed for as long as it runs:
SELECT duckweb_serve('0.0.0.0', 443, '/path/to/assets', 'app.main', {
domains: 'example.com,www.example.com',
contact: 'admin@example.com',
cache: '/var/lib/duckweb/acme'
});
-- duckweb serving example.com, www.example.com on https://0.0.0.0:443| option | |
|---|---|
domains |
comma-separated. Absent — the default — serves plain HTTP |
contact |
comma-separated; an entry with no URI scheme is taken as an email |
cache |
directory for the account key and the certificate. Required |
directory |
letsencrypt (default), staging, or a directory URL |
root |
PEM roots for an ACME server the public list does not cover |
render |
this server’s render function, overriding duckweb_render_function |
Options rather than settings because every one of them identifies a server, and a port can carry several. A misspelled option is an error, not a silently ignored one.
The domain is proven over TLS-ALPN-01, inside a handshake on the port duckweb is already listening on. So there is no second listener on 80, no /.well-known/ route, and nothing for an app to serve: the challenge is a connection carrying the acme-tls/1 ALPN, answered with a certificate generated for it and closed before it can reach a route. What it does need is for the provider to reach that port from the internet, under every name being issued.
duckweb_serve does not return until the first certificate is in place, so a domain that does not point here is an error at the call rather than a site that quietly answers nothing. A failure while renewing is different — that has weeks of slack — and goes to stderr while the existing certificate keeps serving.
The cache is required rather than optional because going without one fails silently and expensively: every restart would register an account and order again, against rate limits measured in a handful per week. Point directory at staging while working on it — those certificates are untrusted, but the limits are generous.
More than one server on a port
443 is the only port a browser will reach without being told otherwise, so several sites usually have to share it. Each is its own duckweb_serve, and each carries its own domains, schemas, assets and certificate:
SELECT duckweb_serve('0.0.0.0', 443, '/srv/blog/assets', 'blog.main',
{domains: 'blog.example.com', contact: 'admin@example.com',
cache: '/var/lib/duckweb/acme'});
SELECT duckweb_serve('0.0.0.0', 443, '/srv/shop/assets', 'shop.main',
{domains: 'shop.example.com', contact: 'admin@example.com',
cache: '/var/lib/duckweb/acme'});The first call binds the port; the second joins the listener already there, and has to name the same host to find it. What separates them is the name in the client hello — so this works for HTTPS and only for HTTPS. Two plain-HTTP servers on one port have nothing to tell them apart, and the second is refused.
A connection that names nothing recognisable is answered by whichever server bound the port first. Each server keeps its own search path, applied per request, so the apps do not have to agree on schemas.
duckweb_stop() stops all of them.