about summary refs log tree commit diff stats
path: root/src/app.ts
diff options
context:
space:
mode:
authorAlan Pearce2023-09-17 17:31:18 +0200
committerAlan Pearce2023-09-17 17:31:18 +0200
commit602f249c2cfac0e7b6613fb63f5fb519aa1ca952 (patch)
treebe522208e3172e62b4777a66af4bd931677f7fcb /src/app.ts
parent1a7abb3723d6b9db0d199c26d2a207e03636738a (diff)
downloadwebsite-602f249c2cfac0e7b6613fb63f5fb519aa1ca952.tar.lz
website-602f249c2cfac0e7b6613fb63f5fb519aa1ca952.tar.zst
website-602f249c2cfac0e7b6613fb63f5fb519aa1ca952.zip
Move servers into app.ts and export for testing
Diffstat (limited to 'src/app.ts')
-rw-r--r--src/app.ts225
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;