239 lines
6.2 KiB
TypeScript
239 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;
|
|
}
|