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.
|
* 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 { readFile, readdir, stat } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { gzipSync } from "node:zlib";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
DigestResult,
|
DigestResult,
|
||||||
@@ -122,13 +123,32 @@ async function readErrors(limit = 100): Promise<ErrorLine[]> {
|
|||||||
|
|
||||||
// ------------------------------------------------------------------ http plumbing
|
// ------------------------------------------------------------------ http plumbing
|
||||||
|
|
||||||
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
/** Send a body with explicit content-length, gzipped when the client accepts it.
|
||||||
const payload = JSON.stringify(body);
|
*
|
||||||
res.writeHead(status, {
|
* 큰 응답을 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",
|
"content-type": "application/json; charset=utf-8",
|
||||||
"cache-control": "no-cache",
|
"cache-control": "no-cache",
|
||||||
});
|
});
|
||||||
res.end(payload);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATIC_TYPES: Record<string, string> = {
|
const STATIC_TYPES: Record<string, string> = {
|
||||||
@@ -138,30 +158,39 @@ const STATIC_TYPES: Record<string, string> = {
|
|||||||
".svg": "image/svg+xml",
|
".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 rel = urlPath === "/" ? "index.html" : urlPath.replace(/^\/+/, "");
|
||||||
const file = path.resolve(PUBLIC_DIR, rel);
|
const file = path.resolve(PUBLIC_DIR, rel);
|
||||||
if (!file.startsWith(PUBLIC_DIR + path.sep) && file !== path.join(PUBLIC_DIR, "index.html")) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const body = await readFile(file);
|
const body = await readFile(file);
|
||||||
res.writeHead(200, {
|
sendBody(req, res, 200, body, {
|
||||||
"content-type": STATIC_TYPES[path.extname(file)] ?? "application/octet-stream",
|
"content-type": STATIC_TYPES[path.extname(file)] ?? "application/octet-stream",
|
||||||
});
|
});
|
||||||
res.end(body);
|
|
||||||
} catch {
|
} catch {
|
||||||
sendJson(res, 404, { error: "not found" });
|
sendJson(req, res, 404, { error: "not found" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = createServer(async (req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
const url = new URL(req.url ?? "/", "http://localhost");
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
const p = url.pathname;
|
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 {
|
try {
|
||||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||||
sendJson(res, 405, { error: "method not allowed" });
|
sendJson(req, res, 405, { error: "method not allowed" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (p === "/api/health") {
|
if (p === "/api/health") {
|
||||||
@@ -172,45 +201,45 @@ const server = createServer(async (req, res) => {
|
|||||||
reports_dir: REPORTS_DIR,
|
reports_dir: REPORTS_DIR,
|
||||||
latest_week: weeks[0] ?? null,
|
latest_week: weeks[0] ?? null,
|
||||||
};
|
};
|
||||||
sendJson(res, 200, body);
|
sendJson(req, res, 200, body);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (p === "/api/digests") {
|
if (p === "/api/digests") {
|
||||||
const digests = await allDigests();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const detail = p.match(/^\/api\/digests\/(\d{4}-W\d{2})$/);
|
const detail = p.match(/^\/api\/digests\/(\d{4}-W\d{2})$/);
|
||||||
if (detail) {
|
if (detail) {
|
||||||
const week = detail[1]!;
|
const week = detail[1]!;
|
||||||
if (!WEEK_RE.test(week)) {
|
if (!WEEK_RE.test(week)) {
|
||||||
sendJson(res, 400, { error: "bad week" });
|
sendJson(req, res, 400, { error: "bad week" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await loadDigest(week);
|
const result = await loadDigest(week);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
sendJson(res, 404, { error: `no digest for ${week}` });
|
sendJson(req, res, 404, { error: `no digest for ${week}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sendJson(res, 200, result);
|
sendJson(req, res, 200, result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (p === "/api/stats") {
|
if (p === "/api/stats") {
|
||||||
sendJson(res, 200, await buildStats());
|
sendJson(req, res, 200, await buildStats());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (p === "/api/errors") {
|
if (p === "/api/errors") {
|
||||||
sendJson(res, 200, await readErrors());
|
sendJson(req, res, 200, await readErrors());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (p.startsWith("/api/")) {
|
if (p.startsWith("/api/")) {
|
||||||
sendJson(res, 404, { error: "not found" });
|
sendJson(req, res, 404, { error: "not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await sendStatic(res, p);
|
await sendStatic(req, res, p);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`request ${p} failed:`, 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