Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
108 lines
4.2 KiB
JavaScript
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 });
|
|
},
|
|
};
|
|
}
|