From 602f249c2cfac0e7b6613fb63f5fb519aa1ca952 Mon Sep 17 00:00:00 2001 From: Alan Pearce Date: Sun, 17 Sep 2023 17:31:18 +0200 Subject: Move servers into app.ts and export for testing --- src/app.ts | 225 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 234 ++--------------------------------------------------- test/index.test.ts | 2 +- 3 files changed, 231 insertions(+), 230 deletions(-) create mode 100644 src/app.ts 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; + 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(); + +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 = {}, +): Promise { + 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; diff --git a/src/index.ts b/src/index.ts index 450fe8f..83ad03d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,231 +1,7 @@ -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 { server, metricsServer } from "./app"; -import readConfig from "./config"; +const metricsServed = Bun.serve(metricsServer); +console.info(`Metrics server started on port ${metricsServed.port}`); -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; - 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(); - -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 = {}, -): Promise { - 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; -} - -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.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(); - } - }, -}); - -console.info(`Serving website on http://${server.hostname}:${server.port}/`); +const served = Bun.serve(server); +console.info(`Serving website on http://${served.hostname}:${served.port}/`); diff --git a/test/index.test.ts b/test/index.test.ts index 2f682a9..6e29e3d 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,7 +1,7 @@ import { type Server } from "bun"; import { expect, test, beforeAll, afterAll } from "bun:test"; -import app from "../src/index"; +import { server as app } from "../src/app"; const port = 33000; const base = `http://localhost:${port}/`; -- cgit 1.4.1