// node docs/browser.mjs : serve the working directory, open it in headless Chrome under // the import map a page uses ("atlibs/" -> javascript/src/), import the snippet as a page module and // print what it logs. node docs/browser.mjs : open that page, print its 's text. A ```js run browser block in docs/ runs through this (docs.test.mjs). // browse() is the same harness for a test that opens a real page (examples/web/hello/hello.test.mjs). import { spawn } from "node:child_process"; import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { extname, join } from "node:path"; export const CHROME = [process.env.CHROME, "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/usr/bin/google-chrome", "/usr/bin/chromium"].find((p) => p && existsSync(p)); const TYPES = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".mjs": "text/javascript", ".json": "application/json", ".wasm": "application/wasm" }; const PAGE = ''; /** * Serve `root` over http on 127.0.0.1 (symlinks followed; `index`, if given, is the page at /), start * headless Chrome on a blank tab and call `use` with that tab's DevTools session: * send(method, params) a CDP command; resolves to its result * on(method, fn) the handler for a CDP event (one per method) * open(path) navigate to `path` on the server and wait for the load event * evaluate(expression) its (awaited, JSON) value in the page; throws what the page throws * Chrome and the server stop when `use` settles; browse returns what it returns. */ export async function browse(use, { root = process.cwd(), index } = {}) { const server = createServer(async (req, res) => { const path = decodeURIComponent(new URL(req.url, "http://localhost").pathname); if (path === "/" && index) return res.writeHead(200, { "content-type": "text/html" }).end(index); const body = await readFile(join(root, path)).catch(() => null); if (!body) return res.writeHead(404).end(); res.writeHead(200, { "content-type": TYPES[extname(path)] ?? "application/octet-stream" }).end(body); }); await new Promise((ok) => server.listen(0, "127.0.0.1", ok)); const origin = `http://127.0.0.1:${server.address().port}`; const profile = mkdtempSync(join(tmpdir(), "atlibs-chrome-")); const chrome = spawn(CHROME, ["--headless=new", "--remote-debugging-port=0", `--user-data-dir=${profile}`, "--no-first-run", "about:blank"], { stdio: ["ignore", "ignore", "pipe"] }); // a crash skips the finally below: Chrome must not outlive this process const orphan = () => chrome.kill(); process.once("exit", orphan); let ws; try { const port = await new Promise((ok, fail) => { let err = ""; chrome.stderr.on("data", (d) => { err += d; const m = /DevTools listening on ws:\/\/[^:]+:(\d+)\//.exec(err); if (m) ok(m[1]); }); chrome.on("exit", () => fail(new Error(`chrome exited:\n${err}`))); }); const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); ws = new WebSocket(targets.find((t) => t.type === "page").webSocketDebuggerUrl); await new Promise((ok) => ws.addEventListener("open", ok, { once: true })); let id = 0; const waiting = new Map(); const events = new Map(); ws.addEventListener("message", ({ data }) => { const msg = JSON.parse(data); if (msg.id) waiting.get(msg.id)?.(msg); else events.get(msg.method)?.(msg.params); }); const send = (method, params = {}) => new Promise((ok, fail) => { waiting.set(++id, (msg) => (msg.error ? fail(new Error(`${method}: ${msg.error.message}`)) : ok(msg.result))); ws.send(JSON.stringify({ id, method, params })); }); const on = (method, fn) => events.set(method, fn); await send("Page.enable"); const open = async (path) => { const loaded = new Promise((ok) => on("Page.loadEventFired", ok)); await send("Page.navigate", { url: origin + path }); await loaded; }; const evaluate = async (expression) => { const { result, exceptionDetails } = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }); if (exceptionDetails) throw new Error(exceptionDetails.exception?.description ?? exceptionDetails.text); return result.value; }; return await use({ origin, send, on, open, evaluate }); } finally { ws?.close(); process.off("exit", orphan); if (chrome.exitCode === null) await new Promise((ok) => chrome.once("exit", ok).kill()); server.close(); rmSync(profile, { recursive: true, force: true }); } } if (import.meta.main) { if (!CHROME) throw new Error("no Chrome: set CHROME to its binary"); const file = process.argv[2]; const page = file.endsWith(".html"); // a page: the text of its once it has some; a snippet: console.log -> the returned lines, so it reads like a page module const expression = page ? `(async () => { for (const end = Date.now() + 20_000; Date.now() < end; await new Promise((ok) => setTimeout(ok, 100))) { const text = document.querySelector("output")?.textContent; if (text) return text; } throw new Error("no text in after 20 s"); })()` : `(async () => { const out = []; const log = console.log; console.log = (...a) => out.push(a.join(" ")); try { await import(${JSON.stringify(`/${file}`)}); } finally { console.log = log; } return out.join("\\n"); })()`; const out = await browse(async ({ open, evaluate }) => (await open(page ? `/${file}` : "/"), evaluate(expression)), { index: PAGE }); if (out) console.log(out); }