Dashboard server: explicit content-length + gzip, request/abort logging
Large chunked responses (the 210KB digest detail) can be cut off by some browser/middlebox combinations (Safari 'Load failed'); send every body with an explicit length, gzipped when accepted, and log finishes/aborts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+50
-21
@@ -5,10 +5,11 @@
|
||||
* dashboard container never contends with the daemon's WAL over virtiofs.
|
||||
*/
|
||||
|
||||
import { createServer, type ServerResponse } from "node:http";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { readFile, readdir, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
import type {
|
||||
DigestResult,
|
||||
@@ -122,13 +123,32 @@ async function readErrors(limit = 100): Promise<ErrorLine[]> {
|
||||
|
||||
// ------------------------------------------------------------------ http plumbing
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
const payload = JSON.stringify(body);
|
||||
res.writeHead(status, {
|
||||
/** Send a body with explicit content-length, gzipped when the client accepts it.
|
||||
*
|
||||
* 큰 응답을 content-length 없는 chunked로 흘리면 일부 브라우저/중간 장비 조합에서 전송이 끊긴다
|
||||
* (Safari의 "Load failed"). 명시적 길이 + gzip으로 프레이밍을 단순하게 유지한다. */
|
||||
function sendBody(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
status: number,
|
||||
body: Buffer,
|
||||
headers: Record<string, string>,
|
||||
): void {
|
||||
const acceptsGzip = String(req.headers["accept-encoding"] ?? "").includes("gzip");
|
||||
if (acceptsGzip && body.length > 1024) {
|
||||
body = gzipSync(body);
|
||||
headers["content-encoding"] = "gzip";
|
||||
}
|
||||
headers["content-length"] = String(body.length);
|
||||
res.writeHead(status, headers);
|
||||
res.end(req.method === "HEAD" ? undefined : body);
|
||||
}
|
||||
|
||||
function sendJson(req: IncomingMessage, res: ServerResponse, status: number, body: unknown): void {
|
||||
sendBody(req, res, status, Buffer.from(JSON.stringify(body)), {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-cache",
|
||||
});
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
const STATIC_TYPES: Record<string, string> = {
|
||||
@@ -138,30 +158,39 @@ const STATIC_TYPES: Record<string, string> = {
|
||||
".svg": "image/svg+xml",
|
||||
};
|
||||
|
||||
async function sendStatic(res: ServerResponse, urlPath: string): Promise<void> {
|
||||
async function sendStatic(req: IncomingMessage, res: ServerResponse, urlPath: string): Promise<void> {
|
||||
const rel = urlPath === "/" ? "index.html" : urlPath.replace(/^\/+/, "");
|
||||
const file = path.resolve(PUBLIC_DIR, rel);
|
||||
if (!file.startsWith(PUBLIC_DIR + path.sep) && file !== path.join(PUBLIC_DIR, "index.html")) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
sendJson(req, res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await readFile(file);
|
||||
res.writeHead(200, {
|
||||
sendBody(req, res, 200, body, {
|
||||
"content-type": STATIC_TYPES[path.extname(file)] ?? "application/octet-stream",
|
||||
});
|
||||
res.end(body);
|
||||
} catch {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
sendJson(req, res, 404, { error: "not found" });
|
||||
}
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const p = url.pathname;
|
||||
// Request log: tells "never arrived" apart from "aborted mid-transfer" in container logs.
|
||||
const t0 = Date.now();
|
||||
let finished = false;
|
||||
res.on("finish", () => {
|
||||
finished = true;
|
||||
console.log(`${req.method} ${p} ${res.statusCode} ${res.getHeader("content-length") ?? "-"}B ${Date.now() - t0}ms`);
|
||||
});
|
||||
res.on("close", () => {
|
||||
if (!finished) console.log(`${req.method} ${p} ABORTED after ${Date.now() - t0}ms`);
|
||||
});
|
||||
try {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
sendJson(res, 405, { error: "method not allowed" });
|
||||
sendJson(req, res, 405, { error: "method not allowed" });
|
||||
return;
|
||||
}
|
||||
if (p === "/api/health") {
|
||||
@@ -172,45 +201,45 @@ const server = createServer(async (req, res) => {
|
||||
reports_dir: REPORTS_DIR,
|
||||
latest_week: weeks[0] ?? null,
|
||||
};
|
||||
sendJson(res, 200, body);
|
||||
sendJson(req, res, 200, body);
|
||||
return;
|
||||
}
|
||||
if (p === "/api/digests") {
|
||||
const digests = await allDigests();
|
||||
sendJson(res, 200, digests.map(({ week, result }) => summarize(week, result)));
|
||||
sendJson(req, res, 200, digests.map(({ week, result }) => summarize(week, result)));
|
||||
return;
|
||||
}
|
||||
const detail = p.match(/^\/api\/digests\/(\d{4}-W\d{2})$/);
|
||||
if (detail) {
|
||||
const week = detail[1]!;
|
||||
if (!WEEK_RE.test(week)) {
|
||||
sendJson(res, 400, { error: "bad week" });
|
||||
sendJson(req, res, 400, { error: "bad week" });
|
||||
return;
|
||||
}
|
||||
const result = await loadDigest(week);
|
||||
if (!result) {
|
||||
sendJson(res, 404, { error: `no digest for ${week}` });
|
||||
sendJson(req, res, 404, { error: `no digest for ${week}` });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, result);
|
||||
sendJson(req, res, 200, result);
|
||||
return;
|
||||
}
|
||||
if (p === "/api/stats") {
|
||||
sendJson(res, 200, await buildStats());
|
||||
sendJson(req, res, 200, await buildStats());
|
||||
return;
|
||||
}
|
||||
if (p === "/api/errors") {
|
||||
sendJson(res, 200, await readErrors());
|
||||
sendJson(req, res, 200, await readErrors());
|
||||
return;
|
||||
}
|
||||
if (p.startsWith("/api/")) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
sendJson(req, res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
await sendStatic(res, p);
|
||||
await sendStatic(req, res, p);
|
||||
} catch (err) {
|
||||
console.error(`request ${p} failed:`, err);
|
||||
sendJson(res, 500, { error: "internal error" });
|
||||
sendJson(req, res, 500, { error: "internal error" });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user