This release is 2 versions behind 0.5.2 — the latest version of @astral/astral. Jump to latest
Built and signed on GitHub ActionsBuilt and signed on GitHub Actions
Built and signed on GitHub Actions
Astral is the browser automation library for Deno
This package works with Deno
JSR Score
100%
Published
2 months ago (0.5.0)
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328import { retry } from "jsr:/@std/async@^1/retry"; import { deadline } from "jsr:/@std/async@^1/deadline"; import { Celestial, PROTOCOL_VERSION } from "../bindings/celestial.ts"; import { getBinary } from "./cache.ts"; import { Page, type SandboxOptions, type UserAgentOptions, type WaitForOptions, } from "./page.ts"; import { WEBSOCKET_ENDPOINT_REGEX, websocketReady } from "./util.ts"; import { DEBUG } from "./debug.ts"; async function runCommand( command: Deno.Command, { retries = 60 } = {}, ): Promise<{ process: Deno.ChildProcess; endpoint: string }> { const process = command.spawn(); let endpoint = null; // Wait until write to stdout containing the localhost address // This probably means that the process is read to accept communication const textDecoder = new TextDecoder(); const stack: string[] = []; let error = true; for await (const chunk of process.stderr) { const message = textDecoder.decode(chunk); stack.push(message); endpoint = message.trim().match(WEBSOCKET_ENDPOINT_REGEX)?.[1]; if (endpoint) { error = false; break; } // Recover from garbage "SingletonLock" nonsense if (message.includes("SingletonLock")) { const path = message.split("Failed to create ")[1].split(":")[0]; process.kill(); await process.status; try { Deno.removeSync(path); } catch (error) { if (!(error instanceof Deno.errors.NotFound)) { throw error; } } return runCommand(command); } } if (error) { const { code } = await process.status; stack.push(`Process exited with code ${code}`); // Handle recoverable error code 21 on Windows // https://source.chromium.org/chromium/chromium/src/+/main:net/base/net_error_list.h;l=90-91 if (Deno.build.os === "windows" && code === 21 && retries > 0) { return runCommand(command, { retries: retries - 1 }); } console.error(stack.join("\n")); // https://github.com/lino-levan/astral/issues/82 if (stack.join("").includes("error while loading shared libraries")) { throw new Error( "Your binary refused to boot due to missing system dependencies. This can happen if you are using a minimal Docker image. If you're running in a Debian-based container, the following code could work:\n\nRUN apt-get update && apt-get install -y wget gnupg && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - && sh -c 'echo \"deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main\" >> /etc/apt/sources.list.d/google.list' && apt-get update && apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf libxss1 --no-install-recommends && rm -rf /var/lib/apt/lists/*\n\nLook at puppeteer docs for more information: https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md#running-puppeteer-in-docker", ); } throw new Error("Your binary refused to boot"); } if (!endpoint) throw new Error("Somehow did not get a websocket endpoint"); return { process, endpoint }; } /** Options for launching a browser */ export interface BrowserOptions { headless?: boolean; product?: "chrome" | "firefox"; userAgent?: string; } /** * The browser class is instantiated when you run the `launch` method. * * @example * ```ts * const browser = await launch(); * ``` */ export class Browser { #options: BrowserOptions; #celestial: Celestial; #process: Deno.ChildProcess | null; readonly pages: Page[] = []; constructor( ws: WebSocket, process: Deno.ChildProcess | null, opts: BrowserOptions, ) { this.#celestial = new Celestial(ws); this.#process = process; this.#options = opts; } [Symbol.asyncDispose](): Promise<void> { if (this.isRemoteConnection) return this.disconnect(); return this.close(); } /** Returns true if browser is connected remotely instead of using a subprocess */ get isRemoteConnection(): boolean { return !this.#process; } /** * Returns raw celestial bindings for the browser. Super unsafe unless you know what you're doing. */ unsafelyGetCelestialBindings(): Celestial { return this.#celestial; } /** * Disconnects the browser from the websocket connection. This is useful if you want to keep the browser running but don't want to use it anymore. */ async disconnect() { await this.#celestial.close(); } /** * Closes the browser and all of its pages (if any were opened). The Browser object itself is considered to be disposed and cannot be used anymore. */ async close() { await this.#celestial.Browser.close(); await this.#celestial.close(); // First we get the process, if this is null then this is a remote connection const process = this.#process; // If we use a remote connection, then close all pages websockets if (!process) { await Promise.allSettled(this.pages.map((page) => page.close())); } else { try { // ask nicely first process.kill(); await deadline(process.status, 10 * 1000); } catch { // then force process.kill("SIGKILL"); await process.status; } } } /** * Promise which resolves to a new `Page` object. */ async newPage( url?: string, options?: WaitForOptions & SandboxOptions & UserAgentOptions, ): Promise<Page> { const { targetId } = await this.#celestial.Target.createTarget({ url: "", }); const browserWsUrl = new URL(this.#celestial.ws.url); const wsUrl = `${browserWsUrl.origin}/devtools/page/${targetId}${browserWsUrl.search}`; const websocket = new WebSocket(wsUrl); await websocketReady(websocket); const { waitUntil, sandbox } = options ?? {}; const page = new Page(targetId, url, websocket, this, { sandbox }); this.pages.push(page); const celestial = page.unsafelyGetCelestialBindings(); const { userAgent: defaultUserAgent } = await celestial.Browser .getVersion(); const userAgent = options?.userAgent || this.#options.userAgent || defaultUserAgent.replaceAll("Headless", ""); await Promise.all([ celestial.Emulation.setUserAgentOverride({ userAgent }), celestial.Page.enable(), celestial.Runtime.enable(), celestial.Network.enable({}), celestial.Page.setInterceptFileChooserDialog({ enabled: true }), sandbox ? celestial.Fetch.enable({}) : null, ]); if (url) { await page.goto(url, { waitUntil }); } return page; } /** * The browser's original user agent. */ async userAgent(): Promise<string> { const { userAgent } = await this.#celestial.Browser.getVersion(); return userAgent; } /** * A string representing the browser name and version. */ async version(): Promise<string> { const { product, revision } = await this.#celestial.Browser.getVersion(); return `${product}/${revision}`; } /** * The browser's websocket endpoint */ wsEndpoint(): string { return this.#celestial.ws.url; } /** * Returns true if the browser and its websocket have benn closed */ get closed(): boolean { return this.#celestial.ws.readyState === WebSocket.CLOSED; } } export type LaunchOptions = BrowserOptions & { path?: string; args?: string[]; cache?: string; }; export type ConnectOptions = BrowserOptions & { wsEndpoint: string; }; /** * Connects to a given browser over a WebSockets endpoint. */ export async function connect(opts: ConnectOptions): Promise<Browser> { const { wsEndpoint, product = "chrome" } = opts; const options: BrowserOptions = { product, }; const ws = new WebSocket(wsEndpoint); await websocketReady(ws); return new Browser(ws, null, options); } /** * Launches a browser instance with given arguments and options when specified. */ export async function launch(opts?: LaunchOptions): Promise<Browser> { const headless = opts?.headless ?? true; const product = opts?.product ?? "chrome"; const args = opts?.args ?? []; const cache = opts?.cache; let path = opts?.path; const options: BrowserOptions = { headless, product, }; if (!path) { path = await getBinary(product, { cache }); } if (!args.find((arg) => arg.startsWith("--user-data-dir="))) { const tempDir = Deno.makeTempDirSync(); args.push(`--user-data-dir=${tempDir}`); } // Launch child process const binArgs = [ "--remote-debugging-port=0", "--no-first-run", "--password-store=basic", "--use-mock-keychain", // "--no-startup-window", ...(headless ? [ product === "chrome" ? "--headless=new" : "--headless", "--hide-scrollbars", ] : []), ...args, ]; if (DEBUG) { console.log(`Launching: ${path} ${binArgs.join(" ")}`); } const launch = new Deno.Command(path, { args: binArgs, stderr: "piped", }); const { process, endpoint } = await runCommand(launch); // Fetch browser websocket const browserRes = await retry(async () => { const browserReq = await fetch(`http://${endpoint}/json/version`); return await browserReq.json(); }); if (browserRes["Protocol-Version"] !== PROTOCOL_VERSION) { throw new Error("Differing protocol versions between binary and bindings."); } // Set up browser websocket const ws = new WebSocket(browserRes.webSocketDebuggerUrl); // Make sure that websocket is open before continuing await websocketReady(ws); // Construct browser and return return new Browser(ws, process, options); }