A DevTools panel and the page it inspects live in different worlds. The page runs the app (and the bQuery bridge); the panel runs in the DevTools window. Everything between them is a message channel with an untrusted party on one end.
┌─────────────────────────────────────────────────────────────────────┐
│ inspected page │
│ app code → connectDevtoolsBridge() ← the stable contract (v1) │
└───────────────▲───────────────────────────────────┬─────────────────┘
│ window.postMessage │
┌────────────┴─────────────┐ ┌────────────▼───────────────┐
│ EvalTransport (default) │ │ content.js relay (opt-in) │
│ inspectedWindow.eval │ │ chrome.runtime │
└────────────┬─────────────┘ └────────────┬───────────────┘
│ │ port + session token
│ ┌───────────▼───────────────┐
│ │ background/router.ts │
│ └───────────┬───────────────┘
┌────────────▼───────────────────────────────────▼───────────────┐
│ BridgeClient — handshake, capabilities, request/response, │
│ timeouts, reconnection │
└────────────────────────────┬───────────────────────────────────┘
┌────────────────────────────▼───────────────────────────────────┐
│ PanelState — signals, stores, tree, timeline buffer, │
│ time-travel reconstruction │
└────────────────────────────┬───────────────────────────────────┘
┌────────────────────────────▼───────────────────────────────────┐
│ Web Components — <bq-panel>, <bq-component-tree>, │
│ <bq-inspector>, <bq-timeline>, <bq-value>, <bq-status-bar> │
└────────────────────────────────────────────────────────────────┘
| Layer | Module | Responsibility |
|---|---|---|
| Protocol | src/protocol/messages.ts |
Message shapes, builders, and validation of page input |
| Protocol | src/protocol/results.ts |
Validation of method results |
| Protocol | src/protocol/client.ts |
Handshake, capabilities, request correlation, timeouts |
| Transport | src/transports/*.ts |
Two ways to move bytes between panel and page |
| Routing | src/background/router.ts |
Tab-scoped, token-checked routing for the port transport |
| State | src/panel/*.ts |
Buffering, filtering, time travel, preferences |
| View | src/panel/components/*.ts |
Custom elements rendering from panel state |
Each layer only knows the one below it. The views never talk to a transport; the client never touches the DOM; the protocol modules have no browser dependencies at all, which is why most of them are unit-testable without a DOM.
src/protocol/messages.ts declares the version and capability list using
typeof import('@bquery/bquery/devtools') type queries:
export const BRIDGE_PROTOCOL_VERSION: typeof import('@bquery/bquery/devtools').BRIDGE_PROTOCOL_VERSION = 1;This is a type-only reference, so the framework's page-side bridge runtime is never bundled into the extension — but if upstream bumps the protocol or changes the capability union, this repository fails to compile. The contract is enforced by the type-checker rather than by a comment.
Both implement BridgeTransport (start / send / dispose plus a status
callback), so BridgeClient is unaware of which one it drives.
EvalTransport (default). A DevTools panel may evaluate expressions in the
page it inspects without any host permission. The transport evaluates a small
expression that installs a message listener buffering page-channel bridge
messages into an array, and returns (and clears) that array as JSON. The
install is idempotent and re-runs on every poll, which makes the transport
self-healing across navigations. Outbound messages are evaluated as
window.postMessage(JSON.parse("…"), '*') — the message is data inside the
expression, never source.
PortTransport (opt-in). A long-lived chrome.runtime port to the
background worker, which relays to a content script injected into the inspected
tab. Push instead of poll, at the cost of one per-site permission. MV3 service
workers are evicted aggressively, so a dropped port reconnects with backoff and
re-attaches; the client re-runs the handshake on the fresh route.
One background worker serves every open panel, so BridgeRouter keys its table
by inspected tab id and enforces two rules:
- panel → page: forwarded only to the tab that port attached to, and only when the envelope carries the session token the router issued on attach. A message that arrives without the token — or with another port's — is dropped.
- page → panel: routed by
sender.tab.id, which the browser fills in and a page script cannot forge, and only to the panel registered for that tab.
The token is defence in depth, not the primary boundary (that is the browser's own port isolation): it means a stray message inside the extension's own message space cannot steer another panel's route.
Everything arriving from the page is attacker-controlled — a hostile page can
name a component <img src=x onerror=…> or return a cyclic value. Three rules
hold throughout:
- Validate, don't cast.
parseOutboundand theresults.tsparsers narrow unknown input, dropping malformed members instead of rendering them. Tree recursion is depth-capped. - Text sinks only.
panel/dom.tssetstextContent, neverinnerHTML. Inline styles go throughstyle.setProperty, since the panel's CSP forbidsstyleattributes. - Bound everything. Previews are truncated, child lists capped, the timeline is a ring buffer, and the in-page relay queue is bounded too.
bQuery is modular, and its bridge is a public contract. An app may load
reactive without store, run devtools without ever mounting a component, or
hand-roll a bridge server that implements two of the four methods. The panel is
built so that none of that produces a blank window, a spinner that never stops,
or a claim the page never made.
Advertisement is a hint; evidence decides. createBridgeServer advertises
the full capability list regardless of which modules the app actually loaded,
and a trimmed bridge may advertise nothing while answering everything. So the
init capability list is recorded but not obeyed: panel/features.ts tracks,
per feature, what the page has actually proved it can serve.
| Status | Meaning | Retried? |
|---|---|---|
unknown |
Not attempted yet on this connection | yes — including a one-shot probe of capabilities the page never advertised |
available |
The page returned data the panel could parse | yes, on every refresh |
unsupported |
The page cannot serve it (no such method, or an unusable answer) | no — until the next handshake or an explicit Refresh all |
failed |
It should work but the last attempt did not | yes, on every refresh |
Four rules follow from that model:
- Sections fail independently.
refreshAllruns the three fetches concurrently and none of them rejects; a page that implementsgetTimelinebut notgetSnapshotstill gets a timeline. (This was a real defect: the fetches used to be chained throughPromise.all, so one missing method took the whole panel down with it.) - Probe once, then stop asking. A capability that was never advertised is still tried once per connection — that is what lets a bridge advertising nothing light up. A method the page refuses is not asked again until the next handshake or an explicit refresh, so an absent feature costs exactly one request.
- Absent is not empty. A snapshot carrying
signalsbut nostoreskey leaves the stores view saying the page does not report stores, rather than a confident and wrong "0 stores" — and does not wipe a component registry thatgetComponentTreefilled in. - Degrade to what is there. With no component tree but a snapshot that
lists components, the tree view shows that flat registry instead of an empty
panel. Time travel is gated on having a base snapshot and recorded events —
it is reconstructed by the panel, so it works whether or not the page claims
a
time-travelcapability.
A page speaking a protocol version this panel does not know is a related case.
Its messages are still discarded — parsing a contract you do not understand is
how a validator becomes an attack surface — but the panel says so
("the page speaks bridge protocol v2…") instead of sitting in waiting for the
page while the page answers every hello. The handshake keeps retrying, so
navigating to a compatible app recovers without reopening DevTools.
The bridge exposes primitives, not history: getSnapshot is the state now
and event messages stream what changed after. Time travel is reconstructed in
the panel — the connect-time snapshot is the base, and panel/timeTravel.ts
replays recorded events onto it up to a chosen index.
Event payloads are app-defined (payload?: unknown), so replay is deliberately
tolerant: it recognizes { value }, { next }, { to } and bare payloads for
signals, and { patch }, { state }, { next } or a plain object for stores.
When a payload cannot be interpreted, the value is reported as not recorded
and the previous value is kept — the panel never invents state, and the UI
labels every row as replayed, unchanged or not recorded.
Reconstruction is read-only: nothing is ever written back into the page.
vite builds the module entries (panel, devtools, settings,
background). The content script cannot be an ES module, so tools/content.ts
bundles it separately with esbuild as a self-contained IIFE. tools/parse.ts
substitutes the branding tokens into the HTML pages and links the emitted CSS;
tools/v2.ts rewrites the manifest for Firefox (MV2); tools/verifyBuild.ts
checks the result is actually loadable before it is packaged.