~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs-socialweb-computer-requester

socialweb.computer market requester and policy modules for atlibs, with its web pages and lexicons

0
0
main
96 lines · 5.3 KB Raw History
// vendored from atlibs a3cc4f0 docs/browser.mjs (not in the npm package), via atlibs-agent-reference-spa
// 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. 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();
		try { rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } catch {}
	}
}

if (import.meta.main) {
	if (!CHROME) throw new Error("no Chrome: set CHROME to its binary");
	// console.log -> the returned lines, so the snippet reads like a page module
	const expression = `(async () => { const out = []; const log = console.log; console.log = (...a) => out.push(a.join(" ")); try { await import(${JSON.stringify(`/${process.argv[2]}`)}); } finally { console.log = log; } return out.join("\\n"); })()`;
	const out = await browse(async ({ open, evaluate }) => (await open("/"), evaluate(expression)), { index: PAGE });
	if (out) console.log(out);
}