diff options
author | Alan Pearce | 2023-09-17 17:31:18 +0200 |
---|---|---|
committer | Alan Pearce | 2023-09-17 17:31:18 +0200 |
commit | 602f249c2cfac0e7b6613fb63f5fb519aa1ca952 (patch) | |
tree | be522208e3172e62b4777a66af4bd931677f7fcb | |
parent | 1a7abb3723d6b9db0d199c26d2a207e03636738a (diff) | |
download | website-main.tar.xz website-main.zip |
-rw-r--r-- | src/app.ts | 225 | ||||
-rw-r--r-- | src/index.ts | 234 | ||||
-rw-r--r-- | test/index.test.ts | 2 |
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 @@ | |||
1 | import path from "node:path"; | ||
2 | import fs, { Stats } from "node:fs"; | ||
3 | import type { BunFile, Serve } from "bun"; | ||
4 | import * as Sentry from "@sentry/node"; | ||
5 | import prom from "bun-prometheus-client"; | ||
6 | |||
7 | import readConfig from "./config"; | ||
8 | |||
9 | Sentry.init({ | ||
10 | release: `homestead@${Bun.env.FLY_MACHINE_VERSION}`, | ||
11 | tracesSampleRate: 1.0, | ||
12 | }); | ||
13 | |||
14 | const base = "."; | ||
15 | const publicDir = path.resolve(base, "public") + path.sep; | ||
16 | |||
17 | const config = readConfig(base); | ||
18 | const defaultHeaders = { | ||
19 | ...config.extra.headers, | ||
20 | vary: "Accept-Encoding", | ||
21 | }; | ||
22 | |||
23 | type 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 | |||
33 | const 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 | |||
46 | let files = new Map<string, File>(); | ||
47 | |||
48 | function 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 | |||
74 | function 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 | |||
95 | walkDirectory(publicDir, ""); | ||
96 | |||
97 | async 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 | |||
112 | function parseIfModifiedSinceHeader(header: string | null): number { | ||
113 | return header ? new Date(header).getTime() + 999 : 0; | ||
114 | } | ||
115 | |||
116 | export 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 | |||
129 | export 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 @@ | |||
1 | import path from "node:path"; | 1 | import { server, metricsServer } from "./app"; |
2 | import fs, { Stats } from "node:fs"; | ||
3 | import type { BunFile, Serve } from "bun"; | ||
4 | import * as Sentry from "@sentry/node"; | ||
5 | import prom from "bun-prometheus-client"; | ||
6 | 2 | ||
7 | import readConfig from "./config"; | 3 | const metricsServed = Bun.serve(metricsServer); |
4 | console.info(`Metrics server started on port ${metricsServed.port}`); | ||
8 | 5 | ||
9 | Sentry.init({ | 6 | const served = Bun.serve(server); |
10 | release: `homestead@${Bun.env.FLY_MACHINE_VERSION}`, | 7 | console.info(`Serving website on http://${served.hostname}:${served.port}/`); |
11 | tracesSampleRate: 1.0, | ||
12 | }); | ||
13 | |||
14 | const base = "."; | ||
15 | const publicDir = path.resolve(base, "public") + path.sep; | ||
16 | |||
17 | const config = readConfig(base); | ||
18 | const defaultHeaders = { | ||
19 | ...config.extra.headers, | ||
20 | vary: "Accept-Encoding", | ||
21 | }; | ||
22 | |||
23 | type 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 | |||
33 | const 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 | |||
46 | let files = new Map<string, File>(); | ||
47 | |||
48 | function 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 | |||
74 | function 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 | |||
95 | walkDirectory(publicDir, ""); | ||
96 | |||
97 | async 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 | |||
112 | function parseIfModifiedSinceHeader(header: string | null): number { | ||
113 | return header ? new Date(header).getTime() + 999 : 0; | ||
114 | } | ||
115 | |||
116 | const 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 | |||
129 | console.info( | ||
130 | `Serving metrics on http://${metricsServer.hostname}:${metricsServer.port}/metrics`, | ||
131 | ); | ||
132 | |||
133 | const 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 | |||
231 | console.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 @@ | |||
1 | import { type Server } from "bun"; | 1 | import { type Server } from "bun"; |
2 | import { expect, test, beforeAll, afterAll } from "bun:test"; | 2 | import { expect, test, beforeAll, afterAll } from "bun:test"; |
3 | 3 | ||
4 | import app from "../src/index"; | 4 | import { server as app } from "../src/app"; |
5 | 5 | ||
6 | const port = 33000; | 6 | const port = 33000; |
7 | const base = `http://localhost:${port}/`; | 7 | const base = `http://localhost:${port}/`; |