muddle/core/http.ts
2026-10-23 09:12:49 -04:00

242 lines
6.2 KiB
TypeScript

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<string> {
return { status, headers: { "content-type": HTML_CONTENT_TYPE }, body };
}
/** Creates a JSON response. */
export function json(
body: unknown,
status: StatusCode = 200,
): HttpResponse<unknown> {
return { status, headers: { "content-type": JSON_CONTENT_TYPE }, body };
}
/** Creates a plain text response. */
export function text(
body: string,
status: StatusCode = 200,
): HttpResponse<string> {
return { status, headers: { "content-type": TEXT_CONTENT_TYPE }, body };
}
/** Creates a redirect response. */
export function redirect(
location: string,
status: StatusCode = 302,
): HttpResponse<never> {
return { status, headers: { location }, body: undefined as never };
}
/** Creates a `404` response. */
export function notFound(message = "Not Found"): HttpResponse<string> {
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<unknown> {
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<string, unknown> = {};
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<string, string> {
const params = new URLSearchParams(search);
const result: Record<string, string> = {};
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<string, string>,
): Record<string, string> {
const result: Record<string, string> = {};
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<HttpResponse | undefined> {
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;
}