Server
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, and runtime-agnostic WebSocket session routing.
import { createServer } from '@bquery/bquery/server';Public surface
Runtime helpers:
| Export | Purpose |
|---|---|
createServer() | Create an app-like server handle with middleware, HTTP routes, SSR responses, and WebSocket routing. |
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. |
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(...). |
import type { ServerContext, 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().
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 coordinationisWebSocketRequest—truefor upgrade handshakes
Response helpers:
ctx.response(body, init?)ctx.text(body, init?)ctx.html(body, init?)— sanitizes by defaultctx.json(data, init?)ctx.redirect(location, status?)ctx.render(template, data, options?)— wrapsrenderToString()with the same DOM-free fallback used by@bquery/bquery/ssr
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"] }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 });
},
});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.
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.
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().
Unlike ctx.render(), ctx.html() sanitization still relies on DOM-compatible globals. If your Node runtime does not provide document / DOMParser, install and register a compatible implementation before returning sanitized HTML, or pass { trusted: true } only when the HTML is already known to be safe.
Register the DOM shim once during application startup before handling any requests that call ctx.html() without { trusted: true }.
For example, install happy-dom separately (bun add happy-dom / npm install happy-dom) and register it like this, or use another compatible DOM implementation.
import { Window } from 'happy-dom';
const window = new Window();
globalThis.window = window;
globalThis.document = window.document;
globalThis.DOMParser = window.DOMParser;