1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// node docs/browser.mjs <snippet.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 <page.html>: open that page, print its <output>'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 = '<!DOCTYPE html><script type="importmap">{"imports":{"atlibs/":"/javascript/src/"}}</script>';
/**
* 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 <output> 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 <output> 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);
}