From 15521956feb5ff57228c07d0310d2fcc38303e44 Mon Sep 17 00:00:00 2001 From: jsahn Date: Wed, 2 Sep 2026 20:19:00 +0900 Subject: [PATCH] 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 --- dashboard/src/server.ts | 71 +++++++++++++++++++++++++++++------------ 1 file changed, 50 insertions(+), 21 deletions(-) diff --git a/dashboard/src/server.ts b/dashboard/src/server.ts index e7dece1..a268869 100644 --- a/dashboard/src/server.ts +++ b/dashboard/src/server.ts @@ -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 { // ------------------------------------------------------------------ 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, +): 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 = { @@ -138,30 +158,39 @@ const STATIC_TYPES: Record = { ".svg": "image/svg+xml", }; -async function sendStatic(res: ServerResponse, urlPath: string): Promise { +async function sendStatic(req: IncomingMessage, res: ServerResponse, urlPath: string): Promise { 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" }); } });