diff options
Diffstat (limited to 'src/index.ts')
-rw-r--r-- | src/index.ts | 211 |
1 files changed, 211 insertions, 0 deletions
diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ee092f2 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,211 @@ +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({}); + +const base = "./website/"; +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>; + size: number; + mtime: Date; +}; + +const collectDefaultMetrics = prom.collectDefaultMetrics; +collectDefaultMetrics({ + labels: { + FLY_APP_NAME: Bun.env.FLY_APP_NAME, + FLY_ALLOC_ID: Bun.env.FLY_ALLOC_ID, + FLY_REGION: Bun.env.FLY_REGION, + }, +}); +const metrics = { + requestsByStatus: new prom.Counter({ + name: "homestead_requests_by_status", + help: "Number of requests by status code", + labelNames: ["status_code"], + }), + requestsByPath: new prom.Counter({ + name: "homestead_requests_by_path", + help: "Number of requests by path", + labelNames: ["path"], + }), + 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); + } + files.set(pathname, { + filename, + relPath: "/" + path, + handle: Bun.file(filename), + 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> { + if (file && (await file.handle.exists())) { + metrics.requestsByStatus.inc({ status_code: statusCode }); + return new Response(file.handle, { + headers: { + "last-modified": file.mtime.toUTCString(), + ...extraHeaders, + ...(file.headers || defaultHeaders), + }, + status: statusCode, + }); + } else { + metrics.requestsByStatus.inc({ status_code: 404 }); + // TODO return encoded + return serveFile(files.get("/404.html"), 404); + } +} + +async function serveEncodedFile( + file: File | undefined, + statusCode: number = 200, + extraHeaders: Record<string, string> = {}, +): Promise<Response> { + const res = await serveFile(file, statusCode, extraHeaders); + res.headers.delete("content-disposition"); + return res; +} + +function parseIfModifiedSinceHeader(header: string | null): number { + return header ? new Date(header).getTime() + 999 : 0; +} + +const metricsServer = Bun.serve({ + 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 }); + } + }, +}); + +console.info( + `Serving metrics on http://${metricsServer.hostname}:${metricsServer.port}/metrics`, +); + +const server = Bun.serve({ + fetch: async function (request) { + const pathname = new URL(request.url).pathname; + const endTimer = metrics.requestDuration.startTimer({ path: pathname }); + try { + const file = files.get(pathname); + metrics.requestsByPath.inc({ path: pathname }); + if (file) { + if ( + parseIfModifiedSinceHeader( + request.headers.get("if-modified-since"), + ) >= file?.mtime.getTime() + ) { + metrics.requestsByStatus.inc({ status_code: 304 }); + return new Response("", { status: 304, headers: defaultHeaders }); + } + const encodings = (request.headers.get("accept-encoding") || "") + .split(",") + .map((x) => x.trim().toLowerCase()); + if (encodings.includes("br") && files.has(file.relPath + ".br")) { + return serveEncodedFile(files.get(file.relPath + ".br"), 200, { + "content-encoding": "br", + "content-type": file.handle.type, + }); + } else if ( + encodings.includes("zstd") && + files.has(file.relPath + ".zst") + ) { + return serveEncodedFile(files.get(file.relPath + ".zst"), 200, { + "content-encoding": "zstd", + "content-type": file.handle.type, + }); + } else if ( + encodings.includes("gzip") && + files.has(file.relPath + ".gz") + ) { + return serveEncodedFile(files.get(file.relPath + ".gz"), 200, { + "content-encoding": "gzip", + "content-type": file.handle.type, + }); + } + } + return serveFile(file); + } catch (error) { + metrics.requestsByStatus.inc({ status_code: 503 }); + Sentry.captureException(error); + return new Response("Something went wrong", { status: 503 }); + } finally { + const seconds = endTimer(); + metrics.requestDuration.observe(seconds); + } + }, +}); + +console.info(`Serving website on http://${server.hostname}:${server.port}/`); |