import type { IncomingMessage, ServerResponse } from 'node:http' import { safeParse } from './json.ts' export class HttpError extends Error { readonly status: number constructor(status: number, message: string) { super(message) this.status = status } } export async function readJsonBody(req: IncomingMessage, limitBytes = 10 << 20): Promise { const chunks: Buffer[] = [] let size = 0 for await (const chunk of req) { const buf = chunk as Buffer size += buf.length if (size > limitBytes) throw new HttpError(413, 'request body too large') chunks.push(buf) } if (chunks.length === 0) throw new HttpError(400, 'empty request body') const parsed = safeParse(Buffer.concat(chunks).toString('utf8')) if (parsed === undefined) throw new HttpError(400, 'invalid JSON body') return parsed } export async function readRawBody( req: IncomingMessage, limitBytes: number = 100 << 20, ): Promise { const chunks: Buffer[] = [] let size = 0 for await (const chunk of req) { const buf = chunk as Buffer size += buf.length if (size > limitBytes) throw new HttpError(413, 'request body too large') chunks.push(buf) } return Buffer.concat(chunks) } export function sendJson(res: ServerResponse, status: number, body: unknown): void { const data = JSON.stringify(body) res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(data), 'cache-control': 'no-store', }) res.end(data) } export function sendError(res: ServerResponse, status: number, message: string): void { sendJson(res, status, { error: message }) } export interface RouteParams { [key: string]: string | undefined } export type Handler = ( req: IncomingMessage, res: ServerResponse, params: RouteParams, query: URLSearchParams, ) => Promise | void interface Route { method: string segments: string[] handler: Handler } /** Minimal zero-dep router with `:param` path segments; longest match wins by registration order. */ export function createRouter() { const routes: Route[] = [] const add = (method: string, pattern: string, handler: Handler) => { routes.push({ method, segments: pattern.split('/').filter((s) => s !== ''), handler }) } const match = ( method: string, pathname: string, ): { handler: Handler; params: RouteParams } | undefined => { const parts = pathname.split('/').filter((s) => s !== '') for (const route of routes) { if (route.method !== method) continue if (route.segments.length !== parts.length) continue const params: RouteParams = {} let ok = true for (let i = 0; i < route.segments.length; i++) { const pat = route.segments[i] ?? '' const part = parts[i] ?? '' if (pat.startsWith(':')) params[pat.slice(1)] = decodeURIComponent(part) else if (pat !== part) { ok = false break } } if (ok) return { handler: route.handler, params } } return undefined } return { get: (p: string, h: Handler) => add('GET', p, h), post: (p: string, h: Handler) => add('POST', p, h), put: (p: string, h: Handler) => add('PUT', p, h), del: (p: string, h: Handler) => add('DELETE', p, h), async dispatch(req: IncomingMessage, res: ServerResponse): Promise { const url = new URL(req.url ?? '/', 'http://localhost') const found = match(req.method ?? 'GET', url.pathname) if (!found) throw new HttpError(404, `no route for ${req.method} ${url.pathname}`) await found.handler(req, res, found.params, url.searchParams) }, } } /** SSE write helper; ids are durable audit seq numbers. */ export function sseInit(res: ServerResponse): void { res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive', 'x-accel-buffering': 'no', }) res.write(':ok\n\n') } export function sseEvent( res: ServerResponse, id: string | number, event: string, data: unknown, ): void { res.write(`id: ${id}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`) }