about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorAlan Pearce2023-09-17 17:31:18 +0200
committerAlan Pearce2023-09-17 17:31:18 +0200
commit602f249c2cfac0e7b6613fb63f5fb519aa1ca952 (patch)
treebe522208e3172e62b4777a66af4bd931677f7fcb
parent1a7abb3723d6b9db0d199c26d2a207e03636738a (diff)
downloadwebsite-main.tar.xz
website-main.zip
Move servers into app.ts and export for testing HEAD main
-rw-r--r--src/app.ts225
-rw-r--r--src/index.ts234
-rw-r--r--test/index.test.ts2
3 files changed, 231 insertions, 230 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 @@
1import path from "node:path";
2import fs, { Stats } from "node:fs";
3import type { BunFile, Serve } from "bun";
4import * as Sentry from "@sentry/node";
5import prom from "bun-prometheus-client";
6
7import readConfig from "./config";
8
9Sentry.init({
10 release: `homestead@${Bun.env.FLY_MACHINE_VERSION}`,
11 tracesSampleRate: 1.0,
12});
13
14const base = ".";
15const publicDir = path.resolve(base, "public") + path.sep;
16
17const config = readConfig(base);
18const defaultHeaders = {
19 ...config.extra.headers,
20 vary: "Accept-Encoding",
21};
22
23type File = {
24 filename: string;
25 handle: BunFile;
26 relPath: string;
27 headers?: Record<string, string>;
28 type: string;
29 size: number;
30 mtime: Date;
31};
32
33const metrics = {
34 requests: new prom.Counter({
35 name: "homestead_requests",
36 help: "Number of requests by path, status code, and method",
37 labelNames: ["path", "status_code", "method"],
38 }),
39 requestDuration: new prom.Histogram({
40 name: "homestead_request_duration_seconds",
41 help: "Request duration in seconds",
42 labelNames: ["path"],
43 }),
44};
45
46let files = new Map<string, File>();
47
48function registerFile(
49 path: string,
50 pathname: string,
51 filename: string,
52 stat: Stats,
53): void {
54 pathname = "/" + (pathname === "." || pathname === "./" ? "" : pathname);
55
56 if (files.get(pathname) !== undefined) {
57 console.warn("File already registered:", pathname);
58 }
59 const handle = Bun.file(filename);
60 files.set(pathname, {
61 filename,
62 relPath: "/" + path,
63 handle: handle,
64 type: pathname.startsWith("/feed-styles.xsl") ? "text/xsl" : handle.type,
65 headers:
66 pathname === "/404.html"
67 ? Object.assign({}, defaultHeaders, { "cache-control": "no-cache" })
68 : undefined,
69 size: stat.size,
70 mtime: stat.mtime,
71 });
72}
73
74function walkDirectory(root: string, dir: string) {
75 const absDir = path.join(root, dir);
76 for (let pathname of fs.readdirSync(absDir)) {
77 const relPath = path.join(dir, pathname);
78 const absPath = path.join(absDir, pathname);
79 const stat = fs.statSync(absPath);
80 if (stat.isDirectory()) {
81 walkDirectory(root, relPath + path.sep);
82 } else if (stat.isFile()) {
83 if (pathname.startsWith("index.html")) {
84 const dir = relPath.replace("index.html", "");
85 registerFile(relPath, dir, absPath, stat);
86 if (dir !== "") {
87 registerFile(relPath, dir + path.sep, absPath, stat);
88 }
89 }
90 registerFile(relPath, relPath, absPath, stat);
91 }
92 }
93}
94
95walkDirectory(publicDir, "");
96
97async function serveFile(
98 file: File | undefined,
99 statusCode: number = 200,
100 extraHeaders: Record<string, string> = {},
101): Promise<Response> {
102 return new Response(await file.handle.arrayBuffer(), {
103 headers: {
104 "last-modified": file.mtime.toUTCString(),
105 ...extraHeaders,
106 ...(file.headers || defaultHeaders),
107 },
108 status: statusCode,
109 });
110}
111
112function parseIfModifiedSinceHeader(header: string | null): number {
113 return header ? new Date(header).getTime() + 999 : 0;
114}
115
116export const metricsServer = {
117 port: 9091,
118 fetch: async function (request) {
119 const pathname = new URL(request.url).pathname;
120 switch (pathname) {
121 case "/metrics":
122 return new Response(await prom.register.metrics());
123 default:
124 return new Response("", { status: 404 });
125 }
126 },
127} satisfies Serve;
128
129export const server = {
130 fetch: async function (request) {
131 const pathname = new URL(request.url).pathname.replace(/\/\/+/g, "/");
132 const endTimer = metrics.requestDuration.startTimer({ path: pathname });
133 const transaction = Sentry.startTransaction({
134 name: pathname,
135 op: "http.server",
136 description: `${request.method} ${pathname}`,
137 tags: {
138 url: request.url,
139 "http.method": request.method,
140 "http.user_agent": request.headers.get("user-agent"),
141 },
142 });
143 try {
144 const file = files.get(pathname);
145 metrics.requests.inc({ path: pathname });
146 if (file && (await file.handle.exists())) {
147 if (
148 parseIfModifiedSinceHeader(
149 request.headers.get("if-modified-since"),
150 ) >= file?.mtime.getTime()
151 ) {
152 metrics.requests.inc({
153 method: request.method,
154 path: pathname,
155 status_code: 304,
156 });
157 transaction.setHttpStatus(304);
158 return new Response("", { status: 304, headers: defaultHeaders });
159 }
160 metrics.requests.inc({
161 method: request.method,
162 path: pathname,
163 status_code: 200,
164 });
165 const encodings = (request.headers.get("accept-encoding") || "")
166 .split(",")
167 .map((x) => x.trim().toLowerCase());
168 if (encodings.includes("br") && files.has(file.relPath + ".br")) {
169 transaction.setHttpStatus(200);
170 transaction.setTag("http.content-encoding", "br");
171 return serveFile(files.get(file.relPath + ".br"), 200, {
172 "content-encoding": "br",
173 "content-type": file.type,
174 });
175 } else if (
176 encodings.includes("zstd") &&
177 files.has(file.relPath + ".zst")
178 ) {
179 transaction.setHttpStatus(200);
180 transaction.setTag("http.content-encoding", "zstd");
181 return serveFile(files.get(file.relPath + ".zst"), 200, {
182 "content-encoding": "zstd",
183 "content-type": file.type,
184 });
185 } else if (
186 encodings.includes("gzip") &&
187 files.has(file.relPath + ".gz")
188 ) {
189 transaction.setHttpStatus(200);
190 transaction.setTag("http.content-encoding", "gzip");
191 return serveFile(files.get(file.relPath + ".gz"), 200, {
192 "content-encoding": "gzip",
193 "content-type": file.type,
194 });
195 }
196 transaction.setHttpStatus(200);
197 transaction.setTag("http.content-encoding", "identity");
198 return serveFile(file);
199 } else {
200 metrics.requests.inc({
201 method: request.method,
202 path: pathname,
203 status_code: 404,
204 });
205 transaction.setHttpStatus(404);
206 transaction.setTag("http.content-encoding", "identity");
207 return serveFile(files.get("/404.html"), 404);
208 }
209 } catch (error) {
210 transaction.setTag("http.content-encoding", "identity");
211 transaction.setHttpStatus(503);
212 metrics.requests.inc({
213 method: request.method,
214 path: pathname,
215 status_code: 503,
216 });
217 Sentry.captureException(error);
218 return new Response("Something went wrong", { status: 503 });
219 } finally {
220 const seconds = endTimer();
221 metrics.requestDuration.observe(seconds);
222 transaction.finish();
223 }
224 },
225} 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 @@
1import path from "node:path"; 1import { server, metricsServer } from "./app";
2import fs, { Stats } from "node:fs";
3import type { BunFile, Serve } from "bun";
4import * as Sentry from "@sentry/node";
5import prom from "bun-prometheus-client";
6 2
7import readConfig from "./config"; 3const metricsServed = Bun.serve(metricsServer);
4console.info(`Metrics server started on port ${metricsServed.port}`);
8 5
9Sentry.init({ 6const served = Bun.serve(server);
10 release: `homestead@${Bun.env.FLY_MACHINE_VERSION}`, 7console.info(`Serving website on http://${served.hostname}:${served.port}/`);
11 tracesSampleRate: 1.0,
12});
13
14const base = ".";
15const publicDir = path.resolve(base, "public") + path.sep;
16
17const config = readConfig(base);
18const defaultHeaders = {
19 ...config.extra.headers,
20 vary: "Accept-Encoding",
21};
22
23type File = {
24 filename: string;
25 handle: BunFile;
26 relPath: string;
27 headers?: Record<string, string>;
28 type: string;
29 size: number;
30 mtime: Date;
31};
32
33const metrics = {
34 requests: new prom.Counter({
35 name: "homestead_requests",
36 help: "Number of requests by path, status code, and method",
37 labelNames: ["path", "status_code", "method"],
38 }),
39 requestDuration: new prom.Histogram({
40 name: "homestead_request_duration_seconds",
41 help: "Request duration in seconds",
42 labelNames: ["path"],
43 }),
44};
45
46let files = new Map<string, File>();
47
48function registerFile(
49 path: string,
50 pathname: string,
51 filename: string,
52 stat: Stats,
53): void {
54 pathname = "/" + (pathname === "." || pathname === "./" ? "" : pathname);
55
56 if (files.get(pathname) !== undefined) {
57 console.warn("File already registered:", pathname);
58 }
59 const handle = Bun.file(filename);
60 files.set(pathname, {
61 filename,
62 relPath: "/" + path,
63 handle: handle,
64 type: pathname.startsWith("/feed-styles.xsl") ? "text/xsl" : handle.type,
65 headers:
66 pathname === "/404.html"
67 ? Object.assign({}, defaultHeaders, { "cache-control": "no-cache" })
68 : undefined,
69 size: stat.size,
70 mtime: stat.mtime,
71 });
72}
73
74function walkDirectory(root: string, dir: string) {
75 const absDir = path.join(root, dir);
76 for (let pathname of fs.readdirSync(absDir)) {
77 const relPath = path.join(dir, pathname);
78 const absPath = path.join(absDir, pathname);
79 const stat = fs.statSync(absPath);
80 if (stat.isDirectory()) {
81 walkDirectory(root, relPath + path.sep);
82 } else if (stat.isFile()) {
83 if (pathname.startsWith("index.html")) {
84 const dir = relPath.replace("index.html", "");
85 registerFile(relPath, dir, absPath, stat);
86 if (dir !== "") {
87 registerFile(relPath, dir + path.sep, absPath, stat);
88 }
89 }
90 registerFile(relPath, relPath, absPath, stat);
91 }
92 }
93}
94
95walkDirectory(publicDir, "");
96
97async function serveFile(
98 file: File | undefined,
99 statusCode: number = 200,
100 extraHeaders: Record<string, string> = {},
101): Promise<Response> {
102 return new Response(await file.handle.arrayBuffer(), {
103 headers: {
104 "last-modified": file.mtime.toUTCString(),
105 ...extraHeaders,
106 ...(file.headers || defaultHeaders),
107 },
108 status: statusCode,
109 });
110}
111
112function parseIfModifiedSinceHeader(header: string | null): number {
113 return header ? new Date(header).getTime() + 999 : 0;
114}
115
116const metricsServer = Bun.serve({
117 port: 9091,
118 fetch: async function (request) {
119 const pathname = new URL(request.url).pathname;
120 switch (pathname) {
121 case "/metrics":
122 return new Response(await prom.register.metrics());
123 default:
124 return new Response("", { status: 404 });
125 }
126 },
127});
128
129console.info(
130 `Serving metrics on http://${metricsServer.hostname}:${metricsServer.port}/metrics`,
131);
132
133const server = Bun.serve({
134 fetch: async function (request) {
135 const pathname = new URL(request.url).pathname.replace(/\/\/+/g, "/");
136 const endTimer = metrics.requestDuration.startTimer({ path: pathname });
137 const transaction = Sentry.startTransaction({
138 name: pathname,
139 op: "http.server",
140 description: `${request.method} ${pathname}`,
141 tags: {
142 url: request.url,
143 "http.method": request.method,
144 "http.user_agent": request.headers.get("user-agent"),
145 },
146 });
147 try {
148 const file = files.get(pathname);
149 metrics.requests.inc({ path: pathname });
150 if (file && (await file.handle.exists())) {
151 if (
152 parseIfModifiedSinceHeader(
153 request.headers.get("if-modified-since"),
154 ) >= file?.mtime.getTime()
155 ) {
156 metrics.requests.inc({
157 method: request.method,
158 path: pathname,
159 status_code: 304,
160 });
161 transaction.setHttpStatus(304);
162 return new Response("", { status: 304, headers: defaultHeaders });
163 }
164 metrics.requests.inc({
165 method: request.method,
166 path: pathname,
167 status_code: 200,
168 });
169 const encodings = (request.headers.get("accept-encoding") || "")
170 .split(",")
171 .map((x) => x.trim().toLowerCase());
172 if (encodings.includes("br") && files.has(file.relPath + ".br")) {
173 transaction.setHttpStatus(200);
174 transaction.setTag("http.content-encoding", "br");
175 return serveFile(files.get(file.relPath + ".br"), 200, {
176 "content-encoding": "br",
177 "content-type": file.type,
178 });
179 } else if (
180 encodings.includes("zstd") &&
181 files.has(file.relPath + ".zst")
182 ) {
183 transaction.setHttpStatus(200);
184 transaction.setTag("http.content-encoding", "zstd");
185 return serveFile(files.get(file.relPath + ".zst"), 200, {
186 "content-encoding": "zstd",
187 "content-type": file.type,
188 });
189 } else if (
190 encodings.includes("gzip") &&
191 files.has(file.relPath + ".gz")
192 ) {
193 transaction.setHttpStatus(200);
194 transaction.setTag("http.content-encoding", "gzip");
195 return serveFile(files.get(file.relPath + ".gz"), 200, {
196 "content-encoding": "gzip",
197 "content-type": file.type,
198 });
199 }
200 transaction.setHttpStatus(200);
201 transaction.setTag("http.content-encoding", "identity");
202 return serveFile(file);
203 } else {
204 metrics.requests.inc({
205 method: request.method,
206 path: pathname,
207 status_code: 404,
208 });
209 transaction.setHttpStatus(404);
210 transaction.setTag("http.content-encoding", "identity");
211 return serveFile(files.get("/404.html"), 404);
212 }
213 } catch (error) {
214 transaction.setTag("http.content-encoding", "identity");
215 transaction.setHttpStatus(503);
216 metrics.requests.inc({
217 method: request.method,
218 path: pathname,
219 status_code: 503,
220 });
221 Sentry.captureException(error);
222 return new Response("Something went wrong", { status: 503 });
223 } finally {
224 const seconds = endTimer();
225 metrics.requestDuration.observe(seconds);
226 transaction.finish();
227 }
228 },
229});
230
231console.info(`Serving website on http://${server.hostname}:${server.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 @@
1import { type Server } from "bun"; 1import { type Server } from "bun";
2import { expect, test, beforeAll, afterAll } from "bun:test"; 2import { expect, test, beforeAll, afterAll } from "bun:test";
3 3
4import app from "../src/index"; 4import { server as app } from "../src/app";
5 5
6const port = 33000; 6const port = 33000;
7const base = `http://localhost:${port}/`; 7const base = `http://localhost:${port}/`;