Skip to content

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.

ts
import { createServer } from '@bquery/bquery/server';

Public surface

Runtime helpers:

ExportPurpose
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:

TypePurpose
ServerAppThe app handle returned by createServer().
ServerContextPer-request context passed to handlers and middleware.
ServerRouteRoute definition accepted by app.add().
ServerRequestInitLightweight request input accepted by handle() and handleWebSocket().
ServerResultResult union for WebSocket resolution: Response, ServerWebSocketSession, or null.
ServerWebSocketSessionRuntime-agnostic session object returned for matched WebSocket upgrades.
ServerWebSocketPeerMinimal runtime socket shape consumed by a session.
ServerWebSocketConnectionWrapped peer passed to WebSocket handlers, including sendJson().
ServerWebSocketHandlerSetWebSocket lifecycle callbacks and handshake metadata.
ServerWebSocketMiddlewareMiddleware shape for WebSocket route pipelines.
ServerWebSocketDataRaw payload union accepted by socket.send(...).
ts
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().

ts
const app = createServer();

Basic usage

ts
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 — normalized Request
  • url — parsed URL for the current request
  • path — normalized pathname without query string
  • method — uppercase HTTP method
  • params — null-prototype route params captured from :param segments
  • query — null-prototype query params (string or string[] for repeated keys)
  • state — mutable per-request bag for middleware coordination
  • isWebSocketRequesttrue for upgrade handshakes

Response helpers:

  • ctx.response(body, init?)
  • ctx.text(body, init?)
  • ctx.html(body, init?) — sanitizes by default
  • ctx.json(data, init?)
  • ctx.redirect(location, status?)
  • ctx.render(template, data, options?) — wraps renderToString() 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:

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

ts
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():

ts
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 matched
  • Response — middleware or error handling short-circuited the upgrade
  • ServerWebSocketSession — ready to attach to your runtime socket
ts
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.

ts
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:

ts
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() trusts renderToString() 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.

ts
import { Window } from 'happy-dom';

const window = new Window();
globalThis.window = window;
globalThis.document = window.document;
globalThis.DOMParser = window.DOMParser;