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. 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 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 connectingconnected → (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.