@docyrus/chrome-extension-bridge
v0.1.0
Published
Iframe SDK and host adapter for talking to the Docyrus chrome extension shell over postMessage
Readme
@docyrus/chrome-extension-bridge
Iframe SDK and host adapter for talking to the Docyrus Chrome extension
shell over postMessage.
External Docyrus apps run as iframes inside the Docyrus shell (web, desktop, chrome side panel). The shell already exposes Docyrus auth and data APIs to the iframe via a postMessage RPC channel. When the user has the Docyrus Chrome extension installed, the chrome side panel has additional access to the user's active browser tab — navigation, scripting, cookies, network inspection, screenshots, etc. — implemented in the extension's background service worker.
This package gives external apps a small, typed SDK to reach those
capabilities. The same code keeps working in the desktop and web shells — it
just reports isAvailable === false and rejects method calls with a typed
ChromeExtensionUnavailableError.
Status:
0.1.x— protocol stable enough to build against, but still iterating; minor versions may add new RPC methods. Host and SDK pin a major version againstCHROME_EXTENSION_PROTOCOL_VERSIONto fail loudly if they diverge.
Install
pnpm add @docyrus/chrome-extension-bridge
# or
npm install @docyrus/chrome-extension-bridgeThere are no required peer dependencies. The iframe SDK is framework-agnostic.
Quick start (iframe app)
import { createChromeExtensionBridge } from "@docyrus/chrome-extension-bridge"
const chromeExt = createChromeExtensionBridge()
await chromeExt.ready()
if (!chromeExt.isAvailable) {
// Running outside the Docyrus chrome extension. Hide the chrome-only UI
// and keep going.
return
}
const tab = await chromeExt.tabs.getActive()
console.log("Active tab:", tab.url, tab.title)
await chromeExt.tabs.navigate({ url: "https://example.com" })
await chromeExt.tabs.wait({ idle: true, timeout: 10_000 })
const off = chromeExt.on("tabChanged", info => {
console.log("User switched tabs →", info.url)
})
// later, when your component unmounts
off()
chromeExt.dispose()React example
import { useEffect, useState } from "react"
import {
ChromeExtensionUnavailableError,
createChromeExtensionBridge,
type TargetTabInfo
} from "@docyrus/chrome-extension-bridge"
function ActiveTabPanel() {
const [tab, setTab] = useState<TargetTabInfo | null>(null)
const [available, setAvailable] = useState<boolean | null>(null)
useEffect(() => {
const bridge = createChromeExtensionBridge()
let cancelled = false
bridge.ready().then(async () => {
if (cancelled) return
setAvailable(bridge.isAvailable)
if (!bridge.isAvailable) return
try {
setTab(await bridge.tabs.getActive())
} catch (error) {
if (error instanceof ChromeExtensionUnavailableError) return
console.error(error)
}
})
const off = bridge.on("tabChanged", info => {
if (!cancelled) setTab(info)
})
return () => {
cancelled = true
off()
bridge.dispose()
}
}, [])
if (available === false) return <p>Open this app in the Docyrus Chrome extension to see the active tab.</p>
if (!tab) return <p>Loading…</p>
return <p>{tab.title} — {tab.url}</p>
}API
createChromeExtensionBridge(options?)
Creates a bridge instance bound to the parent window (window.parent).
| Option | Type | Default | Description |
| ------------ | -------- | ----------------- | ----------- |
| hostOrigin | string | ?hostOrigin= query param or "*" | Origin to post to. The Docyrus host injects ?hostOrigin= via getEmbeddedAppUrl; the SDK reads it automatically. |
| timeout | number | 30000 | RPC timeout in milliseconds. Set to 0 to disable. |
| target | Window | window.parent | Override the postMessage target (rarely needed). |
Returns a ChromeExtensionBridge:
interface ChromeExtensionBridge {
ready(): Promise<void>
readonly isAvailable: boolean
readonly shell: "chrome" | "desktop" | "web" | null
readonly protocolVersion: number
tabs: { /* see below */ }
cookies: { /* see below */ }
console: { /* see below */ }
network: { /* see below */ }
on<E>(event: E, listener): () => void
dispose(): void
}ready() resolves once the bridge has handshaked with the host and probed
availability. Awaiting ready() is optional — every method internally waits
on ready() before sending — but it's the easiest way to branch on
isAvailable.
If isAvailable === false, every method rejects with
ChromeExtensionUnavailableError.
bridge.tabs
All methods operate on the user's currently active (or pinned) browser tab.
URLs the extension can't touch (chrome://, chrome-extension://,
about:) are filtered out by the host; the SDK surfaces a clear error in
that case.
| Method | Signature | Notes |
| --- | --- | --- |
| getActive() | () => Promise<TargetTabInfo> | { tabId, url, title, pinned }. |
| pin({ tabId }) | (p) => Promise<TargetTabInfo> | Pin a specific tab so future calls target it across user navigation. |
| unpin() | () => Promise<TargetTabInfo> | Resume tracking whichever tab the user is on. |
| navigate({ url?, reload? }) | (p) => Promise<{ url }> | Navigate or reload the target tab. |
| wait({ idle?, selector?, url?, ms?, timeout? }) | (p) => Promise<{ waited, url }> | Wait for idle/network-quiet, a CSS selector, a URL match, or a fixed delay. |
| snapshot({ all?, selector? }) | (p) => Promise<{ count, snapshot }> | Capture an interactive snapshot of the DOM (accessibility-style tree). |
| click({ target, x?, y?, timeout? }) | (p) => Promise<{ clicked, url }> | Dispatch a click via CDP at a selector or coordinates. |
| fill({ target, value, timeout? }) | (p) => Promise<{ filled, value }> | Focus a field and type a value. |
| select({ target, value }) | (p) => Promise<{ selected, value }> | Set the value of a <select>. |
| evaluate({ code }) | (p) => Promise<{ result }> | Run JavaScript in the page main world and return the value. |
| screenshot({ full? }) | (p) => Promise<{ base64 }> | PNG (base64); full: true captures the full page. |
| getContent() | () => Promise<{ url, title, content }> | Readable text content of the page. |
| getInfo() | () => Promise<Record<string, unknown>> | Misc metadata (meta tags, language, etc.). |
| openExternal({ url }) | (p) => Promise<{ webviewId }> | Open a tracked side tab. |
| closeExternal({ webviewId }) | (p) => Promise<void> | Close a tab opened by openExternal. |
bridge.cookies
| Method | Signature | Notes |
| --- | --- | --- |
| getAll({ domain?, name?, url? }) | (p) => Promise<{ cookies }> | Returns name, value, domain, path, secure, httpOnly, expirationDate. |
bridge.console / bridge.network
Both expose a CDP-backed ring buffer the extension fills while the side panel is open.
const { messages } = await bridge.console.read({ level: "error" })
const { requests } = await bridge.network.read({ status: 500 })Events
bridge.on("tabChanged", (info) => {
// info: TargetTabInfo
})tabChanged fires whenever the host detects the target tab moved
(navigated, reloaded, or the user switched tabs/windows). Multiple
listeners are supported; the return value of on removes the listener.
Capability detection
The same app can run in the chrome extension, the Docyrus desktop app, and the Docyrus web app. The SDK keeps that cross-shell story simple:
const bridge = createChromeExtensionBridge()
await bridge.ready()
if (bridge.shell === "chrome") {
// Chrome-only UI: show active tab tools.
} else {
// Desktop or web: hide the panel, or surface a "Install the Docyrus
// Chrome extension" upsell.
}bridge.isAvailable is a strict alias for bridge.shell === "chrome".
Host integration
The host adapter is what makes the SDK actually talk to chrome.* APIs. It lives in the same package, and is consumed by the chrome extension shell — not by your app code. Most readers can skip this section.
import { createChromeExtensionHostApi } from "@docyrus/chrome-extension-bridge/host"
import { getDocyrusDesktopBridge } from "@workspace/client-web/desktop"
const hostApi = createChromeExtensionHostApi({
getBridge: () => getDocyrusDesktopBridge() ?? null,
shell: "chrome"
})
useEffect(() => {
const detach = hostApi.attach()
return () => {
detach()
hostApi.dispose()
}
}, [hostApi])
<ExternalAppContainer
ref={hostApi.containerRefFor(app.slug)}
rootApi={hostApi.rootApi}
src={getEmbeddedAppUrl(src)}
title={app.name}
/>hostApi.rootApi is a fragment of an ExternalAppApiMap (Record<string,
fn>). It merges into the default root API exposed by ExternalAppContainer
without colliding (all methods are prefixed chromeExtension.).
hostApi.containerRefFor(key) returns a stable MutableRefObject keyed
per iframe so events broadcast from the background worker fan out to every
mounted bridge.
hostApi.attach() subscribes to chrome.runtime.onMessage and forwards
docyrus:browser:target-tab-changed broadcasts as the SDK-visible
tabChanged event.
Permissions
This package does not request any chrome permissions itself. It only forwards calls to the Docyrus extension, whose manifest already declares:
tabs,activeTab,scripting,webNavigation— required for tab inspection and automation.cookies— required forcookies.getAll.debugger— required forevaluate,screenshot,console,network, and CDP-backed input dispatch (click,fill).storage,sidePanel,identity— used by the shell itself; the bridge does not surface them.
Limitations
- One target tab at a time. The extension automation surface drives
whichever tab is active or pinned via
tabs.pin. - Privileged URLs are unreachable.
chrome://,chrome-extension://,about:,edge://,brave://are filtered out by the host. Methods return an error if the user is on one of those pages. - CDP-backed methods require the side panel to be open. Console and network buffers only fill while the Docyrus side panel is attached; closing it detaches CDP.
- No bidirectional event channel. The bridge currently only pushes events from host → iframe. If you need to push from iframe → host, open an issue.
Versioning
CHROME_EXTENSION_PROTOCOL_VERSION is exported from the package root and
will increment on every breaking RPC/event change. The SDK refuses to talk
to a host that reports a lower major protocol version.
The package follows semver; pre-1.0.0 the protocol is still
allowed to shift between minor versions, but every change is documented in
CHANGELOG.md.
License
MIT
