repositories / will
will
owned by admin
src/shared/http.ts
Rawimport 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<T>(req: IncomingMessage, limitBytes = 10 << 20): Promise<T> {
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<T>(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<Buffer> {
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> | 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<void> {
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`)
}