From fac60264af539148875c3599023ac86ce255e839 Mon Sep 17 00:00:00 2001 From: scuffi Date: Thu, 17 Sep 2026 16:22:02 +0100 Subject: [PATCH] computer, examples/browser-rendering: Add browser automation --- .changeset/tidy-browsers-render.md | 7 + .github/workflows/ci.yml | 3 + .gitignore | 1 + README.md | 4 + docs/17_isolate_javascript.md | 45 +- docs/20_browser_automation.md | 174 ++++ docs/README.md | 4 +- examples/browser-rendering/.gitignore | 4 + examples/browser-rendering/README.md | 131 +++ examples/browser-rendering/package.json | 23 + .../src/artifact-response.test.ts | 21 + .../src/artifact-response.ts | 15 + .../browser-rendering/src/demo-auth.test.ts | 56 ++ examples/browser-rendering/src/demo-auth.ts | 36 + .../src/execution-source.test.ts | 104 +++ .../browser-rendering/src/execution-source.ts | 224 +++++ examples/browser-rendering/src/index.ts | 305 +++++++ .../src/retryable-once.test.ts | 38 + .../browser-rendering/src/retryable-once.ts | 12 + examples/browser-rendering/src/ui.test.ts | 57 ++ examples/browser-rendering/src/ui.ts | 512 +++++++++++ examples/browser-rendering/tsconfig.json | 14 + examples/browser-rendering/wrangler.jsonc | 40 + package-lock.json | 827 +++++++++++++++++- packages/computer/README.md | 67 +- packages/computer/package.json | 19 +- packages/computer/rolldown.config.ts | 5 + .../src/backends/worker-javascript/index.ts | 1 + .../worker-javascript/module-graph.ts | 164 +++- .../worker-javascript.test.ts | 397 ++++++++- .../worker-javascript/worker-javascript.ts | 127 ++- .../backends/worker-shell/browser/cli.test.ts | 157 ++++ .../src/backends/worker-shell/browser/cli.ts | 222 +++++ .../worker-shell/browser/command.test.ts | 420 +++++++++ .../backends/worker-shell/browser/command.ts | 263 ++++++ .../backends/worker-shell/browser/index.ts | 16 + .../backends/worker-shell/entrypoint.test.ts | 77 ++ .../src/backends/worker-shell/entrypoint.ts | 45 +- .../worker-shell/script/build-bundle.mjs | 52 +- .../worker-shell/worker-shell.test.ts | 167 +++- .../src/backends/worker-shell/worker-shell.ts | 69 +- .../src/plugins/puppeteer/build-bundle.mjs | 70 ++ .../src/plugins/puppeteer/constants.ts | 1 + .../src/plugins/puppeteer/index.test.ts | 21 + .../computer/src/plugins/puppeteer/index.ts | 29 + .../plugins/puppeteer/runtime-helpers.test.ts | 65 ++ .../src/plugins/puppeteer/runtime-helpers.ts | 40 + .../computer/src/plugins/puppeteer/runtime.ts | 31 + .../plugins/puppeteer/workspace-bindings.d.ts | 3 + packages/computer/src/runtime/types.ts | 12 +- .../test-helpers/shell-module-aliases.ts | 1 + .../computer/tests/script-runner-worker.ts | 26 + packages/computer/tests/script-runner.test.ts | 16 + .../computer/tests/worker-backend-worker.ts | 44 +- .../computer/tests/worker-backend.test.ts | 57 ++ 55 files changed, 5278 insertions(+), 63 deletions(-) create mode 100644 .changeset/tidy-browsers-render.md create mode 100644 docs/20_browser_automation.md create mode 100644 examples/browser-rendering/.gitignore create mode 100644 examples/browser-rendering/README.md create mode 100644 examples/browser-rendering/package.json create mode 100644 examples/browser-rendering/src/artifact-response.test.ts create mode 100644 examples/browser-rendering/src/artifact-response.ts create mode 100644 examples/browser-rendering/src/demo-auth.test.ts create mode 100644 examples/browser-rendering/src/demo-auth.ts create mode 100644 examples/browser-rendering/src/execution-source.test.ts create mode 100644 examples/browser-rendering/src/execution-source.ts create mode 100644 examples/browser-rendering/src/index.ts create mode 100644 examples/browser-rendering/src/retryable-once.test.ts create mode 100644 examples/browser-rendering/src/retryable-once.ts create mode 100644 examples/browser-rendering/src/ui.test.ts create mode 100644 examples/browser-rendering/src/ui.ts create mode 100644 examples/browser-rendering/tsconfig.json create mode 100644 examples/browser-rendering/wrangler.jsonc create mode 100644 packages/computer/src/backends/worker-shell/browser/cli.test.ts create mode 100644 packages/computer/src/backends/worker-shell/browser/cli.ts create mode 100644 packages/computer/src/backends/worker-shell/browser/command.test.ts create mode 100644 packages/computer/src/backends/worker-shell/browser/command.ts create mode 100644 packages/computer/src/backends/worker-shell/browser/index.ts create mode 100644 packages/computer/src/plugins/puppeteer/build-bundle.mjs create mode 100644 packages/computer/src/plugins/puppeteer/constants.ts create mode 100644 packages/computer/src/plugins/puppeteer/index.test.ts create mode 100644 packages/computer/src/plugins/puppeteer/index.ts create mode 100644 packages/computer/src/plugins/puppeteer/runtime-helpers.test.ts create mode 100644 packages/computer/src/plugins/puppeteer/runtime-helpers.ts create mode 100644 packages/computer/src/plugins/puppeteer/runtime.ts create mode 100644 packages/computer/src/plugins/puppeteer/workspace-bindings.d.ts diff --git a/.changeset/tidy-browsers-render.md b/.changeset/tidy-browsers-render.md new file mode 100644 index 00000000..b8f4af17 --- /dev/null +++ b/.changeset/tidy-browsers-render.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/computer": minor +--- + +Add Worker JavaScript plugins and an opt-in Puppeteer plugin backed by a Browser Run binding, plus a `browser` shell command group that runs a Workspace task module against that plugin. + +Separate caller-source limits from complete Loader-graph limits, and include Worker code and compatibility settings in the shell Loader cache identity. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c663f9c0..f99412f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,6 +139,9 @@ jobs: - name: assets workspace: "@example/computer-assets" path: examples/assets + - name: browser-rendering + workspace: "@example/computer-browser-rendering" + path: examples/browser-rendering - name: container workspace: "@example/computer-container" path: examples/container 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..2c12e2ca 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) — stores one + browser task in the workspace and runs it two ways, as a JavaScript module + through the Puppeteer plugin and as a `browser` shell command, writing the + same Markdown, JSON, and screenshot bundle either way. - [`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..de207647 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` and `maxLoaderModules` separately bound 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. @@ -132,7 +132,44 @@ 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. +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. On a backend with plugins, their dynamic imports must use string literals so the backend can keep the private plugin binding bridge out of ordinary configured modules. + +## 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, "*." + input.hostname], + allowedDomainSets: ["common-cdns"], + }, + }); +} +``` + +See [Browser automation](./20_browser_automation.md) for the complete setup and [`examples/browser-rendering`](../examples/browser-rendering) for a working application. ## Trusted Workspace modules @@ -184,7 +221,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 limits (`maxSourceBytes`, `maxLoaderSourceBytes`, `maxLoaderModules`, `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..5d47c8df --- /dev/null +++ b/docs/20_browser_automation.md @@ -0,0 +1,174 @@ +# Browser automation + +Computer reaches Cloudflare Browser Run from both execution backends. `@cloudflare/computer/plugins/puppeteer` gives `WorkerJavaScriptBackend` a bundled Puppeteer client, and `@cloudflare/computer/shell/browser` gives `WorkerShellBackend` a `browser` command that runs a task module from the Workspace. + +Both paths end in the same place. Puppeteer, its `Browser`, and its `Page` objects stay inside an isolated JavaScript Dynamic Worker, and Chromium runs in Browser Run. The shell command does not drive a browser itself; it dispatches the task into the JavaScript backend and reports the structured result. + +Reach for the plugin when your own code decides what to browse, since it hands you the browser directly and returns a structured value. Reach for the command when something working in the shell decides, such as an agent composing a pipeline, because a task stored in the Workspace can be listed, edited, and rerun by name. Installing both costs one extra backend registration, and the example does exactly that. + +## 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.hostname], + allowedDomainSets: ["common-cdns"], + }, + }); + `, + { + input: { + url: "https://developers.cloudflare.com/agents/", + hostname: "developers.cloudflare.com", + }, + }, +); + +const result = await execution.result(); +``` + +Confining a session to the single requested host is usually too tight. Pages pull fonts, images, and scripts from subdomains and shared CDNs, so a page rendered under that policy comes out half-loaded. The shell `browser` command applies the wider policy above by default when it is given a `--url`. + +`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. + +## Run a browser task from the shell + +The Worker shell reaches the same capability through a `browser` command. Import the command group and pass it alongside any other shell commands: + +```ts +import browser from "@cloudflare/computer/shell/browser"; +import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell"; + +new WorkerShellBackend({ + loader: env.LOADER, + workspace: { binding: "BrowserWorkspace", id: ctx.id.toString() }, + ctx, + commands: [browser], +}); +``` + +The command needs a JavaScript backend carrying the Puppeteer plugin in the same Workspace, because that is where the task actually runs. It dispatches to the backend named `worker-javascript`, and reads `BROWSER_BACKEND` from the shell environment to pick another. That target has to accept structured input, which in practice means a JavaScript backend; anything else fails with a message saying the backend is not callable. Since the variable is shell-settable, a script can aim the command at any such backend in the same Workspace, which is the same authority the shell already has through the Workspace runtime and worth knowing when deciding which backends share one. + +A task module exports a default function that receives the browser alongside the caller's input: + +```js +// /workspace/tasks/title.js +export default async ({ url, browser }) => { + const page = await browser.newPage(); + await page.goto(url); + return { title: await page.title() }; +}; +``` + +```sh +browser puppeteer --url https://developers.cloudflare.com/agents/ tasks/title.js +browser puppeteer --url https://developers.cloudflare.com/agents/ --stdin < tasks/title.js +``` + +The command prints the returned value as JSON and exits with the task's exit code. `--timeout ` sets the execution budget, and `--input ` merges a JSON object into the task input. The task runs in the JavaScript backend, so it can write to the Workspace through `node:fs/promises` exactly as an inline module does. + +The browser is opened and closed around the task by the generated entry, so a task never manages the session itself. Scripts stored in the Workspace survive the execution that wrote them, which is what makes a task worth naming: the shell can rerun it, and the JavaScript backend can import it. + +## 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 runnable example that stores one task and runs it both ways, writing the same Markdown, JSON, and screenshot output to a durable Workspace from either path. diff --git a/docs/README.md b/docs/README.md index acec5888..91e720cf 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) | Reach Cloudflare Browser Run from isolated JavaScript through the Puppeteer plugin or from the shell through the `browser` command, and persist browser artifacts. | ## 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..d8962496 --- /dev/null +++ b/examples/browser-rendering/README.md @@ -0,0 +1,131 @@ +# Computer browser rendering example + +One browser task, stored in a durable Workspace and run two ways: as a JavaScript module, and as a shell command. Both write the same three files, because both run the same module. + +This is the runnable companion to [Browser automation](../../docs/20_browser_automation.md). + +## Run it + +You need a Cloudflare account with Browser Run access and a completed `wrangler login`. Runs spend Browser Run quota during local development as well as after deployment, because the binding reaches the real service either way. + +From the repository root: + +```sh +npm install +npm run build --workspace @cloudflare/computer +npm run dev --workspace @example/computer-browser-rendering +``` + +The build step is what the example imports: it depends on the package's built output rather than its sources. Local development needs no authentication. + +Open the address Wrangler prints, leave the default URL in place, and run the JavaScript path. You should get a page title, an HTTP status, a full-page screenshot, and a run directory holding three files. Now run the shell path against the same URL. The invocation shown with the result changes, and the output does not. + +## The task + +The task is an ordinary module with a default function. It receives a live browser from whoever invoked it, so it never opens or closes a session itself. The example seeds it at `/workspace/tasks/report.js`: + +```js +export default async ({ url, browser }) => { + const page = await browser.newPage(); + await page.goto(url); + return { title: await page.title() }; +}; +``` + +The seeded task is longer than that. It also pulls out a summary, headings, code samples, and links, and writes the three files described below. `src/execution-source.ts` holds the real one, and the interface will show it to you. + +## Path one: a JavaScript module + +Register the plugin on a Worker JavaScript backend: + +```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 })], +}); +``` + +Executed modules then import the bound lifecycle helper: + +```js +import { withBrowser } from "@cloudflare/puppeteer"; +import report from "./report.js"; + +export default (input) => withBrowser((browser) => report({ ...input, browser })); +``` + +`withBrowser()` uses the configured Browser Run binding and closes the browser after the callback settles. `Browser`, `Page`, selectors, and page evaluation stay inside the Dynamic Worker. + +## Path two: a shell command + +Register the command group on a Worker shell backend in the same Workspace: + +```ts +import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell"; +import browser from "@cloudflare/computer/shell/browser"; + +new WorkerShellBackend({ + loader: env.LOADER, + workspace: { binding: "BrowserWorkspace", id: ctx.id.toString() }, + ctx, + commands: [browser], +}); +``` + +The shell then runs the stored task by name: + +```sh +browser puppeteer --url https://developers.cloudflare.com/agents/ report.js +``` + +The command generates the same wrapper shown above and dispatches it to the JavaScript backend, so the browser still runs there. It prints the task's return value as JSON. + +## What the example adds + +The rest of this directory is an example application, not code required by either path. Its web interface accepts a user-provided HTTP or HTTPS URL, and the stored task writes three files into one run directory: + +```text +/workspace/browser-runs// +├── report.md +├── page.json +└── screenshot.png +``` + +The interface serves those files back over a validated artifact route and draws the run directory as a file tree. Because the run directory is chosen inside the task, a run started from the shell lands beside one started from the JavaScript path. + +## Deploy it + +Set a token first, because the deployed interface will run a browser for anyone who reaches it: + +```sh +cd examples/browser-rendering +npx wrangler secret put DEMO_TOKEN +npm run deploy +``` + +The deployed site uses HTTP Basic authentication. The username is `demo` and the password is the secret you set. + +The Worker needs the bindings in `wrangler.jsonc`, where one Worker Loader serves both backends: + +```jsonc +{ + "compatibility_flags": ["nodejs_compat", "experimental"], + "worker_loaders": [{ "binding": "LOADER" }], + "browser": { "binding": "BROWSER" } +} +``` + +## Files + +- `src/index.ts` registers both backends, seeds the task, and exposes the API and durable artifact routes. +- `src/execution-source.ts` holds the shared task module and the two invocations that run it. +- `src/artifact-response.ts` sets the media type and browser protections for stored files. +- `src/demo-auth.ts` gates the deployed site behind HTTP Basic authentication. +- `src/retryable-once.ts` retries task seeding after a failed write. +- `src/ui.ts` contains the dependency-free 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/artifact-response.test.ts b/examples/browser-rendering/src/artifact-response.test.ts new file mode 100644 index 00000000..0ecd7b1c --- /dev/null +++ b/examples/browser-rendering/src/artifact-response.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { artifactResponseHeaders } from "./artifact-response.js"; + +describe("artifactResponseHeaders", () => { + it.each([ + ["report.md", "text/markdown; charset=utf-8"], + ["page.json", "application/json; charset=utf-8"], + ["screenshot.png", "image/png"], + ])("serves %s without allowing content sniffing", (filename, mediaType) => { + const headers = artifactResponseHeaders(filename); + + expect(headers).toMatchObject({ + "content-type": mediaType, + "content-disposition": `inline; filename="${filename}"`, + "cache-control": "private, max-age=300", + "x-content-type-options": "nosniff", + "content-security-policy": "sandbox; default-src 'none'", + }); + }); +}); diff --git a/examples/browser-rendering/src/artifact-response.ts b/examples/browser-rendering/src/artifact-response.ts new file mode 100644 index 00000000..5d7c1f92 --- /dev/null +++ b/examples/browser-rendering/src/artifact-response.ts @@ -0,0 +1,15 @@ +export function artifactResponseHeaders(filename: string): Record { + return { + "content-type": artifactContentType(filename), + "content-disposition": `inline; filename="${filename}"`, + "cache-control": "private, max-age=300", + "x-content-type-options": "nosniff", + "content-security-policy": "sandbox; default-src 'none'", + }; +} + +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"; +} 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..85456749 --- /dev/null +++ b/examples/browser-rendering/src/demo-auth.ts @@ -0,0 +1,36 @@ +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 (constantTimeEqual(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); +} + +function constantTimeEqual(left: string, right: string): boolean { + const encoder = new TextEncoder(); + const leftBytes = encoder.encode(left); + const rightBytes = encoder.encode(right); + const length = Math.max(leftBytes.length, rightBytes.length); + let mismatch = leftBytes.length ^ rightBytes.length; + for (let index = 0; index < length; index += 1) { + mismatch |= (leftBytes[index] ?? 0) ^ (rightBytes[index] ?? 0); + } + return mismatch === 0; +} 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..7b495ff2 --- /dev/null +++ b/examples/browser-rendering/src/execution-source.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; + +import { + JAVASCRIPT_ENTRY, + parseCommandResult, + RUNS_DIRECTORY, + shellCommand, + shellQuote, + TASK_PATH, + TASK_SOURCE, + TASK_TIMEOUT_MS, +} from "./execution-source.js"; + +describe("task module", () => { + it("takes the browser from its caller instead of opening one", () => { + expect(TASK_SOURCE).toContain("export default async function report({ url, browser })"); + expect(TASK_SOURCE).not.toContain("withBrowser"); + }); + + it("writes all three artifacts into one run directory", () => { + expect(TASK_SOURCE).toContain('import fs from "node:fs/promises"'); + expect(TASK_SOURCE).toContain(`"${RUNS_DIRECTORY}/" + crypto.randomUUID()`); + for (const artifact of ["report.md", "page.json", "screenshot.png"]) { + expect(TASK_SOURCE).toContain(artifact); + } + }); + + it("is seeded where both paths look for it", () => { + expect(TASK_PATH).toBe("/workspace/tasks/report.js"); + }); +}); + +describe("JAVASCRIPT_ENTRY", () => { + it("runs the seeded task inside a managed browser", () => { + expect(JAVASCRIPT_ENTRY).toContain('import { withBrowser } from "@cloudflare/puppeteer"'); + expect(JAVASCRIPT_ENTRY).toContain('import report from "./report.js"'); + expect(JAVASCRIPT_ENTRY).toContain("withBrowser((browser) => report({ ...input, browser })"); + }); + + it("confines the session the way the shell command does", () => { + expect(JAVASCRIPT_ENTRY).toContain("allowedDomains"); + expect(JAVASCRIPT_ENTRY).toContain('"*." + input.hostname'); + expect(JAVASCRIPT_ENTRY).toContain("common-cdns"); + }); +}); + +describe("shellCommand", () => { + it("invokes the seeded task by name", () => { + expect(shellCommand("https://example.com/")).toBe( + `browser puppeteer --url 'https://example.com/' --timeout ${TASK_TIMEOUT_MS} report.js`, + ); + }); + + it("names the task relative to the directory it runs from", () => { + expect(shellCommand("https://example.com/")).toContain( + TASK_PATH.slice(TASK_PATH.lastIndexOf("/") + 1), + ); + expect(shellCommand("https://example.com/")).not.toContain("/workspace"); + }); + + it("keeps a quote in the URL from becoming shell syntax", () => { + const command = shellCommand("https://example.com/?q='; rm -rf /"); + + expect(command).toContain(`'https://example.com/?q='\\''; rm -rf /'`); + expect(command.endsWith("report.js")).toBe(true); + }); +}); + +describe("parseCommandResult", () => { + it("reads a result that is the whole output", () => { + expect(parseCommandResult('{\n "title": "Example"\n}\n')).toEqual({ title: "Example" }); + }); + + it("reads the result printed after the task's own output", () => { + const stdout = 'scraping...\ndone\n{\n "title": "Example"\n}\n'; + + expect(parseCommandResult(stdout)).toEqual({ title: "Example" }); + }); + + it("reports output that carries no result", () => { + expect(() => parseCommandResult("nothing to see\n")).toThrow("no JSON result"); + }); +}); + +describe("shellQuote", () => { + it("wraps a plain value", () => { + expect(shellQuote("plain")).toBe("'plain'"); + }); + + it("escapes every embedded quote", () => { + expect(shellQuote("a'b'c")).toBe(`'a'\\''b'\\''c'`); + }); + + it("escapes a quote the URL parser leaves in the path", () => { + // A quote in the query is percent-encoded by the URL parser, but one + // in the path survives, so this is the shape that reaches the shell + // intact. Unescaped, it closes the quoting and the rest of the URL + // is parsed as commands. + const href = new URL("https://example.com/a';id;'.html").href; + + expect(href).toContain("'"); + expect(shellQuote(href)).toBe(`'https://example.com/a'\\'';id;'\\''.html'`); + }); +}); diff --git a/examples/browser-rendering/src/execution-source.ts b/examples/browser-rendering/src/execution-source.ts new file mode 100644 index 00000000..e3d761f3 --- /dev/null +++ b/examples/browser-rendering/src/execution-source.ts @@ -0,0 +1,224 @@ +// The browser task this example runs, and the two ways it is invoked. +// +// TASK_SOURCE is seeded into the Workspace at TASK_PATH. It is an +// ordinary module with a default function, which is the shape the +// `browser` shell command expects: the caller's input arrives with a +// live browser attached. Nothing in it is specific to either path, +// which is what lets both produce byte-identical artifacts. +// +// JAVASCRIPT_ENTRY is what the JavaScript path executes. It is the +// plugin integration in full: import withBrowser, call the task +// inside it. The shell command generates the same wrapper itself, so +// the CLI path runs this module without an entry of its own. + +/** Directory the task module is seeded into. */ +export const TASK_DIRECTORY = "/workspace/tasks"; + +/** Workspace path of the shared task module. */ +export const TASK_PATH = `${TASK_DIRECTORY}/report.js`; + +/** Root the task writes its per-run output under. */ +export const RUNS_DIRECTORY = "/workspace/browser-runs"; + +/** + * Budget for the browser work itself. The shell path adds headroom + * on top for the command's own dispatch, so the inner execution is + * the one that times out first and reports why. + */ +export const TASK_TIMEOUT_MS = 60_000; + +export const TASK_SOURCE = String.raw`import fs from "node:fs/promises"; + +// The task receives a live browser from whoever invoked it. The +// JavaScript entry and the shell command both open it the same way, +// so this module never manages a session itself. + +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 async function report({ url, browser }) { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 }); + page.setDefaultNavigationTimeout(30_000); + const response = await page.goto(url, { waitUntil: "domcontentloaded" }); + + 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 = { + requestedUrl: url, + finalUrl: page.url(), + status: response?.status() ?? null, + title: await page.title(), + ...extracted, + }; + + const markdown = buildMarkdown(pageData); + const json = JSON.stringify(pageData, null, 2) + "\n"; + const png = await page.screenshot({ type: "png", fullPage: true }); + + // The run directory is chosen here rather than by the caller, so a + // run started from the shell lands beside one started from the + // JavaScript path. + const outputDirectory = "${RUNS_DIRECTORY}/" + crypto.randomUUID(); + const reportPath = outputDirectory + "/report.md"; + const dataPath = outputDirectory + "/page.json"; + const screenshotPath = outputDirectory + "/screenshot.png"; + + await fs.mkdir(outputDirectory, { recursive: true }); + await fs.writeFile(reportPath, markdown); + await fs.writeFile(dataPath, json); + await fs.writeFile(screenshotPath, png); + + return { + ...pageData, + outputDirectory, + reportPath, + dataPath, + screenshotPath, + files: [ + { + name: "report.md", + path: reportPath, + mediaType: "text/markdown", + bytes: new TextEncoder().encode(markdown).byteLength, + }, + { + name: "page.json", + path: dataPath, + mediaType: "application/json", + bytes: new TextEncoder().encode(json).byteLength, + }, + { + name: "screenshot.png", + path: screenshotPath, + mediaType: "image/png", + bytes: png.byteLength, + }, + ], + }; +}`; + +export const JAVASCRIPT_ENTRY = `import { withBrowser } from "@cloudflare/puppeteer"; +import report from "./report.js"; + +export default (input) => withBrowser((browser) => report({ ...input, browser }), { + guardrails: { + allowedDomains: [input.hostname, "*." + input.hostname], + allowedDomainSets: ["common-cdns"], + }, +});`; + +/** + * Shell invocation for the same task. The command generates its own + * entry, applies the same guardrails from `--url`, and prints the + * task's return value as JSON. + */ +export function shellCommand(url: string): string { + const script = TASK_PATH.slice(TASK_DIRECTORY.length + 1); + return `browser puppeteer --url ${shellQuote(url)} --timeout ${TASK_TIMEOUT_MS} ${script}`; +} + +// The URL reaches this example from a request body, and a URL may +// legally contain a single quote. Quote it the way a shell expects so +// the value can never become syntax. +export function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +/** + * Read the task's return value out of the command's stdout. + * + * The command prints anything the task logged before it prints the + * value, so a task with a `console.log` in it would defeat a plain + * parse. The value is the pretty-printed object at the end, which + * starts at the last line that begins with a brace. + */ +export function parseCommandResult(stdout: string): unknown { + const start = stdout.startsWith("{") ? 0 : stdout.lastIndexOf("\n{") + 1; + const candidate = start > 0 || stdout.startsWith("{") ? stdout.slice(start) : ""; + try { + return JSON.parse(candidate); + } catch { + throw new Error(`browser command printed no JSON result: ${stdout.trim().slice(0, 200)}`); + } +} diff --git a/examples/browser-rendering/src/index.ts b/examples/browser-rendering/src/index.ts new file mode 100644 index 00000000..a9acddc2 --- /dev/null +++ b/examples/browser-rendering/src/index.ts @@ -0,0 +1,305 @@ +import { DurableObject } from "cloudflare:workers"; +import { type DurableObjectStorageLike, Workspace } from "@cloudflare/computer"; +import { WorkerJavaScriptBackend } from "@cloudflare/computer/backends/worker-javascript"; +import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell"; +import { puppeteer } from "@cloudflare/computer/plugins/puppeteer"; +import browser from "@cloudflare/computer/shell/browser"; +import { artifactResponseHeaders } from "./artifact-response.js"; +import { authorizeDemoRequest } from "./demo-auth.js"; +import { + JAVASCRIPT_ENTRY, + parseCommandResult, + RUNS_DIRECTORY, + shellCommand, + TASK_DIRECTORY, + TASK_PATH, + TASK_SOURCE, + TASK_TIMEOUT_MS, +} from "./execution-source.js"; +import { retryableOnce } from "./retryable-once.js"; +import { UI_HTML } from "./ui.js"; + +// The Worker Loader wires this loopback binding into the shell's +// Dynamic Worker so it can reach this Durable Object's Workspace. +export { WorkspaceServiceProxy } from "@cloudflare/computer"; + +interface Env { + BrowserWorkspace: DurableObjectNamespace; + BROWSER: Fetcher; + DEMO_TOKEN?: string; + LOADER: WorkerLoader; +} + +/** Which of the two invocation paths a run used. */ +export type BrowserPath = "javascript" | "shell"; + +// Only the three files a run writes are readable through the artifact +// route, and only inside a run directory the task named. +const ARTIFACT_PATH = new RegExp( + `^${RUNS_DIRECTORY}/[0-9a-f-]+/(?:report\\.md|page\\.json|screenshot\\.png)$`, +); + +// The `browser` command dispatches to the JavaScript backend by its +// default id, so the two names have to line up. +const JAVASCRIPT_BACKEND = "worker-javascript"; +const SHELL_BACKEND = "worker-shell"; + +interface BrowserRunRequest { + path?: BrowserPath; + url?: string; +} + +interface BrowserArtifact { + name: string; + path: string; + mediaType: string; + bytes: number; +} + +interface BrowserResultValue { + requestedUrl: string; + finalUrl: string; + status: number | null; + title: string; + description?: string | null; + language?: string | null; + summary?: string; + outputDirectory?: string; + reportPath?: string; + dataPath?: string; + screenshotPath?: string; + sections?: Array<{ level: string; text: string }>; + codeSamples?: Array<{ text: string }>; + internalLinks?: Array<{ text: string; href: string }>; + files?: BrowserArtifact[]; + document?: { elements: number; images: number; links: number; scripts: number }; +} + +interface BrowserRunResult { + path: BrowserPath; + invocation: string; + exitCode: number; + value: BrowserResultValue; +} + +export class BrowserWorkspace extends DurableObject { + readonly #workspace: Workspace; + // Both paths import the same module. Keep a successful seed for this + // instance, but let a later run retry if the write fails. + readonly #seedTask = retryableOnce(async () => { + await this.#workspace.fs.mkdir(TASK_DIRECTORY, { recursive: true }); + await this.#workspace.fs.writeFile(TASK_PATH, TASK_SOURCE); + }); + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + // These two registrations are the entire host-side integration. + // The plugin puts Puppeteer in JavaScript executions; the command + // group puts a `browser` command in the shell, which dispatches + // back into the JavaScript backend below. The limits are this + // example's own: a full-page screenshot crosses the capability + // bridge encoded, so the defaults are too small for it. + const javascript = new WorkerJavaScriptBackend({ + id: JAVASCRIPT_BACKEND, + loader: env.LOADER, + plugins: [puppeteer({ browser: env.BROWSER })], + defaultTimeoutMs: 60_000, + maxTimeoutMs: 90_000, + maxConcurrentExecutions: 3, + maxCapabilityBytes: 8 * 1024 * 1024, + maxCapabilityRequestBytes: 16 * 1024 * 1024, + }); + const shell = new WorkerShellBackend({ + id: SHELL_BACKEND, + loader: env.LOADER, + workspace: { binding: "BrowserWorkspace", id: ctx.id.toString() }, + ctx, + commands: [browser], + }); + this.#workspace = new Workspace({ + storage: ctx.storage as unknown as DurableObjectStorageLike, + backends: [javascript, shell], + }); + } + + // The shell's Dynamic Worker reaches this Workspace by id through + // WorkspaceServiceProxy, which calls this method. + async __getWorkspaceStub() { + await this.#workspace.ready(); + return this.#workspace.stub(); + } + + async run(path: BrowserPath, target: string): Promise { + const url = new URL(target); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("url must use http or https"); + } + await this.#seedTask(); + + const invocation = path === "shell" ? shellCommand(url.href) : JAVASCRIPT_ENTRY; + using execution = + path === "shell" + ? await this.#workspace.runtime.exec(invocation, { + backend: SHELL_BACKEND, + cwd: TASK_DIRECTORY, + encoding: "utf8", + // The command applies TASK_TIMEOUT_MS to the browser work + // itself, so the shell needs room for its own dispatch on + // top of it. + timeoutMs: TASK_TIMEOUT_MS + 30_000, + }) + : await this.#workspace.runtime.exec(invocation, { + backend: JAVASCRIPT_BACKEND, + cwd: TASK_DIRECTORY, + input: { url: url.href, hostname: url.hostname }, + encoding: "utf8", + timeoutMs: TASK_TIMEOUT_MS, + }); + + const result = await execution.result(); + if (result.exitCode !== 0) { + throw new Error(result.stderr.trim() || `browser run exited with ${result.exitCode}`); + } + // The JavaScript path returns the task's value as a structured + // result. The shell path gets the same object, printed as JSON by + // the command, because stdout is all a shell can carry. + const value = path === "shell" ? parseCommandResult(result.stdout) : result.value; + return { path, invocation, exitCode: result.exitCode, value: parseBrowserResult(value) }; + } + + readArtifact(path: string): Promise> { + if (!ARTIFACT_PATH.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(TASK_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 (!isBrowserPath(input.path)) throw new Error("unknown browser path"); + 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.path, 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: artifactResponseHeaders(filename), + }); + } 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 run returned an invalid result"); + const parsed: BrowserResultValue = { + requestedUrl: requiredString(value.requestedUrl, "requestedUrl"), + finalUrl: requiredString(value.finalUrl, "finalUrl"), + title: requiredString(value.title, "title"), + status: value.status === null ? null : requiredNumber(value.status, "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.outputDirectory === "string") parsed.outputDirectory = value.outputDirectory; + 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.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.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"), + }; + } + 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 isBrowserPath(value: unknown): value is BrowserPath { + return value === "javascript" || value === "shell"; +} + +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/retryable-once.test.ts b/examples/browser-rendering/src/retryable-once.test.ts new file mode 100644 index 00000000..f40dbad5 --- /dev/null +++ b/examples/browser-rendering/src/retryable-once.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; + +import { retryableOnce } from "./retryable-once.js"; + +describe("retryableOnce", () => { + it("retries a failed attempt and caches the first successful one", async () => { + const operation = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("write failed")) + .mockResolvedValue(); + const run = retryableOnce(operation); + + await expect(run()).rejects.toThrow("write failed"); + await expect(run()).resolves.toBeUndefined(); + await expect(run()).resolves.toBeUndefined(); + + expect(operation).toHaveBeenCalledTimes(2); + }); + + it("shares an attempt between concurrent callers", async () => { + let finish: (() => void) | undefined; + const operation = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const run = retryableOnce(operation); + + const first = run(); + const second = run(); + finish?.(); + await Promise.all([first, second]); + + expect(second).toBe(first); + expect(operation).toHaveBeenCalledOnce(); + }); +}); diff --git a/examples/browser-rendering/src/retryable-once.ts b/examples/browser-rendering/src/retryable-once.ts new file mode 100644 index 00000000..07e007d8 --- /dev/null +++ b/examples/browser-rendering/src/retryable-once.ts @@ -0,0 +1,12 @@ +export function retryableOnce(operation: () => Promise): () => Promise { + let successful: Promise | undefined; + return () => { + if (successful !== undefined) return successful; + const attempt = operation(); + successful = attempt; + void attempt.catch(() => { + if (successful === attempt) successful = undefined; + }); + return attempt; + }; +} diff --git a/examples/browser-rendering/src/ui.test.ts b/examples/browser-rendering/src/ui.test.ts new file mode 100644 index 00000000..24486ccd --- /dev/null +++ b/examples/browser-rendering/src/ui.test.ts @@ -0,0 +1,57 @@ +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("offers exactly the two invocation paths", () => { + expect(UI_HTML).toContain('data-path="javascript"'); + expect(UI_HTML).toContain('data-path="shell"'); + for (const removed of ["scrape", "screenshot", "page-info", "research"]) { + expect(UI_HTML).not.toContain(`data-action="${removed}"`); + } + }); + + it("separates the integration from the example application", () => { + expect(UI_HTML).toContain("Plugin setup"); + expect(UI_HTML).toContain("plugins: [puppeteer({ browser: env.BROWSER })]"); + expect(UI_HTML).toContain("commands: [browser]"); + expect(UI_HTML).toContain('import { withBrowser } from "@cloudflare/puppeteer"'); + expect(UI_HTML).toContain("browser puppeteer --url https://example.com/ report.js"); + expect(UI_HTML).toContain("The task module, the report format, and this interface are"); + }); + + it("shows the durable artifacts both paths write", () => { + expect(UI_HTML).toContain("@phosphor-icons/web@2.1.2"); + expect(UI_HTML).toContain('id="research-result"'); + expect(UI_HTML).toContain('id="workspace-tree"'); + expect(UI_HTML).toContain('id="artifacts"'); + expect(UI_HTML).toContain('id="screenshot-result"'); + }); + + it("reports which path produced a result and how it was invoked", () => { + expect(UI_HTML).toContain('id="invocation"'); + expect(UI_HTML).toContain("invocation.textContent = payload.invocation;"); + }); + + it("renders an in-flight response against the submitted path", () => { + expect(UI_HTML).toContain("const submittedPath = path;"); + expect(UI_HTML).toContain("JSON.stringify({ path: submittedPath, url: target })"); + expect(UI_HTML).toContain("renderValue({ ...payload, path: submittedPath })"); + }); + + it("drops the removed per-action renderers", () => { + for (const removed of ["renderScrape", "renderPageInfo", "addMetric", "showOnly("]) { + expect(UI_HTML).not.toContain(removed); + } + }); + + 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..3f1074de --- /dev/null +++ b/examples/browser-rendering/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "WebWorker"], + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "strict": true, + "target": "ESNext", + "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..16558553 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`), @@ -134,7 +135,10 @@ one optional group per command at `@cloudflare/computer/shell/`. Import the groups you want and pass them to `WorkerShellBackend`'s `commands` option; a group you never import is unreachable in your bundle and the bundler drops -it. The optional groups are `curl`, `html-to-markdown`, `python`, +it. The `browser` group is this package's own command rather than one +of just-bash's; it needs a JavaScript backend carrying the Puppeteer +plugin in the same Workspace. The optional groups are `browser`, +`curl`, `html-to-markdown`, `python`, `sqlite`, `js-exec`, `yq`, `file`, `xan`, and `jq`. `curl` runs on the isolate's global `fetch` (no `undici` in the bundle); egress stays governed by the Dynamic Worker's `globalOutbound`. @@ -259,6 +263,58 @@ 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 a + page scraped and written to the Workspace as Markdown, JSON, and a + screenshot, driven both from a JavaScript module and from the shell. + +### 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(); +}); +``` + +The worker shell reaches the same capability through the `browser` +command group, which runs a task module from the Workspace against the +JavaScript backend that carries the plugin: + +```ts +import browser from "@cloudflare/computer/shell/browser"; + +const shell = new WorkerShellBackend({ + loader: env.LOADER, + workspace: { binding: "Agent", id: ctx.id.toString() }, + ctx, + commands: [browser], +}); +``` + +```sh +browser puppeteer --url https://example.com/ tasks/title.js +``` + +See [Browser automation](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 +474,8 @@ 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/shell/browser` | Opt-in `browser` command that runs a Workspace task module against that plugin. | | `@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 +566,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) — one + browser task run two ways, as a JavaScript module and as a shell + command, writing the same durable Markdown, JSON, and screenshot. - [`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..39749ecf 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" @@ -87,6 +91,10 @@ "types": "./dist/backends/worker-shell/shell/jq.d.ts", "default": "./dist/backends/worker-shell/shell/jq.js" }, + "./shell/browser": { + "types": "./dist/backends/worker-shell/shell/browser.d.ts", + "default": "./dist/backends/worker-shell/shell/browser.js" + }, "./observe/cloudflare": { "types": "./dist/observe/cloudflare.d.ts", "import": "./dist/observe/cloudflare.js" @@ -101,10 +109,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 +149,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..1d4828eb 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 @@ -51,6 +52,10 @@ export default defineConfig({ "backends/worker-shell/shell/file": "src/backends/worker-shell/generated/file.ts", "backends/worker-shell/shell/xan": "src/backends/worker-shell/generated/xan.ts", "backends/worker-shell/shell/jq": "src/backends/worker-shell/generated/jq.ts", + // Not one of just-bash's commands: the `browser` command is + // this package's own, bundled separately and published under + // the extras seam name the shell entrypoint imports. + "backends/worker-shell/shell/browser": "src/backends/worker-shell/generated/browser.ts", "observe/cloudflare": "src/observe/cloudflare.ts", }, external: [ 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..519bc63b 100644 --- a/packages/computer/src/backends/worker-javascript/module-graph.ts +++ b/packages/computer/src/backends/worker-javascript/module-graph.ts @@ -12,13 +12,22 @@ 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 PreparedConfiguredModule { + source: string; + hasDefault: boolean; +} + +export type PreparedConfiguredModules = Readonly>; + export interface BuildModuleGraphOptions { source: string; cwd: string; capability: WorkspaceRuntimeCapability; - configuredModules: Record; + configuredModules: PreparedConfiguredModules; trustedModuleNames?: string[]; maxSourceBytes: number; maxCapabilityBytes: number; @@ -26,6 +35,42 @@ export interface BuildModuleGraphOptions { maxDepth?: number; } +export function prepareConfiguredModules( + sources: Record, + pluginModuleNames: ReadonlySet, + protectPluginBindings: boolean, +): PreparedConfiguredModules { + const prepared: Record = Object.create(null); + for (const [specifier, source] of Object.entries(sources)) { + let ast: ModuleAst; + try { + ast = parseModule(source); + } catch (error) { + throw new Error( + `Configured module ${JSON.stringify(specifier)} is not valid JavaScript: ${messageOf(error)}`, + { cause: error }, + ); + } + if (protectPluginBindings && !pluginModuleNames.has(specifier)) { + let imported: string[]; + try { + imported = importSpecifiers(ast); + } catch (error) { + throw new Error(`Configured module ${JSON.stringify(specifier)}: ${messageOf(error)}`, { + cause: error, + }); + } + if (imported.some((name) => name.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.`, + ); + } + } + prepared[specifier] = { source, hasDefault: hasDefaultExport(ast) }; + } + return prepared; +} + export async function buildModuleGraph(options: BuildModuleGraphOptions) { const cwd = normalizeCwd(await options.capability.resolveConfined(options.cwd, true)); const entryPath = `${cwd === "/" ? "" : cwd}/${ENTRY_BASENAME}`; @@ -33,6 +78,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 +110,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 +164,9 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { specifier === ENTRY_BASENAME || specifier === RUNNER_MODULE || specifier === CAPABILITIES_MODULE || - specifier.includes("/") + specifier.split("/").at(-1) === PLUGIN_BINDINGS_MODULE || + specifier === CONFIGURED_MODULES_DIRECTORY || + !isConfiguredModuleName(specifier) ) { throw new Error( `Configured module ${JSON.stringify(specifier)} uses a reserved module name.`, @@ -131,7 +179,21 @@ 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, configured]) => ({ + specifier, + ...configured, + canonicalName: `${CONFIGURED_MODULES_DIRECTORY}/${specifier}`, + }), + ); + 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,24 +203,74 @@ 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)};` : "" + }`; +} + +interface ModuleAst { + body: unknown[]; +} + +function parseModule(source: string): ModuleAst { + return parse(source, { ecmaVersion: "latest", sourceType: "module" }) as unknown as ModuleAst; +} + +function hasDefaultExport(ast: ModuleAst): boolean { + const nodes = ast.body as Array<{ + type?: string; + exported?: { name?: unknown; value?: unknown } | null; + specifiers?: Array<{ exported?: { name?: unknown; value?: unknown } }>; + }>; + const isDefault = (name: { name?: unknown; value?: unknown } | null | undefined) => + name?.name === "default" || name?.value === "default"; + return nodes.some( + (node) => + node.type === "ExportDefaultDeclaration" || + (node.type === "ExportNamedDeclaration" && + node.specifiers?.some((specifier) => isDefault(specifier.exported))) || + (node.type === "ExportAllDeclaration" && isDefault(node.exported)), + ); +} + function imports(source: string): string[] { - const ast = parse(source, { ecmaVersion: "latest", sourceType: "module" }) as unknown as { - body: unknown[]; - }; + return importSpecifiers(parseModule(source)); +} + +function importSpecifiers(ast: ModuleAst): string[] { const found: string[] = []; walk(ast, (node) => { const item = node as { type?: string; source?: { type?: string; value?: unknown } }; @@ -179,6 +291,10 @@ function imports(source: string): string[] { return found; } +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + function walk(value: unknown, visit: (node: unknown) => void): void { if (value === null || typeof value !== "object") return; visit(value); @@ -228,14 +344,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 ( + (name !== "." && name !== ".." && name.length > 0 && !name.includes("/")) || + /^@[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..d5d5fd9e 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts @@ -157,6 +157,16 @@ describe("WorkerJavaScriptBackend", () => { ).toThrow(/positive finite/); }); + it("validates the Loader module limit", () => { + expect( + () => + new WorkerJavaScriptBackend({ + loader: throwingLoader("unused"), + maxLoaderModules: 0, + }), + ).toThrow(/maxLoaderModules.*positive integer/); + }); + it("includes the configured capability byte limit in generated errors", async () => { const load = vi.fn(() => ({ getEntrypoint() { @@ -229,6 +239,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 +425,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 +436,185 @@ 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("preserves namespace default re-exports through configured module aliases", async () => { + 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 }, + modules: { + "@example/facade": `export * as default from "@example/source";`, + "@example/source": "export const answer = 42;", + }, + }), + ], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + + const execution = await workspace.runtime.exec( + `import value from "@example/facade"; export default value.answer;`, + ); + await expect(execution.result()).resolves.toMatchObject({ status: "completed" }); + + expect(load.mock.calls[0]?.[0].modules["workspace/@example/facade"]).toEqual({ + js: expect.stringContaining("export { default }"), + }); + }); + + it("allows computed dynamic imports in configured modules without plugins", async () => { + 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 }, + modules: { loader: "export const load = (specifier) => import(specifier);" }, + }), + ], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + + const execution = await workspace.runtime.exec( + `import { load } from "loader"; export default typeof load;`, + ); + + await expect(execution.result()).resolves.toMatchObject({ status: "completed" }); + expect(load).toHaveBeenCalledOnce(); + }); + + it("keeps configured module names accepted by earlier releases", async () => { + 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 }, + modules: Object.fromEntries( + ["_internal", "$shim", "my+lib", "lib~1", "some lib"].map((name) => [ + name, + "export default null;", + ]), + ), + }), + ], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + + const execution = await workspace.runtime.exec("export default null;"); + + await expect(execution.result()).resolves.toMatchObject({ status: "completed" }); + expect(load).toHaveBeenCalledOnce(); + }); + + it("retains loader module headroom after configured modules are canonicalized", async () => { + const load = vi.fn(() => ({ + getEntrypoint() { + return { + evaluate: ( + _input: unknown, + host: { + assertResult(value: unknown): Promise; + attachOutput(readable: ReadableStream): Promise; + }, + ) => evaluateResult(host, null), + }; + }, + })); + const modules = Object.fromEntries( + Array.from({ length: 126 }, (_, index) => [`module-${index}`, "export default null;"]), + ); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [new WorkerJavaScriptBackend({ loader: { load }, modules })], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + + const execution = await workspace.runtime.exec("export default null;"); + + await expect(execution.result()).resolves.toMatchObject({ status: "completed" }); + expect(load).toHaveBeenCalledOnce(); + }); + it("records synchronous loader startup failure as a completed failed execution", async () => { const workspace = new Workspace({ storage: new SQLiteTestStorage(), @@ -997,6 +1229,147 @@ 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.each([ + ["a plugin module and binding", { plugin: "export default null;" }], + ["a binding without its own module", {}], + ])("rejects bridge imports with %s", async (_description, pluginModules) => { + 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: pluginModules, + 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(), @@ -1036,6 +1409,28 @@ describe("WorkerJavaScriptBackend", () => { expect(load).not.toHaveBeenCalled(); }); + it.each(["@scope/workspace-plugin-bindings.js", ".", ".."])( + "rejects configured module name %s", + async (name) => { + const load = vi.fn(); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + new WorkerJavaScriptBackend({ + loader: { load }, + modules: { [name]: "export default null;" }, + }), + ], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + + await expect(workspace.runtime.exec("export default null;")).rejects.toThrow( + /reserved module name/, + ); + expect(load).not.toHaveBeenCalled(); + }, + ); + it("rejects configured module names that collide with generated modules", async () => { const load = vi.fn(); const workspace = new Workspace({ diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index 4bd5fcfe..6ba52085 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -14,14 +14,31 @@ import type { WorkspaceTrustedModule, } from "../../runtime/types.js"; import { decodeRuntimeFrames, type RuntimeFrame } from "./frames.js"; -import { buildModuleGraph } from "./module-graph.js"; +import { + buildModuleGraph, + type PreparedConfiguredModules, + prepareConfiguredModules, +} 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 +46,12 @@ 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; + /** Complete Worker Loader graph module count. Defaults to 512. */ + maxLoaderModules?: number; maxInputBytes?: number; maxStdinBytes?: number; maxEnvBytes?: number; @@ -70,6 +92,8 @@ type ResolvedWorkerJavaScriptBackendOptions = Required< | "defaultTimeoutMs" | "maxTimeoutMs" | "maxSourceBytes" + | "maxLoaderSourceBytes" + | "maxLoaderModules" | "maxInputBytes" | "maxStdinBytes" | "maxEnvBytes" @@ -90,8 +114,11 @@ type ResolvedWorkerJavaScriptBackendOptions = Required< | "compatibilityFlags" > > & - Omit & { + Omit & { egress: WorkspaceEgressPolicy; + pluginBindings: Record; + pluginModuleNames: ReadonlySet; + protectPluginBindings: boolean; }; interface WorkspaceExecutionContext { @@ -152,6 +179,8 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { assertPositiveFinite(maxTimeoutMs, "maxTimeoutMs"); assertPositiveFinite(defaultTimeoutMs, "defaultTimeoutMs"); assertPositiveFinite(options.maxSourceBytes ?? 1024 * 1024, "maxSourceBytes"); + assertPositiveFinite(options.maxLoaderSourceBytes ?? 8 * 1024 * 1024, "maxLoaderSourceBytes"); + assertPositiveInteger(options.maxLoaderModules ?? 512, "maxLoaderModules"); assertPositiveFinite(options.maxInputBytes ?? 1024 * 1024, "maxInputBytes"); assertPositiveFinite(options.maxStdinBytes ?? 256 * 1024, "maxStdinBytes"); assertPositiveFinite(options.maxEnvBytes ?? 1024 * 1024, "maxEnvBytes"); @@ -187,7 +216,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 +227,18 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { : { mode: "http-gateway" as const, gateway: globalOutbound }); this.#options = { ...backendOptions, + modules: pluginConfiguration.modules, + pluginBindings: pluginConfiguration.bindings, + pluginModuleNames: pluginConfiguration.moduleNames, + protectPluginBindings: Object.keys(pluginConfiguration.bindings).length > 0, egress: resolvedEgress, root: options.root ?? "/workspace", access: options.access ?? "read-write", defaultTimeoutMs, maxTimeoutMs, maxSourceBytes: options.maxSourceBytes ?? 1024 * 1024, + maxLoaderSourceBytes: options.maxLoaderSourceBytes ?? 8 * 1024 * 1024, + maxLoaderModules: options.maxLoaderModules ?? 512, maxInputBytes: options.maxInputBytes ?? 1024 * 1024, maxStdinBytes: options.maxStdinBytes ?? 256 * 1024, maxEnvBytes: options.maxEnvBytes ?? 1024 * 1024, @@ -232,6 +268,7 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { readonly #options: ResolvedWorkerJavaScriptBackendOptions; readonly #host: WorkspaceModuleBackendHost; + #configuredModules: PreparedConfiguredModules | undefined; readonly #records = new Map(); readonly #pendingIds = new Set(); #closed = false; @@ -298,6 +335,15 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { } } + #preparedConfiguredModules(): PreparedConfiguredModules { + this.#configuredModules ??= prepareConfiguredModules( + this.#options.modules ?? {}, + this.#options.pluginModuleNames, + this.#options.protectPluginBindings, + ); + return this.#configuredModules; + } + async exec(input: ModuleExecutionInput): Promise { if (this.#closed) throw runtimeError("ECLOSED", "Workspace JavaScript backend is closed"); const id = input.id ?? crypto.randomUUID(); @@ -353,7 +399,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { source: input.source, cwd: input.cwd ?? this.#options.root, capability, - configuredModules: this.#options.modules ?? {}, + configuredModules: this.#preparedConfiguredModules(), trustedModuleNames: Object.keys(this.#options.trustedModules ?? {}), maxSourceBytes: this.#options.maxSourceBytes, maxCapabilityBytes: this.#options.maxCapabilityBytes, @@ -420,7 +466,9 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { compatibilityDate: this.#options.compatibilityDate, compatibilityFlags: this.#options.compatibilityFlags, maxStdioBytes: this.#options.maxStdioBytes, - maxSourceBytes: this.#options.maxSourceBytes, + maxLoaderSourceBytes: this.#options.maxLoaderSourceBytes, + maxLoaderModules: this.#options.maxLoaderModules, + pluginBindings: this.#options.pluginBindings, onComplete: () => this.#finalize(record), onError: (message) => this.#finalize(record, message), }); @@ -924,7 +972,7 @@ function decodeEvent( } function startJavaScriptExecution(options: { - loader: WorkspaceRuntimeLoader; + loader: WorkspaceRuntimeLoader; modules: Record; entryName: string; input: WorkspaceRuntimeValue; @@ -935,7 +983,9 @@ function startJavaScriptExecution(options: { compatibilityDate: string; compatibilityFlags: string[]; maxStdioBytes: number; - maxSourceBytes: number; + maxLoaderSourceBytes: number; + maxLoaderModules: number; + pluginBindings: Record; onComplete(): void | Promise; onError(message: string): void | Promise; }): ActiveControl { @@ -943,15 +993,19 @@ function startJavaScriptExecution(options: { ...options.modules, "workspace-runtime-runner.js": runtimeWorkerModule(options.entryName, options.maxStdioBytes), }; - assertLoaderGraph(modules, options.maxSourceBytes); + assertLoaderGraph(modules, options.maxLoaderSourceBytes, options.maxLoaderModules); 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 +1082,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 +1219,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,13 +1248,60 @@ 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, + maxModules: number, ) { const entries = Object.values(modules); - if (entries.length > 256) { - throw new Error("Workspace JavaScript loader graph exceeds 256 modules."); + if (entries.length > maxModules) { + throw new Error(`Workspace JavaScript loader graph exceeds ${maxModules} modules.`); } const bytes = entries.reduce( (total, value) => diff --git a/packages/computer/src/backends/worker-shell/browser/cli.test.ts b/packages/computer/src/backends/worker-shell/browser/cli.test.ts new file mode 100644 index 00000000..9b1e3527 --- /dev/null +++ b/packages/computer/src/backends/worker-shell/browser/cli.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; + +import { BROWSER_USAGE, browserEntryModule, parseBrowserCommand, resolveTaskPath } from "./cli.js"; + +function run(argv: string[]) { + const request = parseBrowserCommand(argv); + if (request.kind !== "run") throw new Error(`expected a run request, got ${request.kind}`); + return request; +} + +function failure(argv: string[]) { + const request = parseBrowserCommand(argv); + if (request.kind !== "error") throw new Error(`expected an error request, got ${request.kind}`); + return request.message; +} + +describe("parseBrowserCommand", () => { + it("reads a script path and target URL", () => { + const request = run(["puppeteer", "--url", "https://example.com/", "run.js"]); + + expect(request.script).toBe("run.js"); + expect(request.url).toBe("https://example.com/"); + expect(request.stdin).toBe(false); + }); + + it("accepts flags joined with an equals sign", () => { + expect(run(["puppeteer", "--url=https://example.com/", "run.js"]).url).toBe( + "https://example.com/", + ); + }); + + it("reads the task from stdin", () => { + const request = run(["puppeteer", "--url", "https://example.com/", "--stdin"]); + + expect(request.stdin).toBe(true); + expect(request.script).toBeUndefined(); + }); + + it("parses a timeout in milliseconds", () => { + expect(run(["puppeteer", "--timeout", "45000", "run.js"]).timeoutMs).toBe(45_000); + }); + + it("parses structured task input", () => { + expect(run(["puppeteer", "--input", '{"depth":2}', "run.js"]).input).toEqual({ depth: 2 }); + }); + + it("requests help for --help", () => { + expect(parseBrowserCommand(["--help"]).kind).toBe("help"); + expect(parseBrowserCommand(["puppeteer", "--help"]).kind).toBe("help"); + }); + + it("requires an engine", () => { + expect(failure([])).toContain("usage"); + }); + + it("rejects an unknown engine", () => { + expect(failure(["chrome", "run.js"])).toContain("unknown engine"); + }); + + it("requires a script path or --stdin", () => { + expect(failure(["puppeteer", "--url", "https://example.com/"])).toContain("--stdin"); + }); + + it("rejects a script path together with --stdin", () => { + expect(failure(["puppeteer", "--stdin", "run.js"])).toContain("not both"); + }); + + it("rejects a second script path", () => { + expect(failure(["puppeteer", "one.js", "two.js"])).toContain("one script"); + }); + + it("rejects a URL that is not HTTP or HTTPS", () => { + expect(failure(["puppeteer", "--url", "file:///etc/passwd", "run.js"])).toContain("http"); + }); + + it("rejects a malformed URL", () => { + expect(failure(["puppeteer", "--url", "example.com", "run.js"])).toContain("http"); + }); + + it("rejects a timeout that is not a positive integer", () => { + expect(failure(["puppeteer", "--timeout", "0", "run.js"])).toContain("timeout"); + expect(failure(["puppeteer", "--timeout", "later", "run.js"])).toContain("timeout"); + }); + + it("rejects input that is not a JSON object", () => { + expect(failure(["puppeteer", "--input", "[1]", "run.js"])).toContain("JSON object"); + expect(failure(["puppeteer", "--input", "{", "run.js"])).toContain("JSON object"); + }); + + it("rejects a flag without a value", () => { + expect(failure(["puppeteer", "--url"])).toContain("--url"); + }); + + it("rejects an unknown flag", () => { + expect(failure(["puppeteer", "--headless", "run.js"])).toContain("--headless"); + }); + + it("documents the engine subcommand in its usage", () => { + expect(BROWSER_USAGE).toContain("browser puppeteer"); + }); +}); + +describe("resolveTaskPath", () => { + it("resolves a relative path against the working directory", () => { + expect(resolveTaskPath("/workspace/tasks", "run.js")).toEqual({ + path: "/workspace/tasks/run.js", + cwd: "/workspace/tasks", + specifier: "./run.js", + }); + }); + + it("keeps an absolute path", () => { + expect(resolveTaskPath("/workspace", "/workspace/tasks/run.js").path).toBe( + "/workspace/tasks/run.js", + ); + }); + + it("collapses dot segments", () => { + expect(resolveTaskPath("/workspace/tasks", "../shared/./run.js")).toEqual({ + path: "/workspace/shared/run.js", + cwd: "/workspace/shared", + specifier: "./run.js", + }); + }); + + it("runs the task from its own directory", () => { + expect(resolveTaskPath("/workspace", "tasks/deep/run.js").cwd).toBe("/workspace/tasks/deep"); + }); +}); + +describe("browserEntryModule", () => { + it("wraps the task module in a managed browser session", () => { + const source = browserEntryModule("./run.js"); + + expect(source).toContain('import task from "./run.js";'); + expect(source).toContain('import { withBrowser } from "@cloudflare/puppeteer";'); + expect(source).toContain("withBrowser("); + expect(source).toContain("browser"); + }); + + it("reports a task module that does not export a function", () => { + expect(browserEntryModule("./run.js")).toContain("default function"); + }); + + it("launches without guardrails when no host is known", () => { + expect(browserEntryModule("./run.js")).not.toContain("allowedDomains"); + }); + + it("confines the session to the requested host, its subdomains, and common CDNs", () => { + const source = browserEntryModule("./run.js", "example.com"); + + expect(source).toContain('"example.com"'); + expect(source).toContain('"*.example.com"'); + expect(source).toContain('"common-cdns"'); + expect(source).toContain("withBrowser("); + }); +}); diff --git a/packages/computer/src/backends/worker-shell/browser/cli.ts b/packages/computer/src/backends/worker-shell/browser/cli.ts new file mode 100644 index 00000000..eef5d26d --- /dev/null +++ b/packages/computer/src/backends/worker-shell/browser/cli.ts @@ -0,0 +1,222 @@ +// Argv parsing and entry-module generation for the shell `browser` +// command. +// +// Kept free of just-bash and of any host binding so the behavioral +// choices are testable on their own. command.ts adapts this to the +// just-bash Command signature and dispatches the generated module +// into a browser-enabled worker-javascript backend. + +/** Task run requested by a well-formed argv. */ +export interface BrowserRunRequest { + kind: "run"; + engine: "puppeteer"; + /** Task module path as written by the caller, resolved later against cwd. */ + script?: string; + /** Whether the task module arrives on stdin instead of from a file. */ + stdin: boolean; + url?: string; + timeoutMs?: number; + input?: Record; +} + +export type BrowserCommandRequest = + | BrowserRunRequest + | { kind: "help" } + | { kind: "error"; message: string }; + +export const BROWSER_USAGE = `usage: browser puppeteer [options]