From c1ead2e9c3a0be4bfe29ba64252e540b8415fd24 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Tue, 20 Oct 2026 15:24:50 -0400 Subject: [PATCH] refactor(web): share the dev generators' headless Chrome key.mjs's server, Chrome and DevTools helpers move to dev/headless.mjs, with one renderer process and a capped heap, so a second generator can use them. Co-Authored-By: Claude Opus 5.5 Change-Id: I966cec28cfa9354f0e85c98851ccc314d5d12e32 --- web/dev/headless.mjs | 132 +++++++++++++++++++++++++++++++++++++++++++ web/dev/key.mjs | 109 +++++------------------------------ 2 files changed, 145 insertions(+), 96 deletions(-) create mode 100644 web/dev/headless.mjs diff --git a/web/dev/headless.mjs b/web/dev/headless.mjs new file mode 100644 index 00000000..04c76305 --- /dev/null +++ b/web/dev/headless.mjs @@ -0,0 +1,132 @@ +// What the dev generators share: web/ served as the site serves it, one +// headless Chrome, and its DevTools protocol over a WebSocket. key.mjs and +// brand/brand.mjs draw with it. Needs Node 22 or later, for its WebSocket. +import { spawn } from "node:child_process"; +import { createServer } from "node:http"; +import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { extname, join, normalize, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const WEB = resolve(fileURLToPath(new URL("..", import.meta.url))); +const TYPES = { ".html": "text/html", ".js": "text/javascript", ".mjs": "text/javascript", ".css": "text/css", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".webp": "image/webp", ".woff2": "font/woff2" }; + +/** web/, served as the site serves it, on a port of its own. */ +export function serve() { + const server = createServer((request, response) => { + const path = normalize(decodeURIComponent(new URL(request.url, "http://x").pathname)).replace(/^(\.\.[/\\])+/, ""); + let file = join(WEB, path); + if (!file.startsWith(WEB)) return response.writeHead(403).end(); + try { + if (statSync(file).isDirectory()) file = join(file, "index.html"); + response.writeHead(200, { "content-type": TYPES[extname(file)] || "application/octet-stream" }).end(readFileSync(file)); + } catch { + response.writeHead(404).end(); + } + }); + return new Promise((done) => server.listen(0, "127.0.0.1", () => done(server))); +} + +/** + * One headless Chrome (set CHROME to pick one) on a profile of its own, so a + * Chrome already open on this machine is left alone, and the address of its + * DevTools. Its renderers share one process and a capped heap: the machine + * these run on is shared. + */ +function launch(profile) { + const binary = process.env.CHROME || "google-chrome"; + const child = spawn( + binary, + ["--headless=new", "--remote-debugging-port=0", `--user-data-dir=${profile}`, "--no-first-run", "--hide-scrollbars", "--disable-gpu", "--renderer-process-limit=1", "--js-flags=--max-old-space-size=1024", "about:blank"], + { stdio: ["ignore", "ignore", "pipe"] }, + ); + return new Promise((done, fail) => { + let said = ""; + child.stderr.on("data", (chunk) => { + said += chunk; + const found = said.match(/DevTools listening on (ws:\/\/\S+)/); + if (found) done({ child, url: found[1] }); + }); + child.on("exit", (code) => fail(new Error(`chrome exited (${code}): ${said.slice(-400)}`))); + }); +} + +/** The DevTools protocol over a WebSocket: `send(method, params, session)`. */ +function devtools(url) { + const socket = new WebSocket(url); + let next = 0; + const waiting = new Map(); + socket.addEventListener("message", ({ data }) => { + const message = JSON.parse(data); + const pending = waiting.get(message.id); + if (!pending) return; + waiting.delete(message.id); + if (message.error) pending.fail(new Error(`${pending.method}: ${message.error.message}`)); + else pending.done(message.result); + }); + const send = (method, params = {}, sessionId) => + new Promise((done, fail) => { + const id = (next += 1); + waiting.set(id, { done, fail, method }); + socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); + }); + return new Promise((done) => socket.addEventListener("open", () => done({ send, close: () => socket.close() }))); +} + +/** + * Runs `work(page)` with one tab in one headless Chrome and web/ served + * beside it, and takes both down after, however `work` ends. `page`: + * + * - `page.base`: where web/ is served, `http://127.0.0.1:`. + * - `page.send(method, params)`: a DevTools call on the tab. + * - `page.size(width, height, scale)`: the tab's viewport. + * - `page.open(url)`: navigates. + * - `page.run(expression)`: evaluates, awaits a promise, returns the value. + * - `page.until(expression, what)`: polls until it is truthy, for 20 s. + * - `page.shot(clip, format, quality)`: a screenshot as a Buffer. + */ +export async function withPage(work) { + const server = await serve(); + const profile = mkdtempSync(join(tmpdir(), "vibescrobble-headless-")); + const { child, url } = await launch(profile); + try { + const cdp = await devtools(url); + const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" }); + const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true }); + const send = (method, params) => cdp.send(method, params, sessionId); + await send("Page.enable"); + const run = async (expression) => { + const { result, exceptionDetails } = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }); + if (exceptionDetails) throw new Error(`${expression.slice(0, 200)}: ${exceptionDetails.exception?.description || exceptionDetails.text}`); + return result.value; + }; + const page = { + base: `http://127.0.0.1:${server.address().port}`, + send, + run, + size: (width, height, deviceScaleFactor = 1) => send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor, mobile: false }), + open: (to) => send("Page.navigate", { url: to }), + async until(expression, what, most = 20000) { + const end = Date.now() + most; + while (!(await run(expression).catch(() => false))) { + if (Date.now() > end) throw new Error(`${what} never happened`); + await new Promise((done) => setTimeout(done, 100)); + } + }, + async shot(clip, format = "png", quality) { + const { data } = await send("Page.captureScreenshot", { format, ...(quality ? { quality } : {}), ...(clip ? { clip: { ...clip, scale: 1 } } : {}), captureBeyondViewport: false }); + return Buffer.from(data, "base64"); + }, + }; + const result = await work(page); + cdp.close(); + return result; + } finally { + // Chrome lets go of its profile only once it has gone. + const gone = new Promise((done) => child.on("exit", done)); + child.kill(); + await gone; + server.close(); + rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + } +} diff --git a/web/dev/key.mjs b/web/dev/key.mjs index 2deaf48c..836d592d 100644 --- a/web/dev/key.mjs +++ b/web/dev/key.mjs @@ -8,108 +8,25 @@ // CHROME to pick one), and asks key-scene.js to set each picture up; nothing // here talks to the network. Run it after anything on the board changes how // it looks. Needs Node 22 or later, for its WebSocket. -import { spawn } from "node:child_process"; -import { createServer } from "node:http"; -import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { extname, join, normalize, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { WEB, withPage } from "./headless.mjs"; -const WEB = resolve(fileURLToPath(new URL("..", import.meta.url))); const OUT = join(WEB, "key"); -const TYPES = { ".html": "text/html", ".js": "text/javascript", ".mjs": "text/javascript", ".css": "text/css", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".webp": "image/webp", ".woff2": "font/woff2" }; - -/** web/, served as the site serves it, on a port of its own. */ -function serve() { - const server = createServer((request, response) => { - const path = normalize(decodeURIComponent(new URL(request.url, "http://x").pathname)).replace(/^(\.\.[/\\])+/, ""); - let file = join(WEB, path); - if (!file.startsWith(WEB)) return response.writeHead(403).end(); - try { - if (statSync(file).isDirectory()) file = join(file, "index.html"); - response.writeHead(200, { "content-type": TYPES[extname(file)] || "application/octet-stream" }).end(readFileSync(file)); - } catch { - response.writeHead(404).end(); - } - }); - return new Promise((done) => server.listen(0, "127.0.0.1", () => done(server))); -} - -/** One headless Chrome, and the address of its DevTools. */ -function chrome(profile) { - const binary = process.env.CHROME || "google-chrome"; - const child = spawn(binary, ["--headless=new", "--remote-debugging-port=0", `--user-data-dir=${profile}`, "--no-first-run", "--hide-scrollbars", "--disable-gpu", "about:blank"], { stdio: ["ignore", "ignore", "pipe"] }); - return new Promise((done, fail) => { - let said = ""; - child.stderr.on("data", (chunk) => { - said += chunk; - const found = said.match(/DevTools listening on (ws:\/\/\S+)/); - if (found) done({ child, url: found[1] }); - }); - child.on("exit", (code) => fail(new Error(`chrome exited (${code}): ${said.slice(-400)}`))); - }); -} - -/** The DevTools protocol over a WebSocket: `send(method, params, session)`. */ -function devtools(url) { - const socket = new WebSocket(url); - let next = 0; - const waiting = new Map(); - socket.addEventListener("message", ({ data }) => { - const message = JSON.parse(data); - const pending = waiting.get(message.id); - if (!pending) return; - waiting.delete(message.id); - if (message.error) pending.fail(new Error(`${pending.method}: ${message.error.message}`)); - else pending.done(message.result); - }); - const send = (method, params = {}, sessionId) => - new Promise((done, fail) => { - const id = (next += 1); - waiting.set(id, { done, fail, method }); - socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); - }); - return new Promise((done) => socket.addEventListener("open", () => done({ send, close: () => socket.close() }))); -} const wanted = process.argv.slice(2); -const server = await serve(); -const profile = mkdtempSync(join(tmpdir(), "vibescrobble-key-")); -const { child, url } = await chrome(profile); -try { - const cdp = await devtools(url); - const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" }); - const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true }); - const page = (method, params) => cdp.send(method, params, sessionId); - await page("Emulation.setDeviceMetricsOverride", { width: 1440, height: 900, deviceScaleFactor: 2, mobile: false }); - await page("Page.enable"); - await page("Page.navigate", { url: `http://127.0.0.1:${server.address().port}/dev/key.html#live=off` }); - const run = async (expression) => { - const { result, exceptionDetails } = await page("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }); - if (exceptionDetails) throw new Error(`${expression}: ${exceptionDetails.exception?.description || exceptionDetails.text}`); - return result.value; - }; +await withPage(async (page) => { + await page.size(1440, 900, 2); + await page.open(`${page.base}/dev/key.html#live=off`); // The scene script is written into the page after it loads. - for (let tries = 0; !(await run("Boolean(window.KeyScene)")); tries += 1) { - if (tries > 200) throw new Error("the key's page never started"); - await new Promise((done) => setTimeout(done, 100)); - } - await run("KeyScene.ready"); - if (process.env.KEY_PROBE) console.log(JSON.stringify(await run(process.env.KEY_PROBE), null, 1)); - const names = wanted.length ? wanted : await run("KeyScene.names"); + await page.until("Boolean(window.KeyScene)", "the key's page starting"); + await page.run("KeyScene.ready"); + if (process.env.KEY_PROBE) console.log(JSON.stringify(await page.run(process.env.KEY_PROBE), null, 1)); + const names = wanted.length ? wanted : await page.run("KeyScene.names"); for (const name of names) { - const clip = await run(`KeyScene.stage(${JSON.stringify(name)})`); - const { data } = await page("Page.captureScreenshot", { format: "webp", quality: 82, clip: { ...clip, scale: 1 } }); + const clip = await page.run(`KeyScene.stage(${JSON.stringify(name)})`); const file = join(OUT, `${name}.webp`); - writeFileSync(file, Buffer.from(data, "base64")); + writeFileSync(file, await page.shot(clip, "webp", 82)); console.log(`${file}: ${clip.width}×${clip.height}, ${Math.round(statSync(file).size / 1024)} KB`); } - cdp.close(); -} finally { - // Chrome lets go of its profile only once it has gone. - const gone = new Promise((done) => child.on("exit", done)); - child.kill(); - await gone; - server.close(); - rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); -} +}); -- knotmirror 0.0.1