import { isPrototypePollutionKey } from '../core/utils/object'; import { sanitizeHtml } from '../security/index'; import { callWorkerMethod, runTask } from '../concurrency/index'; import { createNodeHandler, detectRuntime, renderToResponse, renderToStream, renderToString, serializeStoreState, } from '../ssr/index'; import { serializeCookie } from './cookies'; import { ServerHttpError } from './errors'; import type { CreateServerOptions, ServerApp, ServerContext, ServerHandler, ServerHtmlResponseInit, ServerListenHandle, ServerListenOptions, ServerMiddleware, ServerNext, ServerQuery, ServerRenderResponseOptions, ServerResult, ServerRequestInit, ServerResponseInit, ServerRoute, ServerSseEvent, ServerSseOptions, ServerWebSocketConnection, ServerWebSocketHandlerSet, ServerWebSocketMiddleware, ServerWebSocketNext, ServerWebSocketPeer, ServerWebSocketRouteHandler, ServerWebSocketSession, } from './types'; interface CompiledRoute { handler: ServerHandler; methods: Set | null; middlewares: ServerMiddleware[]; paramNames: string[]; path: string; pattern: RegExp; } type CompiledWebSocketRoute = Omit & { handler: ServerWebSocketRouteHandler; middlewares: ServerWebSocketMiddleware[]; }; type PipelineHandler = (context: ServerContext, next: ServerNext) => Response | Promise; type WebSocketPipelineHandler = ( context: ServerContext, next: ServerWebSocketNext ) => ServerResult | Promise; const DEFAULT_BASE_URL = 'http://localhost'; const JSON_ESCAPE_LOOKUP: Record = { '<': '\\u003C', '>': '\\u003E', '&': '\\u0026', '\u2028': '\\u2028', '\u2029': '\\u2029', }; const JSON_ESCAPE_PATTERN = /[<>&\u2028\u2029]/g; const METHOD_ALL = null; const WEBSOCKET_PASSTHROUGH_HEADER = 'x-bquery-websocket-passthrough'; const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); /** * Creates a null-prototype dictionary for request-derived data. * * Request-controlled keys such as query params and route params must never write * into the default object prototype, otherwise names like `__proto__` can trigger * prototype-pollution bugs. Using `Object.create(null)` keeps these maps isolated * even before higher-level validation runs. */ const createDictionary = (): Record => Object.create(null) as Record; const normalizePath = (path: string): string => { if (!path) { throw new Error(`route path must be a non-empty string; received ${String(path)}`); } if (path === '*' || path === '/*') { return '/*'; } const withLeadingSlash = path.startsWith('/') ? path : `/${path}`; if (withLeadingSlash.length > 1 && withLeadingSlash.endsWith('/')) { return withLeadingSlash.slice(0, -1); } return withLeadingSlash; }; const compileRoutePath = (path: string): Pick => { const normalizedPath = normalizePath(path); if (normalizedPath === '/*') { return { path: normalizedPath, paramNames: [], pattern: /^\/.*$/ }; } const segments = normalizedPath.split('/').filter(Boolean); if (segments.length === 0) { return { path: normalizedPath, paramNames: [], pattern: /^\/$/ }; } const paramNames: string[] = []; let source = '^'; for (const [index, segment] of segments.entries()) { source += '/'; if (segment === '*') { if (index !== segments.length - 1) { throw new Error(`invalid route path: "*" must be the final segment in "${normalizedPath}"`); } source += '.*'; break; } if (segment.startsWith(':')) { const paramName = segment.slice(1); if (!/^[A-Za-z_$][\w$]*$/.test(paramName)) { throw new Error( `invalid route param name: ${paramName} - must start with a letter, $, or _ and contain only word characters` ); } if (isPrototypePollutionKey(paramName)) { throw new Error(`invalid route param name: ${paramName} - reserved for object safety`); } paramNames.push(paramName); source += '([^/]+)'; continue; } source += escapeRegex(segment); } source += '/?$'; return { path: normalizedPath, paramNames, pattern: new RegExp(source) }; }; const normalizeMethods = (method?: string | string[]): Set | null => { if (typeof method === 'undefined') { return METHOD_ALL; } const values = Array.isArray(method) ? method : [method]; if (values.length === 0) { throw new Error('route method must be specified - received empty array'); } const normalizedMethods = new Set( values.map((value) => value.trim().toUpperCase()).filter(Boolean) ); if (normalizedMethods.size === 0) { throw new Error( `route method must include at least one non-empty method string; received ${JSON.stringify(method)}` ); } return normalizedMethods; }; const parseQuery = (url: URL): ServerQuery => { const query = createDictionary() as ServerQuery; for (const [key, value] of url.searchParams.entries()) { if (isPrototypePollutionKey(key)) { continue; } const current = query[key]; if (typeof current === 'undefined') { query[key] = value; } else if (Array.isArray(current)) { current.push(value); } else { query[key] = [current, value]; } } return query; }; const normalizeUrl = (value: string | URL, baseUrl: string): URL => { return value instanceof URL ? new URL(value.toString()) : new URL(value, baseUrl); }; const normalizeRequest = ( input: Request | string | URL | ServerRequestInit, baseUrl: string ): Request => { if (input instanceof Request) { return input; } if (typeof input === 'string' || input instanceof URL) { return new Request(normalizeUrl(input, baseUrl).toString()); } const { url, method = 'GET', headers, body = null } = input; return new Request(normalizeUrl(url, baseUrl).toString(), { method, headers, body }); }; const normalizeWebSocketProtocols = (protocols?: string | string[]): string[] => { if (typeof protocols === 'undefined') { return []; } const values = Array.isArray(protocols) ? protocols : [protocols]; return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; }; const defaultDeserialize = (event: MessageEvent): TReceive => { const raw = event.data; if (typeof raw === 'string') { try { return JSON.parse(raw) as TReceive; } catch { // Match `useWebSocket()` in `src/reactive/websocket.ts`: malformed JSON // payloads fall back to the original string instead of throwing. return raw as TReceive; } } return raw as TReceive; }; const escapeJsonString = (value: string): string => value.replace(JSON_ESCAPE_PATTERN, (match) => JSON_ESCAPE_LOOKUP[match]); const createHeaders = (headers?: HeadersInit): Headers => new Headers(headers); const withContentType = (headers: Headers, contentType: string): Headers => { if (!headers.has('content-type')) { headers.set('content-type', contentType); } return headers; }; const parseCookies = (header: string | null): Record => { const cookies = createDictionary(); if (!header) { return cookies; } for (const pair of header.split(/;\s*/)) { const index = pair.indexOf('='); if (index === -1) { continue; } const key = pair.slice(0, index).trim(); if (!key || isPrototypePollutionKey(key)) { continue; } const rawValue = pair.slice(index + 1).trim(); try { cookies[key] = decodeURIComponent(rawValue); } catch { cookies[key] = rawValue; } } return cookies; }; interface AcceptEntry { q: number; range: string; } interface IndexedAcceptEntry extends AcceptEntry { index: number; } const parseAcceptHeader = (header: string): AcceptEntry[] => header .split(',') .map((entry, index) => { const [rawRange, ...params] = entry.trim().split(';'); const qValue = params.find((param) => param.trim().startsWith('q='))?.split('=')[1]; const q = qValue === undefined ? 1 : Number.parseFloat(qValue); return { index, q: Number.isFinite(q) ? q : 0, range: rawRange.trim().toLowerCase(), }; }) .filter((entry) => entry.range && entry.q > 0) .sort((left, right) => right.q - left.q || left.index - right.index) .map(({ q, range }) => ({ q, range })); const matchesAcceptedType = (type: string, range: string): boolean => { if (range === '*/*') { return true; } const [rangeMajor, rangeMinor] = range.split('/'); const [typeMajor, typeMinor] = type.split('/'); if (!rangeMajor || !rangeMinor || !typeMajor || !typeMinor) { return false; } return ( (rangeMajor === '*' || rangeMajor === typeMajor) && (rangeMinor === '*' || rangeMinor === typeMinor) ); }; const getMediaType = (contentType: string): string => { const [mediaType = ''] = contentType.split(';', 1); return mediaType.trim().toLowerCase(); }; const isJsonMediaType = (mediaType: string): boolean => mediaType === 'application/json' || mediaType.endsWith('+json'); const accepts = (request: Request, types: string[]): string | null => { const accept = request.headers.get('accept'); if (!accept || types.length === 0) { return types[0] ?? null; } const acceptedTypes = parseAcceptHeader(accept); for (const accepted of acceptedTypes) { for (const type of types) { if (matchesAcceptedType(type.toLowerCase(), accepted.range)) { return type; } } } return null; }; const decodeFormUrlEncoded = (textBody: string): Record => { const out = createDictionary(); for (const [key, value] of new URLSearchParams(textBody).entries()) { if (isPrototypePollutionKey(key)) { continue; } const current = out[key]; if (typeof current === 'undefined') { out[key] = value; } else if (Array.isArray(current)) { current.push(value); } else { out[key] = [current, value]; } } return out; }; const formatSseChunk = (event: ServerSseEvent | string, defaultRetry?: number): string => { if (typeof event === 'string') { const data = event .split('\n') .map((line) => `data: ${line}\n`) .join(''); return `${data}${typeof defaultRetry === 'number' ? `retry: ${defaultRetry}\n` : ''}\n`; } let chunk = ''; if (event.id) chunk += `id: ${event.id}\n`; if (event.event) chunk += `event: ${event.event}\n`; if (typeof event.retry === 'number') chunk += `retry: ${Math.trunc(event.retry)}\n`; else if (typeof defaultRetry === 'number') chunk += `retry: ${Math.trunc(defaultRetry)}\n`; for (const line of event.data.split('\n')) { chunk += `data: ${line}\n`; } return `${chunk}\n`; }; const readRequestBodyBuffer = async ( request: Request, limit: number | undefined, errorMessage: string ): Promise => { const contentLength = Number.parseInt(request.headers.get('content-length') ?? '', 10); if ( typeof limit === 'number' && Number.isFinite(limit) && limit >= 0 && Number.isFinite(contentLength) && contentLength >= 0 && contentLength > limit ) { throw new ServerHttpError(413, errorMessage); } const clone = request.clone(); const body = clone.body; if (!body) { return new Uint8Array(0); } const reader = body.getReader(); const chunks: Uint8Array[] = []; let total = 0; try { while (true) { const { done, value } = await reader.read(); if (done) { break; } if (!value) { continue; } total += value.byteLength; if (typeof limit === 'number' && Number.isFinite(limit) && limit >= 0 && total > limit) { throw new ServerHttpError(413, errorMessage); } chunks.push(value); } } finally { await reader.cancel().catch(() => undefined); } const output = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { output.set(chunk, offset); offset += chunk.byteLength; } return output; }; const createSseResponse = ( source: AsyncIterable | Iterable, init: ServerSseOptions = {} ): Response => { const headers = withContentType(createHeaders(init.headers), 'text/event-stream; charset=utf-8'); headers.set('cache-control', headers.get('cache-control') ?? 'no-cache'); headers.set('connection', headers.get('connection') ?? 'keep-alive'); const encoder = new TextEncoder(); const asyncSource = Symbol.asyncIterator in Object(source) ? (source as AsyncIterable) : (async function* () { yield* source as Iterable; })(); const iterator = asyncSource[Symbol.asyncIterator](); let cancelled = false; let iteratorClosed = false; const closeIterator = async (): Promise => { if (iteratorClosed || typeof iterator.return !== 'function') { return; } iteratorClosed = true; await iterator.return(); }; const stream = new ReadableStream({ async start(controller) { try { while (!cancelled) { const { done, value } = await iterator.next(); if (done || cancelled) { break; } controller.enqueue(encoder.encode(formatSseChunk(value, init.retry))); } if (!cancelled) { controller.close(); } } catch (error) { if (!cancelled) { controller.error(error); } } finally { await closeIterator().catch(() => undefined); } }, async cancel() { cancelled = true; await closeIterator().catch(() => undefined); }, }); return response(stream, { ...init, headers }); }; const response = (body?: BodyInit | null, init: ServerResponseInit = {}): Response => { const { headers, ...rest } = init; return new Response(body, { ...rest, headers: headers instanceof Headers ? headers : createHeaders(headers), }); }; const text = (body: string, init: ServerResponseInit = {}): Response => { const headers = withContentType(createHeaders(init.headers), 'text/plain; charset=utf-8'); return response(body, { ...init, headers }); }; const html = (body: string, init: ServerHtmlResponseInit = {}): Response => { const { trusted = false, ...rest } = init; const headers = withContentType(createHeaders(rest.headers), 'text/html; charset=utf-8'); return response(trusted ? body : sanitizeHtml(body), { ...rest, headers }); }; const json = (data: unknown, init: ServerResponseInit = {}): Response => { const headers = withContentType(createHeaders(init.headers), 'application/json; charset=utf-8'); let serialized: string; try { serialized = JSON.stringify(data) ?? 'null'; } catch { serialized = 'null'; } return response(escapeJsonString(serialized), { ...init, headers }); }; const stream = (body: ReadableStream, init: ServerResponseInit = {}): Response => { return response(body, init); }; const render = ( template: string, data: Parameters[1], options: ServerRenderResponseOptions = {} ): Response => { const { includeStoreState = false, status = 200, headers, ...renderOptions } = options; const result = renderToString(template, data, { ...renderOptions, includeStoreState: false }); const storeState = includeStoreState ? serializeStoreState({ storeIds: Array.isArray(includeStoreState) ? includeStoreState : undefined, }).scriptTag : ''; const body = `${result.html}${storeState}`; return html(body, { headers, status, trusted: true }); }; const renderStreamResponse = ( template: string, data: Parameters[1], options: ServerRenderResponseOptions = {} ): Response => { const { headers, status = 200, ...renderOptions } = options; return response(renderToStream(template, data, renderOptions), { headers: withContentType(createHeaders(headers), 'text/html; charset=utf-8'), status, }); }; /** * Returns `true` when the request is a WebSocket upgrade handshake. */ export const isWebSocketRequest = (request: Request): boolean => { if (request.method.toUpperCase() !== 'GET') { return false; } const upgrade = request.headers.get('upgrade'); if (typeof upgrade !== 'string' || upgrade.trim().toLowerCase() !== 'websocket') { return false; } const connection = request.headers.get('connection'); if (typeof connection !== 'string') { return false; } if (!connection.split(',').some((part) => part.trim().toLowerCase() === 'upgrade')) { return false; } const version = request.headers.get('sec-websocket-version'); if (version?.trim() !== '13') { return false; } const key = request.headers.get('sec-websocket-key')?.trim(); return typeof key === 'string' && /^[A-Za-z0-9+/]{22}==$/.test(key); }; /** * Type guard for values returned by `handleWebSocket()`. */ export const isServerWebSocketSession = (value: unknown): value is ServerWebSocketSession => { if (typeof value !== 'object' || value === null || value instanceof Response) { return false; } const candidate = value as Record; return ( typeof candidate.open === 'function' && typeof candidate.message === 'function' && typeof candidate.close === 'function' && typeof candidate.error === 'function' ); }; const createWebSocketConnectionFactory = () => { const cache = new WeakMap(); return (socket: ServerWebSocketPeer): ServerWebSocketConnection => { const existing = cache.get(socket); if (existing) { return existing; } const connection: ServerWebSocketConnection = { get protocol() { return socket.protocol; }, get readyState() { return socket.readyState; }, get url() { return socket.url; }, send(data) { socket.send(data); }, sendJson(data) { const payload = JSON.stringify(data); if (typeof payload !== 'string') { throw new TypeError('socket.sendJson() does not support undefined values'); } socket.send(payload); }, close(code, reason) { socket.close(code, reason); }, }; cache.set(socket, connection); return connection; }; }; const createWebSocketSession = ( context: ServerContext, handlers: ServerWebSocketHandlerSet ): ServerWebSocketSession => { const toConnection = createWebSocketConnectionFactory(); const deserialize = handlers.deserialize ?? defaultDeserialize; return { context, protocols: normalizeWebSocketProtocols(handlers.protocols), headers: handlers.headers, async open(socket) { if (handlers.onOpen) { await handlers.onOpen(toConnection(socket), context); } }, async message(socket, event) { if (handlers.onMessage) { await handlers.onMessage(deserialize(event), toConnection(socket), context, event); } }, async close(socket, event) { if (handlers.onClose) { await handlers.onClose(event, toConnection(socket), context); } }, async error(socket, event) { if (handlers.onError) { await handlers.onError(event, toConnection(socket), context); } }, }; }; const createWebSocketPassthroughResponse = (): Response => { const headers = createHeaders({ [WEBSOCKET_PASSTHROUGH_HEADER]: '1', }); return response(null, { headers, status: 204 }); }; const isWebSocketPassthroughResponse = (value: Response): boolean => { return value.headers.get(WEBSOCKET_PASSTHROUGH_HEADER) === '1'; }; const matchRoute = ( route: Pick, method: string, path: string ): Record | null => { if (route.methods && !route.methods.has(method)) { return null; } const match = route.pattern.exec(path); if (!match) { return null; } const params = createDictionary(); for (const [index, paramName] of route.paramNames.entries()) { try { params[paramName] = decodeURIComponent(match[index + 1] ?? ''); } catch (error) { if (error instanceof URIError) { return null; } throw error; } } return params; }; const resolveMatchingRoute = ( routes: TRoute[], method: string, path: string, context: ServerContext ): TRoute | null => { for (const candidate of routes) { const params = matchRoute(candidate, method, path); if (!params) { continue; } context.params = params; return candidate; } return null; }; const runPipeline = async ( context: ServerContext, handlers: PipelineHandler[], terminal: ServerNext ): Promise => { const dispatch = async (index: number): Promise => { const current = handlers[index]; if (!current) { return terminal(); } let advanced = false; return await current(context, async () => { if (advanced) { throw new Error( 'middleware next() called multiple times - if a middleware calls next(), it may only do so once' ); } advanced = true; return await dispatch(index + 1); }); }; return await dispatch(0); }; const runWebSocketPipeline = async ( context: ServerContext, handlers: WebSocketPipelineHandler[], terminal: ServerWebSocketNext ): Promise => { const dispatch = async (index: number): Promise => { const current = handlers[index]; if (!current) { return terminal(); } let advanced = false; return await current(context, async () => { if (advanced) { throw new Error( 'middleware next() called multiple times - if a middleware calls next(), it may only do so once' ); } advanced = true; return await dispatch(index + 1); }); }; return await dispatch(0); }; const adaptHttpMiddlewareToWebSocket = (middleware: ServerMiddleware): WebSocketPipelineHandler => { return async (context, next) => { let downstream: ServerResult = null; let downstreamResponse: Response | null = null; const middlewareResponse = await middleware(context, async () => { downstream = await next(); if (downstream instanceof Response) { downstreamResponse = downstream; return downstream; } return createWebSocketPassthroughResponse(); }); if (downstreamResponse) { return middlewareResponse; } if ( middlewareResponse instanceof Response && isWebSocketPassthroughResponse(middlewareResponse) ) { return downstream; } return middlewareResponse; }; }; const compileRoute = (route: ServerRoute): CompiledRoute => { const compiledPath = compileRoutePath(route.path); return { handler: route.handler, methods: normalizeMethods(route.method), middlewares: route.middlewares ?? [], paramNames: compiledPath.paramNames, path: compiledPath.path, pattern: compiledPath.pattern, }; }; const createBodyReader = ( request: Request, limits: CreateServerOptions['limits'] ): (() => Promise) => { let cached: Promise | null = null; return async (): Promise => { cached ??= (async () => { const contentType = request.headers.get('content-type') ?? ''; const mediaType = getMediaType(contentType); if (!mediaType) { return null; } if (isJsonMediaType(mediaType)) { const textBody = new TextDecoder().decode( await readRequestBodyBuffer( request, limits?.json, 'Request JSON body exceeds the configured limit.' ) ); try { return textBody ? JSON.parse(textBody) : null; } catch { throw new ServerHttpError(400, 'Request body contains invalid JSON.'); } } if (mediaType === 'application/x-www-form-urlencoded') { const textBody = new TextDecoder().decode( await readRequestBodyBuffer( request, limits?.form, 'Form body exceeds the configured limit.' ) ); return decodeFormUrlEncoded(textBody); } if (mediaType === 'multipart/form-data') { if (typeof request.formData !== 'function') { throw new ServerHttpError(415, 'multipart/form-data is not supported in this runtime.'); } const bodyBuffer = await readRequestBodyBuffer( request, limits?.multipart, 'Multipart form body exceeds the configured limit.' ); const requestBody = bodyBuffer.byteLength > 0 ? bodyBuffer.slice().buffer : undefined; const formData = await new Request(request.url, { body: requestBody, headers: request.headers, method: request.method, }).formData(); const out = new Map(); for (const [key, value] of formData.entries()) { if (isPrototypePollutionKey(key)) { continue; } const current = out.get(key); if (typeof current === 'undefined') { out.set(key, value); } else if (Array.isArray(current)) { current.push(value); } else { out.set(key, [current, value]); } } return out; } if (mediaType.startsWith('text/')) { const textBody = new TextDecoder().decode( await readRequestBodyBuffer( request, limits?.text, 'Text body exceeds the configured limit.' ) ); return textBody; } const rawBody = await readRequestBodyBuffer( request, limits?.raw, 'Request body exceeds the configured limit.' ); return rawBody.buffer.slice(rawBody.byteOffset, rawBody.byteOffset + rawBody.byteLength); })(); return cached; }; }; /** * Formats a listen URL, bracketing IPv6 hosts so the returned string is a * valid absolute URL. * * @internal */ const formatListenUrl = (hostname: string, port: number): string => { const host = hostname.includes(':') && !hostname.startsWith('[') && !hostname.endsWith(']') ? `[${hostname}]` : hostname; return `http://${host}:${port}`; }; /** * Merge headers from `from` into `to`, keeping `Set-Cookie` values separate so * that multiple cookies are not collapsed into a single comma-joined header by * the Fetch/undici `Headers.forEach` behaviour. * * @internal */ const mergeResponseHeaders = ( from: Headers, to: Headers, trackedSetCookies: readonly string[] = [] ): void => { const getSetCookie = (from as Headers & { getSetCookie?(): string[] }).getSetCookie; const setCookies = typeof getSetCookie === 'function' ? getSetCookie.call(from) : []; from.forEach((value, name) => { if (name.toLowerCase() !== 'set-cookie') { to.append(name, value); } }); if (trackedSetCookies.length > 0) { for (const value of trackedSetCookies) { to.append('set-cookie', value); } return; } const fallbackSetCookie = setCookies.length === 0 ? from.get('set-cookie') : null; for (const v of setCookies) { to.append('set-cookie', v); } if (fallbackSetCookie !== null) { to.append('set-cookie', fallbackSetCookie); } }; const createServerContext = ( request: Request, baseUrl: string, limits: CreateServerOptions['limits'] ): ServerContext => { const url = new URL(request.url, baseUrl); const method = request.method.toUpperCase(); const path = normalizePath(url.pathname || '/'); const query = parseQuery(url); const cookies = parseCookies(request.headers.get('cookie')); const readBody = createBodyReader(request, limits); const responseHeaders = createHeaders(); const responseSetCookies: string[] = []; return { request, url, method, path, params: createDictionary(), query, cookies, state: {}, body: readBody, response: (body, init = {}) => { const headers = createHeaders(init.headers); mergeResponseHeaders(responseHeaders, headers, responseSetCookies); return response(body, { ...init, headers }); }, text: (body, init = {}) => { const headers = createHeaders(init.headers); mergeResponseHeaders(responseHeaders, headers, responseSetCookies); return text(body, { ...init, headers }); }, html: (body, init = {}) => { const headers = createHeaders(init.headers); mergeResponseHeaders(responseHeaders, headers, responseSetCookies); return html(body, { ...init, headers }); }, json: (data, init = {}) => { const headers = createHeaders(init.headers); mergeResponseHeaders(responseHeaders, headers, responseSetCookies); return json(data, { ...init, headers }); }, stream: (body, init = {}) => { const headers = createHeaders(init.headers); mergeResponseHeaders(responseHeaders, headers, responseSetCookies); return stream(body, { ...init, headers }); }, sse: (source, init = {}) => { const headers = createHeaders(init.headers); mergeResponseHeaders(responseHeaders, headers, responseSetCookies); return createSseResponse(source, { ...init, headers }); }, accepts: (types) => accepts(request, types), setCookie(name, value, options = {}) { const serializedCookie = serializeCookie(name, value, options); responseSetCookies.push(serializedCookie); responseHeaders.append('set-cookie', serializedCookie); }, redirect: (location, status = 302) => { const headers = createHeaders({ location: location.toString() }); mergeResponseHeaders(responseHeaders, headers, responseSetCookies); return response(null, { headers, status }); }, render: (template, data, options = {}) => { const headers = createHeaders(options.headers); mergeResponseHeaders(responseHeaders, headers, responseSetCookies); return render(template, data, { ...options, headers }); }, renderStream: (template, data, options = {}) => { const headers = createHeaders(options.headers); mergeResponseHeaders(responseHeaders, headers, responseSetCookies); return renderStreamResponse(template, data, { ...options, headers }); }, async renderResponse(template, data, options = {}) { const headers = createHeaders(options.headers); mergeResponseHeaders(responseHeaders, headers, responseSetCookies); return await renderToResponse(template, data, { ...options, headers }); }, runTask, callWorker: callWorkerMethod, isWebSocketRequest: isWebSocketRequest(request), }; }; /** * Create a lightweight, Express-inspired request pipeline for SSR-aware * backends without introducing runtime dependencies. * * @example * ```ts * import { createServer } from '@bquery/bquery/server'; * * const app = createServer(); * app.get('/health', (ctx) => ctx.json({ ok: true })); * * const response = await app.handle('/health'); * ``` */ export const createServer = (options: CreateServerOptions = {}): ServerApp => { const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; const limits = options.limits; const middlewares = [...(options.middlewares ?? [])]; const routes: CompiledRoute[] = []; const webSocketRoutes: CompiledWebSocketRoute[] = []; const notFound = options.notFound ?? ((context: ServerContext) => { return context.text('Not Found', { status: 404 }); }); const onError = options.onError ?? ((error: unknown, context: ServerContext) => { if (error instanceof Response) { return error; } if (error instanceof ServerHttpError) { return context.text(error.expose ? error.message : 'Internal Server Error', { status: error.status, }); } return context.text('Internal Server Error', { status: 500 }); }); const addRoute = ( method: string | string[] | undefined, path: string, handler: ServerHandler, routeMiddlewares?: ServerMiddleware[] ): ServerApp => { routes.push( compileRoute({ handler, method, middlewares: routeMiddlewares, path, }) ); return app; }; const addWebSocketRoute = ( path: string, handler: ServerWebSocketRouteHandler, routeMiddlewares?: ServerWebSocketMiddleware[] ): ServerApp => { const compiledPath = compileRoutePath(path); webSocketRoutes.push({ handler, methods: new Set(['GET']), middlewares: routeMiddlewares ?? [], paramNames: compiledPath.paramNames, path: compiledPath.path, pattern: compiledPath.pattern, }); return app; }; const app: ServerApp = { use(middleware) { middlewares.push(middleware); return app; }, add(route) { routes.push(compileRoute(route)); return app; }, get(path, handler, routeMiddlewares) { return addRoute('GET', path, handler, routeMiddlewares); }, post(path, handler, routeMiddlewares) { return addRoute('POST', path, handler, routeMiddlewares); }, put(path, handler, routeMiddlewares) { return addRoute('PUT', path, handler, routeMiddlewares); }, patch(path, handler, routeMiddlewares) { return addRoute('PATCH', path, handler, routeMiddlewares); }, delete(path, handler, routeMiddlewares) { return addRoute('DELETE', path, handler, routeMiddlewares); }, all(path, handler, routeMiddlewares) { return addRoute(undefined, path, handler, routeMiddlewares); }, ws(path, handler, routeMiddlewares) { return addWebSocketRoute( path, handler as ServerWebSocketRouteHandler, routeMiddlewares ); }, async handle(input) { const request = normalizeRequest(input, baseUrl); const context = createServerContext(request, baseUrl, limits); try { const route = resolveMatchingRoute(routes, context.method, context.path, context); if (!route) { return await notFound(context); } const stack: PipelineHandler[] = [ ...middlewares, ...route.middlewares, async (ctx) => await route.handler(ctx), ]; const result = await runPipeline(context, stack, async () => { return await notFound(context); }); return result; } catch (error) { return await onError(error, context); } }, async handleWebSocket(input) { const request = normalizeRequest(input, baseUrl); const context = createServerContext(request, baseUrl, limits); if (!context.isWebSocketRequest) { return null; } try { const route = resolveMatchingRoute(webSocketRoutes, context.method, context.path, context); if (!route) { return null; } const stack: WebSocketPipelineHandler[] = [ ...middlewares.map(adaptHttpMiddlewareToWebSocket), ...route.middlewares, ]; return await runWebSocketPipeline(context, stack, async () => { const handlers = typeof route.handler === 'function' ? await route.handler(context) : route.handler; return createWebSocketSession(context, handlers as ServerWebSocketHandlerSet); }); } catch (error) { return await onError(error, context); } }, async listen(listenOptions: ServerListenOptions = {}): Promise { const runtime = listenOptions.runtime ?? 'auto'; const resolvedRuntime = runtime === 'auto' ? detectRuntime() : runtime; const port = listenOptions.port ?? 3000; const hostname = listenOptions.hostname ?? '127.0.0.1'; if (resolvedRuntime === 'bun') { const bunGlobal = globalThis as typeof globalThis & { Bun?: { serve(options: { fetch: (request: Request) => Promise; hostname?: string; port?: number; }): { hostname?: string; port?: number; stop(): void }; }; }; if (!bunGlobal.Bun?.serve) { throw new Error('Bun runtime APIs are unavailable.'); } const server = bunGlobal.Bun.serve({ fetch: (request) => app.handle(request), hostname, port, }); listenOptions.signal?.addEventListener('abort', () => server.stop(), { once: true }); const listenUrl = formatListenUrl(server.hostname ?? hostname, server.port ?? port); return { addresses: [listenUrl], async close() { server.stop(); }, async stop() { server.stop(); }, url: listenUrl, }; } if (resolvedRuntime === 'node') { const nodeHttp = (await import('node:http')) as typeof import('node:http'); const handler = createNodeHandler((request) => app.handle(request)); const server = nodeHttp.createServer(handler); await new Promise((resolve, reject) => { const onError = (err: Error) => reject(err); server.once('error', onError); server.listen(port, hostname, () => { server.removeListener('error', onError); resolve(); }); }); listenOptions.signal?.addEventListener('abort', () => server.close(), { once: true }); const address = server.address(); const resolvedAddress = address && typeof address !== 'string' ? { hostname: address.address || hostname, port: address.port ?? port } : { hostname, port }; const url = formatListenUrl(resolvedAddress.hostname, resolvedAddress.port); return { addresses: [url], async close() { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); }, async stop() { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); }, url, }; } if (resolvedRuntime === 'deno') { const denoGlobal = globalThis as typeof globalThis & { Deno?: { serve( options: { hostname?: string; port?: number; signal?: AbortSignal; onListen?: (address: { hostname: string; port: number }) => void; }, handler: (request: Request) => Promise ): { addr?: { hostname?: string; port?: number }; shutdown(): Promise }; }; }; if (!denoGlobal.Deno?.serve) { throw new Error('Deno runtime APIs are unavailable.'); } // `Deno.serve` binds before returning, so `server.addr` is populated // synchronously; `onListen` is overridden only to suppress the default // "Listening on…" log line. const server = denoGlobal.Deno.serve( { hostname, port, signal: listenOptions.signal, onListen: () => undefined, }, (request) => app.handle(request) ); const resolvedHost = server.addr?.hostname || hostname; const resolvedPort = server.addr?.port ?? port; const url = formatListenUrl(resolvedHost, resolvedPort); return { addresses: [url], async close() { await server.shutdown(); }, async stop() { await server.shutdown(); }, url, }; } throw new Error(`createServer().listen() is not supported in runtime "${resolvedRuntime}".`); }, }; return app; };