on GitHub

From the browser

The n6k extension is compiled to WASM as well as native, so a browser tab is a first-class client. It attaches a catalog, runs SQL against it in the tab, and gets Arrow back over one WebSocket.

This is not a separate product from the rest of the site — it is how every page here works. A data.py publishes tables, the server serves them as a catalog, and the page's DuckDB-WASM attaches that catalog and queries it. The charts on the showcase pages are SELECT statements running a few inches from your eyes.

The default: the driver owns the socket

Same statement as the CLI. In the browser the token comes from the host application rather than a persistent secret, since there is nowhere durable to keep it:

await conn.query(
  `ATTACH 'n6ks://data.example' AS db (TYPE n6k, token '${jwt}')`,
);
await conn.query(`SELECT region, sum(amount) FROM db.orders GROUP BY 1`);

Riding a socket the app already opened

An application that already holds a WebSocket — for auth, for reconnection, for its own messages — can hand it to the extension instead of having a second one opened alongside it:

const { conn, registerWebsocket, replaceWebsocket } = await createDuckDB();
const wsId = registerWebsocket(ws); // returns "ext_1"
await conn.query(`ATTACH '' AS db (TYPE n6k, wsId '${wsId}')`);

The URL is empty because there is nothing left to dial. Three things then become the application's job:

  • Catalog binding — from ?catalog=<name> on the socket's URL, or, if the URL omits it, from the alias the driver declares in its handshake. If both are present and disagree, the server rejects the attach.
  • Auth — whatever the upgrade carried (a cookie, an Authorization header, a query parameter) already authenticated the socket.
  • Reconnection — the driver cannot reopen a socket it does not own. On a drop it reports disconnected; the app opens a fresh socket and calls replaceWebsocket(wsId, newWs). The catalog stays attached, so there is no DETACH/ATTACH cycle — only the in-flight requests fail, and the app re-issues them.

Sharing one socket with your own traffic

Every n6k frame is a binary WebSocket message. The driver claims binary frames and leaves text alone, attaching its listener with addEventListener rather than overwriting onmessage — so the application keeps sending and receiving its own text messages on the same socket.

Two conditions apply. Don't put your own binary traffic on a shared socket (binaryType is forced to "arraybuffer"), and your server must demultiplex the mirror image: route inbound binary frames into an n6k connection and keep your text handling separate. A socket dedicated to n6k needs no server change at all — point it at /ws?catalog=<name>.

One registered socket backs one catalog, matching the server's model. Use a separate socket per catalog.

The native equivalent

A native host process can do the same thing one level down, handing the extension a connected socket fd — typically from socket.socketpair() — and pumping frames between it and a WebSocket the host opened itself:

ATTACH '' AS db (TYPE n6k, wsFd 7);

wsId is browser-only and wsFd is native-only; each throws on the other build. The fd path adds one requirement the WebSocket path gets for free: a raw byte stream has no message boundaries, so every frame must be written as [4-byte big-endian length][frame bytes].

Connection readiness

A server session can take seconds to build — attaching a remote source, warming a catalog. The driver separates transport up from session usable:

ATTACH returns as soon as the WebSocket handshake completes. It creates only the default schema, with no round trip. The first catalog access is what waits for the session, bounded by ready_timeout (default 60s):

ATTACH 'n6ks://data.example' AS db (TYPE n6k, ready_timeout '120s');

Exceed it and the first query fails with a typed n6k session not ready after … rather than a bare socket error. A build that fails surfaces the server's own error on that first query.

Browser timing differs

ready_timeout is honored on native only. The WASM build gates the first request the same way, but surfaces a stuck or failed build by closing the socket rather than by counting down a timer.