import type { Headers, HttpResponse, ResponseBuilder, StatusCode, } from "../contracts/typegen/http/index.ts"; import { FORM_CONTENT_TYPE, HTML_CONTENT_TYPE, JSON_CONTENT_TYPE, TEXT_CONTENT_TYPE, mediaType, } from "../contracts/typegen/http/index.ts"; import type { RequestContext } from "../contracts/typegen/http/request.ts"; import { toFormFields } from "./form.ts"; import { existsSync, statSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { extname, resolve } from "node:path"; /** Creates an HTML response. */ export function html( body: string, status: StatusCode = 200, ): HttpResponse { return { status, headers: { "content-type": HTML_CONTENT_TYPE }, body }; } /** Creates a JSON response. */ export function json( body: unknown, status: StatusCode = 200, ): HttpResponse { return { status, headers: { "content-type": JSON_CONTENT_TYPE }, body }; } /** Creates a plain text response. */ export function text( body: string, status: StatusCode = 200, ): HttpResponse { return { status, headers: { "content-type": TEXT_CONTENT_TYPE }, body }; } /** Creates a redirect response. */ export function redirect( location: string, status: StatusCode = 302, ): HttpResponse { return { status, headers: { location }, body: undefined as never }; } /** Creates a `404` response. */ export function notFound(message = "Not Found"): HttpResponse { return text(message, 404); } /** Creates a response builder bound to a status code and a set of headers. */ export function createResponseBuilder( initialStatus: StatusCode = 200, initialHeaders: Headers = {}, ): ResponseBuilder { let status = initialStatus; const headers: Headers = { ...initialHeaders }; const builder: ResponseBuilder = { html(body, code) { return { status: code ?? status, headers: { ...headers, "content-type": HTML_CONTENT_TYPE }, body, }; }, json(body, code) { return { status: code ?? status, headers: { ...headers, "content-type": JSON_CONTENT_TYPE }, body, }; }, text(body, code) { return { status: code ?? status, headers: { ...headers, "content-type": TEXT_CONTENT_TYPE }, body, }; }, redirect(location, code) { return { status: code ?? 302, headers: { ...headers, location }, body: undefined as never, }; }, status(code) { status = code as StatusCode; return builder; }, header(name, value) { headers[name.toLowerCase()] = value; return builder; }, send(body) { return { status, headers: { ...headers }, body }; }, }; return builder; } /** Parses the body of a fetch `Request` according to its content type. */ export async function parseRequestBody(request: Request): Promise { const type = mediaType(request.headers.get("content-type") ?? ""); if (request.method === "GET" || request.method === "HEAD") return undefined; if (type === "application/json") { const raw = await request.text(); if (!raw) return undefined; try { return JSON.parse(raw); } catch (cause) { throw new Error(`Invalid JSON body: ${(cause as Error).message}`, { cause, }); } } if (type === FORM_CONTENT_TYPE) { const form = await request.formData(); const result: Record = {}; for (const [key, value] of form.entries()) { result[key] = typeof value === "string" ? value : value.name; } return result; } if (type === "text/plain") { return await request.text(); } if ( type === "" || type === "multipart/form-data" || type === "application/octet-stream" ) { const buffer = await request.arrayBuffer(); return buffer.byteLength === 0 ? undefined : buffer; } return await request.text(); } /** Parses a query string into a plain object. */ export function parseQuery(search: string): Record { const params = new URLSearchParams(search); const result: Record = {}; for (const [key, value] of params.entries()) { result[key] = value; } return result; } /** Lower-cases every header name of a header record. */ export function normalizeHeaders( headers: Record, ): Record { const result: Record = {}; for (const [name, value] of Object.entries(headers)) { result[name.toLowerCase()] = value; } return result; } /** Content type of a file extension. */ export function contentTypeOf(extension: string): string { switch (extension) { case ".html": case ".htm": return "text/html; charset=utf-8"; case ".css": return "text/css; charset=utf-8"; case ".js": case ".mjs": return "text/javascript; charset=utf-8"; case ".json": return "application/json; charset=utf-8"; case ".svg": return "image/svg+xml"; case ".png": return "image/png"; case ".jpg": case ".jpeg": return "image/jpeg"; case ".webp": return "image/webp"; case ".ico": return "image/x-icon"; case ".txt": return "text/plain; charset=utf-8"; case ".woff2": return "font/woff2"; default: return "application/octet-stream"; } } /** Serves a file of a static root. */ export async function serveStaticFile( root: string, path: string, ): Promise { const relative = decodeURIComponent(path.split("?")[0] ?? ""); const target = resolve( root, `.${relative.startsWith("/") ? relative : `/${relative}`}`, ); if (!target.startsWith(resolve(root))) return undefined; if (!existsSync(target)) return undefined; if (!statSync(target).isFile()) return undefined; const file = await readFile(target); return { status: 200, headers: { "content-type": contentTypeOf(extname(target)) }, body: new Uint8Array(file), }; } /** Builds the request context handed to controllers. */ export function createRequestContext(request: Request): RequestContext { const url = new URL(request.url); const headers = normalizeHeaders( Object.fromEntries(request.headers.entries()), ); const context: RequestContext = { method: request.method as RequestContext["method"], url: request.url, path: url.pathname, params: {}, query: parseQuery(url.search), body: undefined, form: toFormFields(undefined), headers, signal: request.signal, locals: {}, raw: request, respond: createResponseBuilder(), }; return context; }