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:
jsahn
2026-09-02 20:19:00 +09:00
co-authored by Claude Fable 5
parent e7d9645e41
commit 15521956fe
+50 -21
View File
@@ -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" });
}
});