Luigit
repositories / will

will

owned by admin

src/shared/http.ts

Raw
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<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`)
}