Server
What's new in 1.15.0
The server module gained first-party session, CSRF, guard, and auth primitives (session, memoryStore, csrf, csrfToken, guard, basicAuth, bearerAuth) plus the Web-Crypto signing utilities they build on (signValue, unsignValue, timingSafeEqual, randomToken, randomId). app.listen() now also supports Deno. With these, server graduated to Stable in 1.15.0 — see Stability.
What's new in 1.14.0
The server module gained ServerHttpError, expanded ctx helpers (ctx.body, ctx.cookies, ctx.setCookie, ctx.accepts, ctx.stream, ctx.sse, ctx.renderStream, ctx.renderResponse), and app.listen() in 1.14.0. Cookies now validate header-safe characters and body parsing enforces size limits by streamed byte count before decoding JSON / form / multipart / text bodies. See the 1.14.0 release notes.
The server module adds a lightweight, Express-inspired backend layer to bQuery without introducing runtime dependencies. It focuses on the smallest useful primitives for request pipelines: middleware, route params, query parsing, safe response helpers, direct SSR rendering, runtime-agnostic WebSocket session routing, and — as of 1.15.0 — request-scoped sessions, CSRF protection, route guards, and authentication helpers.
import {
ServerHttpError,
badRequest,
basicAuth,
bearerAuth,
createServer,
csrf,
csrfToken,
guard,
memoryStore,
session,
} from '@bquery/bquery/server';Stability
server was introduced in 1.11.0 and expanded materially in 1.14.0; the work to graduate it is tracked in #131, and its session/middleware prerequisite #132 is resolved (see Sessions, CSRF, guards, and auth). It graduated to Stable in 1.15.0, with the ctx/app surface frozen under the no-breaking-changes-between-minors contract.
Exit criteria
- [x]
ctx/appcontract frozen and documented (below). The 1.15.0 session work is additive (ctx.sessionis optional). - [x] Session + middleware primitives (#132) —
session,memoryStore,csrf,guard,basicAuth,bearerAuth. - [x] Test coverage across routing, streaming, SSE, cookies, content negotiation, sessions, CSRF, and auth (
tests/server.test.ts,tests/server-stable.test.ts). - [x] Per-runtime support matrix published (below).
- [x] File-based routing bridge (#149) — shipped in 1.15.0 (
createFileRoutes+mountFileRoutes). - [x] Public surface frozen (no additive breaking changes) — committed under the Stable contract from 1.15.0.
Frozen surface
The contract that must not break once Stable:
- App:
createServer,app.use,app.add,app.get/post/put/patch/delete/all,app.ws,app.handle,app.handleWebSocket,app.listen. - Context:
ctx.{request,url,method,path,params,query,cookies,state,session,body,response,text,html,json,stream,sse,accepts,setCookie,redirect,render,renderStream,renderResponse,runTask,callWorker,isWebSocketRequest}. - Primitives:
session/memoryStore,csrf/csrfToken,guard,basicAuth/bearerAuth, and the signing utilitiessignValue/unsignValue/timingSafeEqual/randomToken/randomId. - Errors:
ServerHttpError,badRequest,unauthorized,forbidden,notFound,conflict.
Per-runtime support matrix
| Capability | Node ≥ 24 | Bun | Deno | Edge |
|---|---|---|---|---|
app.handle (fetch-style request) | yes | yes | yes | yes |
app.listen (native HTTP listener) | yes | yes | yes | n/a |
app.handleWebSocket sessions | adapter | yes | adapter | runtime |
| Signed sessions / CSRF (Web Crypto) | yes | yes | yes | yes |
SSR responses (ctx.render*) | yes | yes | yes | yes |
Sessions and CSRF rely on the standard globalThis.crypto.subtle, available on every targeted runtime. The cross-runtime CI smoke (tests/cross-runtime/run.mjs) exercises signing, sessions, and CSRF on Node, Bun, and Deno. On edge runtimes without persistent sockets, use app.handle from a fetch handler; WebSocket adoption depends on runtime APIs.
Public surface
Runtime helpers:
| Export | Purpose |
|---|---|
createServer() | Create an app-like server handle with middleware, HTTP routes, SSR responses, and WebSocket routing. |
ServerHttpError / badRequest() etc. | Structured HTTP errors for reusable status-aware failures. |
isWebSocketRequest(request) | Check whether a Request is a valid WebSocket upgrade handshake. |
isServerWebSocketSession(value) | Narrow the result of handleWebSocket() to a runtime-agnostic session descriptor. |
session() / memoryStore() | Signed cookie sessions backed by a pluggable store (1.15.0). |
csrf() / csrfToken() | Double-submit CSRF protection and per-request token accessor (1.15.0). |
guard() | Predicate-based route guard middleware (1.15.0). |
basicAuth() / bearerAuth() | Authorization-header auth helpers with a verify hook (1.15.0). |
rateLimit() | Fixed-window request throttling with RateLimit-* headers and a pluggable store. |
serveStatic() | Serve files from disk, with ETag/304, Range, and precompressed sidecars. |
signValue() / unsignValue() | HMAC-SHA-256 sign/verify with secret rotation (1.15.0). |
timingSafeEqual() | Constant-time string comparison (1.15.0). |
randomToken() / randomId() | CSPRNG-backed token and id generation (1.15.0). |
base64UrlEncode() / base64UrlDecode() | URL-safe base64 codecs used by the signing helpers (1.15.0). |
Commonly used types:
| Type | Purpose |
|---|---|
ServerApp | The app handle returned by createServer(). |
ServerContext | Per-request context passed to handlers and middleware. |
ServerRoute | Route definition accepted by app.add(). |
ServerRequestInit | Lightweight request input accepted by handle() and handleWebSocket(). |
ServerResult | Result union for WebSocket resolution: Response, ServerWebSocketSession, or null. |
ServerWebSocketSession | Runtime-agnostic session object returned for matched WebSocket upgrades. |
ServerWebSocketPeer | Minimal runtime socket shape consumed by a session. |
ServerWebSocketConnection | Wrapped peer passed to WebSocket handlers, including sendJson(). |
ServerWebSocketHandlerSet | WebSocket lifecycle callbacks and handshake metadata. |
ServerWebSocketMiddleware | Middleware shape for WebSocket route pipelines. |
ServerWebSocketData | Raw payload union accepted by socket.send(...). |
ServerSession | Request-scoped session surface attached to ctx.session. |
SessionStore / SessionData | Pluggable session store contract and its payload shape. |
SessionOptions / MemoryStoreOptions | Configuration for session() and memoryStore(). |
CsrfOptions | Configuration for the csrf() middleware. |
GuardOptions | Configuration for the guard() middleware. |
BasicAuthOptions / BasicAuthCredentials / BearerAuthOptions | Configuration and parsed credentials for the auth helpers. |
import type { ServerContext, ServerSession, ServerWebSocketSession } from '@bquery/bquery/server';createServer()
Creates an app-like request handler with use(), get(), post(), put(), patch(), delete(), all(), add(), ws(), handle(), and handleWebSocket().
Recent additions:
app.listen()for runtime-native listeners on supported runtimesctx.body()for content-type-aware body parsingctx.cookiesandctx.setCookie()ctx.stream(),ctx.sse(),ctx.renderStream(), andctx.renderResponse()- structured
ServerHttpErrorhelpers
const app = createServer();Basic usage
const app = createServer();
app.use(async (ctx, next) => {
ctx.state.startedAt = Date.now();
return await next();
});
app.get('/health', (ctx) => ctx.json({ ok: true }));
app.get('/users/:id', (ctx) =>
ctx.json({
id: ctx.params.id,
include: ctx.query.include,
})
);
const response = await app.handle('/users/42?include=roles&include=teams');Context helpers
Each handler receives a ServerContext with:
request— normalizedRequesturl— parsedURLfor the current requestpath— normalized pathname without query stringmethod— uppercase HTTP methodparams— null-prototype route params captured from:paramsegmentsquery— null-prototype query params (stringorstring[]for repeated keys)state— mutable per-request bag for middleware coordinationcookies— parsed request cookiesisWebSocketRequest—truefor upgrade handshakesbody()— parse JSON, urlencoded form, multipart form-data, text, or raw buffersaccepts(types)— returns the first matching accepted media typesetCookie(name, value, options?)— appendsSet-Cookie
Response helpers:
ctx.response(body, init?)ctx.text(body, init?)ctx.html(body, init?)— sanitizes by defaultctx.json(data, init?)ctx.stream(stream, init?)ctx.sse(source, init?)ctx.redirect(location, status?)ctx.render(template, data, options?)— wrapsrenderToString()with the same DOM-free fallback used by@bquery/bquery/ssrctx.renderStream(template, data, options?)ctx.renderResponse(template, data, options?)
params and query are created as null-prototype dictionaries and reserved keys such as __proto__, constructor, and prototype are rejected or ignored to keep request-derived data isolated from object prototypes.
Repeated query values are preserved as arrays:
app.get('/search', (ctx) =>
ctx.json({
tags: ctx.query.tag,
})
);
await app.handle('/search?tag=docs&tag=server');
// => { "tags": ["docs", "server"] }Body parsing and cookies
app.post('/profile', async (ctx) => {
const body = (await ctx.body()) as { name: string };
ctx.setCookie('seen-profile', '1', { httpOnly: true, path: '/' });
return ctx.json({ name: body.name, theme: ctx.cookies.theme });
});Streaming and SSE helpers
app.get('/events', (ctx) =>
ctx.sse([
{ event: 'ready', data: 'connected' },
{ event: 'message', data: 'hello' },
])
);
app.get('/stream', (ctx) =>
ctx.renderStream('<main><h1 bq-text="title"></h1></main>', { title: 'Streamed' })
);Middleware and error handling
Global middleware registered with app.use() runs before route-scoped middleware. Both can share per-request values through ctx.state and can short-circuit the pipeline by returning a Response instead of calling next().
const app = createServer({
middlewares: [
async (ctx, next) => {
ctx.state.requestId = ctx.request.headers.get('x-request-id') ?? 'local';
return await next();
},
],
notFound: (ctx) => ctx.json({ message: 'Not Found' }, { status: 404 }),
onError(error, ctx) {
const message = error instanceof Error ? error.message : 'Unknown error';
return ctx.json({ message }, { status: 500 });
},
});You can also throw ServerHttpError (or helpers such as badRequest()) to return a status-aware response through the default error handler:
app.get('/input', () => {
throw badRequest('Missing required query parameter.');
});Route-scoped middleware is passed as the third argument to method helpers or through ServerRoute.middlewares when using app.add():
const requireUser = async (ctx, next) => {
if (!ctx.request.headers.has('authorization')) {
return ctx.json({ message: 'Unauthorized' }, { status: 401 });
}
return await next();
};
app.get('/admin', (ctx) => ctx.json({ requestId: ctx.state.requestId }), [requireUser]);createServer() also accepts baseUrl for resolving relative inputs passed to handle() and handleWebSocket(). This keeps tests and zero-build examples ergonomic while still normalizing every request to a standard Web Request internally.
Sessions, CSRF, guards, and auth
As of 1.15.0 the server module ships first-party, secure-by-default primitives for the things every non-trivial service needs. Each is independently importable and tree-shakeable, and none pull a runtime dependency — signing is built on the standard globalThis.crypto.subtle.
Sessions
session(options) returns middleware that loads, exposes, and persists a request-scoped session. The session id lives in an HMAC-signed cookie (tampered cookies are ignored); the payload lives in a pluggable store (default: process-local memoryStore(), capped at 10 000 sessions with least-recently-used eviction), so the cookie never carries session data. memoryStore() also sweeps expired sessions during writes, so abandoned sessions do not accumulate; for more than one process, plug in a shared store.
import { createServer, session, memoryStore } from '@bquery/bquery/server';
const app = createServer();
app.use(session({ secret: process.env.SECRET!, store: memoryStore() }));
app.post('/login', (ctx) => {
ctx.session!.userId = 'u_123'; // marks the session dirty → persisted + cookie set
return ctx.json({ ok: true });
});
app.get('/me', (ctx) => ctx.json({ userId: ctx.session?.userId ?? null }));Read and write payload as plain properties. Lifecycle operations use $-prefixed members so they never collide with data keys:
| Member | Description |
|---|---|
ctx.session.$id | Current session id, or null until first write. |
ctx.session.$isNew | true when no existing session was loaded. |
ctx.session.$data | Shallow snapshot of the payload. |
ctx.session.$regenerate() | Rotate the id, keep the payload (session-fixation defense). |
ctx.session.$destroy() | Clear the payload and expire the cookie. |
ctx.session.$clear() | Remove every key without destroying the session. |
Options: secret (string or array for rotation), store, cookieName, cookie (attributes; defaults to httpOnly, sameSite: 'lax', path: '/' and secure: true, so set secure: false for local HTTP dev), ttlMs (1 day), rolling, and genId.
The session cookie is named __Host-bq.sid by default. Browsers accept a __Host- cookie only over HTTPS, with Path=/ and without a Domain, so a sibling subdomain cannot plant a session id of its own on your users (session swapping). With secure: false, a custom path or a domain, the prefix is not allowed and the name falls back to bq.sid. That name, like an explicit cookieName: 'bq.sid', has no __Host- protection: a sibling subdomain can plant a validly signed session id of its own again, so keep the defaults in production. A cookieName whose __Host- or __Secure- prefix conflicts with the attributes throws at startup, because the browser would drop the cookie and no session would ever stick. So does a mis-cased prefix such as __host-, which older browsers do not enforce.
Session changes are persisted (and the cookie issued) when the handler resolves a response. If a handler throws, the changes are not persisted — keep session writes on the success path, or write before any operation that may throw.
Bring your own store by implementing SessionStore — no client is bundled:
import type { SessionStore } from '@bquery/bquery/server';
const redisStore = (client): SessionStore => ({
async get(id) {
const raw = await client.get(`sess:${id}`);
return raw ? JSON.parse(raw) : null;
},
async set(id, data, ttlMs) {
const payload = JSON.stringify(data);
if (ttlMs && ttlMs > 0) await client.set(`sess:${id}`, payload, 'PX', ttlMs);
else await client.set(`sess:${id}`, payload);
},
async destroy(id) {
await client.del(`sess:${id}`);
},
});CSRF
csrf(options) enforces the OWASP double-submit-cookie pattern. Safe requests (GET/HEAD/OPTIONS) mint a per-client secret cookie and expose the matching token via csrfToken(ctx); state-changing requests must echo that token back in the x-csrf-token header (or a _csrf body field) or are rejected with 403. Provide a secret to upgrade to signed double-submit.
Signing alone does not stop cookie injection: someone who can set cookies for your users (a sibling subdomain, or a network attacker for a non-__Host- cookie) can plant the secret from a validly signed pair minted for themselves. So in signed mode, when session() runs before csrf() and the request carries a stored session, the secret is kept in that session instead of a cookie (synchronizer token): tokens are bound to the session and no CSRF cookie is set. $regenerate() (call it on login) invalidates the token, so a session planted before login cannot carry a known token into the authenticated one; render a fresh csrfToken(ctx) after logging in. Visitors without a session get the signed CSRF cookie instead, so anonymous traffic never creates sessions or fills the session store. When a handler creates the session (e.g. adding to a cart), the session adopts that cookie's secret, so forms rendered before it keep working; a session created with $regenerate() (a login) gets a fresh secret instead, so a planted cookie cannot reach the authenticated session. Pass bindToSession: false to keep the cookie-based behaviour for everyone.
Anonymous forms (such as the login form) still rely on the cookie. It is therefore named __Host-bq.csrf by default: browsers accept a __Host- cookie only over HTTPS, with Path=/ and without a Domain. A sibling subdomain or a network attacker therefore cannot plant or overwrite it, and a planted bq.csrf is ignored. With secure: false (local HTTP dev), a custom path or a domain, the prefix is not allowed and the cookie falls back to bq.csrf. A cookieName whose __Host- or __Secure- prefix conflicts with the attributes throws at startup instead of failing every request with 403. So does a mis-cased prefix such as __host-, which older browsers do not enforce. Client code that reads the cookie directly must use the new name.
Unsigned mode (no secret) is plain double-submit: the token is the cookie value. It still stops a cross-site form that cannot read the cookie. It does not stop anyone who can set cookies for your users, because they choose both the cookie and the token. The __Host- default closes that path in browsers, but prefer signed mode with session() wherever the app has sessions.
app.use(session({ secret: process.env.SESSION_SECRET! }));
app.use(csrf({ secret: process.env.CSRF_SECRET! })); // token bound to the sessionimport { createServer, csrf, csrfToken } from '@bquery/bquery/server';
const app = createServer();
app.use(csrf({ secret: process.env.SECRET! }));
app.get('/form', (ctx) =>
ctx.html(
`<form method="post"><input type="hidden" name="_csrf" value="${csrfToken(ctx)}"></form>`,
{
trusted: true,
}
)
);CSRF guards request integrity; it composes with the security module (sanitizeHtml(), Trusted Types), which guards output — use both for defense in depth.
Guards and auth
guard(predicate, options?) allows a request only when predicate(ctx) resolves truthy, otherwise it denies with onDeny or a ServerHttpError (default 403). basicAuth and bearerAuth parse the Authorization header and delegate the credential check to your verify callback, storing the resolved user on ctx.state.user.
import { createServer, guard, bearerAuth } from '@bquery/bquery/server';
const app = createServer();
app.use(bearerAuth({ verify: (token) => verifyJwt(token) }));
const requireUser = guard((ctx) => Boolean(ctx.state.user), { status: 401 });
app.get('/me', (ctx) => ctx.json({ user: ctx.state.user }), [requireUser]);Rate limiting
rateLimit() caps how many requests one key may make per window. Pair it with the auth helpers above: an unprotected login route is a brute-force target, and that is exactly where a limit belongs.
import { createServer, rateLimit, session } from '@bquery/bquery/server';
const app = createServer();
app.use(session({ secret: process.env.SECRET! }));
app.post('/login', handleLogin, [
rateLimit({
window: 15 * 60_000,
max: 5,
// Behind a proxy, key on the address it reports. A session id is `null`
// for the cookie-less request a brute-force script sends — and a `null`
// key skips the limit, so it would protect nothing here.
trustProxy: true,
skipSuccessfulRequests: true, // a valid login should not use up the budget
}),
]);Under the limit, responses carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. Over it, the request is answered 429 Too Many Requests with Retry-After — and Retry-After is sent even with headers: false, since a client needs something to back off on.
Choosing keyBy
keyBy is required, and that is deliberate. The obvious default — the client's address from X-Forwarded-For — is a header the client sets unless a proxy you control overwrites it. Keying on it without that proxy gives a limiter an attacker bypasses by sending a different header per request: worse than no limiter, because it looks like protection.
So pick the identity that is actually meaningful for the route:
keyBy | Good for |
|---|---|
(ctx) => ctx.session?.$id ?? 'anon' | Per-browser limits on session-bearing routes |
(ctx) => (ctx.state.user as User)?.id ?? 'anon' | Per-account limits after auth |
(ctx) => ctx.request.headers.get('x-api-key') ?? 'anon' | Per-API-key quotas |
trustProxy: true (instead of keyBy) | Behind a proxy (see below) |
A null key fails open
Returning null skips the limit entirely for that request. Every value in the table above is null for exactly the caller you most want to throttle — ctx.session?.$id before a session exists, ctx.state.user?.id before authentication, a missing x-api-key header. Written as ?? null, a cookie-less brute-force script gets unlimited attempts.
The ?? 'anon' fallbacks keep those requests in one counted bucket instead. That bucket is shared, so size max for it accordingly, or use trustProxy behind a proxy to separate callers by address.
trustProxy and which header it reads
trustProxy: true reads the rightmost X-Forwarded-For entry, and that header only. The list grows left to right as a request is forwarded, so the rightmost entry is the address the proxy closest to you observed — and it is there whether that proxy appends (Cloudflare, and nginx's stock proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for) or overwrites. Everything further left is whatever the client sent, so keying on the leftmost entry would leave the limit bypassable by rotating one header.
rateLimit({ window: 60_000, max: 10, trustProxy: true }); // rightmost XFF hop
rateLimit({ window: 60_000, max: 10, trustProxy: 'cf-connecting-ip' }); // that header, wholeCF-Connecting-IP, True-Client-IP and X-Real-IP are not read unless you name one. Reading whichever of them happened to be present would reopen the same bypass from the other side: a proxy that sets CF-Connecting-IP does not necessarily strip X-Real-IP, so a client could pick its own bucket by sending a header the proxy never writes.
Name a header only if your proxy sets it on every request
trustProxy: 'x-real-ip' is safe when your proxy writes X-Real-IP itself, overwriting whatever arrived. If it merely passes the header through, the value is client-controlled and the limiter is bypassable. When in doubt, trustProxy: true is the safe choice: the rightmost X-Forwarded-For entry is proxy-written by construction.
Behind a chain of proxies the rightmost entry is the inner proxy rather than the client, so those requests share a bucket. That over-limits rather than under-limits; a deployment that needs per-client buckets behind a chain should name the header its edge sets, or pass its own keyBy.
A request that reaches the origin without the header — an internal hop, a proxy misconfigured after a deploy — is counted in a single shared bucket rather than skipped, so it cannot slip past the limit unnoticed.
The origin must be reachable only through the proxy
That shared bucket catches requests arriving with no forwarding header. It does nothing about a client that reaches the origin directly and sends one: the rightmost hop is then the client's own invention, no proxy having appended anything, so rotating it mints a fresh counter per request and the limit stops applying.
trustProxy is a statement about the network path, not just about the header. Firewall the origin to the proxy's addresses. An origin port left listening alongside the CDN is the usual way this is lost — and from the outside the app still looks protected.
Options
| Option | Default | Notes |
|---|---|---|
window | (required) | Window length in milliseconds. |
max | (required) | Requests allowed per key per window. |
keyBy | (required*) | Identity to count against. null skips — see the warning above. *Or set trustProxy. |
trustProxy | false | true keys on the rightmost X-Forwarded-For hop; a header name keys on that header. See above. |
store | bounded memoryStore() | Any SessionStore. The default is per process, capped at 10 000 keys. |
prefix | 'rl:' | Store-key prefix, so counters cannot collide with sessions. |
headers | true | Emit the RateLimit-* headers. Retry-After is sent either way. |
status / message | 429 / text | The default rejection response. |
skip | — | Skip a request entirely, without consuming budget. |
onLimit | — | Handle rejection yourself; the headers are still applied. |
skipSuccessfulRequests | false | Refund requests that ended 2xx. A 3xx still counts, so a redirect-on-failure login form stays limited. |
The default store only limits one process
memoryStore() is process-local, so with several instances behind a load balancer each enforces its own count. Pass a shared SessionStore — the same interface sessions use — to make the limit hold across all of them.
The default is bounded at 10 000 keys, because rate-limit keys are attacker-chosen and usually seen once: an unbounded store would turn the limiter into a memory-exhaustion vector. For the same reason, do not pass your session store here — counter churn would evict live sessions.
Within one process the counter is serialized per key, so concurrent requests cannot all read the same value and slip past the limit. Across processes that guarantee needs a store with an atomic increment.
The window is fixed, not sliding: the first request starts it and the counter resets wholesale when it ends. That allows a burst of up to 2 × max across a window boundary, which is the standard trade-off — a sliding window needs per-request timestamps in the store, a much larger write cost for a limiter whose job is to be cheap.
Signing utilities
The primitives above build on small, cross-runtime Web-Crypto helpers that are also exported for custom token logic: signValue / unsignValue (HMAC-SHA-256 sign/verify with secret rotation), timingSafeEqual (constant-time compare), randomToken / randomId (CSPRNG ids), and the base64UrlEncode / base64UrlDecode codecs they use.
WebSocket routes
Register WebSocket endpoints with app.ws(path, handlerSetOrFactory, middlewares?).
handleWebSocket() resolves upgrade requests into a runtime-agnostic session object:
null— request is not a WebSocket handshake or no WebSocket route matchedResponse— middleware or error handling short-circuited the upgradeServerWebSocketSession— ready to attach to your runtime socket
import { createServer, isServerWebSocketSession, isWebSocketRequest } from '@bquery/bquery/server';
const app = createServer();
app.ws('/chat/:room', (ctx) => ({
protocols: ['chat'],
onOpen(socket) {
socket.sendJson({ type: 'ready', room: ctx.params.room });
},
onMessage(message, socket) {
socket.sendJson({ type: 'echo', message });
},
}));
export default async function handler(request: Request) {
if (isWebSocketRequest(request)) {
const result = await app.handleWebSocket(request);
if (result instanceof Response || result === null) {
return result ?? new Response('Not Found', { status: 404 });
}
if (isServerWebSocketSession(result)) {
const { socket, response } = Deno.upgradeWebSocket(request, {
protocol: result.protocols[0],
});
socket.onopen = () => {
void result.open(socket);
};
socket.onmessage = (event) => {
void result.message(socket, event);
};
socket.onclose = (event) => {
void result.close(socket, event);
};
socket.onerror = (event) => {
void result.error(socket, event);
};
return response;
}
}
return app.handle(request);
}Use socket.send(...) for raw frames or socket.sendJson(...) for JSON payloads. Incoming string frames are parsed with JSON.parse() by default and fall back to the raw string when parsing fails; provide deserialize(event) on the route to override that behavior.
Middleware still runs for WebSocket routes, so auth, logging, and per-request state can be shared between HTTP and upgrade flows. Middleware may also short-circuit a WebSocket request by returning a normal Response.
HTTP middleware registered with app.use() is adapted for WebSocket routes. If it calls next(), the WebSocket route can continue resolving to a session; if it returns a Response, the upgrade is blocked before any socket lifecycle callback runs.
Static assets
serveStatic() sends files from disk, so an app can deliver its own client.js without a reverse proxy in front of it.
import { createServer, serveStatic } from '@bquery/bquery/server';
const app = createServer();
app.use(
serveStatic({
root: './dist/client',
prefix: '/assets',
maxAge: 31_536_000,
immutable: true, // content-hashed filenames only
precompressed: true,
})
);
app.get('/', (ctx) => ctx.render('<div bq-text="title"></div>', { title: 'Home' }));It is middleware, not a route: it answers GET and HEAD for paths that resolve to a file under root, and calls next() for everything else — a missing file, another method, a path outside prefix — so your routes still see those requests.
Options
| Option | Default | Notes |
|---|---|---|
root | (required) | Directory to serve. Nothing outside it is reachable, symlinks included. |
prefix | '/' | URL mount point; stripped before resolving against root. |
maxAge | 0 | Cache-Control max-age in seconds. 0 emits no-cache. |
immutable | false | Adds immutable. Only correct for content-hashed filenames. |
index | 'index.html' | File served for a directory. false disables directory indexes. |
precompressed | false | Serve a .br/.gz sidecar when the client accepts that encoding (q=0 is honoured). |
dotfiles | false | Serve dotfiles. Off by default so .env is not exposed; a dotted path is skipped, not refused. |
contentTypes | — | Extra or overriding extension → MIME mappings. |
defaultContentType | 'application/octet-stream' | Fallback MIME type. |
What it handles for you
- Caching. A weak
ETagfrom size and mtime, plusLast-Modified.If-None-MatchandIf-Modified-Sinceare answered with304. Withprecompressed, every response carriesVary: Accept-Encodingand each encoding gets its ownETag, so a cache cannot hand compressed bytes to a client that asked for identity. A sidecar'sETagandLast-Modifiedcome from the sidecar file, so rebuilding only the.br/.gzstill invalidates cached copies of it. - Ranges. Single byte ranges — closed, open-ended and suffix — answered with
206andContent-Range; out-of-range requests get416. Multi-range requests fall back to the whole body. Ranges are not offered over a precompressed body, since those bytes are not the identity representation the client asked to slice — on its304as well as its200. - Directory redirects.
/dirredirects to/dir/with308, so relative links inside the index resolve. - Path traversal. Rejected with
403. Paths are decoded and checked segment by segment, and the resolved path is re-checked againstroot. Encoded traversal (%2e%2e), backslash separators and NUL bytes are all covered. Symlinks are resolved before serving, so a link insiderootpointing outside it is refused too — build outputs are a realistic place for those to appear. - Dotfiles and undecodable paths are not traversal. They are skipped with
next(), not answered403, so a root-mountedserveStatic()does not veto them for the whole app. That keeps/.well-known/...— ACME HTTP-01 renewal,security.txt— answerable by a route, and a URL with a stray%reaches your catch-all.
immutable is a promise about your filenames
immutable tells caches never to revalidate for the whole maxAge. That is only true when the URL changes whenever the content does — content-hashed build output. On a stable filename like /assets/app.js, clients can be stuck with a stale copy for as long as maxAge.
SSR-aware responses
Use ctx.render() when you want to return bQuery SSR markup directly from the backend layer.
app.get('/dashboard', (ctx) =>
ctx.render(
'<main><h1 bq-text="title"></h1></main>',
{ title: 'Dashboard' },
{
includeStoreState: true,
}
)
);ctx.render() appends serialized store state when includeStoreState is enabled, so the response can be sent directly to the client.
ctx.render() uses the existing SSR renderToString() implementation and inherits its 1.11.0 DOM-free fallback. Plain Node.js ≥ 24, Deno, and Bun can therefore render without installing a DOM shim unless you explicitly force the DOM backend via configureSSR({ backend: 'dom' }).
If you need head injection, asset management, caching headers, or ETag handling, pair createServer() with renderToResponse() directly:
import { createServer } from '@bquery/bquery/server';
import { createSSRContext, renderToResponse } from '@bquery/bquery/ssr';
const app = createServer();
app.get('/', (ctx) => {
const ssr = createSSRContext({ request: ctx.request });
ssr.head.add({ title: 'Home' });
ssr.assets.module('/client.js');
return renderToResponse(
'<html><head></head><body><main><h1 bq-text="title"></h1></main></body></html>',
{ title: 'Home' },
{ context: ssr, etag: true, cacheControl: 'public, max-age=60' }
);
});Security defaults
ctx.html()sanitizes markup by default using bQuery's HTML sanitizer.ctx.json()escapes unsafe HTML-significant characters so JSON can be embedded more safely.ctx.render()trustsrenderToString()output, preserving SSR HTML and optional serialized store-state script tags.
If you already have trusted HTML and need to skip sanitization, pass { trusted: true } to ctx.html().
ctx.html() sanitization needs no DOM. Since 1.17.0, sanitizeHtml() falls back to a DOM-free parser when no DOMParser is available, so plain Node.js ≥ 24, Deno and Bun sanitize without installing happy-dom or linkedom. See the security model.
Request validation with Standard Schema
validate(schema) checks the request against any Standard Schema — Zod, Valibot, ArkType, … — before the route runs. bQuery does not depend on the library; it calls the schema's ~standard.validate().
import { z } from 'zod';
import { createServer, validate } from '@bquery/bquery/server';
const Signup = z.object({ email: z.string().email(), age: z.number().min(18) });
const signup = validate(Signup);
const app = createServer();
app.post(
'/signup',
(ctx) => {
const { email, age } = signup.data(ctx); // typed as z.output<typeof Signup>
return ctx.json({ email, age }, { status: 201 });
},
[signup]
);An invalid request is answered with 400 and
{
"error": "Validation failed",
"issues": [{ "message": "Invalid email", "path": ["email"] }]
}On success, signup.data(ctx) returns the validated value — the schema's output, so transforms and coercions apply — and for body validation ctx.body() returns it too. JSON, URL-encoded and multipart bodies are supported (multipart fields become a plain object).
| Option | Default | Description |
|---|---|---|
source | 'body' | Validate 'body', 'query' or route 'params' |
status | 400 | Status of the default failure response |
onInvalid | — | (issues, ctx) => Response for a custom failure response |
The same schema drives createForm({ schema }) on the client — see the shared-schema recipe.
Request body limits
ctx.body() enforces a size limit per content type while it streams the body, so an oversized request is answered with 413 Payload Too Large without being buffered. Every content type has a default:
| Content type | Key | Default |
|---|---|---|
application/json, *+json | json | 1 MiB |
application/x-www-form-urlencoded | form | 1 MiB |
text/* | text | 1 MiB |
multipart/form-data | multipart | 10 MiB |
anything else (raw ArrayBuffer) | raw | 1 MiB |
Override one key and the others keep their default. Infinity lifts a limit:
const app = createServer({
limits: {
json: 5 * 1024 * 1024, // 5 MiB API payloads
multipart: Infinity, // uploads are capped by the reverse proxy instead
},
});The body is read exactly once. ctx.body() caches its result, and ctx.request stays readable afterwards: it is rebuilt from the cached bytes when you access it. Read ctx.request after ctx.body(); a reference you stored before (const req = ctx.request) points at the consumed original. File-route load/action handlers are the exception — the request they receive stays readable either way.
On Node, app.listen() streams the request body into the app on demand instead of reading it up front, so a route that never calls ctx.body() never buffers its body. The largest configured limit also caps the transport: a larger declared Content-Length is answered with 413 before any route runs. With an Infinity limit the transport cap is lifted too. After a 413, or whenever a route answers without reading the whole body, the response carries Connection: close and the rest of the body is not read, so a client cannot keep the server busy by streaming on.
Changed in 1.18
Before 1.18 every limit was unbounded unless configured, and the Node adapter read each non-GET body completely into memory before the app ran. Set a limit to Infinity to restore the unbounded behaviour for that content type.
Body parsing, cookies, and streaming (1.14.0 deep-dive)
import { createServer, badRequest } from '@bquery/bquery/server';
const app = createServer({
limits: {
json: 5_000_000,
},
});
// JSON, form, multipart, or text — auto-detected from Content-Type
app.post('/upload', async (ctx) => {
const body = await ctx.body();
if (!body || typeof body !== 'object') {
throw badRequest('Expected a JSON body.');
}
return ctx.json({ received: Object.keys(body).length });
});
// Cookies are header-safe; reserved keys are rejected
app.get('/me', (ctx) => {
const session = ctx.cookies.session;
ctx.setCookie('seen', '1', { httpOnly: true, sameSite: 'Strict', path: '/' });
return ctx.json({ session });
});
// Streaming with manual chunks
app.get('/log', (ctx) =>
ctx.stream(
new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
controller.enqueue(encoder.encode('start\n'));
setTimeout(() => {
controller.enqueue(encoder.encode('mid\n'));
controller.enqueue(encoder.encode('end\n'));
controller.close();
}, 50);
},
}),
{ headers: { 'content-type': 'text/plain; charset=utf-8' } }
)
);
// Server-Sent Events
app.get('/events', (ctx) =>
ctx.sse(async function* () {
yield { event: 'ready', data: 'connected' };
for (let i = 0; i < 3; i++) {
await new Promise((r) => setTimeout(r, 100));
yield { event: 'tick', data: String(i) };
}
})
);Listening on a runtime
app.listen() (1.14.0) starts a runtime-native listener when available:
// Node 24+
await app.listen({ port: 3000 });
// Bun
Bun.serve({ port: 3000, fetch: app.handle });
// Deno
Deno.serve({ port: 3000 }, app.handle);When app.listen() is unavailable (e.g. edge), use handle() / handleWebSocket() directly from your runtime's request handler.
Error handling
- Throw
ServerHttpError(orbadRequest(),notFound(),unauthorized(),forbidden(), …) to return a status-aware response. - Define
onError(error, ctx)oncreateServer()for a default mapper. - Route-level middleware can short-circuit by returning a
Responseinstead of callingnext().
Pitfalls and gotchas
paramsandqueryare null-prototype dicts — do not rely on inherited methods (hasOwnProperty, etc.).- Body limits are on by default (1 MiB, 10 MiB for multipart) and are enforced before JSON / form parsing; raise them per content type with
createServer({ limits })rather than disabling them. ctx.setCookie()validates header-safe characters and rejects malformed values.ctx.html()sanitizes by default; pass{ trusted: true }only with fully trusted content.- The session and CSRF cookies are
__Host-bq.sidand__Host-bq.csrfby default (since 1.17.2), andbq.sid/bq.csrfwithsecure: false, a custompathor adomain. Client code that reads the CSRF cookie must use the name that matches its attributes. - WebSocket sessions returned by
handleWebSocket()are runtime-agnostic — you must adapt them to your runtime's socket viaresult.open(socket)/result.message(socket, event)/result.close(socket, event).
Performance notes
- Reuse one
createServer()instance per process; route registration is O(1) lookup after a one-time compile. - For high-throughput endpoints, prefer
ctx.jsonoverctx.render*and cache server-rendered HTML viacreateSSRCache(). - Stream large responses with
ctx.streamrather than buffering.
Testing this module
tests/server.test.tscovers routing, middleware, body parsing, cookies, SSE, and WebSocket session resolution.tests/server-stable.test.tscovers sessions, CSRF, guards, auth helpers, and the signing utilities.app.handle(input)accepts astring,URL, orRequest— ideal forbun:testcases.
Deployment targets
| Runtime | Recommended entry | Notes |
|---|---|---|
| Node 24+ | app.listen({ port }) | Native HTTP server via node:http. |
| Bun | app.listen({ port }) or Bun.serve({ fetch: app.handle }) | First-class WebSocket upgrade via Bun.serve. |
| Deno | app.listen({ port }) or Deno.serve(app.handle) | listen() uses Deno.serve (1.15.0). Use Deno.upgradeWebSocket to adopt sessions. |
| Edge | createEdgeHandler(handler) from @bquery/bquery/ssr | Streams responses without persistent sockets. |
| Workers | app.handle from a fetch handler | WebSocket support depends on runtime APIs. |
File-route actions
When you adopt the opt-in file-route convention, the action functions on your +page modules become reachable server routes. Pass the entries from createFileRoutes() to mountFileRoutes(app, entries, options?) (or createFileRouteServerRoutes(entries, options?) for the raw ServerRoute[]):
import { createServer, csrf, mountFileRoutes } from '@bquery/bquery/server';
import { createFileRoutes } from '@bquery/bquery/router';
const { entries } = createFileRoutes(import.meta.glob('./routes/**/+page.ts'));
const app = createServer();
mountFileRoutes(app, entries, {
middlewares: [csrf()], // mutations are CSRF-guarded
dataPath: '/__data', // optional: also serve each route's `load` as JSON
});Routes whose module statically exports no action are skipped; lazily-imported routes reply 405 when no action is present. The FileRouteServerOptions type documents actionMethod, dataPath, basePath, and the middleware hooks.
Related modules
- File-based Routing — defines the
action/loadthese endpoints serve. - SSR —
ctx.render*wrapsrenderToString*/renderToResponse. - Reactive —
useWebSocketChannelconsumes server sessions. - Security — default-sanitized
ctx.html(). - Store — hydrate server state on the client.
Version history
- 1.18.0 —
validate(schema, options)middleware for Standard Schema validation of the body, query or params; request-body limits by default (1 MiB forjson,form,text,raw; 10 MiB formultipart;Infinitylifts a limit);ctx.body()reads the body once, and the Node adapter streams it on demand and rejects an oversized declaredContent-Lengthwith413. - 1.17.2 —
csrf()andsession()name their cookies__Host-bq.csrf/__Host-bq.sidby default so a sibling subdomain cannot plant them;__Host-/__Secure-cookieNames whose attributes a browser would reject, or whose prefix is mis-cased, throw at startup. Users are logged out once on upgrade. - 1.17.1 — signed CSRF tokens bound to the session (
bindToSession);memoryStore()sweeps expired sessions and evicts the least recently used beyondmaxEntries, and the default store is capped at 10 000; the Node adapter answers handler errors with500and cancels streams on disconnect;serveStaticchecks precompressed sidecars againstroot; encoded static route segments match;listen({ signal })honours an aborted signal. - 1.17.0 —
serveStatic()andrateLimit(); global middleware also runs for unmatched routes;ctx.html()sanitizes without a DOM. - 1.15.0 — first-party
session/memoryStore,csrf/csrfToken,guard,basicAuth/bearerAuth, and Web-Crypto signing utilities (signValue,unsignValue,timingSafeEqual,randomToken,randomId,base64UrlEncode,base64UrlDecode);ctx.session;app.listen()on Deno;mountFileRoutes/createFileRouteServerRoutesfor file-route actions.servertargets Stable. - 1.14.0 —
ServerHttpError,ctx.body,ctx.cookies,ctx.setCookie,ctx.accepts,ctx.stream,ctx.sse,ctx.renderStream,ctx.renderResponse,app.listen(). - 1.11.0 —
createServer, runtime-agnostic WebSocket sessions, dependency-free routing.