Files

108 lines
4.2 KiB
JavaScript

import { spawn, spawnSync } from 'node:child_process';
import { createServer } from 'node:http';
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { extname, join, resolve, sep } from 'node:path';
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
export const CHROME_PATH =
['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'].find(
(name) => spawnSync('which', [name], { stdio: 'ignore' }).status === 0,
) ?? null;
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css' };
// Serves a directory over http://127.0.0.1 so a page can import ES modules from it.
export function startStaticServer(root) {
const base = resolve(root);
const server = createServer((req, res) => {
const path = resolve(join(base, decodeURIComponent(new URL(req.url, 'http://localhost').pathname)));
let isFile = false;
try {
isFile = path.startsWith(base + sep) && statSync(path).isFile();
} catch {
// not found
}
if (!isFile) {
res.writeHead(404).end('not found');
return;
}
res.writeHead(200, { 'content-type': TYPES[extname(path)] ?? 'application/octet-stream' });
res.end(readFileSync(path));
});
return new Promise((done) => {
server.listen(0, '127.0.0.1', () => {
done({ origin: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((closed) => server.close(closed)) });
});
});
}
export async function launchBrowser() {
const profile = mkdtempSync(join(tmpdir(), 'outlier-saver-chrome-'));
// Chrome puts a unix socket in TMPDIR and dies with "Socket path too long" if that path is long.
const { TMPDIR: _ignored, ...env } = process.env;
const proc = spawn(
CHROME_PATH,
['--headless=new', '--remote-debugging-port=0', `--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check', 'about:blank'],
{ stdio: 'ignore', env },
);
const portFile = join(profile, 'DevToolsActivePort');
let port = null;
for (let attempt = 0; attempt < 80 && port === null; attempt += 1) {
if (existsSync(portFile)) port = Number(readFileSync(portFile, 'utf8').split('\n')[0]);
else await sleep(250);
}
if (port === null) {
proc.kill();
throw new Error('Chrome did not start');
}
return {
async openPage(url) {
const target = await (await fetch(`http://127.0.0.1:${port}/json/new?${url}`, { method: 'PUT' })).json();
const ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((opened, failed) => {
ws.onopen = opened;
ws.onerror = failed;
});
let nextId = 0;
const pending = new Map();
ws.onmessage = ({ data }) => {
const message = JSON.parse(data);
const waiter = pending.get(message.id);
if (!waiter) return;
pending.delete(message.id);
if (message.error) waiter.fail(new Error(message.error.message));
else waiter.done(message.result);
};
const send = (method, params = {}) =>
new Promise((done, fail) => {
nextId += 1;
pending.set(nextId, { done, fail });
ws.send(JSON.stringify({ id: nextId, method, params }));
});
const evaluate = async (expression) => {
const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text);
}
return result.result.value;
};
// A fresh tab is "complete" on about:blank before it navigates, so wait for the real page.
for (let attempt = 0; attempt < 80; attempt += 1) {
const state = await evaluate('JSON.stringify([location.href, document.readyState])');
const [href, readyState] = JSON.parse(state);
if (href !== 'about:blank' && readyState === 'complete') break;
await sleep(100);
}
return { evaluate, close: () => ws.close() };
},
async close() {
proc.kill('SIGTERM');
await sleep(300);
rmSync(profile, { recursive: true, force: true, maxRetries: 3 });
},
};
}