diff options
Diffstat (limited to 'src/app.ts')
-rw-r--r-- | src/app.ts | 225 |
1 files changed, 225 insertions, 0 deletions
diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..b41de44 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,225 @@ +import path from "node:path"; +import fs, { Stats } from "node:fs"; +import type { BunFile, Serve } from "bun"; +import * as Sentry from "@sentry/node"; +import prom from "bun-prometheus-client"; + +import readConfig from "./config"; + +Sentry.init({ + release: `homestead@${Bun.env.FLY_MACHINE_VERSION}`, + tracesSampleRate: 1.0, +}); + +const base = "."; +const publicDir = path.resolve(base, "public") + path.sep; + +const config = readConfig(base); +const defaultHeaders = { + ...config.extra.headers, + vary: "Accept-Encoding", +}; + +type File = { + filename: string; + handle: BunFile; + relPath: string; + headers?: Record<string, string>; + type: string; + size: number; + mtime: Date; +}; + +const metrics = { + requests: new prom.Counter({ + name: "homestead_requests", + help: "Number of requests by path, status code, and method", + labelNames: ["path", "status_code", "method"], + }), + requestDuration: new prom.Histogram({ + name: "homestead_request_duration_seconds", + help: "Request duration in seconds", + labelNames: ["path"], + }), +}; + +let files = new Map<string, File>(); + +function registerFile( + path: string, + pathname: string, + filename: string, + stat: Stats, +): void { + pathname = "/" + (pathname === "." || pathname === "./" ? "" : pathname); + + if (files.get(pathname) !== undefined) { + console.warn("File already registered:", pathname); + } + const handle = Bun.file(filename); + files.set(pathname, { + filename, + relPath: "/" + path, + handle: handle, + type: pathname.startsWith("/feed-styles.xsl") ? "text/xsl" : handle.type, + headers: + pathname === "/404.html" + ? Object.assign({}, defaultHeaders, { "cache-control": "no-cache" }) + : undefined, + size: stat.size, + mtime: stat.mtime, + }); +} + +function walkDirectory(root: string, dir: string) { + const absDir = path.join(root, dir); + for (let pathname of fs.readdirSync(absDir)) { + const relPath = path.join(dir, pathname); + const absPath = path.join(absDir, pathname); + const stat = fs.statSync(absPath); + if (stat.isDirectory()) { + walkDirectory(root, relPath + path.sep); + } else if (stat.isFile()) { + if (pathname.startsWith("index.html")) { + const dir = relPath.replace("index.html", ""); + registerFile(relPath, dir, absPath, stat); + if (dir !== "") { + registerFile(relPath, dir + path.sep, absPath, stat); + } + } + registerFile(relPath, relPath, absPath, stat); + } + } +} + +walkDirectory(publicDir, ""); + +async function serveFile( + file: File | undefined, + statusCode: number = 200, + extraHeaders: Record<string, string> = {}, +): Promise<Response> { + return new Response(await file.handle.arrayBuffer(), { + headers: { + "last-modified": file.mtime.toUTCString(), + ...extraHeaders, + ...(file.headers || defaultHeaders), + }, + status: statusCode, + }); +} + +function parseIfModifiedSinceHeader(header: string | null): number { + return header ? new Date(header).getTime() + 999 : 0; +} + +export const metricsServer = { + port: 9091, + fetch: async function (request) { + const pathname = new URL(request.url).pathname; + switch (pathname) { + case "/metrics": + return new Response(await prom.register.metrics()); + default: + return new Response("", { status: 404 }); + } + }, +} satisfies Serve; + +export const server = { + fetch: async function (request) { + const pathname = new URL(request.url).pathname.replace(/\/\/+/g, "/"); + const endTimer = metrics.requestDuration.startTimer({ path: pathname }); + const transaction = Sentry.startTransaction({ + name: pathname, + op: "http.server", + description: `${request.method} ${pathname}`, + tags: { + url: request.url, + "http.method": request.method, + "http.user_agent": request.headers.get("user-agent"), + }, + }); + try { + const file = files.get(pathname); + metrics.requests.inc({ path: pathname }); + if (file && (await file.handle.exists())) { + if ( + parseIfModifiedSinceHeader( + request.headers.get("if-modified-since"), + ) >= file?.mtime.getTime() + ) { + metrics.requests.inc({ + method: request.method, + path: pathname, + status_code: 304, + }); + transaction.setHttpStatus(304); + return new Response("", { status: 304, headers: defaultHeaders }); + } + metrics.requests.inc({ + method: request.method, + path: pathname, + status_code: 200, + }); + const encodings = (request.headers.get("accept-encoding") || "") + .split(",") + .map((x) => x.trim().toLowerCase()); + if (encodings.includes("br") && files.has(file.relPath + ".br")) { + transaction.setHttpStatus(200); + transaction.setTag("http.content-encoding", "br"); + return serveFile(files.get(file.relPath + ".br"), 200, { + "content-encoding": "br", + "content-type": file.type, + }); + } else if ( + encodings.includes("zstd") && + files.has(file.relPath + ".zst") + ) { + transaction.setHttpStatus(200); + transaction.setTag("http.content-encoding", "zstd"); + return serveFile(files.get(file.relPath + ".zst"), 200, { + "content-encoding": "zstd", + "content-type": file.type, + }); + } else if ( + encodings.includes("gzip") && + files.has(file.relPath + ".gz") + ) { + transaction.setHttpStatus(200); + transaction.setTag("http.content-encoding", "gzip"); + return serveFile(files.get(file.relPath + ".gz"), 200, { + "content-encoding": "gzip", + "content-type": file.type, + }); + } + transaction.setHttpStatus(200); + transaction.setTag("http.content-encoding", "identity"); + return serveFile(file); + } else { + metrics.requests.inc({ + method: request.method, + path: pathname, + status_code: 404, + }); + transaction.setHttpStatus(404); + transaction.setTag("http.content-encoding", "identity"); + return serveFile(files.get("/404.html"), 404); + } + } catch (error) { + transaction.setTag("http.content-encoding", "identity"); + transaction.setHttpStatus(503); + metrics.requests.inc({ + method: request.method, + path: pathname, + status_code: 503, + }); + Sentry.captureException(error); + return new Response("Something went wrong", { status: 503 }); + } finally { + const seconds = endTimer(); + metrics.requestDuration.observe(seconds); + transaction.finish(); + } + }, +} satisfies Serve; |