feat(client): add shared Chromium driver

This commit is contained in:
LukeParkerDev 2026-07-28 16:10:02 +10:00
commit f147ff64f1
7 changed files with 1548 additions and 17 deletions

View file

@ -16,25 +16,26 @@ The Promise root remains structural and has no Core, Effect, Schema, Protocol, o
## Node browser attachments ## Node browser attachments
The Node entrypoint owns the control connection, Session lease, authenticated proxy, and network tunnels. Consumers provide a browser adapter once with `BrowserDriver.define`; normal attachment calls only provide a Session ID and that descriptor. The Node entrypoint owns the control connection, Session lease, authenticated proxy, and network tunnels. Chromium hosts provide a small platform port once with `BrowserDriver.chromium`; the SDK owns command semantics, CDP input, snapshots, generations, cancellation, and limits.
```ts ```ts
import { BrowserDriver, BrowserDriverError, OpenCode } from "@opencode-ai/client/node" import { BrowserDriver, OpenCode } from "@opencode-ai/client/node"
const driver = BrowserDriver.define(async ({ proxy, signal }) => { const driver = BrowserDriver.chromium(async ({ proxy, signal }) => {
const browser = await launchBrowser({ proxy, signal }) const view = await createChromiumView({ proxy, signal })
return { return {
resource: browser, resource: view,
state: () => browser.state(), state: () => view.state(),
subscribe: (listener) => browser.subscribe(listener), subscribe: (listener) => view.subscribe((state, mainDocumentChanged) => listener({ state, mainDocumentChanged })),
execute: async (command, options) => { navigate: (url) => view.navigate(url),
try { back: () => view.back(),
return await browser.execute(command, options) forward: () => view.forward(),
} catch (cause) { reload: () => view.reload(),
throw new BrowserDriverError("internal", "Browser command failed", { cause }) stop: () => view.stop(),
} send: (method, params) => view.sendCDP(method, params),
}, viewport: () => view.viewport(),
dispose: () => browser.close(), screenshot: ({ maxDimension }) => view.capturePNG({ maxDimension }),
dispose: () => view.close(),
} }
}) })
@ -44,6 +45,8 @@ const client = OpenCode.make({
}) })
const attachment = await client.browser.attach({ sessionID, driver }) const attachment = await client.browser.attach({ sessionID, driver })
await attachment.resource.navigate("example.com")
const view = attachment.resource.resource
await attachment.close() await attachment.close()
``` ```
@ -51,7 +54,9 @@ await attachment.close()
Driver factories should return after configuring their resource rather than await a proxied navigation: tunnel dialing is deliberately held behind the first lease acknowledgement, which is published after the driver supplies its initial state. Driver factories should return after configuring their resource rather than await a proxied navigation: tunnel dialing is deliberately held behind the first lease acknowledgement, which is published after the driver supplies its initial state.
`BrowserDriver` descriptors are structural factory functions, so adapters remain compatible across duplicate client package instances. The Node entrypoint also re-exports canonical `Browser` contracts. Throw `BrowserDriverError` for typed command failures; structurally equivalent errors are accepted only when their `code` is a valid `Browser.ErrorCode`. Port state events set `mainDocumentChanged` only when the main document changes; this advances the public generation and invalidates element refs. `send` must dispatch CDP calls in invocation order. `screenshot` returns PNG bytes and dimensions, proportionally scaled to `maxDimension` without upscaling. The returned controller serializes local navigation with remote commands; `stop` immediately interrupts active work, and controller disposal is idempotent. An aborted or timed-out operation that reached the platform disposes the port so late native completion cannot cross the queue fence.
`BrowserDriver` descriptors are structural factory functions, so adapters remain compatible across duplicate client package instances. The Node entrypoint also re-exports canonical `Browser` contracts. `BrowserDriver.define` remains the advanced escape hatch for non-Chromium semantics; throw `BrowserDriverError` for typed command failures there. Structurally equivalent errors are accepted only when their `code` is a valid `Browser.ErrorCode`.
Effect consumers construct canonical decoded inputs: Effect consumers construct canonical decoded inputs:

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
import type { Browser } from "@opencode-ai/schema/browser" import type { Browser } from "@opencode-ai/schema/browser"
import { chromiumDriver, type ChromiumDriver, type ChromiumPort } from "./chromium.js"
/** Connection details for the attachment's private authenticated proxy. */ /** Connection details for the attachment's private authenticated proxy. */
export interface BrowserProxy { export interface BrowserProxy {
@ -50,6 +51,12 @@ export const BrowserDriver = {
if (typeof create !== "function") throw new TypeError("Browser driver factory must be a function") if (typeof create !== "function") throw new TypeError("Browser driver factory must be a function")
return Object.freeze(create) return Object.freeze(create)
}, },
chromium<Resource>(
create: (context: BrowserDriverContext) => PromiseLike<ChromiumPort<Resource>> | ChromiumPort<Resource>,
): ChromiumDriver<Resource> {
if (typeof create !== "function") throw new TypeError("Chromium port factory must be a function")
return Object.freeze(chromiumDriver(create))
},
} }
export function browserDriverFactory<Resource>(driver: BrowserDriver<Resource>) { export function browserDriverFactory<Resource>(driver: BrowserDriver<Resource>) {

View file

@ -23,6 +23,7 @@ export type {
BrowserDriverInstance, BrowserDriverInstance,
BrowserProxy, BrowserProxy,
} from "./browser/driver.js" } from "./browser/driver.js"
export type { ChromiumController, ChromiumDriver, ChromiumPort } from "./browser/chromium.js"
export type { BrowserAttachment, BrowserAttachOptions, BrowserClient } from "./browser/client.js" export type { BrowserAttachment, BrowserAttachOptions, BrowserClient } from "./browser/client.js"
export type { EventSubscribeOutput as OpenCodeEvent } from "../promise/generated/types.js" export type { EventSubscribeOutput as OpenCodeEvent } from "../promise/generated/types.js"
export type OpenCodeClient = ReturnType<typeof import("./client.js").make> export type OpenCodeClient = ReturnType<typeof import("./client.js").make>

View file

@ -0,0 +1,363 @@
import { describe, expect, test } from "bun:test"
import {
Browser,
BrowserDriver,
BrowserDriverError,
type BrowserDriverContext,
type ChromiumPort,
} from "@opencode-ai/client/node"
type PortListener = Parameters<ChromiumPort<unknown>["subscribe"]>[0]
type PortState = ReturnType<ChromiumPort<unknown>["state"]>
describe("Chromium browser driver", () => {
test("increments generations and invalidates refs only for main-document changes", async () => {
const app = await setup()
const snapshot = await app.instance.execute(
{ type: "snapshot", generation: 0 },
{ signal: new AbortController().signal },
)
if (snapshot.type !== "snapshot") throw new Error("Expected snapshot result")
expect(snapshot.content).toContain('e1 [button] "Save" disabled=false')
await app.instance.execute(
{ type: "click", ref: Browser.Ref.make("e1"), generation: 0 },
{ signal: new AbortController().signal },
)
app.port.emit({ title: "Updated" }, false)
expect(app.controller.state()).toMatchObject({ title: "Updated", generation: 0 })
app.port.emit({ url: "https://next.example/" }, true)
expect(app.controller.state()).toMatchObject({ url: "https://next.example/", generation: 1 })
expect(app.port.calls.some((call) => call.method === "Runtime.releaseObject")).toBe(true)
expect(
requireDriverError(
await rejected(
app.instance.execute(
{ type: "click", ref: Browser.Ref.make("e1"), generation: 1 },
{ signal: new AbortController().signal },
),
),
),
).toMatchObject({ code: "stale_ref", message: "The element reference is stale. Call browser_snapshot again." })
expect(
requireDriverError(
await rejected(
app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }),
),
),
).toMatchObject({ code: "stale_ref", message: "The browser page changed. Call browser_snapshot again." })
await app.controller.dispose()
})
test("parses bounded snapshots and rejects untrusted oversized nodes", async () => {
const app = await setup()
const result = await app.instance.execute(
{ type: "snapshot", generation: 0 },
{ signal: new AbortController().signal },
)
if (result.type !== "snapshot") throw new Error("Expected snapshot result")
expect(result.content).toBe(
["Page: Example", "URL: https://example.com/", "", ' e1 [button] "Save" disabled=false'].join("\n"),
)
expect(app.port.expressions[0]).toContain("while (visited++ < 500)")
expect(app.port.expressions[0]).toContain('editable ? ""')
expect(app.port.expressions[0]).not.toContain("textContent")
expect(() => new Bun.Transpiler({ loader: "js" }).transformSync(app.port.expressions[0] ?? "")).not.toThrow()
app.port.snapshotValue = {
nodes: Array.from({ length: 501 }, () => ({ role: "button", name: "Save", value: "", depth: 0 })),
nextRef: 501,
}
expect(
requireDriverError(
await rejected(
app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }),
),
),
).toMatchObject({ code: "internal", message: "Invalid browser snapshot nodes." })
app.port.snapshotValue = {
nodes: [{ token: "e2", role: "bad role", name: "Save", value: "", depth: 0 }],
nextRef: 2,
}
expect(
requireDriverError(
await rejected(
app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }),
),
),
).toMatchObject({ code: "internal", message: "Invalid browser snapshot nodes." })
expect(app.port.calls.filter((call) => call.method === "Runtime.releaseObject").length).toBeGreaterThanOrEqual(2)
await app.controller.dispose()
})
test("releases paired input and fences the port when cancellation races a press", async () => {
const app = await setup()
await app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal })
const abort = new AbortController()
const input: string[] = []
app.port.onSend = (method, params) => {
if (method !== "Input.dispatchMouseEvent") return app.port.response(method, params)
if (params?.type === "mousePressed") {
input.push("press")
abort.abort()
return new Promise<never>(() => undefined)
}
if (params?.type === "mouseReleased") input.push("release")
return app.port.response(method, params)
}
expect(
requireDriverError(
await rejected(
app.instance.execute({ type: "click", ref: Browser.Ref.make("e1"), generation: 0 }, { signal: abort.signal }),
),
),
).toMatchObject({ code: "aborted", message: "The browser action was aborted." })
await app.controller.dispose()
expect(input).toEqual(["press", "release"])
expect(app.port.stopCount).toBe(1)
expect(app.port.disposeCount).toBe(1)
expect(
requireDriverError(
await rejected(
app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }),
),
).code,
).toBe("not_attached")
})
test("serializes local controller work with remote commands", async () => {
const app = await setup()
const navigation = deferred()
const started = deferred()
app.port.onNavigate = () => {
started.resolve()
return navigation.promise
}
const local = app.controller.navigate("example.com")
await started.promise
const remote = app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal })
await Promise.resolve()
expect(app.port.calls.some((call) => call.method === "Runtime.evaluate")).toBe(false)
expect(app.port.navigateURLs).toEqual(["https://example.com/"])
navigation.resolve()
await local
expect((await remote).type).toBe("snapshot")
expect(app.port.calls.some((call) => call.method === "Runtime.evaluate")).toBe(true)
await app.controller.dispose()
})
test("maps navigation, CDP, and adapter failures to driver error codes", async () => {
const app = await setup()
expect(requireDriverError(await rejected(app.controller.navigate("javascript:alert(1)")))).toMatchObject({
code: "invalid_url",
})
expect(app.port.navigateURLs).toEqual([])
app.port.onNavigate = () => Promise.reject(new Error("net::ERR_NAME_NOT_RESOLVED"))
expect(requireDriverError(await rejected(app.controller.navigate("missing.example")))).toMatchObject({
code: "navigation_failed",
message: "net::ERR_NAME_NOT_RESOLVED",
})
app.port.onSend = () => Promise.reject(new Error("Could not find object with given id"))
expect(
requireDriverError(
await rejected(
app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }),
),
),
).toMatchObject({ code: "stale_ref", message: "The element reference is stale. Call browser_snapshot again." })
app.port.onSend = () => Promise.reject(new Error("Method not found"))
expect(
requireDriverError(
await rejected(
app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }),
),
),
).toMatchObject({ code: "internal", message: "Method not found" })
app.port.onSend = () => Promise.reject(new BrowserDriverError("page_crashed", "Renderer gone"))
expect(
requireDriverError(
await rejected(
app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }),
),
),
).toMatchObject({ code: "page_crashed", message: "Renderer gone" })
await app.controller.dispose()
})
test("preserves viewport scroll and bounded PNG capture contracts", async () => {
const app = await setup()
app.port.viewportSize = { width: 101, height: 99 }
await app.instance.execute(
{ type: "scroll", direction: "down", pixels: 5_000, generation: 0 },
{ signal: new AbortController().signal },
)
expect(app.port.calls.find((call) => call.params?.type === "mouseWheel")?.params).toEqual({
type: "mouseWheel",
x: 51,
y: 50,
deltaX: 0,
deltaY: 2_000,
})
const result = await app.instance.execute(
{ type: "screenshot", generation: 0 },
{ signal: new AbortController().signal },
)
if (result.type !== "screenshot") throw new Error("Expected screenshot result")
expect(app.port.screenshotLimits).toEqual([2_000])
expect(result).toMatchObject({ mediaType: "image/png", width: 100, height: 50 })
expect(result.data).toEqual(new Uint8Array([137, 80, 78, 71]))
app.port.screenshotValue = {
data: new Uint8Array(5 * 1_024 * 1_024 + 1),
width: 100,
height: 50,
}
expect(
requireDriverError(
await rejected(
app.instance.execute({ type: "screenshot", generation: 0 }, { signal: new AbortController().signal }),
),
),
).toMatchObject({ code: "result_too_large", message: "The browser screenshot exceeds 5 MiB." })
await app.controller.dispose()
})
})
class FakeChromiumPort implements ChromiumPort<{ readonly name: string }> {
readonly resource = { name: "fake-chromium" }
readonly listeners = new Set<PortListener>()
readonly calls: Array<{ readonly method: string; readonly params?: Record<string, unknown> }> = []
readonly expressions: string[] = []
readonly navigateURLs: string[] = []
readonly screenshotLimits: number[] = []
current: PortState = {
url: "https://example.com/",
title: "Example",
loading: false,
canGoBack: false,
canGoForward: false,
}
snapshotValue: unknown = {
nodes: [{ token: "e1", role: "button", name: "Save", value: "", depth: 1, disabled: false }],
nextRef: 1,
}
screenshotValue = { data: new Uint8Array([137, 80, 78, 71]), width: 100, height: 50 }
viewportSize = { width: 800, height: 600 }
onNavigate?: (url: string) => PromiseLike<void> | void
onSend?: (method: string, params?: Record<string, unknown>) => unknown
stopCount = 0
disposeCount = 0
private object = 0
state() {
return this.current
}
subscribe(listener: PortListener) {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
navigate(url: string) {
this.navigateURLs.push(url)
return Promise.resolve().then(() => this.onNavigate?.(url))
}
back() {}
forward() {}
reload() {}
stop() {
this.stopCount++
}
send(method: string, params?: Record<string, unknown>) {
this.calls.push({ method, ...(params ? { params } : {}) })
if (method === "Runtime.evaluate" && typeof params?.expression === "string") {
this.expressions.push(params.expression)
}
return Promise.resolve().then(() => (this.onSend ? this.onSend(method, params) : this.response(method, params)))
}
response(method: string, params?: Record<string, unknown>) {
if (method === "Runtime.evaluate") return { result: { objectId: `snapshot-${++this.object}` } }
if (method !== "Runtime.callFunctionOn") return {}
if (params?.functionDeclaration === "function() { return this.result }") {
return { result: { value: this.snapshotValue } }
}
if (typeof params?.functionDeclaration === "string" && params.functionDeclaration.includes("const editable")) {
return { result: { value: true } }
}
return { result: { value: { x: 25, y: 40 } } }
}
viewport() {
return this.viewportSize
}
screenshot(options: { readonly maxDimension: number }) {
this.screenshotLimits.push(options.maxDimension)
return Promise.resolve(this.screenshotValue)
}
dispose() {
this.disposeCount++
}
emit(state: Partial<PortState>, mainDocumentChanged: boolean) {
this.current = { ...this.current, ...state }
this.listeners.forEach((listener) => listener({ state: this.current, mainDocumentChanged }))
}
}
async function setup() {
const port = new FakeChromiumPort()
const lifetime = new AbortController()
const driver = BrowserDriver.chromium(() => port)
const instance = await driver({
proxy: {
url: "https://127.0.0.1:1234",
host: "127.0.0.1",
port: 1234,
credentials: { username: "user", password: "pass" },
certificateFingerprint: "fingerprint",
},
signal: lifetime.signal,
} satisfies BrowserDriverContext)
return { port, lifetime, instance, controller: instance.resource }
}
function deferred() {
let resolve!: () => void
const promise = new Promise<void>((next) => {
resolve = next
})
return { promise, resolve }
}
function rejected(promise: Promise<unknown>) {
return promise.then(
() => new Error("Expected operation to reject"),
(error: unknown) => error,
)
}
function requireDriverError(input: unknown) {
if (!(input instanceof BrowserDriverError)) throw input
return input
}

View file

@ -127,6 +127,7 @@ function nodeScenario(moduleURL: string) {
const sdk = await import(${JSON.stringify(moduleURL)}) const sdk = await import(${JSON.stringify(moduleURL)})
if (typeof sdk.OpenCode.make !== "function") throw new Error("Missing OpenCode.make") if (typeof sdk.OpenCode.make !== "function") throw new Error("Missing OpenCode.make")
if (typeof sdk.BrowserDriver.define !== "function") throw new Error("Missing BrowserDriver.define") if (typeof sdk.BrowserDriver.define !== "function") throw new Error("Missing BrowserDriver.define")
if (typeof sdk.BrowserDriver.chromium !== "function") throw new Error("Missing BrowserDriver.chromium")
if (typeof sdk.BrowserDriverError !== "function") throw new Error("Missing BrowserDriverError") if (typeof sdk.BrowserDriverError !== "function") throw new Error("Missing BrowserDriverError")
if (!sdk.Browser.State) throw new Error("Missing canonical Browser export") if (!sdk.Browser.State) throw new Error("Missing canonical Browser export")

View file

@ -1,4 +1,13 @@
import { Browser, BrowserDriver, BrowserDriverError, OpenCode, type BrowserAttachment } from "@opencode-ai/client/node" import {
Browser,
BrowserDriver,
BrowserDriverError,
OpenCode,
type BrowserAttachment,
type ChromiumController,
type ChromiumDriver,
type ChromiumPort,
} from "@opencode-ai/client/node"
const state: Browser.State = { const state: Browser.State = {
url: "about:blank", url: "about:blank",
@ -19,6 +28,8 @@ const factory: BrowserDriver<{ readonly proxyURL: string }> = (context) => ({
dispose: () => undefined, dispose: () => undefined,
}) })
const driver = BrowserDriver.define(factory) const driver = BrowserDriver.define(factory)
declare const port: ChromiumPort<{ readonly page: true }>
const chromium: ChromiumDriver<{ readonly page: true }> = BrowserDriver.chromium(() => port)
declare const client: ReturnType<typeof OpenCode.make> declare const client: ReturnType<typeof OpenCode.make>
const attachment: Promise<BrowserAttachment<{ readonly proxyURL: string }>> = client.browser.attach({ const attachment: Promise<BrowserAttachment<{ readonly proxyURL: string }>> = client.browser.attach({
@ -27,3 +38,9 @@ const attachment: Promise<BrowserAttachment<{ readonly proxyURL: string }>> = cl
}) })
void attachment void attachment
const chromiumAttachment: Promise<BrowserAttachment<ChromiumController<{ readonly page: true }>>> =
client.browser.attach({
sessionID: "ses_chromium_type_fixture",
driver: chromium,
})
void chromiumAttachment