diff --git a/.changeset/tidy-browsers-render.md b/.changeset/tidy-browsers-render.md new file mode 100644 index 00000000..dd973b81 --- /dev/null +++ b/.changeset/tidy-browsers-render.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": minor +--- + +Add Worker JavaScript plugins and an opt-in Puppeteer plugin backed by a Browser Run binding. diff --git a/.gitignore b/.gitignore index c6413c2b..e7b8f87f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ PLAN.md # packages/computer/src/backends/worker-shell/script/build-bundle.mjs on # prepare / pretest / pretypecheck. packages/computer/src/backends/worker-shell/generated/ +packages/computer/src/plugins/puppeteer/generated.ts # SEA binary destinations populated at publish time from # artifacts/computerd/ via the build-bin step. The @cloudflare/computer diff --git a/README.md b/README.md index dd124e41..06591365 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,10 @@ public surface. Each is a Worker workspace with its own README. - [`examples/worker-javascript`](examples/worker-javascript) — mirrors `worker-shell`, but `exec` evaluates an ECMAScript module in a Dynamic Worker instead of running a shell command. +- [`examples/browser-rendering`](examples/browser-rendering) — installs the + Puppeteer plugin in the Worker JavaScript backend, then scrapes pages and + writes Markdown, JSON, and screenshot bundles to the durable workspace from + one isolated execution. - [`examples/egress`](examples/egress) — sends one URL through the container, Worker shell, and Worker JavaScript backends with matching `none`, `all`, or custom egress policies. diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index 2db84ecf..1830a8aa 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -76,13 +76,13 @@ await workspace.runtime.exec( ); ``` -Workspace parses the graph before loading the Worker, confines every durable path, rejects symlink traversal, and enforces aggregate source, module-count, and import-depth limits. Dynamic imports must use string literals. +Workspace parses the graph before loading the Worker, confines every durable path, rejects symlink traversal, and enforces aggregate source, module-count, and import-depth limits. Dynamic imports must use string literals. Configured and plugin modules are stored once under an internal canonical name; small directory-local aliases provide bare-import resolution without duplicating bundled package source throughout a nested caller graph. ## Execution limits and retention The backend admits up to twenty-four executions at a time by default. A concurrent start past that ceiling fails with `EEXEC_BUSY` instead of creating an unbounded number of Dynamic Workers. Adjust `maxConcurrentExecutions` after measuring the Durable Object and Worker Loader limits for the deployment. -Each execution also bounds combined stdout and stderr output, active event subscribers, directory entries per read, concurrent and total capability calls, and cumulative capability request and response bytes. The corresponding `maxStdioBytes`, `maxExecutionSubscribers`, `maxDirectoryEntries`, and `max*Capability*` options may be lowered for public workloads. Directory reads apply their limit in SQLite before materializing rows. Requests are checked inside the isolate before Workers RPC and again by the host. +Each execution also bounds combined stdout and stderr output, active event subscribers, directory entries per read, concurrent and total capability calls, and cumulative capability request and response bytes. `maxSourceBytes` applies to caller-owned entry and relative module source, while `maxLoaderSourceBytes` separately bounds the complete generated Loader graph, including configured plugin bundles. The corresponding `maxStdioBytes`, `maxExecutionSubscribers`, `maxDirectoryEntries`, and `max*Capability*` options may be lowered for public workloads. Directory reads apply their limit in SQLite before materializing rows. Requests are checked inside the isolate before Workers RPC and again by the host. Completed execution records remain available for replay for sixty minutes by default. The backend also keeps at most 100 completed records. Configure these bounds with `retentionMs` and `maxRetainedExecutions`. Completed records leave the in-memory active set immediately; replay reads them from SQLite. @@ -134,6 +134,40 @@ new WorkerJavaScriptBackend({ Unknown bare imports fail before Worker creation. `node:fs` and `node:fs/promises` are host-installed exceptions backed by the durable Workspace. Configured modules are code, not host authority, and may not use the reserved `ws:` namespace or shadow either filesystem specifier. +## Plugins and host bindings + +Plugins install a prebuilt module and the host bindings it needs. The bindings are fixed at backend construction, stay separate from `process.env`, and are not available to caller or ordinary configured modules. Plugins installed on one backend are mutually trusted. + +The Puppeteer plugin bundles Cloudflare's Worker-compatible client and passes a Browser Run binding into the Dynamic Worker: + +```ts +import { WorkerJavaScriptBackend } from "@cloudflare/computer/backends/worker-javascript"; +import { puppeteer } from "@cloudflare/computer/plugins/puppeteer"; + +new WorkerJavaScriptBackend({ + loader: env.LOADER, + plugins: [puppeteer({ browser: env.BROWSER })], +}); +``` + +Execution source imports the configured package name. Browser objects remain inside that execution; Chromium runs in Browser Run: + +```js +import { withBrowser } from "@cloudflare/puppeteer"; + +export default function main(input) { + return withBrowser(async (browser) => { + const page = await browser.newPage(); + await page.goto(input.url); + return { title: await page.title() }; + }, { + guardrails: { allowedDomains: [input.hostname] }, + }); +} +``` + +See [Browser automation in Worker JavaScript](./20_browser_automation.md) for the complete setup and [`examples/browser-rendering`](../examples/browser-rendering) for a working application. + ## Trusted Workspace modules Filesystem access uses the familiar asynchronous Node API, but is backed by the durable Workspace rather than an isolate-local filesystem. Both forms are installed automatically: @@ -184,7 +218,7 @@ Each execution receives a fresh Dynamic Worker with: - a host wall-clock deadline; - `globalOutbound: null` by default; - finite, acyclic JSON-compatible input and structured result validation; -- configurable source/module graph, input, result, stdin, stdio, file/capability request, and response byte limits (`maxSourceBytes`, `maxInputBytes`, `maxResultBytes`, `maxStdinBytes`, `maxStdioBytes`, and `maxCapabilityBytes`); +- configurable caller source, complete Loader graph, input, result, stdin, stdio, file/capability request, and response byte limits (`maxSourceBytes`, `maxLoaderSourceBytes`, `maxInputBytes`, `maxResultBytes`, `maxStdinBytes`, `maxStdioBytes`, and `maxCapabilityBytes`); - explicit entrypoint and Worker disposal; - host-owned cancellation; - retained events and result rows in the Workspace database. diff --git a/docs/20_browser_automation.md b/docs/20_browser_automation.md new file mode 100644 index 00000000..204012f1 --- /dev/null +++ b/docs/20_browser_automation.md @@ -0,0 +1,127 @@ +# Browser automation in Worker JavaScript + +`@cloudflare/computer/plugins/puppeteer` lets code running in `WorkerJavaScriptBackend` use Cloudflare Browser Run. Puppeteer and its `Browser` and `Page` objects stay inside the isolated Dynamic Worker; Chromium runs in Browser Run. + +## Configure the plugin + +The host Worker needs Worker Loader and Browser Run bindings: + +```jsonc +{ + "compatibility_flags": ["nodejs_compat", "experimental"], + "worker_loaders": [{ "binding": "LOADER" }], + "browser": { "binding": "BROWSER" } +} +``` + +Pass both bindings to the backend: + +```ts +import { DurableObject } from "cloudflare:workers"; +import { type DurableObjectStorageLike, Workspace } from "@cloudflare/computer"; +import { WorkerJavaScriptBackend } from "@cloudflare/computer/backends/worker-javascript"; +import { puppeteer } from "@cloudflare/computer/plugins/puppeteer"; + +export class BrowserWorkspace extends DurableObject { + readonly workspace: Workspace; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.workspace = new Workspace({ + storage: ctx.storage as unknown as DurableObjectStorageLike, + backends: [ + new WorkerJavaScriptBackend({ + loader: env.LOADER, + plugins: [puppeteer({ browser: env.BROWSER })], + }), + ], + }); + } +} +``` + +The plugin bundles the Worker-compatible Puppeteer client, so the application does not need a separate runtime dependency on `@cloudflare/puppeteer`. + +## Run a browser task + +Code passed to `workspace.runtime.exec()` imports the configured module normally: + +```ts +using execution = await workspace.runtime.exec( + ` + import { withBrowser } from "@cloudflare/puppeteer"; + + export default (input) => withBrowser(async (browser) => { + const page = await browser.newPage(); + await page.goto(input.url, { waitUntil: "domcontentloaded" }); + return { title: await page.title(), finalUrl: page.url() }; + }, { + guardrails: { allowedDomains: [input.hostname] }, + }); + `, + { + input: { + url: "https://developers.cloudflare.com/agents/", + hostname: "developers.cloudflare.com", + }, + }, +); + +const result = await execution.result(); +``` + +`withBrowser(callback, options?)` launches a connection-bound browser, runs the callback, and closes the browser afterward. Use `launch(options?)` when code needs to manage the browser itself: + +```js +import { launch } from "@cloudflare/puppeteer"; + +const browser = await launch(); +try { + // Use Puppeteer normally. +} finally { + await browser.close(); +} +``` + +The module also exports `browserBinding`, the unchanged upstream default export, and upstream runtime exports. `browserBinding` is useful for APIs such as `puppeteer.sessions()` that take the Browser Run binding directly. + +Do not return Puppeteer objects from an execution. Return structured data or write larger output to the Workspace. + +## Save browser output + +Worker JavaScript provides Workspace-backed `node:fs` and `node:fs/promises`. A screenshot can be written without returning its bytes through the structured result: + +```js +import { withBrowser } from "@cloudflare/puppeteer"; +import fs from "node:fs/promises"; + +export default (input) => withBrowser(async (browser) => { + const page = await browser.newPage(); + await page.goto(input.url); + await fs.writeFile(input.outputPath, await page.screenshot({ type: "png" })); + return { title: await page.title(), outputPath: input.outputPath }; +}); +``` + +The Dynamic Worker is disposable, but files written to the Workspace remain available to later executions. + +## Authority and limits + +Installing the plugin grants every execution on that backend access to its public browser API. Put browser-enabled work on a separate named backend when only some callers should have that authority. Plugins installed on one backend are mutually trusted and share the plugin binding authority domain; caller modules and ordinary configured modules cannot import the internal binding bridge. + +Browser navigation happens through Browser Run, not through the backend's `globalOutbound` policy. Validate user input and set Browser Run guardrails when the application accepts URLs from other users. + +Computer execution timeouts and Puppeteer navigation timeouts are separate. Set both for the workload. `withBrowser()` closes the browser after normal completion or an error. Cancellation and timeout dispose the Dynamic Worker and its client connection, so application cleanup code may not finish in those paths. + +Screenshots are encoded when they cross the Workspace filesystem bridge. For larger screenshots, raise the capability limits deliberately: + +```ts +new WorkerJavaScriptBackend({ + loader: env.LOADER, + plugins: [puppeteer({ browser: env.BROWSER })], + maxCapabilityBytes: 8 * 1024 * 1024, + maxCapabilityRequestBytes: 16 * 1024 * 1024, +}); +``` + +See [`examples/browser-rendering`](../examples/browser-rendering) for a complete self-hosted example that scrapes pages and writes Markdown, JSON, and PNG output to a durable Workspace. diff --git a/docs/README.md b/docs/README.md index acec5888..2f918840 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,7 +20,7 @@ It provides: - R2-backed mounts for pre-filling read-only data into the workspace tree. - Durability over DO restarts for all file operations. - Pluggable execution backends selected through `workspace.runtime`: a Cloudflare Container shell, a just-bash Dynamic Worker, or an isolated ECMAScript-module Dynamic Worker. - - Isolated JavaScript with structured input/results, durable relative imports, configured libraries, durable `node:fs/promises`, trusted `ws:git` / `ws:artifacts`, and managed execution records. + - Isolated JavaScript with structured input/results, durable relative imports, configured libraries, plugin bindings, durable `node:fs/promises`, trusted `ws:git` / `ws:artifacts`, and managed execution records. - Workspace constructable without a backend, for filesystem-only use cases. - Out-of-the-box AI SDK tools for `@cloudflare/agents` through `@cloudflare/computer/tools`. @@ -46,6 +46,7 @@ The package ships several entrypoints: | `@cloudflare/computer/backends/container` | `CloudflareContainerBackend` and `withWorkspaceContainer`. Pulls in the computerd / capnweb sync plumbing. | | `@cloudflare/computer/backends/worker-shell` | `WorkerShellBackend` and the bundled just-bash command runtime. | | `@cloudflare/computer/backends/worker-javascript` | `WorkerJavaScriptBackend`, configured libraries, durable relative imports, `node:fs/promises`, and trusted `ws:git` / `ws:artifacts`. | +| `@cloudflare/computer/plugins/puppeteer` | Opt-in Cloudflare Puppeteer module and Browser Run binding for isolated JavaScript. | | `@cloudflare/computer/git` | Opt-in isomorphic-git glue for working with checkouts inside the workspace. Bundled lazily, with `pako` replaced by Workers `node:zlib`, and kept out of the default `@cloudflare/computer` graph. | | `@cloudflare/computer/artifacts` | `createArtifact`, an optionally session-scoped wrapper over the Cloudflare Artifacts Workers binding, plus its argv CLI. | | `@cloudflare/computer/tools` | AI SDK tools for agents: read, write, edit, ls, optional exec, and optional publish. | @@ -243,6 +244,7 @@ above, then dive into the area you're working on. | [17. Isolate JavaScript runtime](./17_isolate_javascript.md) | ECMAScript modules, durable imports, configured libraries, durable `node:fs/promises`, trusted `ws:git` / `ws:artifacts`, and managed lifecycle. | | [18. Runtime migration](./18_runtime_migration.md) | Breaking preview-API mappings from public shell and script-execution surfaces to `workspace.runtime`. | | [19. Performance](./19_performance.md) | Filesystem benchmarks: `fs-bench` numbers, an `npm install` comparison, and how to reproduce them. | +| [20. Browser automation](./20_browser_automation.md) | Install Cloudflare Puppeteer and Browser Run in isolated JavaScript, persist browser artifacts, and apply production lifecycle, security, and resource limits. | ## High-level API diff --git a/examples/browser-rendering/.gitignore b/examples/browser-rendering/.gitignore new file mode 100644 index 00000000..28cd4129 --- /dev/null +++ b/examples/browser-rendering/.gitignore @@ -0,0 +1,4 @@ +dist/ +node_modules/ +.wrangler/ +worker-configuration.d.ts diff --git a/examples/browser-rendering/README.md b/examples/browser-rendering/README.md new file mode 100644 index 00000000..308c20ec --- /dev/null +++ b/examples/browser-rendering/README.md @@ -0,0 +1,92 @@ +# Computer browser rendering example + +This is a self-hosted demonstration of the public `@cloudflare/computer/plugins/puppeteer` integration. A generated ECMAScript module gets the normal Puppeteer `Browser` and `Page` APIs, while Cloudflare Browser Run supplies the managed Chromium session. + +## Plugin usage + +The reusable integration has two pieces. First, register the plugin on a Worker JavaScript backend: + +```ts +import { Workspace } from "@cloudflare/computer"; +import { WorkerJavaScriptBackend } from "@cloudflare/computer/backends/worker-javascript"; +import { puppeteer } from "@cloudflare/computer/plugins/puppeteer"; + +const workspace = new Workspace({ + storage: ctx.storage, + backends: [ + new WorkerJavaScriptBackend({ + loader: env.LOADER, + plugins: [puppeteer({ browser: env.BROWSER })], + }), + ], +}); +``` + +Then use the bound lifecycle helper inside an ordinary `workspace.runtime.exec()` module: + +```js +import { withBrowser } from "@cloudflare/puppeteer"; + +export default (input) => withBrowser(async (browser) => { + const page = await browser.newPage(); + await page.goto(input.url); + return { title: await page.title() }; +}, { + guardrails: { allowedDomains: [input.hostname] }, +}); +``` + +That is the complete plugin boundary. `withBrowser()` uses the configured Browser Run binding and closes the connection-bound browser after the callback. `Browser`, `Page`, selectors, and page evaluation stay inside the Dynamic Worker; Chromium runs in Browser Run. + +## What the example adds + +The rest of this directory is an example application, not code required by the plugin. Its web interface accepts a user-provided HTTP(S) URL and adds: + +- text, heading, metadata, and link scraping; +- a full-page screenshot written to the durable Workspace; +- response, document, viewport, and navigation timing data; +- a research workflow that writes `report.md`, `page.json`, and `screenshot.png` to one durable Workspace directory; +- artifact routes, result components, and a Workspace file tree. + +Those pieces show ways to combine Browser Run with Computer's durable filesystem. Applications can instead execute a module as small as the one above. + +## Run it + +From the repository root: + +```sh +npm install +npm run build --workspace @cloudflare/computer +npm run dev --workspace @example/computer-browser-rendering +``` + +Open the URL printed by Wrangler. Local development uses the Browser Run binding, so requests consume Browser Run quota and need a Cloudflare account with Browser Run access. + +Local development does not require authentication. Before deploying, set a token and deploy from the example directory: + +```sh +cd examples/browser-rendering +npx wrangler secret put DEMO_TOKEN +npm run deploy +``` + +The deployed site uses HTTP Basic authentication. Enter `demo` as the username and the secret as the password. + +The Worker needs both bindings shown in `wrangler.jsonc`: + +```jsonc +{ + "compatibility_flags": ["nodejs_compat", "experimental"], + "worker_loaders": [{ "binding": "LOADER" }], + "browser": { "binding": "BROWSER" } +} +``` + +## Files + +- `src/index.ts` configures Computer and exposes the API and durable artifact routes. +- `src/execution-source.ts` defines the browser task that runs inside the Dynamic Worker and writes the research bundle through Computer's built-in `node:fs/promises` module. +- `src/ui.ts` contains the dependency-free demonstration interface. +- `wrangler.jsonc` declares the Worker Loader, Browser Run, and Durable Object bindings. + +The example requires authentication when deployed, applies Browser Run guardrails for the requested host, and uses connection-bound browser sessions. Add workload-specific URL policy and quota handling if you adapt it for a shared service. diff --git a/examples/browser-rendering/package.json b/examples/browser-rendering/package.json new file mode 100644 index 00000000..402bab16 --- /dev/null +++ b/examples/browser-rendering/package.json @@ -0,0 +1,23 @@ +{ + "name": "@example/computer-browser-rendering", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Browser Run demo that executes Cloudflare Puppeteer inside the Computer Worker JavaScript backend.", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "build:types": "wrangler types", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@cloudflare/computer": "*" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260616.1", + "typescript": "^6.0.3", + "vitest": "^4.1.11", + "wrangler": "^4.130.0" + } +} diff --git a/examples/browser-rendering/src/demo-auth.test.ts b/examples/browser-rendering/src/demo-auth.test.ts new file mode 100644 index 00000000..f988e6c8 --- /dev/null +++ b/examples/browser-rendering/src/demo-auth.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { authorizeDemoRequest } from "./demo-auth.js"; + +describe("authorizeDemoRequest", () => { + it("keeps local development open", () => { + expect(authorizeDemoRequest(new Request("http://localhost/api/run"), undefined)).toBeNull(); + }); + + it("requires a token before deployment", async () => { + const response = authorizeDemoRequest( + new Request("https://browser.example.com/api/run"), + undefined, + ); + + expect(response?.status).toBe(503); + await expect(response?.text()).resolves.toContain("DEMO_TOKEN"); + }); + + it("accepts matching HTTP Basic credentials", () => { + const authorization = basicAuthorization("demo:secret"); + const request = new Request("https://browser.example.com/api/run", { + headers: { authorization }, + }); + + expect(authorizeDemoRequest(request, "secret")).toBeNull(); + }); + + it("accepts a Unicode token encoded as UTF-8", () => { + const authorization = basicAuthorization("demo:secret-🔒"); + const request = new Request("https://browser.example.com/api/run", { + headers: { authorization }, + }); + + expect(authorizeDemoRequest(request, "secret-🔒")).toBeNull(); + }); + + it("challenges missing or incorrect credentials", () => { + const response = authorizeDemoRequest( + new Request("https://browser.example.com/api/run"), + "secret", + ); + + expect(response?.status).toBe(401); + expect(response?.headers.get("www-authenticate")).toBe( + 'Basic realm="Browser demo", charset="UTF-8"', + ); + }); +}); + +function basicAuthorization(credentials: string): string { + const bytes = new TextEncoder().encode(credentials); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return `Basic ${btoa(binary)}`; +} diff --git a/examples/browser-rendering/src/demo-auth.ts b/examples/browser-rendering/src/demo-auth.ts new file mode 100644 index 00000000..8da4b000 --- /dev/null +++ b/examples/browser-rendering/src/demo-auth.ts @@ -0,0 +1,24 @@ +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); + +export function authorizeDemoRequest(request: Request, token: string | undefined): Response | null { + if (LOCAL_HOSTS.has(new URL(request.url).hostname)) return null; + if (!token) { + return new Response("Set the DEMO_TOKEN secret before deploying this example.", { + status: 503, + }); + } + + const expected = `Basic ${encodeBase64Utf8(`demo:${token}`)}`; + if (request.headers.get("authorization") === expected) return null; + return new Response("Authentication required", { + status: 401, + headers: { "www-authenticate": 'Basic realm="Browser demo", charset="UTF-8"' }, + }); +} + +function encodeBase64Utf8(value: string): string { + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} diff --git a/examples/browser-rendering/src/execution-source.test.ts b/examples/browser-rendering/src/execution-source.test.ts new file mode 100644 index 00000000..de9743f4 --- /dev/null +++ b/examples/browser-rendering/src/execution-source.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { EXECUTION_SOURCE } from "./execution-source.js"; + +describe("browser execution source", () => { + it("creates a durable research bundle in one execution", () => { + expect(EXECUTION_SOURCE).toContain('input.action === "research"'); + expect(EXECUTION_SOURCE).toContain("await fs.writeFile(input.reportPath"); + expect(EXECUTION_SOURCE).toContain("await fs.writeFile(input.dataPath"); + expect(EXECUTION_SOURCE).toContain("await fs.writeFile(input.screenshotPath"); + expect(EXECUTION_SOURCE).toContain("report.md"); + expect(EXECUTION_SOURCE).toContain("page.json"); + expect(EXECUTION_SOURCE).toContain("screenshot.png"); + }); + + it("uses the plugin lifecycle helper to close Browser Run", () => { + expect(EXECUTION_SOURCE).toContain('import { withBrowser } from "@cloudflare/puppeteer"'); + expect(EXECUTION_SOURCE).toContain("return withBrowser(async (browser) =>"); + }); +}); diff --git a/examples/browser-rendering/src/execution-source.ts b/examples/browser-rendering/src/execution-source.ts new file mode 100644 index 00000000..cebb8750 --- /dev/null +++ b/examples/browser-rendering/src/execution-source.ts @@ -0,0 +1,203 @@ +export const EXECUTION_SOURCE = String.raw`import { withBrowser } from "@cloudflare/puppeteer"; +import fs from "node:fs/promises"; + +// withBrowser() is the complete execution-side plugin integration. The +// extraction and report code below belongs to this example application. + +function buildMarkdown(page) { + const lines = [ + "# " + page.title, + "", + page.description || page.summary || "No summary was available.", + "", + "## Page snapshot", + "", + "- URL: " + page.finalUrl, + "- HTTP status: " + (page.status ?? "unknown"), + "- Language: " + (page.language || "unknown"), + "- Elements: " + page.document.elements, + "- Images: " + page.document.images, + "- Links: " + page.document.links, + "", + "## Sections", + "", + ]; + + for (const section of page.sections) { + lines.push("- " + section.level.toUpperCase() + ": " + section.text); + } + + lines.push("", "## Code samples", ""); + if (page.codeSamples.length === 0) lines.push("No code samples found.", ""); + for (const [index, sample] of page.codeSamples.entries()) { + lines.push("### Sample " + (index + 1), ""); + for (const line of sample.text.split("\n")) lines.push(" " + line); + lines.push(""); + } + + lines.push("## Internal links", ""); + for (const link of page.internalLinks) { + lines.push("- [" + (link.text || link.href) + "](" + link.href + ")"); + } + return lines.join("\n") + "\n"; +} + +export default function run(input) { + return withBrowser(async (browser) => { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 }); + page.setDefaultNavigationTimeout(30_000); + const response = await page.goto(input.url, { waitUntil: "domcontentloaded" }); + const responseInfo = { + requestedUrl: input.url, + finalUrl: page.url(), + status: response?.status() ?? null, + title: await page.title(), + }; + + if (input.action === "research") { + const extracted = await page.evaluate(() => { + const clean = (value) => value?.replace(/\s+/g, " ").trim() ?? ""; + const sections = [...document.querySelectorAll("h1, h2, h3, h4")] + .map((heading) => ({ + level: heading.tagName.toLowerCase(), + text: clean(heading.textContent), + })) + .filter((heading) => heading.text) + .slice(0, 80); + const codeSamples = [...document.querySelectorAll("pre")] + .map((sample) => ({ text: sample.textContent?.trim().slice(0, 4_000) ?? "" })) + .filter((sample) => sample.text) + .slice(0, 12); + const seenLinks = new Set(); + const internalLinks = [...document.querySelectorAll("a[href]")] + .map((anchor) => ({ + text: clean(anchor.textContent), + href: anchor.href, + })) + .filter((link) => { + try { + const target = new URL(link.href); + if (target.hostname !== location.hostname || seenLinks.has(target.href)) return false; + seenLinks.add(target.href); + return true; + } catch { + return false; + } + }) + .slice(0, 80); + const paragraphs = [...document.querySelectorAll("main p, article p")] + .map((paragraph) => clean(paragraph.textContent)) + .filter((text) => text.length > 60) + .slice(0, 6); + return { + description: document.querySelector('meta[name="description"]')?.content ?? null, + language: document.documentElement.lang || null, + summary: paragraphs.join(" ").slice(0, 2_400), + sections, + codeSamples, + internalLinks, + document: { + elements: document.querySelectorAll("*").length, + images: document.images.length, + links: document.links.length, + scripts: document.scripts.length, + }, + }; + }); + const pageData = { ...responseInfo, ...extracted }; + const markdown = buildMarkdown(pageData); + const json = JSON.stringify(pageData, null, 2) + "\n"; + const png = await page.screenshot({ type: "png", fullPage: true }); + + await fs.mkdir(input.outputDirectory, { recursive: true }); + await fs.writeFile(input.reportPath, markdown); + await fs.writeFile(input.dataPath, json); + await fs.writeFile(input.screenshotPath, png); + + return { + ...pageData, + reportPath: input.reportPath, + dataPath: input.dataPath, + screenshotPath: input.screenshotPath, + files: [ + { + name: "report.md", + path: input.reportPath, + mediaType: "text/markdown", + bytes: new TextEncoder().encode(markdown).byteLength, + }, + { + name: "page.json", + path: input.dataPath, + mediaType: "application/json", + bytes: new TextEncoder().encode(json).byteLength, + }, + { + name: "screenshot.png", + path: input.screenshotPath, + mediaType: "image/png", + bytes: png.byteLength, + }, + ], + }; + } + + if (input.action === "screenshot") { + const png = await page.screenshot({ type: "png", fullPage: true }); + await fs.writeFile(input.screenshotPath, png); + const dimensions = await page.evaluate(() => ({ + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + })); + return { ...responseInfo, dimensions, screenshotPath: input.screenshotPath }; + } + + if (input.action === "page-info") { + const pageInfo = await page.evaluate(() => { + const navigation = performance.getEntriesByType("navigation")[0]; + return { + description: document.querySelector('meta[name="description"]')?.content ?? null, + language: document.documentElement.lang || null, + viewport: { width: innerWidth, height: innerHeight, devicePixelRatio }, + document: { + elements: document.querySelectorAll("*").length, + images: document.images.length, + links: document.links.length, + scripts: document.scripts.length, + }, + timing: navigation ? { + responseEnd: Math.round(navigation.responseEnd), + domContentLoaded: Math.round(navigation.domContentLoadedEventEnd), + load: Math.round(navigation.loadEventEnd), + } : null, + }; + }); + return { ...responseInfo, ...pageInfo }; + } + + const scraped = await page.evaluate(() => ({ + description: document.querySelector('meta[name="description"]')?.content ?? null, + headings: [...document.querySelectorAll("h1, h2, h3")] + .map((heading) => ({ + level: heading.tagName.toLowerCase(), + text: heading.textContent?.trim() ?? "", + })) + .filter((heading) => heading.text) + .slice(0, 30), + links: [...document.querySelectorAll("a[href]")] + .map((anchor) => ({ + text: anchor.textContent?.trim().replace(/\s+/g, " ") ?? "", + href: anchor.href, + })) + .slice(0, 30), + text: document.body?.innerText.trim().replace(/\n{3,}/g, "\n\n").slice(0, 12_000) ?? "", + })); + return { ...responseInfo, ...scraped }; + }, { + guardrails: { + allowedDomains: [input.hostname, "*." + input.hostname], + allowedDomainSets: ["common-cdns"], + }, + }); +}`; diff --git a/examples/browser-rendering/src/index.ts b/examples/browser-rendering/src/index.ts new file mode 100644 index 00000000..95442070 --- /dev/null +++ b/examples/browser-rendering/src/index.ts @@ -0,0 +1,296 @@ +import { DurableObject } from "cloudflare:workers"; +import { type DurableObjectStorageLike, Workspace } from "@cloudflare/computer"; +import { + WorkerJavaScriptBackend, + type WorkerJavaScriptPlugin, +} from "@cloudflare/computer/backends/worker-javascript"; +import { puppeteer } from "@cloudflare/computer/plugins/puppeteer"; +import { authorizeDemoRequest } from "./demo-auth.js"; +import { EXECUTION_SOURCE } from "./execution-source.js"; +import { UI_HTML } from "./ui.js"; + +interface Env { + BrowserWorkspace: DurableObjectNamespace; + BROWSER: Fetcher; + DEMO_TOKEN?: string; + LOADER: WorkerLoader; +} + +export type BrowserAction = "scrape" | "screenshot" | "page-info" | "research"; + +interface BrowserRunRequest { + action?: BrowserAction; + url?: string; +} + +interface BrowserResultValue { + requestedUrl: string; + finalUrl: string; + status: number | null; + title: string; + description?: string | null; + language?: string | null; + summary?: string; + text?: string; + headings?: Array<{ level: string; text: string }>; + links?: Array<{ text: string; href: string }>; + sections?: Array<{ level: string; text: string }>; + codeSamples?: Array<{ text: string }>; + internalLinks?: Array<{ text: string; href: string }>; + files?: Array<{ name: string; path: string; mediaType: string; bytes: number }>; + reportPath?: string; + dataPath?: string; + screenshotPath?: string; + dimensions?: { width: number; height: number }; + viewport?: { width: number; height: number; devicePixelRatio: number }; + document?: { elements: number; images: number; links: number; scripts: number }; + timing?: { responseEnd: number; domContentLoaded: number; load: number } | null; +} + +interface BrowserRunResult { + exitCode: number; + value: BrowserResultValue; +} + +export class BrowserWorkspace extends DurableObject { + readonly #workspace: Workspace; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + // This registration is the entire host-side plugin integration. The limits + // below belong to this example application. + const browserPlugin: WorkerJavaScriptPlugin = puppeteer({ browser: env.BROWSER }); + const backend = new WorkerJavaScriptBackend({ + loader: env.LOADER, + plugins: [browserPlugin], + defaultTimeoutMs: 60_000, + maxTimeoutMs: 90_000, + maxConcurrentExecutions: 3, + maxCapabilityBytes: 8 * 1024 * 1024, + maxCapabilityRequestBytes: 16 * 1024 * 1024, + }); + this.#workspace = new Workspace({ + storage: ctx.storage as unknown as DurableObjectStorageLike, + backends: [backend], + }); + } + + async run(action: BrowserAction, target: string): Promise { + const url = new URL(target); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("url must use http or https"); + } + const outputDirectory = `/workspace/browser-runs/${crypto.randomUUID()}`; + const screenshotPath = `${outputDirectory}/screenshot.png`; + const reportPath = `${outputDirectory}/report.md`; + const dataPath = `${outputDirectory}/page.json`; + if (action === "screenshot" || action === "research") { + await this.#workspace.fs.mkdir(outputDirectory, { recursive: true }); + } + using execution = await this.#workspace.runtime.exec(EXECUTION_SOURCE, { + backend: "worker-javascript", + input: { + action, + url: url.href, + hostname: url.hostname, + outputDirectory, + screenshotPath, + reportPath, + dataPath, + }, + encoding: "utf8", + timeoutMs: 60_000, + }); + const result = await execution.result(); + if (result.status !== "completed" || result.value === undefined) { + throw new Error(result.stderr.trim() || `browser execution exited with ${result.exitCode}`); + } + return { exitCode: result.exitCode, value: parseBrowserResult(result.value) }; + } + + readArtifact(path: string): Promise> { + if ( + !/^\/workspace\/browser-runs\/[0-9a-f-]+\/(?:report\.md|page\.json|screenshot\.png)$/.test( + path, + ) + ) { + throw new Error("invalid browser artifact path"); + } + return this.#workspace.fs.readFile(path); + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + const authorizationError = authorizeDemoRequest(request, env.DEMO_TOKEN); + if (authorizationError) return authorizationError; + const url = new URL(request.url); + if (request.method === "GET" && url.pathname === "/") { + return new Response(UI_HTML, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } + if (request.method === "GET" && url.pathname === "/api/source") { + return new Response(EXECUTION_SOURCE, { + headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }, + }); + } + + const stub = env.BrowserWorkspace.getByName("demo"); + if (request.method === "POST" && url.pathname === "/api/run") { + let input: BrowserRunRequest; + try { + input = (await request.json()) as BrowserRunRequest; + if (!isBrowserAction(input.action)) throw new Error("unknown browser action"); + if (typeof input.url !== "string") throw new Error("url must be a string"); + } catch (error) { + return errorResponse(error, 400); + } + try { + return Response.json(await stub.run(input.action, input.url)); + } catch (error) { + return errorResponse(error, 500); + } + } + if (request.method === "GET" && url.pathname === "/api/file") { + const path = url.searchParams.get("path"); + if (path === null) return errorResponse(new Error("missing browser artifact path"), 400); + try { + const filename = path.slice(path.lastIndexOf("/") + 1); + return new Response(await stub.readArtifact(path), { + headers: { + "content-type": artifactContentType(filename), + "content-disposition": `inline; filename="${filename}"`, + "cache-control": "private, max-age=300", + }, + }); + } catch (error) { + return errorResponse(error, 404); + } + } + + return new Response("not found", { status: 404 }); + }, +} satisfies ExportedHandler; + +function parseBrowserResult(value: unknown): BrowserResultValue { + if (!isRecord(value)) throw new Error("browser execution returned an invalid result"); + const requestedUrl = requiredString(value.requestedUrl, "requestedUrl"); + const finalUrl = requiredString(value.finalUrl, "finalUrl"); + const title = requiredString(value.title, "title"); + const status = value.status === null ? null : requiredNumber(value.status, "status"); + const parsed: BrowserResultValue = { requestedUrl, finalUrl, title, status }; + if (typeof value.description === "string" || value.description === null) { + parsed.description = value.description; + } + if (typeof value.language === "string" || value.language === null) { + parsed.language = value.language; + } + if (typeof value.summary === "string") parsed.summary = value.summary; + if (typeof value.text === "string") parsed.text = value.text; + if (typeof value.reportPath === "string") parsed.reportPath = value.reportPath; + if (typeof value.dataPath === "string") parsed.dataPath = value.dataPath; + if (typeof value.screenshotPath === "string") parsed.screenshotPath = value.screenshotPath; + if (Array.isArray(value.headings)) { + parsed.headings = value.headings.filter(isRecord).map((heading) => ({ + level: requiredString(heading.level, "heading level"), + text: requiredString(heading.text, "heading text"), + })); + } + if (Array.isArray(value.links)) { + parsed.links = value.links.filter(isRecord).map((link) => ({ + text: requiredString(link.text, "link text"), + href: requiredString(link.href, "link href"), + })); + } + if (Array.isArray(value.sections)) { + parsed.sections = value.sections.filter(isRecord).map((section) => ({ + level: requiredString(section.level, "section level"), + text: requiredString(section.text, "section text"), + })); + } + if (Array.isArray(value.codeSamples)) { + parsed.codeSamples = value.codeSamples.filter(isRecord).map((sample) => ({ + text: requiredString(sample.text, "code sample"), + })); + } + if (Array.isArray(value.internalLinks)) { + parsed.internalLinks = value.internalLinks.filter(isRecord).map((link) => ({ + text: requiredString(link.text, "internal link text"), + href: requiredString(link.href, "internal link href"), + })); + } + if (Array.isArray(value.files)) { + parsed.files = value.files.filter(isRecord).map((file) => ({ + name: requiredString(file.name, "artifact name"), + path: requiredString(file.path, "artifact path"), + mediaType: requiredString(file.mediaType, "artifact media type"), + bytes: requiredNumber(file.bytes, "artifact size"), + })); + } + if (isRecord(value.dimensions)) { + parsed.dimensions = { + width: requiredNumber(value.dimensions.width, "document width"), + height: requiredNumber(value.dimensions.height, "document height"), + }; + } + if (isRecord(value.viewport)) { + parsed.viewport = { + width: requiredNumber(value.viewport.width, "viewport width"), + height: requiredNumber(value.viewport.height, "viewport height"), + devicePixelRatio: requiredNumber(value.viewport.devicePixelRatio, "device pixel ratio"), + }; + } + if (isRecord(value.document)) { + parsed.document = { + elements: requiredNumber(value.document.elements, "element count"), + images: requiredNumber(value.document.images, "image count"), + links: requiredNumber(value.document.links, "link count"), + scripts: requiredNumber(value.document.scripts, "script count"), + }; + } + if (value.timing === null) parsed.timing = null; + else if (isRecord(value.timing)) { + parsed.timing = { + responseEnd: requiredNumber(value.timing.responseEnd, "response timing"), + domContentLoaded: requiredNumber(value.timing.domContentLoaded, "DOM timing"), + load: requiredNumber(value.timing.load, "load timing"), + }; + } + return parsed; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function requiredString(value: unknown, name: string): string { + if (typeof value !== "string") throw new Error(`browser result ${name} must be a string`); + return value; +} + +function requiredNumber(value: unknown, name: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`browser result ${name} must be a finite number`); + } + return value; +} + +function isBrowserAction(value: unknown): value is BrowserAction { + return ( + value === "scrape" || value === "screenshot" || value === "page-info" || value === "research" + ); +} + +function artifactContentType(filename: string): string { + if (filename.endsWith(".png")) return "image/png"; + if (filename.endsWith(".json")) return "application/json; charset=utf-8"; + return "text/markdown; charset=utf-8"; +} + +function errorResponse(error: unknown, status: number): Response { + return Response.json( + { error: error instanceof Error ? error.message : String(error) }, + { status }, + ); +} diff --git a/examples/browser-rendering/src/ui.test.ts b/examples/browser-rendering/src/ui.test.ts new file mode 100644 index 00000000..1de3531c --- /dev/null +++ b/examples/browser-rendering/src/ui.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { UI_HTML } from "./ui.js"; + +describe("browser rendering UI", () => { + it("starts with the Cloudflare Agents documentation", () => { + expect(UI_HTML).toContain('value="https://developers.cloudflare.com/agents/"'); + }); + + it("separates the concise plugin API from the example application", () => { + expect(UI_HTML).toContain("Plugin setup"); + expect(UI_HTML).toContain("new Workspace({"); + expect(UI_HTML).toContain("storage: ctx.storage"); + expect(UI_HTML).toContain("new WorkerJavaScriptBackend({"); + expect(UI_HTML).toContain("plugins: [puppeteer({ browser: env.BROWSER })]"); + expect(UI_HTML).toContain('import { withBrowser } from "@cloudflare/puppeteer"'); + expect(UI_HTML).toContain("Full example task source"); + expect(UI_HTML).toContain("The scraper, research workflow, and artifact UI are example code"); + }); + + it("uses Phosphor icons and action-specific result components", () => { + expect(UI_HTML).toContain("@phosphor-icons/web@2.1.2"); + expect(UI_HTML).toContain('id="metrics"'); + expect(UI_HTML).toContain('id="scrape-result"'); + expect(UI_HTML).toContain('id="screenshot-result"'); + expect(UI_HTML).toContain('data-action="research"'); + expect(UI_HTML).toContain('id="research-result"'); + expect(UI_HTML).toContain('id="workspace-tree"'); + }); + + it("renders an in-flight response with its submitted action", () => { + expect(UI_HTML).toContain("const submittedAction = action;"); + expect(UI_HTML).toContain("JSON.stringify({ action: submittedAction, url: target })"); + expect(UI_HTML).toContain("renderValue(payload.value, submittedAction)"); + }); + + it("ships syntactically valid client JavaScript", () => { + const script = UI_HTML.match(/ + +`; diff --git a/examples/browser-rendering/tsconfig.json b/examples/browser-rendering/tsconfig.json new file mode 100644 index 00000000..6acb4b3d --- /dev/null +++ b/examples/browser-rendering/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "lib": ["ES2023", "WebWorker"], + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "strict": true, + "target": "ES2023", + "types": ["@cloudflare/workers-types"], + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/examples/browser-rendering/wrangler.jsonc b/examples/browser-rendering/wrangler.jsonc new file mode 100644 index 00000000..7a828fb6 --- /dev/null +++ b/examples/browser-rendering/wrangler.jsonc @@ -0,0 +1,40 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "computer-browser-rendering-example", + "main": "src/index.ts", + "compatibility_date": "2026-07-29", + "compatibility_flags": ["nodejs_compat", "experimental"], + + "worker_loaders": [ + { + "binding": "LOADER" + } + ], + + "browser": { + "binding": "BROWSER" + }, + + "durable_objects": { + "bindings": [ + { + "name": "BrowserWorkspace", + "class_name": "BrowserWorkspace" + } + ] + }, + + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["BrowserWorkspace"] + } + ], + + "observability": { + "enabled": true, + "traces": { + "enabled": true + } + } +} diff --git a/package-lock.json b/package-lock.json index 97586512..bf61b80e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,26 @@ "wrangler": "^4.130.0" } }, + "examples/browser-rendering": { + "name": "@example/computer-browser-rendering", + "version": "0.0.0", + "dependencies": { + "@cloudflare/computer": "*" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260616.1", + "typescript": "^6.0.3", + "vitest": "^4.1.11", + "wrangler": "^4.130.0" + } + }, + "examples/browser-rendering/node_modules/@cloudflare/workers-types": { + "version": "4.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", + "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, "examples/celld": { "name": "@example/computer-celld", "version": "0.0.0", @@ -1465,6 +1485,22 @@ "node": ">=22.0.0" } }, + "node_modules/@cloudflare/puppeteer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@cloudflare/puppeteer/-/puppeteer-1.4.0.tgz", + "integrity": "sha512-39lo96Y7ErOJFmu/KIJU1VLaOibhynx/BauKCZOW5+RVH/mG0jZrAEGJlqCdx5ZxLHyWppm78YX3jLNPCBB+fA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.2.4", + "debug": "^4.3.5", + "devtools-protocol": "0.0.1299070", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@cloudflare/sandbox": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@cloudflare/sandbox/-/sandbox-0.11.0.tgz", @@ -2527,6 +2563,10 @@ "resolved": "examples/assets", "link": true }, + "node_modules/@example/computer-browser-rendering": { + "resolved": "examples/browser-rendering", + "link": true + }, "node_modules/@example/computer-celld": { "resolved": "examples/celld", "link": true @@ -4019,6 +4059,170 @@ "dev": true, "license": "MIT" }, + "node_modules/@puppeteer/browsers": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.2.4.tgz", + "integrity": "sha512-BdG2qiI1dn89OTUUsx2GZSpUzW+DRffR1wlMJyKxVHYrhnKoELSDxDd+2XImUkuWPEKk76H5FcM/gPFrEK1Tfw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.3.5", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.4.0", + "semver": "^7.6.2", + "tar-fs": "^3.0.6", + "unbzip2-stream": "^1.4.3", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@puppeteer/browsers/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@puppeteer/browsers/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@puppeteer/browsers/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@puppeteer/browsers/node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/@puppeteer/browsers/node_modules/tar-stream": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.1.tgz", + "integrity": "sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/@puppeteer/browsers/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@puppeteer/browsers/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", @@ -4793,6 +4997,13 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "license": "MIT" }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -4940,6 +5151,17 @@ "@types/node": "*" } }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", @@ -5137,6 +5359,16 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/agents": { "version": "0.20.1", "resolved": "https://registry.npmjs.org/agents/-/agents-0.20.1.tgz", @@ -5354,6 +5586,19 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/async-lock": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", @@ -5394,6 +5639,21 @@ "aywson": "dist/cli.mjs" } }, + "node_modules/b4a": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz", + "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -5413,6 +5673,91 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bare-events": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", + "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.1.tgz", + "integrity": "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz", + "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz", + "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz", + "integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -5446,6 +5791,16 @@ "node": ">=6.0.0" } }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/better-path-resolve": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/better-path-resolve/-/better-path-resolve-1.0.0.tgz", @@ -5658,6 +6013,16 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -5911,6 +6276,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -6069,6 +6454,16 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -6169,6 +6564,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6220,6 +6630,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1299070", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1299070.tgz", + "integrity": "sha512-+qtL3eX50qsJ7c+qVyagqi7AWMoQCBGNfoyJZMwm/NSXVqLYbuitrWEEIzxfUmTNy7//Xe8yhMmQ+elj3uAqSg==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/diff": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", @@ -6343,8 +6760,8 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "once": "^1.4.0" } @@ -6518,6 +6935,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -6532,6 +6971,16 @@ "node": ">=4" } }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-util-is-identifier-name": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", @@ -6552,6 +7001,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -6585,6 +7044,16 @@ "node": ">=0.8.x" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -6701,12 +7170,40 @@ "dev": true, "license": "MIT" }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -6789,6 +7286,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -7064,6 +7571,22 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-tsconfig": { "version": "5.0.0-beta.5", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.5.tgz", @@ -7080,6 +7603,21 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -7333,6 +7871,34 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-id": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.2.0.tgz", @@ -7504,6 +8070,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -9369,6 +9945,16 @@ "node": ">= 0.6" } }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/node-abi": { "version": "3.94.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", @@ -9622,6 +10208,40 @@ "node": ">=6" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/package-manager-detector": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz", @@ -9791,6 +10411,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9978,6 +10605,16 @@ "node": ">= 0.6.0" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", @@ -10001,12 +10638,49 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -10372,6 +11046,16 @@ "integrity": "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==", "license": "Apache-2.0" }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -11116,6 +11800,17 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/smol-toml": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.1.tgz", @@ -11128,6 +11823,47 @@ "url": "https://github.com/sponsors/cyyynthia" } }, + "node_modules/socks": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz", + "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -11194,6 +11930,18 @@ "dev": true, "license": "MIT" }, + "node_modules/streamx": { + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -11428,6 +12176,16 @@ "node": ">= 6" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/term-size": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", @@ -11441,6 +12199,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/thingies": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.1.tgz", @@ -11470,6 +12238,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -11755,6 +12530,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, + "node_modules/unbzip2-stream/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/undici": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", @@ -12761,6 +13572,17 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/youch": { "version": "4.1.0-beta.10", "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", @@ -12840,6 +13662,7 @@ "devDependencies": { "@cloudflare/computer-rpc": "*", "@cloudflare/dofs": "*", + "@cloudflare/puppeteer": "1.4.0", "@cloudflare/vitest-pool-workers": "^0.22.0", "@cloudflare/workers-types": "^4.20260616.1", "@platformatic/vfs": "^0.4.0", diff --git a/packages/computer/README.md b/packages/computer/README.md index 14833957..66ff0c7e 100644 --- a/packages/computer/README.md +++ b/packages/computer/README.md @@ -46,8 +46,9 @@ npm install @cloudflare/computer Your Worker needs the `nodejs_compat` compatibility flag. The worker-shell and worker-javascript backends additionally need the -`experimental` flag and a Worker Loader binding. Each backend has its -own binding requirements — see [Choosing a backend](#choosing-a-backend). +`experimental` flag and a Worker Loader binding. The Puppeteer plugin +also needs a Browser Run binding. Each backend has its own binding +requirements — see [Choosing a backend](#choosing-a-backend). Optional peer dependencies, installed only if you use the matching feature: `ai` and `zod` (for `@cloudflare/computer/tools`), @@ -259,6 +260,39 @@ Alongside `exec`, the runtime exposes `getExec`, `killExec`, and run stays alive while its event stream is consumed. See [`docs/17_isolate_javascript.md`](../../docs/17_isolate_javascript.md) and [`examples/worker-javascript`](../../examples/worker-javascript). + Add `@cloudflare/computer/plugins/puppeteer` to run the Puppeteer client + and its `Browser` / `Page` objects inside these isolated executions. + [`examples/browser-rendering`](../../examples/browser-rendering) shows the + plugin scraping a page and writing Markdown, JSON, and screenshot artifacts + to the Workspace in one execution. + +### Browser automation + +Add a Browser Run binding to an isolated JavaScript backend with the Puppeteer plugin: + +```ts +import { WorkerJavaScriptBackend } from "@cloudflare/computer/backends/worker-javascript"; +import { puppeteer } from "@cloudflare/computer/plugins/puppeteer"; + +const backend = new WorkerJavaScriptBackend({ + loader: env.LOADER, + plugins: [puppeteer({ browser: env.BROWSER })], +}); +``` + +Execution source can then import the bound helper: + +```js +import { withBrowser } from "@cloudflare/puppeteer"; + +export default (url) => withBrowser(async (browser) => { + const page = await browser.newPage(); + await page.goto(url); + return page.title(); +}); +``` + +See [Browser automation in Worker JavaScript](https://github.com/cloudflare/computer/blob/main/docs/20_browser_automation.md) for the complete setup and API. You can register several backends on one Workspace and route each call to a named one — see [Multiple backends](#multiple-backends). @@ -418,6 +452,7 @@ on a computerd instance. | `@cloudflare/computer/backends/container` | `CloudflareContainerBackend` and `withWorkspaceContainer`. Pulls in the computerd / capnweb sync plumbing. | | `@cloudflare/computer/backends/worker-shell` | `WorkerShellBackend` and the bundled just-bash runtime. | | `@cloudflare/computer/backends/worker-javascript` | `WorkerJavaScriptBackend`, configured libraries, durable imports, `node:fs/promises`, and trusted `ws:git` / `ws:artifacts`. | +| `@cloudflare/computer/plugins/puppeteer` | Opt-in Cloudflare Puppeteer module and Browser Run binding for Worker JavaScript executions. | | `@cloudflare/computer/tools` | AI SDK tools for agents: `read`, `ls`, `find`, `grep`, `write`, `edit`, `delete`, and optional `exec` and `publish`. | | `@cloudflare/computer/git` | Opt-in `isomorphic-git` glue for checkouts inside the workspace. | | `@cloudflare/computer/assets` | `createAssets` — share a workspace file to R2 as a presigned URL. | @@ -508,6 +543,9 @@ An adapter for the Cloudflare runtime lives at No container. - [`examples/worker-javascript`](../../examples/worker-javascript) — the same shape, running ECMAScript modules instead of shell commands. +- [`examples/browser-rendering`](../../examples/browser-rendering) — runs + Cloudflare Puppeteer inside a Worker JavaScript execution for scraping, + page inspection, and durable screenshots. - [`examples/container`](../../examples/container) — the container backend running `computerd`. - [`examples/think`](../../examples/think) — a chat agent that uses the diff --git a/packages/computer/package.json b/packages/computer/package.json index 8240061c..8fe51595 100644 --- a/packages/computer/package.json +++ b/packages/computer/package.json @@ -47,6 +47,10 @@ "types": "./dist/backends/worker-shell/index.d.ts", "import": "./dist/backends/worker-shell/index.js" }, + "./plugins/puppeteer": { + "types": "./dist/plugins/puppeteer/index.d.ts", + "import": "./dist/plugins/puppeteer/index.js" + }, "./shell/core": { "types": "./dist/backends/worker-shell/shell/core.d.ts", "default": "./dist/backends/worker-shell/shell/core.js" @@ -101,10 +105,12 @@ "scripts": { "build:deps": "npm run build --workspace @cloudflare/computer-rpc", "build:shell-bundle": "node ./src/backends/worker-shell/script/build-bundle.mjs", - "prebuild": "npm run build:deps && npm run build:shell-bundle", - "pretest": "npm run build:shell-bundle", - "pretypecheck": "npm run build:deps && npm run build:shell-bundle", - "prepare": "npm run build:shell-bundle", + "build:puppeteer-bundle": "node ./src/plugins/puppeteer/build-bundle.mjs", + "build:runtime-bundles": "npm run build:shell-bundle && npm run build:puppeteer-bundle", + "prebuild": "npm run build:deps && npm run build:runtime-bundles", + "pretest": "npm run build:runtime-bundles", + "pretypecheck": "npm run build:deps && npm run build:runtime-bundles", + "prepare": "npm run build:runtime-bundles", "build": "rolldown -c", "typecheck": "tsc -p tsconfig.build.json --noEmit", "test": "vitest run && vitest run --config vitest.config.proxy.ts && vitest run --config vitest.config.worker-backend.ts && vitest run --config vitest.config.script-runner.ts && vitest run --config vitest.config.stub-soak.ts", @@ -139,6 +145,7 @@ "devDependencies": { "@cloudflare/computer-rpc": "*", "@cloudflare/dofs": "*", + "@cloudflare/puppeteer": "1.4.0", "@cloudflare/vitest-pool-workers": "^0.22.0", "@cloudflare/workers-types": "^4.20260616.1", "@platformatic/vfs": "^0.4.0", diff --git a/packages/computer/rolldown.config.ts b/packages/computer/rolldown.config.ts index 7f21309e..05e9ee55 100644 --- a/packages/computer/rolldown.config.ts +++ b/packages/computer/rolldown.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ "backends/container/index": "src/backends/container/index.ts", "backends/worker-javascript/index": "src/backends/worker-javascript/index.ts", "backends/worker-shell/index": "src/backends/worker-shell/index.ts", + "plugins/puppeteer/index": "src/plugins/puppeteer/index.ts", // The shell-module groups build-bundle.mjs emits. Each is its // own entry so it lands at the dist path the ./shell/* package // exports point at; shell-modules.ts imports the core group by diff --git a/packages/computer/src/backends/worker-javascript/index.ts b/packages/computer/src/backends/worker-javascript/index.ts index 41a4e81f..85f17280 100644 --- a/packages/computer/src/backends/worker-javascript/index.ts +++ b/packages/computer/src/backends/worker-javascript/index.ts @@ -2,4 +2,5 @@ export type { WorkspaceEgressPolicy } from "../../runtime/egress.js"; export { WorkerJavaScriptBackend, type WorkerJavaScriptBackendOptions, + type WorkerJavaScriptPlugin, } from "./worker-javascript.js"; diff --git a/packages/computer/src/backends/worker-javascript/module-graph.ts b/packages/computer/src/backends/worker-javascript/module-graph.ts index 8280e7b5..d57a41bf 100644 --- a/packages/computer/src/backends/worker-javascript/module-graph.ts +++ b/packages/computer/src/backends/worker-javascript/module-graph.ts @@ -12,6 +12,8 @@ export type JavaScriptModuleMap = WorkspaceRuntimeLoader extends { const ENTRY_BASENAME = "__workspace_entry__.js"; const RUNNER_MODULE = "workspace-runtime-runner.js"; const CAPABILITIES_MODULE = "workspace-capabilities.js"; +const PLUGIN_BINDINGS_MODULE = "workspace-plugin-bindings.js"; +const CONFIGURED_MODULES_DIRECTORY = "workspace-configured-modules"; const TRUSTED_MODULES = ["node:fs", "node:fs/promises", "ws:git", "ws:artifacts"] as const; export interface BuildModuleGraphOptions { @@ -19,6 +21,7 @@ export interface BuildModuleGraphOptions { cwd: string; capability: WorkspaceRuntimeCapability; configuredModules: Record; + pluginModuleNames?: ReadonlySet; trustedModuleNames?: string[]; maxSourceBytes: number; maxCapabilityBytes: number; @@ -33,6 +36,7 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { const modules: Record = Object.assign(Object.create(null), { [entryName]: options.source, [CAPABILITIES_MODULE]: capabilitiesModule(options.maxCapabilityBytes), + [PLUGIN_BINDINGS_MODULE]: { js: pluginBindingsModule() }, }); const seen = new Set(); const directories = new Set([directoryName(entryName)]); @@ -64,7 +68,7 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { for (const specifier of imports(source)) { if (trustedModuleNames.has(specifier)) continue; - if (specifier === CAPABILITIES_MODULE) { + if (specifier === CAPABILITIES_MODULE || specifier === PLUGIN_BINDINGS_MODULE) { throw new Error(`Module ${JSON.stringify(specifier)} is reserved for Workspace internals.`); } if (specifier.startsWith("ws:")) { @@ -118,7 +122,9 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { specifier === ENTRY_BASENAME || specifier === RUNNER_MODULE || specifier === CAPABILITIES_MODULE || - specifier.includes("/") + specifier === PLUGIN_BINDINGS_MODULE || + specifier === CONFIGURED_MODULES_DIRECTORY || + !isConfiguredModuleName(specifier) ) { throw new Error( `Configured module ${JSON.stringify(specifier)} uses a reserved module name.`, @@ -131,7 +137,30 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { modules["node:fs/promises"] = { js: nodeFsPromisesModule() }; modules["node:fs"] = { js: nodeFsModule() }; - for (const directory of directories) { + const configuredModules = Object.entries(options.configuredModules).map(([specifier, source]) => { + if ( + !options.pluginModuleNames?.has(specifier) && + imports(source).some((imported) => imported.split("/").at(-1) === PLUGIN_BINDINGS_MODULE) + ) { + throw new Error( + `Configured module ${JSON.stringify(specifier)} imports ${JSON.stringify(PLUGIN_BINDINGS_MODULE)}, which is reserved for installed plugin modules.`, + ); + } + return { + specifier, + source, + canonicalName: `${CONFIGURED_MODULES_DIRECTORY}/${specifier}`, + hasDefault: hasDefaultExport(source), + }; + }); + const resolutionDirectories = new Set(directories); + for (const configured of configuredModules) { + modules[configured.canonicalName] = { js: configured.source }; + resolutionDirectories.add(directoryName(configured.canonicalName)); + installPluginBindingAlias(modules, directoryName(configured.canonicalName)); + } + + for (const directory of resolutionDirectories) { const prefix = directory ? `${directory}/` : ""; const toCapabilities = relativeModule(directory, CAPABILITIES_MODULE); modules[`${prefix}ws:git`] = { js: gitModule(toCapabilities) }; @@ -141,20 +170,62 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { js: trustedModule(toCapabilities, specifier), }; } - for (const [specifier, source] of Object.entries(options.configuredModules)) { - const key = `${prefix}${specifier}`; + for (const configured of configuredModules) { + const key = `${prefix}${configured.specifier}`; + if (key === configured.canonicalName) continue; if (key in modules) { throw new Error( - `Configured module ${JSON.stringify(specifier)} collides with ${JSON.stringify(key)}.`, + `Configured module ${JSON.stringify(configured.specifier)} collides with ${JSON.stringify(key)}.`, ); } - modules[key] = { js: source }; + modules[key] = { + js: configuredModuleAlias( + relativeModule(directoryName(key), configured.canonicalName), + configured.hasDefault, + ), + }; } } return { entryName, modules }; } +function installPluginBindingAlias( + modules: Record, + moduleDirectory: string, +) { + const alias = `${moduleDirectory ? `${moduleDirectory}/` : ""}${PLUGIN_BINDINGS_MODULE}`; + if (!(alias in modules)) { + modules[alias] = { + js: `export { binding } from ${JSON.stringify(relativeModule(moduleDirectory, PLUGIN_BINDINGS_MODULE))};`, + }; + } +} + +function configuredModuleAlias(target: string, hasDefault: boolean) { + return `export * from ${JSON.stringify(target)};${ + hasDefault ? `\nexport { default } from ${JSON.stringify(target)};` : "" + }`; +} + +function hasDefaultExport(source: string): boolean { + const ast = parse(source, { ecmaVersion: "latest", sourceType: "module" }) as unknown as { + body: Array<{ + type?: string; + specifiers?: Array<{ exported?: { name?: unknown; value?: unknown } }>; + }>; + }; + return ast.body.some( + (node) => + node.type === "ExportDefaultDeclaration" || + (node.type === "ExportNamedDeclaration" && + node.specifiers?.some( + (specifier) => + specifier.exported?.name === "default" || specifier.exported?.value === "default", + )), + ); +} + function imports(source: string): string[] { const ast = parse(source, { ecmaVersion: "latest", sourceType: "module" }) as unknown as { body: unknown[]; @@ -228,14 +299,40 @@ function directoryName(name: string) { function isInternalModuleName(name: string) { return ( name === CAPABILITIES_MODULE || + name === PLUGIN_BINDINGS_MODULE || + name === CONFIGURED_MODULES_DIRECTORY || + name.startsWith(`${CONFIGURED_MODULES_DIRECTORY}/`) || name === RUNNER_MODULE || name === ENTRY_BASENAME || name.endsWith(`/${CAPABILITIES_MODULE}`) || + name.endsWith(`/${PLUGIN_BINDINGS_MODULE}`) || name.endsWith(`/${RUNNER_MODULE}`) || name.split("/").at(-1)?.startsWith("ws:") === true ); } +function isConfiguredModuleName(name: string) { + return ( + /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || + /^@[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) + ); +} + +function pluginBindingsModule() { + return ` + let bindings = Object.create(null); + export function install(value) { + bindings = value || Object.create(null); + } + export function binding(name) { + if (!Object.hasOwn(bindings, name)) { + throw new Error("Workspace JavaScript plugin binding " + JSON.stringify(name) + " is unavailable"); + } + return bindings[name]; + } + `; +} + function capabilitiesModule(maxCapabilityBytes: number) { const requestTooLargeMessage = `Workspace capability request exceeds ${maxCapabilityBytes} bytes.`; return ` diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts index f75ef9bc..21f766fa 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts @@ -229,6 +229,49 @@ describe("WorkerJavaScriptBackend", () => { expect(workerDisposals).toBe(1); }); + it.each([ + ["timeout", "failed"], + ["cancellation", "cancelled"], + ] as const)("disposes Loader resources after %s", async (mode, expectedStatus) => { + let entrypointDisposals = 0; + let workerDisposals = 0; + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + new WorkerJavaScriptBackend({ + loader: { + load() { + return { + getEntrypoint() { + return { + evaluate: () => new Promise(() => undefined), + [Symbol.dispose]() { + entrypointDisposals += 1; + }, + }; + }, + [Symbol.dispose]() { + workerDisposals += 1; + }, + }; + }, + }, + defaultTimeoutMs: 100, + maxTimeoutMs: 100, + }), + ], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + const execution = await workspace.runtime.exec("export default 1", { + timeoutMs: mode === "timeout" ? 5 : 100, + }); + if (mode === "cancellation") await workspace.runtime.killExec(execution.id); + + await expect(execution.result()).resolves.toMatchObject({ status: expectedStatus }); + expect(entrypointDisposals).toBe(1); + expect(workerDisposals).toBe(1); + }); + it("migrates the legacy execution journal schema", async () => { const db = new Database(new SQLiteTestStorage()); initializeSchema(db, () => 0); @@ -372,7 +415,7 @@ describe("WorkerJavaScriptBackend", () => { const load = vi.fn(); const workspace = new Workspace({ storage: new SQLiteTestStorage(), - backends: [new WorkerJavaScriptBackend({ loader: { load }, maxSourceBytes: 128 })], + backends: [new WorkerJavaScriptBackend({ loader: { load }, maxLoaderSourceBytes: 128 })], }); await workspace.fs.mkdir("/workspace", { recursive: true }); const execution = await workspace.runtime.exec("export default 1", { encoding: "utf8" }); @@ -383,6 +426,49 @@ describe("WorkerJavaScriptBackend", () => { expect(load).not.toHaveBeenCalled(); }); + it("installs one canonical copy of a configured module across nested imports", async () => { + const load = vi.fn(() => ({ + getEntrypoint() { + return { + evaluate: ( + _input: unknown, + host: { + assertResult(value: unknown): Promise; + attachOutput(readable: ReadableStream): Promise; + }, + ) => evaluateResult(host, null), + }; + }, + })); + const configuredSource = `export default ${JSON.stringify("x".repeat(350_000))};`; + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + new WorkerJavaScriptBackend({ + loader: { load }, + maxSourceBytes: 1024, + maxLoaderSourceBytes: 512 * 1024, + plugins: [{ modules: { "@example/large": configuredSource } }], + }), + ], + }); + await workspace.fs.mkdir("/workspace/a/b", { recursive: true }); + await workspace.fs.writeFile("/workspace/a/one.js", `import "./b/two.js";`); + await workspace.fs.writeFile("/workspace/a/b/two.js", `export default 2;`); + + const execution = await workspace.runtime.exec( + `import "./a/one.js"; import value from "@example/large"; export default value.length;`, + ); + await expect(execution.result()).resolves.toMatchObject({ status: "completed" }); + + const loaderModules = load.mock.calls[0]?.[0].modules; + expect( + Object.values(loaderModules).filter( + (module) => (typeof module === "string" ? module : module.js) === configuredSource, + ), + ).toHaveLength(1); + }); + it("records synchronous loader startup failure as a completed failed execution", async () => { const workspace = new Workspace({ storage: new SQLiteTestStorage(), @@ -997,6 +1083,144 @@ describe("WorkerJavaScriptBackend", () => { await handle.close(); }); + it("passes plugin bindings and scoped modules to the Dynamic Worker", async () => { + const browser = { fetch: vi.fn() }; + const load = vi.fn(() => ({ + getEntrypoint() { + return { + evaluate: ( + _input: unknown, + host: { + assertResult(value: unknown): Promise; + attachOutput(readable: ReadableStream): Promise; + }, + ) => evaluateResult(host, null), + }; + }, + })); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + new WorkerJavaScriptBackend({ + loader: { load }, + plugins: [ + { + modules: { + "@cloudflare/puppeteer": "export const browser = true;", + }, + bindings: { BROWSER: browser }, + }, + ], + }), + ], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + + await ( + await workspace.runtime.exec( + `import { browser } from "@cloudflare/puppeteer"; export default browser;`, + ) + ).result(); + + expect(load.mock.calls[0]?.[0].env).toEqual({ BROWSER: browser }); + expect( + load.mock.calls[0]?.[0].modules["workspace-configured-modules/@cloudflare/puppeteer"], + ).toEqual({ js: "export const browser = true;" }); + expect(load.mock.calls[0]?.[0].modules["workspace/@cloudflare/puppeteer"]).toEqual({ + js: expect.stringContaining("workspace-configured-modules/@cloudflare/puppeteer"), + }); + expect(load.mock.calls[0]?.[0].modules["workspace-plugin-bindings.js"]).toEqual({ + js: expect.stringContaining("Object.hasOwn"), + }); + }); + + it("rejects caller imports of the plugin binding bridge", async () => { + const load = vi.fn(); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [new WorkerJavaScriptBackend({ loader: { load } })], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + + await expect( + workspace.runtime.exec(`import "workspace-plugin-bindings.js"; export default null;`), + ).rejects.toThrow(/reserved for Workspace internals/); + expect(load).not.toHaveBeenCalled(); + }); + + it("rejects configured non-plugin imports of the plugin binding bridge", async () => { + const load = vi.fn(); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + new WorkerJavaScriptBackend({ + loader: { load }, + modules: { + sneaky: `import { binding } from "workspace-plugin-bindings.js"; export default binding("BROWSER");`, + }, + plugins: [ + { + modules: { plugin: "export default null;" }, + bindings: { BROWSER: { fetch: vi.fn() } }, + }, + ], + }), + ], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + + await expect( + workspace.runtime.exec(`import value from "sneaky"; export default value;`), + ).rejects.toThrow(/reserved for installed plugin modules/); + expect(load).not.toHaveBeenCalled(); + }); + + it("rejects malformed and prototype-like plugin binding names", () => { + for (const name of ["not a binding", "__proto__", "constructor", "prototype"]) { + const bindings = Object.create(null) as Record; + bindings[name] = {}; + expect( + () => + new WorkerJavaScriptBackend({ + loader: throwingLoader("unused"), + plugins: [{ modules: { plugin: "export default null" }, bindings }], + }), + ).toThrow(/plugin binding name.*safe simple identifier/); + } + }); + + it("rejects duplicate plugin modules and bindings", () => { + const plugin = { + modules: { plugin: "export default null" }, + bindings: { PLUGIN: { fetch: vi.fn() } }, + }; + expect( + () => + new WorkerJavaScriptBackend({ + loader: throwingLoader("unused"), + plugins: [plugin, plugin], + }), + ).toThrow(/plugin module.*configured twice/); + expect( + () => + new WorkerJavaScriptBackend({ + loader: throwingLoader("unused"), + modules: { plugin: "export default null" }, + plugins: [plugin], + }), + ).toThrow(/plugin module.*configured twice/); + expect( + () => + new WorkerJavaScriptBackend({ + loader: throwingLoader("unused"), + plugins: [ + { modules: { one: "export default 1" }, bindings: plugin.bindings }, + { modules: { two: "export default 2" }, bindings: plugin.bindings }, + ], + }), + ).toThrow(/plugin binding.*configured twice/); + }); + it("rejects malformed host trusted-module names", async () => { const workspace = new Workspace({ storage: new SQLiteTestStorage(), diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index 4bd5fcfe..5299e585 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -16,12 +16,25 @@ import type { import { decodeRuntimeFrames, type RuntimeFrame } from "./frames.js"; import { buildModuleGraph } from "./module-graph.js"; +export interface WorkerJavaScriptPlugin { + /** ECMAScript modules installed for every execution. */ + modules: Record; + /** + * Host bindings exposed to installed plugin modules through the reserved + * plugin bridge. Plugins on one backend are mutually trusted. + */ + bindings?: Record; +} + export interface WorkerJavaScriptBackendOptions { - loader: WorkspaceRuntimeLoader; + loader: WorkspaceRuntimeLoader; id?: string; root?: string; access?: WorkspaceRuntimeAccess; + /** Host-installed code without access to the plugin binding bridge. */ modules?: Record; + /** Prebuilt, mutually trusted modules that carry the host bindings they need. */ + plugins?: readonly WorkerJavaScriptPlugin[]; /** * Host-owned capability modules installed under reserved ws:* specifiers. * Caller source may import them, but cannot provide or replace them. @@ -29,7 +42,10 @@ export interface WorkerJavaScriptBackendOptions { trustedModules?: Record<`ws:${string}`, WorkspaceTrustedModule>; defaultTimeoutMs?: number; maxTimeoutMs?: number; + /** Caller-owned entry and relative module bytes. Defaults to 1 MiB. */ maxSourceBytes?: number; + /** Complete Worker Loader graph bytes, including configured modules. Defaults to 8 MiB. */ + maxLoaderSourceBytes?: number; maxInputBytes?: number; maxStdinBytes?: number; maxEnvBytes?: number; @@ -70,6 +86,7 @@ type ResolvedWorkerJavaScriptBackendOptions = Required< | "defaultTimeoutMs" | "maxTimeoutMs" | "maxSourceBytes" + | "maxLoaderSourceBytes" | "maxInputBytes" | "maxStdinBytes" | "maxEnvBytes" @@ -90,8 +107,10 @@ type ResolvedWorkerJavaScriptBackendOptions = Required< | "compatibilityFlags" > > & - Omit & { + Omit & { egress: WorkspaceEgressPolicy; + pluginBindings: Record; + pluginModuleNames: ReadonlySet; }; interface WorkspaceExecutionContext { @@ -152,6 +171,7 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { assertPositiveFinite(maxTimeoutMs, "maxTimeoutMs"); assertPositiveFinite(defaultTimeoutMs, "defaultTimeoutMs"); assertPositiveFinite(options.maxSourceBytes ?? 1024 * 1024, "maxSourceBytes"); + assertPositiveFinite(options.maxLoaderSourceBytes ?? 8 * 1024 * 1024, "maxLoaderSourceBytes"); assertPositiveFinite(options.maxInputBytes ?? 1024 * 1024, "maxInputBytes"); assertPositiveFinite(options.maxStdinBytes ?? 256 * 1024, "maxStdinBytes"); assertPositiveFinite(options.maxEnvBytes ?? 1024 * 1024, "maxEnvBytes"); @@ -187,7 +207,8 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { if (defaultTimeoutMs > maxTimeoutMs) { throw new Error("WorkerJavaScriptBackend defaultTimeoutMs cannot exceed maxTimeoutMs."); } - const { globalOutbound, egress, ...backendOptions } = options; + const { globalOutbound, egress, plugins, ...backendOptions } = options; + const pluginConfiguration = resolvePlugins(options.modules, plugins); const resolvedEgress = egress ?? (globalOutbound === undefined @@ -197,12 +218,16 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { : { mode: "http-gateway" as const, gateway: globalOutbound }); this.#options = { ...backendOptions, + modules: pluginConfiguration.modules, + pluginBindings: pluginConfiguration.bindings, + pluginModuleNames: pluginConfiguration.moduleNames, egress: resolvedEgress, root: options.root ?? "/workspace", access: options.access ?? "read-write", defaultTimeoutMs, maxTimeoutMs, maxSourceBytes: options.maxSourceBytes ?? 1024 * 1024, + maxLoaderSourceBytes: options.maxLoaderSourceBytes ?? 8 * 1024 * 1024, maxInputBytes: options.maxInputBytes ?? 1024 * 1024, maxStdinBytes: options.maxStdinBytes ?? 256 * 1024, maxEnvBytes: options.maxEnvBytes ?? 1024 * 1024, @@ -354,6 +379,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { cwd: input.cwd ?? this.#options.root, capability, configuredModules: this.#options.modules ?? {}, + pluginModuleNames: this.#options.pluginModuleNames, trustedModuleNames: Object.keys(this.#options.trustedModules ?? {}), maxSourceBytes: this.#options.maxSourceBytes, maxCapabilityBytes: this.#options.maxCapabilityBytes, @@ -420,7 +446,8 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { compatibilityDate: this.#options.compatibilityDate, compatibilityFlags: this.#options.compatibilityFlags, maxStdioBytes: this.#options.maxStdioBytes, - maxSourceBytes: this.#options.maxSourceBytes, + maxLoaderSourceBytes: this.#options.maxLoaderSourceBytes, + pluginBindings: this.#options.pluginBindings, onComplete: () => this.#finalize(record), onError: (message) => this.#finalize(record, message), }); @@ -924,7 +951,7 @@ function decodeEvent( } function startJavaScriptExecution(options: { - loader: WorkspaceRuntimeLoader; + loader: WorkspaceRuntimeLoader; modules: Record; entryName: string; input: WorkspaceRuntimeValue; @@ -935,7 +962,8 @@ function startJavaScriptExecution(options: { compatibilityDate: string; compatibilityFlags: string[]; maxStdioBytes: number; - maxSourceBytes: number; + maxLoaderSourceBytes: number; + pluginBindings: Record; onComplete(): void | Promise; onError(message: string): void | Promise; }): ActiveControl { @@ -943,15 +971,19 @@ function startJavaScriptExecution(options: { ...options.modules, "workspace-runtime-runner.js": runtimeWorkerModule(options.entryName, options.maxStdioBytes), }; - assertLoaderGraph(modules, options.maxSourceBytes); + assertLoaderGraph(modules, options.maxLoaderSourceBytes); const worker = options.loader.load({ compatibilityDate: options.compatibilityDate, compatibilityFlags: options.compatibilityFlags, limits: { cpuMs: options.timeoutMs }, mainModule: "workspace-runtime-runner.js", modules, + env: options.pluginBindings, ...dynamicWorkerEgress(options.egress), - }); + }) as { + getEntrypoint(name?: string, options?: { limits?: { cpuMs?: number } }): unknown; + [Symbol.dispose]?: () => void; + }; let entrypoint: JavaScriptEntrypoint; try { entrypoint = worker.getEntrypoint(undefined, { @@ -1028,6 +1060,7 @@ function runtimeWorkerModule(entryName: string, maxStdioBytes: number) { return ` import { WorkerEntrypoint } from "cloudflare:workers"; import { install } from "workspace-capabilities.js"; + import { install as installPluginBindings } from "workspace-plugin-bindings.js"; export default class extends WorkerEntrypoint { async evaluate(input, host, context) { @@ -1164,6 +1197,7 @@ function runtimeWorkerModule(entryName: string, maxStdioBytes: number) { return ""; }; install(host); + installPluginBindings(this.env); // Hand the readable end to the host, which drains it live while // this call stays in flight. Keeping evaluate in flight is what // holds the host bridge stub alive for the whole run; frames @@ -1192,6 +1226,52 @@ function runtimeWorkerModule(entryName: string, maxStdioBytes: number) { `; } +function resolvePlugins( + modules: Record | undefined, + plugins: readonly WorkerJavaScriptPlugin[] | undefined, +): { + modules: Record; + bindings: Record; + moduleNames: ReadonlySet; +} { + const resolvedModules = Object.assign(Object.create(null), modules ?? {}) as Record< + string, + string + >; + const bindings = Object.create(null) as Record; + const moduleNames = new Set(); + for (const plugin of plugins ?? []) { + for (const [name, source] of Object.entries(plugin.modules)) { + if (Object.hasOwn(resolvedModules, name)) { + throw new Error( + `Worker JavaScript plugin module ${JSON.stringify(name)} is configured twice.`, + ); + } + resolvedModules[name] = source; + moduleNames.add(name); + } + for (const [name, binding] of Object.entries(plugin.bindings ?? {})) { + if ( + !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) || + name === "__proto__" || + name === "constructor" || + name === "prototype" + ) { + throw new Error( + `Worker JavaScript plugin binding name ${JSON.stringify(name)} must be a safe simple identifier.`, + ); + } + if (Object.hasOwn(bindings, name)) { + throw new Error( + `Worker JavaScript plugin binding ${JSON.stringify(name)} is configured twice.`, + ); + } + bindings[name] = binding; + } + } + return { modules: resolvedModules, bindings: { ...bindings }, moduleNames }; +} + function assertLoaderGraph( modules: Record, maxSourceBytes: number, diff --git a/packages/computer/src/plugins/puppeteer/build-bundle.mjs b/packages/computer/src/plugins/puppeteer/build-bundle.mjs new file mode 100644 index 00000000..0f9d3b20 --- /dev/null +++ b/packages/computer/src/plugins/puppeteer/build-bundle.mjs @@ -0,0 +1,70 @@ +// Bundle Cloudflare Puppeteer into one source string that the Worker +// JavaScript backend can install in a Dynamic Worker. + +import { mkdir, writeFile } from "node:fs/promises"; +import { builtinModules } from "node:module"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { build } from "esbuild"; + +const here = dirname(fileURLToPath(import.meta.url)); +const output = resolve(here, "generated.ts"); + +const nodeBuiltins = new Set(builtinModules.map((specifier) => specifier.replace(/^node:/, ""))); + +const result = await build({ + entryPoints: [resolve(here, "runtime.ts")], + bundle: true, + write: false, + minify: true, + format: "esm", + target: "es2022", + platform: "neutral", + conditions: ["workerd", "worker", "import"], + mainFields: ["module", "main"], + define: { + "process.env.WS_NO_BUFFER_UTIL": "true", + "process.env.WS_NO_UTF_8_VALIDATE": "true", + }, + metafile: true, + external: ["workspace-plugin-bindings.js", "cloudflare:workers", "node:*"], + plugins: [ + { + name: "node-builtins", + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /.*/ }, (args) => + nodeBuiltins.has(args.path) ? { path: `node:${args.path}`, external: true } : undefined, + ); + }, + }, + ], +}); + +const bundled = result.outputFiles?.[0]?.text; +if (bundled === undefined) throw new Error("Puppeteer bundle produced no output"); +if (new TextEncoder().encode(bundled).byteLength > 768 * 1024) { + throw new Error("Puppeteer bundle exceeds the 768 KiB plugin budget"); +} + +const externalImports = Object.values(result.metafile.outputs) + .flatMap((entry) => entry.imports) + .filter((entry) => entry.external) + .map((entry) => entry.path); +const unsupportedImport = externalImports.find( + (specifier) => specifier !== "workspace-plugin-bindings.js" && !specifier.startsWith("node:"), +); +if (unsupportedImport !== undefined) { + throw new Error(`Puppeteer bundle left unsupported import ${JSON.stringify(unsupportedImport)}`); +} + +await mkdir(dirname(output), { recursive: true }); +await writeFile( + output, + [ + "// Generated by build-bundle.mjs. Do not edit.", + `export default ${JSON.stringify(bundled)};`, + "", + ].join("\n"), +); +console.log(`Wrote ${output} (${new TextEncoder().encode(bundled).byteLength} bytes)`); diff --git a/packages/computer/src/plugins/puppeteer/constants.ts b/packages/computer/src/plugins/puppeteer/constants.ts new file mode 100644 index 00000000..b05cc5a3 --- /dev/null +++ b/packages/computer/src/plugins/puppeteer/constants.ts @@ -0,0 +1 @@ +export const PUPPETEER_BROWSER_BINDING = "__CLOUDFLARE_COMPUTER_PUPPETEER_BROWSER"; diff --git a/packages/computer/src/plugins/puppeteer/index.test.ts b/packages/computer/src/plugins/puppeteer/index.test.ts new file mode 100644 index 00000000..9e15a170 --- /dev/null +++ b/packages/computer/src/plugins/puppeteer/index.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from "vitest"; + +import { puppeteer } from "./index.js"; + +describe("puppeteer plugin", () => { + it("installs the bundled client with its Browser Run binding", () => { + const browser = { fetch: vi.fn() } as unknown as Fetcher; + + const plugin = puppeteer({ browser }); + + expect(Object.keys(plugin.modules)).toEqual(["@cloudflare/puppeteer"]); + expect(plugin.modules["@cloudflare/puppeteer"]).toContain("browserBinding"); + expect(plugin.modules["@cloudflare/puppeteer"]).toContain("withBrowser"); + expect(Object.values(plugin.bindings ?? {})).toEqual([browser]); + }); + + it("rejects a missing Browser Run binding", () => { + expect(() => puppeteer(undefined as never)).toThrow(/Browser Run binding/); + expect(() => puppeteer({ browser: {} as never })).toThrow(/Browser Run binding/); + }); +}); diff --git a/packages/computer/src/plugins/puppeteer/index.ts b/packages/computer/src/plugins/puppeteer/index.ts new file mode 100644 index 00000000..fee9058a --- /dev/null +++ b/packages/computer/src/plugins/puppeteer/index.ts @@ -0,0 +1,29 @@ +import type { WorkerJavaScriptPlugin } from "../../backends/worker-javascript/index.js"; +import { PUPPETEER_BROWSER_BINDING } from "./constants.js"; +import puppeteerModuleSource from "./generated.js"; + +export interface PuppeteerBrowserBinding { + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; +} + +export interface PuppeteerPluginOptions { + /** Browser Run binding from the host Worker's environment. */ + readonly browser: PuppeteerBrowserBinding; +} + +/** Install Cloudflare Puppeteer and its Browser Run binding in JavaScript executions. */ +export function puppeteer(options: PuppeteerPluginOptions): WorkerJavaScriptPlugin { + if (options?.browser === undefined || typeof options.browser.fetch !== "function") { + throw new TypeError("puppeteer plugin requires a Browser Run binding"); + } + return { + modules: { + "@cloudflare/puppeteer": puppeteerModuleSource, + }, + bindings: { + [PUPPETEER_BROWSER_BINDING]: options.browser, + }, + }; +} + +export default puppeteer; diff --git a/packages/computer/src/plugins/puppeteer/runtime-helpers.test.ts b/packages/computer/src/plugins/puppeteer/runtime-helpers.test.ts new file mode 100644 index 00000000..d8990b6c --- /dev/null +++ b/packages/computer/src/plugins/puppeteer/runtime-helpers.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createBindingForwarder, withClosable } from "./runtime-helpers.js"; + +describe("Puppeteer runtime helpers", () => { + it("forwards fetches to the current Browser Run binding", async () => { + const response = new Response("ok"); + const fetch = vi.fn(async () => response); + const resolve = vi.fn(() => ({ fetch })); + const forwarder = createBindingForwarder(resolve); + const init = { method: "POST" } satisfies RequestInit; + + await expect(forwarder.fetch("https://browser.example/session", init)).resolves.toBe(response); + expect(resolve).toHaveBeenCalledOnce(); + expect(fetch).toHaveBeenCalledWith("https://browser.example/session", init); + }); + + it("closes the browser after a successful callback", async () => { + const browser = { close: vi.fn(async () => undefined) }; + + await expect(withClosable(browser, async () => "done")).resolves.toBe("done"); + expect(browser.close).toHaveBeenCalledOnce(); + }); + + it("closes the browser and preserves a callback failure", async () => { + const browser = { close: vi.fn(async () => undefined) }; + const failure = new Error("page failed"); + + await expect( + withClosable(browser, async () => { + throw failure; + }), + ).rejects.toBe(failure); + expect(browser.close).toHaveBeenCalledOnce(); + }); + + it("reports a close failure after a successful callback", async () => { + const closeFailure = new Error("close failed"); + const browser = { + close: vi.fn(async () => { + throw closeFailure; + }), + }; + + await expect(withClosable(browser, async () => "done")).rejects.toBe(closeFailure); + }); + + it("preserves both callback and close failures", async () => { + const callbackFailure = new Error("page failed"); + const closeFailure = new Error("close failed"); + const browser = { + close: vi.fn(async () => { + throw closeFailure; + }), + }; + + const error = await withClosable(browser, async () => { + throw callbackFailure; + }).catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(AggregateError); + expect((error as AggregateError).errors).toEqual([callbackFailure, closeFailure]); + expect((error as AggregateError).cause).toBe(callbackFailure); + }); +}); diff --git a/packages/computer/src/plugins/puppeteer/runtime-helpers.ts b/packages/computer/src/plugins/puppeteer/runtime-helpers.ts new file mode 100644 index 00000000..ec9bed70 --- /dev/null +++ b/packages/computer/src/plugins/puppeteer/runtime-helpers.ts @@ -0,0 +1,40 @@ +export interface BrowserBindingFetcher { + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; +} + +export interface Closable { + close(): Promise; +} + +export function createBindingForwarder( + resolve: () => BrowserBindingFetcher, +): BrowserBindingFetcher { + return { + fetch(input, init) { + return resolve().fetch(input, init); + }, + }; +} + +export async function withClosable( + resource: Resource, + callback: (resource: Resource) => Result | Promise, +): Promise { + let result: Result; + try { + result = await callback(resource); + } catch (callbackFailure) { + try { + await resource.close(); + } catch (closeFailure) { + throw new AggregateError( + [callbackFailure, closeFailure], + "Browser task and cleanup both failed", + { cause: callbackFailure }, + ); + } + throw callbackFailure; + } + await resource.close(); + return result; +} diff --git a/packages/computer/src/plugins/puppeteer/runtime.ts b/packages/computer/src/plugins/puppeteer/runtime.ts new file mode 100644 index 00000000..3a14e1fb --- /dev/null +++ b/packages/computer/src/plugins/puppeteer/runtime.ts @@ -0,0 +1,31 @@ +import puppeteer, { + type Browser, + type BrowserWorker, + type WorkersLaunchOptions, +} from "@cloudflare/puppeteer"; +import { binding } from "workspace-plugin-bindings.js"; + +import { PUPPETEER_BROWSER_BINDING } from "./constants.js"; +import { createBindingForwarder, withClosable } from "./runtime-helpers.js"; + +export * from "@cloudflare/puppeteer"; + +/** Browser Run endpoint installed by the Computer Puppeteer plugin. */ +export const browserBinding: BrowserWorker = createBindingForwarder(() => + binding(PUPPETEER_BROWSER_BINDING), +); + +/** Launch a Browser Run session using the binding installed by Computer. */ +export function launch(options?: WorkersLaunchOptions): Promise { + return puppeteer.launch(browserBinding, options); +} + +/** Launch a connection-bound browser and always close it after the callback settles. */ +export async function withBrowser( + callback: (browser: Browser) => T | Promise, + options?: WorkersLaunchOptions, +): Promise { + return withClosable(await launch(options), callback); +} + +export default puppeteer; diff --git a/packages/computer/src/plugins/puppeteer/workspace-bindings.d.ts b/packages/computer/src/plugins/puppeteer/workspace-bindings.d.ts new file mode 100644 index 00000000..83350abd --- /dev/null +++ b/packages/computer/src/plugins/puppeteer/workspace-bindings.d.ts @@ -0,0 +1,3 @@ +declare module "workspace-plugin-bindings.js" { + export function binding(name: string): T; +} diff --git a/packages/computer/src/runtime/types.ts b/packages/computer/src/runtime/types.ts index c92a9f64..3300f230 100644 --- a/packages/computer/src/runtime/types.ts +++ b/packages/computer/src/runtime/types.ts @@ -67,17 +67,21 @@ export interface WorkspaceRuntimeFilesystem { symlink(target: string, path: string): Promise; } -export interface WorkspaceRuntimeLoader { +export interface WorkspaceRuntimeLoadedWorker { + getEntrypoint(name?: string, options?: { limits?: { cpuMs?: number } }): unknown; + [Symbol.dispose]?: () => void; +} + +export interface WorkspaceRuntimeLoader { load(code: { compatibilityDate: string; compatibilityFlags?: string[]; limits?: { cpuMs?: number }; mainModule: string; modules: Record; + env?: Record; globalOutbound?: Fetcher | null; - }): { - getEntrypoint(name?: string, options?: { limits?: { cpuMs?: number } }): unknown; - }; + }): LoadedWorker; } export type WorkspaceRuntimeStatus = "completed" | "failed" | "cancelled"; diff --git a/packages/computer/tests/script-runner-worker.ts b/packages/computer/tests/script-runner-worker.ts index 244b548b..7ec9b6a7 100644 --- a/packages/computer/tests/script-runner-worker.ts +++ b/packages/computer/tests/script-runner-worker.ts @@ -7,17 +7,31 @@ import type { WorkspaceStub, } from "../src/index.js"; import { Workspace } from "../src/index.js"; +import { puppeteer } from "../src/plugins/puppeteer/index.js"; export interface Env { HOST: DurableObjectNamespace; LOADER: WorkerLoader; } +export class PluginProbe extends WorkerEntrypoint { + value(): string { + return "from-plugin-binding"; + } +} + export class HostDO extends DurableObject { readonly #workspace: Workspace; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); + const pluginProbe = ( + ctx as DurableObjectState & { + exports: { + PluginProbe(options: { props: Record }): PluginProbe; + }; + } + ).exports.PluginProbe({ props: {} }); this.#workspace = new Workspace({ storage: ctx.storage as unknown as DurableObjectStorageLike, waitUntil: ctx.waitUntil.bind(ctx), @@ -31,6 +45,18 @@ export class HostDO extends DurableObject { modules: { "math-kit": "export const double = (value) => value * 2;", }, + plugins: [ + puppeteer({ browser: pluginProbe }), + { + modules: { + "@example/plugin": ` + import { binding } from "workspace-plugin-bindings.js"; + export default () => typeof binding("PLUGIN_PROBE").value; + `, + }, + bindings: { PLUGIN_PROBE: pluginProbe }, + }, + ], trustedModules: { "ws:test-host": { async call(method, args) { diff --git a/packages/computer/tests/script-runner.test.ts b/packages/computer/tests/script-runner.test.ts index 61c8b63f..e3f6ed17 100644 --- a/packages/computer/tests/script-runner.test.ts +++ b/packages/computer/tests/script-runner.test.ts @@ -52,6 +52,8 @@ describe("WorkspaceRuntime", () => { it("executes an ES module with configured and trusted modules", async () => { const response = await runtime({ source: ` + import pluginValue from "@example/plugin"; + import cloudflarePuppeteer, { browserBinding, launch, withBrowser } from "@cloudflare/puppeteer"; import { double } from "math-kit"; import fs from "node:fs/promises"; import { promises as nodeFs } from "node:fs"; @@ -70,6 +72,13 @@ describe("WorkspaceRuntime", () => { isFile: (await nodeFs.stat("/workspace/runtime-result.txt")).isFile(), entries: await nodeFs.readdir("/workspace"), }, + pluginValue: await pluginValue(), + puppeteerPlugin: { + officialLaunch: typeof cloudflarePuppeteer.launch, + boundLaunch: typeof launch, + withBrowser: typeof withBrowser, + browserFetch: typeof browserBinding.fetch, + }, }; } `, @@ -91,6 +100,13 @@ describe("WorkspaceRuntime", () => { isFile: true, entries: expect.arrayContaining(["runtime-result.txt"]), }, + pluginValue: "function", + puppeteerPlugin: { + officialLaunch: "function", + boundLaunch: "function", + withBrowser: "function", + browserFetch: "function", + }, }, }, });