Something went wrong. Try again.
Browser extension: detect and subscribe to standard.site publications on ATProto
Something went wrong. Try again.
5.6 kB · 160 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161// Driving a headless Chrome with the extension loaded, over the DevTools// protocol. Shared by scripts/rc-load.mjs and scripts/smoke-test.mjs.//// Two things about this are not obvious and are the reason it is a module://// - Branded Chrome >= 137 ignores --load-extension, so the extension has to// be loaded over CDP with Extensions.loadUnpacked, which needs// --enable-unsafe-extension-debugging.// - The MV3 service worker target appears *before* Chrome finishes binding// the extension APIs into it: for a few hundred milliseconds `chrome` is// an object while `chrome.runtime` and `chrome.offscreen` are undefined.// Evaluating in that window fails in a way that reads as a broken build// rather than a race, so withChrome waits for the bindings.//// scripts/capture-status-docs.mjs still carries its own copy of this; it is// the oldest and most intricate of the three, and folding it in is a separate// change from adding a new consumer.
import { spawn } from 'node:child_process'import { findChrome } from './render-icons.mjs'
/** Resolve when `fn()` returns something truthy, or throw after `timeoutMs`. */export async function poll(fn, what, timeoutMs = 15_000) { const start = Date.now() for (;;) { const value = await fn() if (value) return value if (Date.now() - start > timeoutMs) throw new Error(`timed out waiting for ${what}`) await new Promise((r) => setTimeout(r, 150)) }}
function connect(wsUrl) { const ws = new WebSocket(wsUrl) const pending = new Map() let id = 0 const ready = new Promise((resolve) => ws.addEventListener('open', resolve)) ws.addEventListener('message', (e) => { const msg = JSON.parse(e.data) if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg) pending.delete(msg.id) } }) return { ready, close: () => ws.close(), send(method, params = {}, sessionId) { return new Promise((resolve) => { const i = ++id pending.set(i, resolve) ws.send(JSON.stringify({ id: i, method, params, sessionId })) }) }, }}
/** * Launch Chrome with `extensionDir` installed, hand `fn` a way to talk to it, * and tear everything down afterwards. * * `fn` receives: * - `extensionId` — the id Chrome assigned (the store id, if the manifest * carries the store `key`) * - `inWorker(expr)` — evaluate an expression inside the extension's service * worker and return its value; awaits promises * - `openTab(url)` / `send` — for anything else */export async function withChrome({ extensionDir, profile, headless = true }, fn) { const args = [ '--remote-debugging-port=0', '--no-sandbox', '--enable-unsafe-extension-debugging', `--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check', 'about:blank', ] if (headless) args.unshift('--headless=new')
const chrome = spawn(findChrome(), args, { stdio: ['ignore', 'pipe', 'pipe'] }) let cdp try { const wsUrl = await new Promise((resolve, reject) => { const timer = setTimeout( () => reject(new Error('Chrome did not report a debugging endpoint')), 30_000, ) chrome.stderr.on('data', (d) => { const m = String(d).match(/ws:\/\/[^\s]+/) if (m) { clearTimeout(timer) resolve(m[0]) } }) chrome.on('exit', (code) => reject(new Error(`Chrome exited early (${code})`))) })
cdp = connect(wsUrl) await cdp.ready
const loaded = await cdp.send('Extensions.loadUnpacked', { path: extensionDir }) if (loaded.error) throw new Error(`loadUnpacked failed: ${JSON.stringify(loaded.error)}`) const extensionId = loaded.result.id
const worker = await poll( async () => { const { result } = await cdp.send('Target.getTargets') return result.targetInfos.find( (t) => t.type === 'service_worker' && t.url.includes(extensionId), ) }, `a service worker for ${extensionId}`, ) const { result: attached } = await cdp.send('Target.attachToTarget', { targetId: worker.targetId, flatten: true, }) const sessionId = attached.sessionId await cdp.send('Runtime.enable', {}, sessionId)
const inWorker = async (expression) => { const { result } = await cdp.send( 'Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, sessionId, ) if (result.exceptionDetails) { throw new Error( `evaluate failed: ${result.exceptionDetails.text} ` + `${result.exceptionDetails.exception?.description ?? ''}`, ) } return result.result.value }
// See the header note: the target exists before the APIs are bound. await poll( () => inWorker('Boolean(globalThis.chrome?.runtime?.id)'), 'the extension APIs to appear in the service worker', )
return await fn({ cdp, inWorker, extensionId }) } finally { cdp?.close() chrome.kill() // Chrome writes to its profile directory on the way out; a caller that // deletes the profile the instant kill() returns races that and fails // with ENOTEMPTY. Wait for the process to actually be gone. if (chrome.exitCode === null && chrome.signalCode === null) { await new Promise((resolve) => { const timer = setTimeout(resolve, 5_000) chrome.on('exit', () => { clearTimeout(timer) resolve() }) }) } }}