In-Page Channel

The 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.

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. A function with agent metadata is available to coding agents through MCP; the field implicitly enables strict JSON serialization (an explicit jsonSerializable: false conflicts). 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() 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 listening

The 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> snapshot

Without 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.status is connecting → connected → (connecting on port loss) → closed, with events.on('status:updated', …) for reactivity.
  • While connecting, call() is queued (and still subject to its deadline) and emit() is buffered (up to eventBufferLimit, 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 forUse RPC for
Page script ↔ panel loops (highlight, scan, measure)Anything involving the node side (files, processes, storage)
Working identically in dev and static buildsData that must survive the tab (server owns it)
Same-tab, same-origin surfacesCross-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.