Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
233 lines
6.5 KiB
JavaScript
233 lines
6.5 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Sibling of scripts/query-counts.mjs that dumps raw query events to JSON
|
|
* files under scripts/query-dumps/{target}/{routeSlug}.{phase}.json
|
|
*
|
|
* Each file is an array of { sql, params, durationMs, route, method, phase }.
|
|
* The harness assumes the fixture is already built and seeded -- we only
|
|
* spin servers, hit routes, and partition events. For sqlite, the main
|
|
* `query-counts.mjs --target sqlite` flow builds and seeds; for d1, run
|
|
* `query-counts.mjs --target d1` once first (or `build-perf-d1.mjs` to
|
|
* build only) so wrangler state and dist/ exist.
|
|
*
|
|
* The dump JSON itself is gitignored — it's an analysis artifact that
|
|
* regenerates from the harness in seconds. The helper scripts in
|
|
* `query-dumps/` (classify.mjs, cold-only.mjs, inspect-other.mjs) are
|
|
* the things worth keeping in source.
|
|
*/
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
import { createConnection } from "node:net";
|
|
import { dirname, resolve } from "node:path";
|
|
import { createInterface } from "node:readline";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = resolve(__dirname, "..");
|
|
const fixtureDir = resolve(repoRoot, "fixtures/perf-site");
|
|
const dumpsDir = resolve(__dirname, "query-dumps");
|
|
|
|
const HOST = "127.0.0.1";
|
|
const PORT = 14322;
|
|
const BASE = `http://${HOST}:${PORT}`;
|
|
const QUERY_LOG_PREFIX = "[emdash-query-log] ";
|
|
|
|
const ROUTES = [
|
|
["GET", "/"],
|
|
["GET", "/posts"],
|
|
["GET", "/posts/building-for-the-long-term"],
|
|
["GET", "/pages/about"],
|
|
["GET", "/category/development"],
|
|
["GET", "/tag/webdev"],
|
|
["GET", "/rss.xml"],
|
|
["GET", "/search?q=static"],
|
|
];
|
|
|
|
function parseArgs(argv) {
|
|
const out = { target: "sqlite", routesOnly: null };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === "--target") out.target = argv[++i];
|
|
else if (a.startsWith("--target=")) out.target = a.slice("--target=".length);
|
|
else if (a === "--routes") out.routesOnly = argv[++i].split(",");
|
|
}
|
|
if (out.target !== "sqlite" && out.target !== "d1") {
|
|
throw new Error(`bad --target ${out.target}`);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const { target, routesOnly } = parseArgs(process.argv.slice(2));
|
|
|
|
function waitForPort(host, port, timeoutMs = 120_000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
return new Promise((resolveReady, rejectReady) => {
|
|
const attempt = () => {
|
|
if (Date.now() > deadline) {
|
|
rejectReady(new Error(`port ${host}:${port} did not open within ${timeoutMs}ms`));
|
|
return;
|
|
}
|
|
const socket = createConnection({ host, port });
|
|
socket.once("connect", () => {
|
|
socket.destroy();
|
|
resolveReady();
|
|
});
|
|
socket.once("error", () => {
|
|
socket.destroy();
|
|
setTimeout(attempt, 100);
|
|
});
|
|
};
|
|
attempt();
|
|
});
|
|
}
|
|
|
|
function startServer(events) {
|
|
let cmd, args;
|
|
if (target === "sqlite") {
|
|
cmd = "node";
|
|
args = ["./dist/server/entry.mjs"];
|
|
} else {
|
|
cmd = "pnpm";
|
|
args = ["exec", "astro", "preview", "--host", HOST, "--port", String(PORT)];
|
|
}
|
|
|
|
const child = spawn(cmd, args, {
|
|
cwd: fixtureDir,
|
|
env: {
|
|
...process.env,
|
|
EMDASH_FIXTURE_TARGET: target,
|
|
EMDASH_QUERY_LOG: "1",
|
|
HOST,
|
|
PORT: String(PORT),
|
|
},
|
|
stdio: ["ignore", "pipe", "inherit"],
|
|
});
|
|
|
|
const ready = waitForPort(HOST, PORT);
|
|
const rl = createInterface({ input: child.stdout });
|
|
rl.on("line", (line) => {
|
|
const idx = line.indexOf(QUERY_LOG_PREFIX);
|
|
if (idx !== -1) {
|
|
const payload = line.slice(idx + QUERY_LOG_PREFIX.length);
|
|
try {
|
|
events.push(JSON.parse(payload));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return;
|
|
}
|
|
process.stdout.write(line + "\n");
|
|
});
|
|
|
|
const exited = new Promise((res) => child.once("exit", res));
|
|
|
|
async function stop() {
|
|
child.kill("SIGTERM");
|
|
await Promise.race([
|
|
exited,
|
|
new Promise((r) => setTimeout(r, 5_000)).then(() => child.kill("SIGKILL")),
|
|
]);
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
}
|
|
|
|
return { ready, stop };
|
|
}
|
|
|
|
async function hit(method, path, phase) {
|
|
let lastErr;
|
|
for (let i = 0; i < 10; i++) {
|
|
try {
|
|
const r = await fetch(`${BASE}${path}`, {
|
|
method,
|
|
headers: { "x-perf-phase": phase },
|
|
redirect: "manual",
|
|
});
|
|
await r.arrayBuffer();
|
|
process.stdout.write(` ${phase.padEnd(5)} ${method} ${path} -> ${r.status}\n`);
|
|
return r.status;
|
|
} catch (err) {
|
|
lastErr = err;
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
}
|
|
}
|
|
throw lastErr;
|
|
}
|
|
|
|
async function warmup() {
|
|
const r = await fetch(BASE, { redirect: "manual" });
|
|
await r.arrayBuffer();
|
|
process.stdout.write(` warmup GET / -> ${r.status}\n`);
|
|
}
|
|
|
|
const ROUTE_LEADING_SLASH = /^\//;
|
|
const ROUTE_NON_ALNUM = /[^a-zA-Z0-9]+/g;
|
|
|
|
function routeSlug(path) {
|
|
if (path === "/") return "root";
|
|
return path.replace(ROUTE_LEADING_SLASH, "").replace(ROUTE_NON_ALNUM, "_");
|
|
}
|
|
|
|
function dumpEventsByRoute(events, dumpTarget) {
|
|
const targetDir = resolve(dumpsDir, dumpTarget);
|
|
if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
|
|
|
|
const groups = new Map();
|
|
for (const e of events) {
|
|
if (e.phase !== "cold" && e.phase !== "warm") continue;
|
|
const key = `${routeSlug(e.route)}.${e.phase}`;
|
|
if (!groups.has(key)) groups.set(key, []);
|
|
groups.get(key).push(e);
|
|
}
|
|
for (const [key, list] of groups) {
|
|
const file = resolve(targetDir, `${key}.json`);
|
|
writeFileSync(file, JSON.stringify(list, null, "\t") + "\n");
|
|
process.stdout.write(`wrote ${file} (${list.length})\n`);
|
|
}
|
|
const allFile = resolve(targetDir, "_all.json");
|
|
writeFileSync(allFile, JSON.stringify(events, null, "\t") + "\n");
|
|
process.stdout.write(`wrote ${allFile} (${events.length})\n`);
|
|
}
|
|
|
|
async function runSqlite(events) {
|
|
const server = startServer(events);
|
|
try {
|
|
await server.ready;
|
|
await warmup();
|
|
const routes = routesOnly ? ROUTES.filter(([_, p]) => routesOnly.includes(p)) : ROUTES;
|
|
for (const [m, p] of routes) await hit(m, p, "cold");
|
|
for (const [m, p] of routes) await hit(m, p, "warm");
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
}
|
|
|
|
async function runD1(events) {
|
|
const routes = routesOnly ? ROUTES.filter(([_, p]) => routesOnly.includes(p)) : ROUTES;
|
|
for (const [m, p] of routes) {
|
|
process.stdout.write(`--- fresh isolate for ${m} ${p} ---\n`);
|
|
const server = startServer(events);
|
|
try {
|
|
await server.ready;
|
|
await hit(m, p, "cold");
|
|
await hit(m, p, "warm");
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const events = [];
|
|
if (target === "sqlite") await runSqlite(events);
|
|
else await runD1(events);
|
|
dumpEventsByRoute(events, target);
|
|
}
|
|
|
|
main()
|
|
.then(() => process.exit(0))
|
|
.catch((err) => {
|
|
process.stderr.write(`${err.stack ?? err.message ?? err}\n`);
|
|
process.exit(1);
|
|
});
|