diff options
-rw-r--r-- | .dockerignore | 173 | ||||
-rw-r--r-- | .envrc | 1 | ||||
-rw-r--r-- | .gitignore | 177 | ||||
-rw-r--r-- | Dockerfile | 66 | ||||
-rw-r--r-- | README.md | 23 | ||||
-rwxr-xr-x | bun.lockb | bin | 0 -> 7240 bytes | |||
-rw-r--r-- | config.toml | 6 | ||||
-rw-r--r-- | flake.lock | 73 | ||||
-rw-r--r-- | flake.nix | 28 | ||||
-rw-r--r-- | fly.toml | 45 | ||||
-rw-r--r-- | package.json | 19 | ||||
-rw-r--r-- | src/config.ts | 9 | ||||
-rw-r--r-- | src/index.ts | 211 | ||||
-rw-r--r-- | test/index.test.ts | 97 | ||||
-rw-r--r-- | tsconfig.json | 22 |
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 + ] + } +} |