@swarmmachina/swm-cdp
v0.1.1
Published
Low-level Chrome DevTools Protocol runtime for SwarmMachina.
Readme
@swarmmachina/swm-cdp
Zero-dependency, ESM-only Chrome DevTools Protocol client for Node.js 22 and 24.
swm-cdp exposes a deliberately small API for typed CDP commands and events, flattened sessions, Chrome process ownership, and HTTP discovery. It uses the WebSocket client built into Node.js and accepts browser-level WebSocket connections only on explicit loopback IP addresses.
Features
- Statically typed CDP commands, parameters, results, events, and payloads.
- Browser-level WebSocket connections restricted to
127.0.0.1and[::1]. - Owned Chrome lifecycle with explicit process creation, attachment, shutdown, and cleanup stages.
- Pipe and WebSocket transports for launched Chrome processes.
- Flattened CDP session support without runtime domain-object generation.
- Bounded pending requests, task queues, payloads, endpoint output, and outbound buffers.
- Configurable event backpressure with fail-closed behavior by default.
- Structured diagnostics without an
EventEmitterin the protocol hot path. - Zero runtime dependencies and no runtime protocol schema loading.
Installation
pnpm add @swarmmachina/swm-cdpRuntime requirements
- Node.js
^22.13.0or 24.x. - Native ESM.
- No runtime npm dependencies.
- A locally installed Chrome or Chromium executable when using
spawnChrome(). - The built-in Node.js
WebSocket; runtimes started with--no-experimental-websocketare not supported.
connect() treats the CDP endpoint as a trusted local boundary. It accepts only the literal hosts 127.0.0.1 and [::1]; localhost, DNS names, Unix sockets, and remote addresses are intentionally rejected.
Quick Start
connect() owns one remote client connection; spawnChrome() owns both Chrome and its client. Both return the same typed CdpClient.
Connect to Running Chrome
Start Chrome with a loopback debugging endpoint, then connect to its browser-level WebSocket:
import connect from '@swarmmachina/swm-cdp'
const cdp = await connect({ host: '127.0.0.1', port: 9222 })
try {
const version = await cdp.send('Browser.getVersion')
console.log(version.product)
} finally {
await cdp.close()
}cdp.close() closes only this client connection. It does not terminate a Chrome process that the caller started independently.
Launch Owned Chrome
import { spawnChrome } from '@swarmmachina/swm-cdp'
const browser = spawnChrome({
chromeExecutable: process.env.CHROME_PATH,
disableDefaultArguments: false,
headless: true,
transport: 'pipe'
})
try {
const cdp = await browser.attach()
const version = await cdp.send('Browser.getVersion')
const target = await cdp.send('Target.createTarget', { url: 'about:blank' })
const { sessionId } = await cdp.send('Target.attachToTarget', {
flatten: true,
targetId: target.targetId
})
await cdp.send('Runtime.enable', undefined, sessionId)
const consoleEvent = cdp.once('Runtime.consoleAPICalled')
await cdp.send('Runtime.evaluate', { expression: "console.log('ready')" }, sessionId)
console.log(version.product, (await consoleEvent).type)
} finally {
await browser.close()
}spawnChrome() creates the process synchronously and returns its lifecycle owner. browser.attach() is the explicit asynchronous readiness stage. Closing the client returned by browser.attach() delegates to browser.close() and therefore also shuts down the owned Chrome process.
Commands, Events, and Sessions
send() accepts a generated CDP method and payload, once() waits for one generated event, and the optional sessionId is written to the flattened CDP envelope. Persistent on() listeners return an unsubscribe function and receive sessionId as their second argument.
Keep the returned function and call it when the subscription is no longer needed:
await cdp.send('Runtime.enable', undefined, sessionId)
const unsubscribe = cdp.on('Runtime.consoleAPICalled', (event, eventSessionId) => {
if (eventSessionId === sessionId) {
console.log(event.type, event.args)
}
})
try {
await cdp.send('Runtime.evaluate', { expression: "console.log('ready')" }, sessionId)
} finally {
unsubscribe()
}Calling unsubscribe() more than once is safe.
Discovery
import { closeTarget, createTarget, list, version } from '@swarmmachina/swm-cdp'
const endpoint = { host: '127.0.0.1', port: 9222 }
const browserVersion = await version(endpoint)
const targets = await list(endpoint)
const target = await createTarget(endpoint, 'about:blank')
console.log(browserVersion.webSocketDebuggerUrl, targets.length)
await closeTarget(target.id, endpoint)Discovery uses the built-in fetch; it does not require an open CDP client.
API Documentation
connect(target, options?)
Opens a browser-level CDP WebSocket and resolves after the handshake succeeds.
Accepted target forms:
| Target | Meaning |
| ------------------------------------------ | ----------------------------------------------------- |
| 'ws://127.0.0.1:9222/devtools/browser/…' | Complete browser-level CDP URL |
| new URL('ws://[::1]:9222/devtools/…') | Complete IPv6 loopback URL |
| { url: string \| URL } | Complete URL wrapped in an object |
| { host?, port?, secure? } | HTTP discovery endpoint; defaults to 127.0.0.1:9222 |
Complete URLs accept only ws: and wss:. Endpoint descriptions are resolved through /json/version over HTTP or HTTPS. The browser WebSocket host must be exactly 127.0.0.1 or [::1].
ConnectOptions:
| Option | Default | Description |
| ----------------------- | --------- | --------------------------------------------------------- |
| attachTimeout | 60_000 | Combined discovery and WebSocket handshake deadline. |
| debugProtocol | false | Emit CDP request, response, and event diagnostics. |
| debugTransport | false | Emit transport lifecycle and message diagnostics. |
| eventBackpressure | 'close' | Queue overflow policy: 'close' or 'drop-oldest'. |
| logger | console | Structured diagnostic callback. |
| maxBufferedWriteBytes | 16 MiB | Maximum queued outbound transport bytes. |
| maxMessageBytes | 64 MiB | Maximum encoded CDP message size accepted by the library. |
| maxPendingRequests | 10_000 | Maximum commands awaiting responses. |
| maxQueueDepth | 10_000 | Maximum queued transport tasks. |
| protocolTimeout | 180_000 | Default command and event-wait deadline in milliseconds. |
Invalid targets and unknown or invalid options throw TypeError before attachment.
CdpClient
connect() and browser.attach() return the same typed client facade.
| Member | Description |
| --------------------------------------------- | ---------------------------------------------------------------------- |
| closed | Whether the underlying connection reached its terminal state. |
| send(method, params?, sessionId?, options?) | Send a typed command and resolve with its typed result. |
| on(event, listener) | Subscribe to a typed event and return an unsubscribe function. |
| once(event) | Resolve with the next typed event payload. |
| once(event, listener) | Register a one-shot listener and return an unsubscribe function. |
| onClose(listener) | Observe connection closure outside the CDP event namespace. |
| onError(listener) | Observe transport and protocol errors outside the CDP event namespace. |
| close() | Close the client; repeated calls return the same promise. |
send() supports per-operation cancellation and deadlines:
const controller = new AbortController()
const result = await cdp.send(
'Runtime.evaluate',
{ expression: 'globalThis.location.href', returnByValue: true },
'page-session-id',
{ signal: controller.signal, timeout: 5_000 }
)For commands without parameters, pass undefined before sessionId or operation options. Unknown protocol extensions can be called with an explicit result type:
const result = await cdp.send<{ value: string }>('Vendor.customMethod', { key: 'value' })CDP failures reject with CdpError. Transport failures and local validation errors reject with ordinary Error subclasses.
spawnChrome(options?)
Starts a Chrome process owned by the returned Browser. When no userDataDir is supplied, a temporary profile is created and removed after process exit.
Process and profile options:
| Option | Default | Description |
| ------------------------- | ------------- | --------------------------------------------------------- |
| chromeExecutable | auto-detected | Chrome or Chromium executable path. |
| additionalArguments | [] | Chrome arguments appended after package arguments. |
| disableDefaultArguments | true | Disable the package's default Chrome flags. |
| headless | false | Add the package's headless flags. |
| url | undefined | Initial URL opened by Chrome. |
| cwd | undefined | Chrome process working directory. |
| env | undefined | Environment overrides; undefined removes a key. |
| extendEnv | true | Extend process.env; when false, use only env. |
| stdio | 'ignore' | Chrome stdout and stderr mode: 'ignore' or 'inherit'. |
| userDataDir | temporary | Existing Chrome profile directory. |
| userDataRoot | OS temp dir | Parent directory for an automatically created profile. |
| cleanupUserDataDir | false | Remove a caller-supplied profile after exit. |
Transport and lifecycle options:
| Option | Default | Description |
| ----------------------- | --------- | --------------------------------------------------------- |
| transport | 'pipe' | 'pipe', 'ws'. |
| port | 0 | WebSocket debugging port; 0 asks Chrome to choose one. |
| startupTimeout | 10_000 | Deadline for Chrome to report its WebSocket endpoint. |
| attachTimeout | 60_000 | WebSocket handshake deadline in milliseconds. |
| protocolTimeout | 180_000 | Default command and event-wait deadline in milliseconds. |
| shutdownTimeout | 5_000 | Graceful shutdown budget in milliseconds. |
| maxEndpointBytes | 64 KiB | Maximum size of the stderr line containing the endpoint. |
| maxBufferedWriteBytes | 16 MiB | Maximum queued outbound transport bytes. |
| maxMessageBytes | 64 MiB | Maximum encoded CDP message size accepted by the library. |
| maxPendingRequests | 10_000 | Maximum commands awaiting responses. |
| maxQueueDepth | 10_000 | Maximum queued transport tasks. |
| eventBackpressure | 'close' | Queue overflow policy: 'close' or 'drop-oldest'. |
Diagnostic options:
| Option | Default | Description |
| ---------------- | --------- | ----------------------------------------------- |
| logger | console | Receives structured LogEntry records. |
| debugSpawn | false | Emit process and profile lifecycle diagnostics. |
| debugTransport | false | Emit transport diagnostics. |
| debugProtocol | false | Emit protocol diagnostics. |
Unknown or invalid options throw TypeError. Chrome discovery and process-start failures throw Error.
Browser
Browser is a single-use lifecycle owner with the states created, attaching, open, closing, and closed.
| Property | Description |
| --------------- | ----------------------------------------------------- |
| args | Arguments passed to Chrome, excluding the executable. |
| attached | Whether the CDP transport is attached and open. |
| chromeProcess | Underlying Node.js child-process handle, or null. |
| closed | Whether the lifecycle reached its terminal state. |
| command | Resolved Chrome executable path. |
| connection | Attached CdpClient, or null before attachment. |
| error | Transport failure that initiated cleanup, if any. |
| isExited | Whether the Chrome process exited. |
| lastError | Most recent process-management error, if any. |
| pid | Chrome process identifier, when available. |
| state | Current lifecycle state. |
| transport | Active CDP transport, or null before attachment. |
| userDataDir | Chrome profile directory used by the process. |
| Method | Description |
| --------------------------------- | ---------------------------------------------------------------------- |
| attach() | Attach once and return the client; concurrent calls share one promise. |
| close(timeout?) | Gracefully close Chrome, then kill it if the deadline expires. |
| dispose(timeout?) | Alias for close(). |
| kill(timeout?) | Terminate Chrome without sending Browser.close. |
| waitForExit(timeout?) | Wait for the owned process to exit. |
| on(event, listener) | Subscribe to 'error' or 'exit'; returns an unsubscribe function. |
| once(event, listener) | Register a one-shot lifecycle listener. |
| removeListener(event, listener) | Remove a lifecycle listener. |
| listenerCount(event) | Return the number of listeners for an event. |
close() is idempotent. If attachment fails, the browser terminates the process and cleans an automatically created profile before rejecting.
Discovery API
Discovery targets accept an http:, https:, ws:, or wss: URL, { url }, or { host?, port?, secure? }. WebSocket schemes are converted to their HTTP equivalents. Object targets default to 127.0.0.1:9222.
| Function | Description |
| --------------------------------------- | ------------------------------------------- |
| list(target?, options?) | Read target metadata from /json/list. |
| version(target?, options?) | Read browser metadata from /json/version. |
| createTarget(target?, url?, options?) | Create a target through /json/new. |
| closeTarget(id, target?, options?) | Close a target through /json/close/:id. |
DiscoveryOptions:
| Option | Default | Description |
| ------------------ | -------- | --------------------------------- |
| maxResponseBytes | 8 MiB | Maximum response body size. |
| timeout | 10_000 | Request deadline in milliseconds. |
The same functions and discovery types are available from @swarmmachina/swm-cdp/discovery.
Chrome executable discovery
Find a local Chrome or Chromium executable without creating a CDP connection or starting a browser:
import { findChrome } from '@swarmmachina/swm-cdp/finder'
const chromeExecutable = findChrome()findChrome() respects CHROME_PATH and LIGHTHOUSE_CHROMIUM_PATH, then checks the platform-specific installation locations. It returns an absolute executable path or throws ChromeNotInstalledError. The ChromeFinder class and finder error types are available from both the package root and @swarmmachina/swm-cdp/finder.
Diagnostics
Enabled diagnostics are written to console by default. Configure logger with a LogSink callback to receive
structured LogEntry records instead:
import connect, { type LogSink } from '@swarmmachina/swm-cdp'
const logger: LogSink = (entry) => {
process.stderr.write(`${JSON.stringify(entry)}\n`)
}
const cdp = await connect({
host: '127.0.0.1',
port: 9222,
debugProtocol: true,
debugTransport: true,
logger
})
try {
await cdp.send('Browser.getVersion')
} finally {
await cdp.close()
}The debugProtocol, debugTransport, and debugSpawn flags enable diagnostics for their respective scopes.
| Field | Type | Description |
| ------- | ----------------------------------------- | -------------------------------- |
| time | number | Unix timestamp in milliseconds. |
| level | 'debug' \| 'info' \| 'warn' \| 'error' | Event severity. |
| scope | 'spawn' \| 'pipe' \| 'ws' \| 'protocol' | Runtime subsystem. |
| event | string | Stable dot-separated event name. |
| data | unknown | Optional event-specific payload. |
Error Handling
import { CdpError } from '@swarmmachina/swm-cdp'
try {
await cdp.send('Vendor.invalidMethod')
} catch (error) {
if (CdpError.isCdpError(error)) {
console.error(error.code, error.message, error.data, error.request)
} else {
throw error
}
}CdpError preserves the original request, numeric CDP error code, optional error data, and raw response.
Resource Limits and Backpressure
| Limit | Default | Failure behavior |
| ----------------------- | -------- | ----------------------------------------------------------------- |
| Pending requests | 10_000 | Reject new commands after the limit is reached. |
| Transport task queue | 10_000 | Close the transport, or drop eligible events under drop-oldest. |
| Outbound buffered bytes | 16 MiB | Reject or close when queued writes exceed the limit. |
| CDP message bytes | 64 MiB | Close the transport when the encoded message exceeds the limit. |
| Endpoint stderr bytes | 64 KiB | Fail WebSocket endpoint discovery. |
| Discovery response body | 8 MiB | Cancel response-body reading and reject the request. |
eventBackpressure: 'drop-oldest' drops only ordinary notifications. Responses and target lifecycle events are retained so request completion and flattened-session state remain coherent.
The Node.js WebSocket implementation reconstructs an incoming WebSocket message before maxMessageBytes can be applied. The local-only endpoint restriction is therefore part of the memory-safety boundary: do not forward an untrusted remote CDP endpoint onto an accepted loopback address.
Runtime Design
- Stateful owners and lifecycle components are explicit classes:
Browser, connection, transports, process owner, user-data directory, Chrome finder, request dispatcher, session registry, and event registry. - Pure normalization, validation, protocol scanning, diagnostic delivery, and argument transformations remain functions.
- Mutable module-level state is rejected by the source-policy check; caches belong to their owning class.
- An
id -> pendingMapgives response dispatch constant-time lookup. - A selective envelope scanner skips
JSON.parsefor events with no subscribers while retaining target lifecycle events required for flattened sessions. - The WebSocket transport uses the client built into Node.js; this package owns attachment, bounds, queueing, validation, and shutdown.
- The generated static protocol map provides editor completion and compile-time checking without loading
protocol.jsonat runtime. - No runtime dependencies and no
EventEmitterin protocol dispatch.
The implementation uses inheritance only for the normal Error hierarchy.
Migration from chrome-remote-interface
There is intentionally no dynamic-domain compatibility layer.
| chrome-remote-interface | @swarmmachina/swm-cdp |
| ----------------------------------------------- | ---------------------------------------------------- |
| const client = await CDP() | const cdp = await connect({ host, port }) |
| await client.Network.enable() | await cdp.send('Network.enable') |
| await client.Runtime.evaluate({ expression }) | await cdp.send('Runtime.evaluate', { expression }) |
| client.Page.loadEventFired(cb) | const off = cdp.on('Page.loadEventFired', cb) |
| client.send(method, params, sessionId) | cdp.send(method, params, sessionId) |
| await client.close() | await cdp.close() |
Use chrome-remote-interface for exploratory scripts, where dynamic domain objects matter more than cold-start cost or dependency count. swm-cdp targets typed services and high-volume CDP workloads where a minimal API and predictable hot path matter.
Performance Status
The committed protocol benchmark measures request/response throughput, flattened-session event delivery, and subscribed and unsubscribed event storms. The Chrome benchmark measures Runtime.evaluate over the pipe and WebSocket transports. Both use @swarmmachina/benchkit 0.3 for bounded latency histograms, throughput, p95/p99 latency, event-loop utilization, memory deltas, and process-memory peaks. Regression thresholds remain disabled until repeated calibration runs complete on the dedicated swm-ci/bench runner.
Testing
corepack enable
pnpm install --frozen-lockfile
pnpm run source-policy
pnpm run release:gateFocused commands:
pnpm run test:unit
pnpm run test:coverage
pnpm run test:leak
pnpm run test:e2e
pnpm run bench:protocol -- --iterations 50000 --warmup 5000 --concurrency 128
pnpm run bench:chrome -- --iterations 10000 --warmup 1000 --concurrency 32 --transport bothStability
The package is currently experimental. Public types and runtime behavior may change before a stable release; changes should be documented and covered by consumer type contracts.
Contributing
Run pnpm run release:gate before opening a pull request. Keep runtime dependencies at zero unless a measured, documented benefit justifies changing that constraint.
License
Licensed under the MPL-2.0 License.
Copyright Contributors to SwarmMachina.
See LICENSE for details. Generated Chrome DevTools Protocol declarations retain their Chromium BSD license headers.
