/** * Zesdex REST API — HTTP server and router (Bun.serve). * Mirrors `apps/interfaces/api/src/lib.rs`. * * Routes: * - Public: /api/v1/auth/{login,register,refresh}, /api/v1/health * - Protected (JWT): /api/v1/sessions..., /api/v1/chat/completions * * Uses Bun's native HTTP server — no external framework dependency. */ import type { ApiState } from "./state.ts"; import { ApiError } from "./error.ts"; import { errorBody } from "./dto.ts"; import { authenticateRequest } from "./middleware/auth.ts"; import { loginHandler, registerHandler, refreshHandler } from "./handlers/auth.ts"; import { listSessionsHandler, createSessionHandler, deleteSessionHandler, } from "./handlers/sessions.ts"; import { getConversationHandler, addMessageHandler, deleteMessageHandler, } from "./handlers/conversations.ts"; import { chatCompletionsHandler } from "./handlers/chat.ts"; /** CORS headers applied to every response (permissive for local/dev). */ const CORS_HEADERS: Record = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", }; /** A handler's response: JSON body plus optional status code. */ interface Result { status: number; body: unknown; } /** Per-request context passed to handlers. */ interface Ctx { state: ApiState; params: string[]; body: unknown; headers: Headers; } /** Route mapping: regex matches URL path after `/api/v1`. */ interface Route { method: string; pattern: RegExp; protected: boolean; handle: (ctx: Ctx) => Promise; } function result(body: unknown, status = 200): Result { return { status, body }; } function json(res: Result): Response { return new Response(JSON.stringify(res.body), { status: res.status, headers: { "Content-Type": "application/json", ...CORS_HEADERS }, }); } function noContent(): Response { return new Response(null, { status: 204, headers: CORS_HEADERS }); } /** Build the route table for the API. Matches against paths without the /api/v1 prefix. */ function buildRoutes(): Route[] { return [ // Auth — public (rate-limited inside handlers) { method: "POST", pattern: /^\/auth\/login$/, protected: false, handle: (c) => loginHandler(c.state, c.headers, c.body as never).then((r) => result(r)) }, { method: "POST", pattern: /^\/auth\/register$/, protected: false, handle: (c) => registerHandler(c.state, c.headers, c.body as never).then((r) => result(r)) }, { method: "POST", pattern: /^\/auth\/refresh$/, protected: false, handle: (c) => refreshHandler(c.state, c.headers, c.body as never).then((r) => result(r)) }, // Health — public { method: "GET", pattern: /^\/health$/, protected: false, handle: () => Promise.resolve(result({ status: "ok" })) }, // Sessions — protected { method: "GET", pattern: /^\/sessions$/, protected: true, handle: (c) => listSessionsHandler(c.state).then((s) => result(s)) }, { method: "POST", pattern: /^\/sessions$/, protected: true, handle: async (c) => { const r = await createSessionHandler(c.state, c.body as { title: string }); return result(r.body, r.status); } }, { method: "DELETE", pattern: /^\/sessions\/([^/]+)$/, protected: true, handle: async (c) => { await deleteSessionHandler(c.state, c.params[0]!); return { status: 204, body: null }; } }, // Conversations (sub-resource of sessions) — protected { method: "GET", pattern: /^\/sessions\/([^/]+)\/conversations$/, protected: true, handle: async (c) => { const conv = await getConversationHandler(c.state, c.params[0]!); return result(conv); } }, { method: "POST", pattern: /^\/sessions\/([^/]+)\/conversations$/, protected: true, handle: async (c) => { const r = await addMessageHandler(c.state, c.params[0]!, c.body as { role: string; content: string }); return result(r.body, r.status); } }, { method: "DELETE", pattern: /^\/sessions\/([^/]+)\/conversations\/([^/]+)$/, protected: true, handle: async (c) => { await deleteMessageHandler(c.state, c.params[0]!, c.params[1]!); return { status: 204, body: null }; } }, // Chat — protected { method: "POST", pattern: /^\/chat\/completions$/, protected: true, handle: (c) => chatCompletionsHandler(c.state, c.body as never).then((s) => result(s)) }, ]; } /** Handle a request against the router; maps ApiError → HTTP response. */ async function handleRequest(state: ApiState, routes: Route[], req: Request): Promise { const url = new URL(req.url); // Strip the /api/v1 version prefix so route patterns match cleanly. const fullPath = url.pathname; const path = fullPath.startsWith("/api/v1") ? fullPath.slice("/api/v1".length) : fullPath; const method = req.method; // CORS preflight if (method === "OPTIONS") { return new Response(null, { status: 204, headers: CORS_HEADERS }); } const route = routes.find((r) => r.method === method && r.pattern.test(path)); if (!route) { return json(result(errorBody("Not found", 404), 404)); } // JWT auth for protected routes (subject is validated but not currently exposed). if (route.protected) { const auth = authenticateRequest(state, req.headers); if (!auth.ok) { return json(result({ error: "Unauthorized", detail: auth.detail }, auth.status)); } } // Parse JSON body if present; read once and reuse. let body: unknown = undefined; const raw = await req.text(); if (raw) { try { body = JSON.parse(raw); } catch { return json(result(errorBody("Bad request: malformed JSON body", 400), 400)); } } const match = path.match(route.pattern); const ctx: Ctx = { state, params: match ? match.slice(1) : [], body, headers: req.headers, }; try { const res = await route.handle(ctx); if (res.status === 204) return noContent(); return json(res); } catch (e) { if (e instanceof ApiError) { const msg = e.exposeDetails ? e.message : "An internal error occurred"; return json(result(errorBody(msg, e.status), e.status)); } console.error("Unhandled error:", e); return json(result(errorBody("An internal error occurred", 500))); } } /** Start the API server on the given port. Returns a handle. */ export function startApiServer(state: ApiState, port: number): { stop: () => void; port: number } { const routes = buildRoutes(); const server = Bun.serve({ port, async fetch(req) { return handleRequest(state, routes, req); }, }); console.log(`zesdex-api listening on http://0.0.0.0:${server.port}`); return { stop: () => server.stop(true), port: server.port ?? port }; }