about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--.dockerignore173
-rw-r--r--.envrc1
-rw-r--r--.gitignore177
-rw-r--r--Dockerfile66
-rw-r--r--README.md23
-rwxr-xr-xbun.lockbbin0 -> 7240 bytes
-rw-r--r--config.toml6
-rw-r--r--flake.lock73
-rw-r--r--flake.nix28
-rw-r--r--fly.toml45
-rw-r--r--package.json19
-rw-r--r--src/config.ts9
-rw-r--r--src/index.ts211
-rw-r--r--test/index.test.ts97
-rw-r--r--tsconfig.json22
15 files changed, 817 insertions, 133 deletions
diff --git a/.dockerignore b/.dockerignore
index dbb2ff0..f81d56e 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,4 +1,169 @@
-*
-!Caddyfile
-!redis.Caddyfile
-!public
+# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
+
+# Logs
+
+logs
+_.log
+npm-debug.log_
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+
+report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
+
+# Runtime data
+
+pids
+_.pid
+_.seed
+\*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+
+lib-cov
+
+# Coverage directory used by tools like istanbul
+
+coverage
+\*.lcov
+
+# nyc test coverage
+
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+
+bower_components
+
+# node-waf configuration
+
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+
+build/Release
+
+# Dependency directories
+
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+
+web_modules/
+
+# TypeScript cache
+
+\*.tsbuildinfo
+
+# Optional npm cache directory
+
+.npm
+
+# Optional eslint cache
+
+.eslintcache
+
+# Optional stylelint cache
+
+.stylelintcache
+
+# Microbundle cache
+
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+
+.node_repl_history
+
+# Output of 'npm pack'
+
+\*.tgz
+
+# Yarn Integrity file
+
+.yarn-integrity
+
+# dotenv environment variable files
+
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# parcel-bundler cache (https://parceljs.org/)
+
+.cache
+.parcel-cache
+
+# Next.js build output
+
+.next
+out
+
+# Nuxt.js build / generate output
+
+.nuxt
+dist
+
+# Gatsby files
+
+.cache/
+
+# Comment in the public line in if your project uses Gatsby and not Next.js
+
+# https://nextjs.org/blog/next-9-1#public-directory-support
+
+# public
+
+# vuepress build output
+
+.vuepress/dist
+
+# vuepress v2.x temp and cache directory
+
+.temp
+.cache
+
+# Docusaurus cache and generated files
+
+.docusaurus
+
+# Serverless directories
+
+.serverless/
+
+# FuseBox cache
+
+.fusebox/
+
+# DynamoDB Local files
+
+.dynamodb/
+
+# TernJS port file
+
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+
+.vscode-test
+
+# yarn v2
+
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.\*
diff --git a/.envrc b/.envrc
index 6a4286b..3550a30 100644
--- a/.envrc
+++ b/.envrc
@@ -1,2 +1 @@
 use flake
-export SITE_ROOT=public
diff --git a/.gitignore b/.gitignore
index 7753449..4947698 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,171 @@
-/public
-/.deploystamp
-/.formatstamp
-/.compressstamp
-.direnv
-/result
+# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
+
+# Logs
+
+logs
+_.log
+npm-debug.log_
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+
+report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
+
+# Runtime data
+
+pids
+_.pid
+_.seed
+\*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+
+lib-cov
+
+# Coverage directory used by tools like istanbul
+
+coverage
+\*.lcov
+
+# nyc test coverage
+
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+
+bower_components
+
+# node-waf configuration
+
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+
+build/Release
+
+# Dependency directories
+
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+
+web_modules/
+
+# TypeScript cache
+
+\*.tsbuildinfo
+
+# Optional npm cache directory
+
+.npm
+
+# Optional eslint cache
+
+.eslintcache
+
+# Optional stylelint cache
+
+.stylelintcache
+
+# Microbundle cache
+
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+
+.node_repl_history
+
+# Output of 'npm pack'
+
+\*.tgz
+
+# Yarn Integrity file
+
+.yarn-integrity
+
+# dotenv environment variable files
+
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# parcel-bundler cache (https://parceljs.org/)
+
+.cache
+.parcel-cache
+
+# Next.js build output
+
+.next
+out
+
+# Nuxt.js build / generate output
+
+.nuxt
+dist
+
+# Gatsby files
+
+.cache/
+
+# Comment in the public line in if your project uses Gatsby and not Next.js
+
+# https://nextjs.org/blog/next-9-1#public-directory-support
+
+# public
+
+# vuepress build output
+
+.vuepress/dist
+
+# vuepress v2.x temp and cache directory
+
+.temp
+.cache
+
+# Docusaurus cache and generated files
+
+.docusaurus
+
+# Serverless directories
+
+.serverless/
+
+# FuseBox cache
+
+.fusebox/
+
+# DynamoDB Local files
+
+.dynamodb/
+
+# TernJS port file
+
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+
+.vscode-test
+
+# yarn v2
+
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.\*
+/.direnv/
+/public/
diff --git a/Dockerfile b/Dockerfile
index 86b26ea..79533e9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,24 +1,60 @@
-ARG VERSION=2.7.4
-ARG VARIANT=alpine
+# syntax = docker/dockerfile:1
 
-FROM docker.io/caddy:${VERSION}-builder-${VARIANT} AS builder
+# Adjust BUN_VERSION as desired
+ARG BUN_VERSION=0.8.1
+ARG ZOLA_VERSION=0.17.1
+FROM oven/bun:${BUN_VERSION} as base
 
-RUN xcaddy build \
-    --with github.com/gamalan/caddy-tlsredis
+LABEL fly_launch_runtime="Bun"
 
-FROM docker.io/caddy:${VERSION}-${VARIANT}
+# Bun app lives here
+WORKDIR /app
 
-COPY --from=builder /usr/bin/caddy /usr/bin/caddy
+# Set production environment
+ENV NODE_ENV="production"
 
-COPY Caddyfile /etc/caddy/
-COPY public /srv
+# Throw-away build stage to reduce size of final image
+FROM base as build
 
-EXPOSE 9091/tcp
+# # Install packages needed to build node modules
+# RUN apt-get update -qq && \
+#     apt-get install -y build-essential pkg-config python-is-python3
 
-ENV SITE_ROOT=/srv
+# Install node modules
+COPY --link bun.lockb package.json ./
+RUN bun install --ci
 
-RUN mkdir /etc/caddy/globals/
-RUN touch /etc/caddy/globals/dummy
-RUN ["/usr/bin/caddy", "validate", "--config", "/etc/caddy/Caddyfile"]
+# Copy application code
+COPY --link src src
 
-COPY redis.Caddyfile /etc/caddy/globals/redis
+FROM ghcr.io/getzola/zola:v${ZOLA_VERSION} as ssg
+
+WORKDIR /web
+
+COPY --link website ./
+
+RUN [ "zola", "build", "--force" ]
+
+FROM alpine:edge as postprocess
+
+WORKDIR /web
+
+RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories
+
+RUN apk add --no-cache prettier@testing make fd brotli gzip zstd
+
+COPY --from=ssg /web ./
+
+RUN make -j4 format compress
+
+# Final stage for app image
+FROM base
+
+# Copy built application
+COPY --from=build /app /app
+COPY --from=postprocess /web/ /app/website
+
+# Start the server by default, this can be overwritten at runtime
+EXPOSE 3000
+EXPOSE 9091
+CMD [ "bun", "run", "src/index.ts" ]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..34f3f42
--- /dev/null
+++ b/README.md
@@ -0,0 +1,23 @@
+# homestead
+
+## Goals
+
+1. Static web server with prometheus-based analytics
+2. Dynamic web server capable of generating Zola-based websites
+3. More indieweb features
+
+## Installing
+
+To install dependencies:
+
+```bash
+bun install
+```
+
+To run:
+
+```bash
+bun run src/index.ts
+```
+
+This project was created using `bun init` in bun v0.8.1. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
diff --git a/bun.lockb b/bun.lockb
new file mode 100755
index 0000000..2608647
--- /dev/null
+++ b/bun.lockb
Binary files differdiff --git a/config.toml b/config.toml
index c3377d5..d2a22ad 100644
--- a/config.toml
+++ b/config.toml
@@ -25,6 +25,12 @@ hide_made_with_line = true
 date_format = "%F"
 webserver_sends_csp_headers = true
 
+[extra.headers]
+cache-control = "max-age=14400"
+x-content-type-options = "nosniff"
+strict-transport-security = "max-age=31536000; includeSubdomains; preload"
+content-security-policy = "default-src 'none'; img-src 'self'; object-src 'none'; script-src 'none'; style-src 'unsafe-inline'; form-action 'none'; base-uri 'self'"
+
 [[extra.main_menu]]
     name = "Posts"
     url = "/post/"
diff --git a/flake.lock b/flake.lock
index 55003d6..0f56f8b 100644
--- a/flake.lock
+++ b/flake.lock
@@ -1,61 +1,12 @@
 {
   "nodes": {
-    "flake-compat": {
-      "flake": false,
-      "locked": {
-        "lastModified": 1673956053,
-        "narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=",
-        "owner": "edolstra",
-        "repo": "flake-compat",
-        "rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9",
-        "type": "github"
-      },
-      "original": {
-        "owner": "edolstra",
-        "repo": "flake-compat",
-        "type": "github"
-      }
-    },
-    "flake-utils": {
-      "inputs": {
-        "systems": "systems"
-      },
-      "locked": {
-        "lastModified": 1687709756,
-        "narHash": "sha256-Y5wKlQSkgEK2weWdOu4J3riRd+kV/VCgHsqLNTTWQ/0=",
-        "owner": "numtide",
-        "repo": "flake-utils",
-        "rev": "dbabf0ca0c0c4bce6ea5eaf65af5cb694d2082c7",
-        "type": "github"
-      },
-      "original": {
-        "owner": "numtide",
-        "repo": "flake-utils",
-        "type": "github"
-      }
-    },
-    "flockenzeit": {
-      "locked": {
-        "lastModified": 1671185345,
-        "narHash": "sha256-+5IWi+iJAYcRxvLN15hKO2hVwNokfN3U+lvWf/zFtCg=",
-        "owner": "balsoft",
-        "repo": "Flockenzeit",
-        "rev": "90abba65671690d95b5d28ce6dd8de7959aa1339",
-        "type": "github"
-      },
-      "original": {
-        "owner": "balsoft",
-        "repo": "Flockenzeit",
-        "type": "github"
-      }
-    },
     "nixpkgs": {
       "locked": {
-        "lastModified": 1687977148,
-        "narHash": "sha256-gUcXiU2GgjYIc65GOIemdBJZ+lkQxuyIh7OkR9j0gCo=",
+        "lastModified": 1689261696,
+        "narHash": "sha256-LzfUtFs9MQRvIoQ3MfgSuipBVMXslMPH/vZ+nM40LkA=",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "60a783e00517fce85c42c8c53fe0ed05ded5b2a4",
+        "rev": "df1eee2aa65052a18121ed4971081576b25d6b5c",
         "type": "github"
       },
       "original": {
@@ -67,26 +18,8 @@
     },
     "root": {
       "inputs": {
-        "flake-compat": "flake-compat",
-        "flake-utils": "flake-utils",
-        "flockenzeit": "flockenzeit",
         "nixpkgs": "nixpkgs"
       }
-    },
-    "systems": {
-      "locked": {
-        "lastModified": 1681028828,
-        "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
-        "owner": "nix-systems",
-        "repo": "default",
-        "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
-        "type": "github"
-      },
-      "original": {
-        "owner": "nix-systems",
-        "repo": "default",
-        "type": "github"
-      }
     }
   },
   "root": "root",
diff --git a/flake.nix b/flake.nix
index d788db4..900c3cf 100644
--- a/flake.nix
+++ b/flake.nix
@@ -13,9 +13,15 @@
       (system:
         let
           pkgs = nixpkgs.legacyPackages.${system};
+          overlays = [
+            (final: prev: rec {
+              nodejs = prev.nodejs-18_x;
+              bun = (prev.bun.override { inherit nodejs; });
+            })
+          ];
           nativeBuildInputs = with pkgs; [
             zola
-            nodePackages_latest.prettier
+            nodePackages.prettier
             fd
             brotli
             gzip
@@ -45,11 +51,21 @@
               website = packages.default;
             };
           };
-          devShell = pkgs.mkShell {
-            buildInputs = with pkgs; [
-              caddy
-              flyctl
-            ] ++ nativeBuildInputs;
+          devShells = {
+            default = pkgs.mkShell {
+              packages = with pkgs; [
+                node2nix
+                nodejs
+              ] ++ (with pkgs.nodePackages; [
+                prettier
+              ]);
+            };
+            ssg = pkgs.mkShell {
+              buildInputs = with pkgs; [
+                caddy
+                flyctl
+              ] ++ nativeBuildInputs;
+            };
           };
         });
 }
diff --git a/fly.toml b/fly.toml
index 4b06784..d58112b 100644
--- a/fly.toml
+++ b/fly.toml
@@ -1,38 +1,21 @@
+# fly.toml app configuration file generated for homestead on 2023-09-14T11:40:37+02:00
+#
+# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
+#
+
 app = "alanpearce-eu"
-kill_signal = "SIGINT"
-kill_timeout = 5
 primary_region = "ams"
 
+[build]
+
 [metrics]
   port = 9091
   path = "/metrics"
 
-[env]
-  CADDY_CLUSTERING_REDIS_HOST = "fly-caddy-storage.upstash.io"
-  SITE_ROOT = "/srv"
-
-[[services]]
-  internal_port = 80
-  protocol = "tcp"
-  [services.concurrency]
-    type = "connections"
-    hard_limit = 200
-    soft_limit = 100
-  [[services.ports]]
-    handlers = ["http"]
-    port = 80
-  [[services.ports]]
-    handlers = ["tls"]
-    port = "443"
-    tls_options = { "alpn" = ["h2"] }
-  [[services.http_checks]]
-    interval = 10000
-    grace_period = "5s"
-    method = "head"
-    path = "/"
-    protocol = "http"
-    restart_limit = 0
-    timeout = 2000
-    tls_skip_verify = false
-    [services.http_checks.headers]
-    Host = "alanpearce.eu"
+[http_service]
+  internal_port = 3000
+  force_https = true
+  auto_stop_machines = false
+  auto_start_machines = true
+  min_machines_running = 3
+  processes = ["app"]
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..acb0224
--- /dev/null
+++ b/package.json
@@ -0,0 +1,19 @@
+{
+  "name": "homestead",
+  "module": "src/index.ts",
+  "scripts": {
+    "start": "bun run ."
+  },
+  "devDependencies": {
+    "bun-types": "latest"
+  },
+  "peerDependencies": {
+    "typescript": "^5.0.0"
+  },
+  "type": "module",
+  "dependencies": {
+    "@sentry/node": "^7.69.0",
+    "bun-prometheus-client": "^0.0.2",
+    "toml": "^3.0.0"
+  }
+}
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..064b038
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,9 @@
+import path from "node:path";
+import fs from "node:fs";
+import toml from "toml";
+
+export default function readConfig(base: string) {
+  const filename = path.join(base, "config.toml");
+
+  return toml.parse(fs.readFileSync(filename, "utf-8"));
+}
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}/`);
diff --git a/test/index.test.ts b/test/index.test.ts
new file mode 100644
index 0000000..2f682a9
--- /dev/null
+++ b/test/index.test.ts
@@ -0,0 +1,97 @@
+import { type Server } from "bun";
+import { expect, test, beforeAll, afterAll } from "bun:test";
+
+import app from "../src/index";
+
+const port = 33000;
+const base = `http://localhost:${port}/`;
+let server: Server;
+
+beforeAll(async function () {
+  server = Bun.serve(Object.assign({}, app, { port }));
+});
+
+afterAll(function () {
+  server.stop();
+});
+
+test("/ returns 200", async function () {
+  const res = await fetch(base);
+  expect(res.status).toBe(200);
+});
+
+test("/asdf returns 404", async function () {
+  const res = await fetch(`${base}asdf`);
+  expect(res.status).toBe(404);
+});
+
+test("/ returns 304 with newer if-modified-since header", async function () {
+  const res = await fetch(base, {
+    headers: {
+      "if-modified-since": new Date().toUTCString(),
+    },
+  });
+  expect(res.status).toBe(304);
+  expect(res.headers.get("vary")).toBe("Accept-Encoding");
+});
+
+test("/ returns 200 with older if-modified-since header", async function () {
+  const res = await fetch(base, {
+    headers: {
+      "if-modified-since": new Date(0).toUTCString(),
+    },
+  });
+  expect(res.status).toBe(200);
+});
+
+test("/ returns gzipped content with accept-encoding: gzip", async function () {
+  const res = await fetch(base, {
+    headers: {
+      "accept-encoding": "gzip",
+    },
+  });
+  expect(res.status).toBe(200);
+  // Bun 0.8.1 this doesn't work, but `verbose` shows it's there
+  // expect(res.headers.get("content-encoding")).toBe("gzip");
+  // response is automatically gunzipped
+  const body = await res.text();
+  expect(body.length).toBeGreaterThan(
+    Number(res.headers.get("content-length")),
+  );
+});
+
+test("/ returns uncompressed content with accept-encoding: identity", async function () {
+  const res = await fetch(base, {
+    headers: {
+      "accept-encoding": "identity",
+    },
+  });
+  expect(res.status).toBe(200);
+  const body = await res.text();
+  expect(body.length).toBe(Number(res.headers.get("content-length")));
+});
+
+test("/ returns brotli-compressed content with accept-encoding: br", async function () {
+  const res = await fetch(base, {
+    headers: {
+      "accept-encoding": "br",
+    },
+  });
+  expect(res.status).toBe(200);
+  expect(res.headers.get("content-encoding")).toBe("br");
+  const body = await res.text();
+  expect(body.length).toBeLessThan(Number(res.headers.get("content-length")));
+});
+
+test("/ returns zstd-compressed content with accept-encoding: zstd", async function () {
+  const res = await fetch(base, {
+    headers: {
+      "accept-encoding": "zstd",
+    },
+  });
+  expect(res.status).toBe(200);
+  expect(res.headers.get("content-encoding")).toBe("zstd");
+  expect(res.headers.get("vary")).toBe("Accept-Encoding");
+  const body = await res.text();
+  expect(body.length).toBeLessThan(Number(res.headers.get("content-length")));
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..1c542d7
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,22 @@
+{
+  "compilerOptions": {
+    "lib": ["ES2017", "ES2019", "ESNext"],
+    "module": "esnext",
+    "target": "esnext",
+    "moduleResolution": "bundler",
+    "moduleDetection": "force",
+    "allowImportingTsExtensions": true,
+    "noEmit": true,
+    "composite": true,
+    "strict": true,
+    "downlevelIteration": true,
+    "skipLibCheck": true,
+    "jsx": "preserve",
+    "allowSyntheticDefaultImports": true,
+    "forceConsistentCasingInFileNames": true,
+    "allowJs": true,
+    "types": [
+      "bun-types" // add Bun global
+    ]
+  }
+}