In-Page Channel
The in-page channel (devframe/in-page-channel) connects a devframe's page script to its panels entirely in the browser: typed events, calls, and page-script-authoritative shared state, with no server involved. It is how a live inspect-the-page loop (like the a11y inspector's scan/highlight cycle) works identically in dev and in a static build.
Overview
The panel finds the page script with a same-origin postMessage handshake: it posts a versioned hello to its ancestor chain and opener, retrying with backoff until the page script answers by transferring a dedicated MessageChannel port. Boot order never matters, a reload of either side is just a re-handshake, and each connected panel gets its own port, so a dock iframe and a picture-in-picture window can watch the same page script at once.
The protocol
Declare the contract once, in a shared file both sides import: a pure type plus the channel-name constant:
// shared/protocol.ts
import type { InPageChannelProtocol } from 'devframe/in-page-channel'
export const MY_CHANNEL = 'devframes:plugin:my-tool'
export interface MyChannelProtocol extends InPageChannelProtocol {
functions: {
/** implemented by the page script, callable by panels */
pageScript: {
measure: (selector: string) => { width: number, height: number }
reset: () => Promise<void>
}
/** implemented by panels, callable by the page script */
panel: {
echo: (message: string) => Promise<string>
}
}
events: {
/** listened to by the page script, emitted by panels */
pageScript: { highlight: (selector: string) => void }
/** listened to by panels, emitted by the page script */
panel: { flash: (message: string) => void }
}
sharedStates: {
state: { selections: string[] }
}
}Channel names are namespaced with the devframe id, like RPC ids. Function names stay bare; the channel name already scopes them.
The page script endpoint
The required functions option and optional events option declare every incoming name on the endpoint's protocol side; use {} for an empty direction. Functions require a handler. Events accept an optional handler, and {} registers an event for runtime subscriptions through on(). Handlers are contextually typed from the shared protocol and support Standard-Schema argument validation and jsonSerializable metadata. defineChannelFunction retains the named definition shape for lower-level authoring.
call() accepts names from functions, including actions returning void or Promise<void>: callers can await completion and catch errors or timeouts. emit(), its deprecated alias callEvent(), and on() use the names declared in events. Function and event names have separate namespaces.
import type { MyChannelProtocol } from '../shared/protocol'
// inject/index.ts: runs in the user app's page
import { createPageScriptChannel } from 'devframe/in-page-channel'
import { MY_CHANNEL } from '../shared/protocol'
const pageChannel = createPageScriptChannel<MyChannelProtocol>({
name: MY_CHANNEL,
functions: {
reset: { type: 'action', handler: async () => clearSelections() },
measure: { // request/response (the default `query` type)
handler: (selector) => {
const rect = document.querySelector(selector)!.getBoundingClientRect()
return { width: rect.width, height: rect.height }
},
},
},
events: {
highlight: {
jsonSerializable: true,
handler: selector => drawRing(document.querySelector(selector)),
},
},
})
pageChannel.emit('flash', 'scanning…') // received by each panel endpoint
pageChannel.events.on('panel:connected', panel => console.log(panel.id))
pageChannel.events.on('panel:disconnected', () => pauseWorkIfNobodyWatches())emit on the page-script endpoint fans out to every connected panel endpoint. Functions declared under functions.panel are called through a specific pageChannel.panels[0].call() peer handle.
The panel endpoint
import type { MyChannelProtocol } from '../shared/protocol'
// spa/main.ts: the devtools SPA (dock iframe, popup, or PiP)
import { connectPanelChannel } from 'devframe/in-page-channel'
import { MY_CHANNEL } from '../shared/protocol'
const panelChannel = connectPanelChannel<MyChannelProtocol>({
name: MY_CHANNEL,
functions: {},
events: {
flash: {},
},
})
const offFlash = panelChannel.on('flash', message => showFlash(message))
// defined and received by the page-script endpoint
panelChannel.emit('highlight', '.hero')
const size = await panelChannel.call('measure', '.hero')
await panelChannel.call('reset')
offFlash() // stop listeningThe snippets form one channel pair: pageChannel.emit('flash', …) invokes panelChannel.on('flash', …). In the other direction, panelChannel.emit('highlight', …) invokes the page-script endpoint's highlight handler and any matching pageChannel.on() listeners. An endpoint never receives its own emission.
Shared state
The channel's shared-state layer mirrors rpc.sharedState (same SharedState<T> handle, same accessor), with the page script playing the server's role as rendezvous and authority. Its first get of a key must provide the initial value; panels are seeded automatically on connect (including late joiners and re-connects) and converge through syncId-deduplicated patches.
// Page script (the authority):
const state = await channel.sharedState.get('state', { initialValue: { selections: [] } })
state.mutate((draft) => {
draft.selections.push('.hero')
})
// Panel (a live mirror):
const state = await channel.sharedState.get('state')
state.on('updated', fullState => render(fullState))
state.value() // Immutable<T> snapshotWithout an initialValue, a panel's get resolves once the first replay arrives, so render(state.value()) never sees a half-initialized value. Keep values serializable: they cross a structured-clone boundary on every sync.
Errors and fallbacks
Every failure mode is a coded InPageChannelError (error.code) with a message that explains itself: timeout (a call or whenConnected(ms) outlived its deadline), closed (endpoint torn down with calls pending), not-serializable / not-cloneable (a payload the port can't carry; the message names the offending path), invalid-args (Standard-Schema validation failed), and state-uninitialized (a shared state read before its initialValue). Causes and fixes per code are in the Browser-Side API reference.
The panel endpoint's connection lifecycle is explicit, so a panel renders a useful fallback instead of hanging:
channel.statusisconnecting→connected→ (connectingon port loss) →closed, withevents.on('status:updated', …)for reactivity.- While
connecting,call()is queued (and still subject to its deadline) andemit()is buffered (up toeventBufferLimit, oldest dropped with a warning); both flush on connect. - A page script may legitimately never appear (the panel opened standalone, the user app not instrumented). Race
whenConnected(timeoutMs)to show a "load the page script" empty state:
try {
await channel.whenConnected(3000)
}
catch {
renderEmptyState('Add the page script to your app to see live data.')
}Recovery is automatic: a dead port (detected by the port's close event or the built-in heartbeat) returns the panel to connecting and resumes the handshake, so a host-page reload reconnects a popup panel by itself.
Reactivity and serialization
Payloads cross the port with structured clone. Framework reactivity wrappers don't survive it; unwrap them before sending, either in handlers or once per endpoint with the serialize/deserialize hooks:
import { toRaw } from 'vue'
const channel = connectPanelChannel<MyChannelProtocol>({
name: MY_CHANNEL,
serialize: value => toRawDeep(value), // applied to every outgoing argument and result
functions: {},
events: { flash: {} },
})These hooks also apply to shared-state subscription snapshots, full-state updates, and patch arrays in both directions. Hooks that restore nested values should traverse objects and arrays, including each patch's value.
Declaring a function jsonSerializable: true additionally enforces strict JSON on its payloads at the receiving endpoint, turning a would-be silent coercion or cryptic DataCloneError into a coded error naming the offending path.
Multiple tabs
The same app open in two tabs means two page scripts on one origin. Each page script carries a per-tab instance id (persisted in sessionStorage), and handshakes are targeted postMessage, so a dock panel always pairs with its own tab's page script. A panel can also pin explicitly:
connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL, instanceId, functions: {}, events: { flash: {} } })Custom transports
Both endpoints accept a pre-established MessagePort that bypasses the handshake, for custom topologies and tests:
const { port1, port2 } = new MessageChannel()
pageScript.addPanelPort(port1)
const panel = connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL, transport: port2, functions: {}, events: { flash: {} } })When to use the in-page channel vs RPC
| Use the in-page channel for | Use RPC for |
|---|---|
| Page script ↔ panel loops (highlight, scan, measure) | Anything involving the node side (files, processes, storage) |
| Working identically in dev and static builds | Data that must survive the tab (server owns it) |
| Same-tab, same-origin surfaces | Cross-origin external viewers, remote panels |
The a11y inspector uses both: the scan/highlight loop rides the in-page channel, while get-config is a static RPC resolved over WebSocket in dev and from the baked dump in a static build.
Client
The RPC client connects any surface (dock iframe, remote page, standalone SPA) to a devframe's node side with type-safe RPC, shared state, and a trust handshake.
Transports
Devframe serves live RPC over two interchangeable transports, WebSocket and SSE, so an RPC client connects even where the WebSocket upgrade is unavailable (serverless, buffering proxies). Both speak the identical birpc wire protocol, transparent to your RPC code.