|
| 1 | +--- |
| 2 | +title: 'In-Page Channel' |
| 3 | +description: '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.' |
| 4 | +--- |
| 5 | + |
| 6 | +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](/plugins/a11y)'s scan/highlight cycle) works identically in dev and in a static build. |
| 7 | + |
| 8 | +## Overview |
| 9 | + |
| 10 | +```mermaid |
| 11 | +flowchart LR |
| 12 | + subgraph Host["User app's page"] |
| 13 | + PS["Page script<br/>createPageScriptChannel()"] |
| 14 | + end |
| 15 | + subgraph Dock["Dock iframe"] |
| 16 | + PA["Panel<br/>connectPanelChannel()"] |
| 17 | + end |
| 18 | + subgraph PiP["Popup / Document PiP"] |
| 19 | + PB["Panel<br/>connectPanelChannel()"] |
| 20 | + end |
| 21 | + PA <-->|"MessageChannel port"| PS |
| 22 | + PB <-->|"MessageChannel port"| PS |
| 23 | +``` |
| 24 | + |
| 25 | +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 — a dock iframe and a picture-in-picture window can watch the same page script at once. |
| 26 | + |
| 27 | +## The protocol |
| 28 | + |
| 29 | +Declare the contract once, in a shared file both sides import — a pure type plus the channel-name constant: |
| 30 | + |
| 31 | +```ts |
| 32 | +// shared/protocol.ts |
| 33 | +import type { InPageChannelProtocol } from 'devframe/in-page-channel' |
| 34 | + |
| 35 | +export const MY_CHANNEL = 'devframes:plugin:my-tool' |
| 36 | + |
| 37 | +export interface MyChannelProtocol extends InPageChannelProtocol { |
| 38 | + pageScript: { // implemented by the page script, called by panels |
| 39 | + highlight: (selector: string) => void |
| 40 | + measure: (selector: string) => { width: number, height: number } |
| 41 | + } |
| 42 | + panel: { // implemented by panels, called by the page script |
| 43 | + flash: (message: string) => void |
| 44 | + } |
| 45 | + sharedStates: { |
| 46 | + state: { selections: string[] } |
| 47 | + } |
| 48 | +} |
| 49 | +``` |
| 50 | + |
| 51 | +Channel names are namespaced with the devframe id, like RPC ids. Function names stay bare — the channel name already scopes them. |
| 52 | + |
| 53 | +## The page script endpoint |
| 54 | + |
| 55 | +Functions are defined with `defineChannelFunction` — the same authoring shape as `defineRpcFunction` (`name`, `type`, Standard-Schema `args`/`returns`, `jsonSerializable`, `handler`), narrowed to the browser. Define each side's functions in that side's source files; the shared protocol file carries only types. |
| 56 | + |
| 57 | +```ts |
| 58 | +import type { MyChannelProtocol } from '../shared/protocol' |
| 59 | +// inject/index.ts — runs in the user app's page |
| 60 | +import { createPageScriptChannel, defineChannelFunction } from 'devframe/in-page-channel' |
| 61 | +import { MY_CHANNEL } from '../shared/protocol' |
| 62 | + |
| 63 | +const channel = createPageScriptChannel<MyChannelProtocol>({ |
| 64 | + name: MY_CHANNEL, |
| 65 | + functions: [ |
| 66 | + defineChannelFunction({ |
| 67 | + name: 'highlight', |
| 68 | + type: 'event', // fire-and-forget |
| 69 | + jsonSerializable: true, |
| 70 | + handler: (selector: string) => drawRing(document.querySelector(selector)), |
| 71 | + }), |
| 72 | + defineChannelFunction({ |
| 73 | + name: 'measure', // request/response (the default `query` type) |
| 74 | + handler: (selector: string) => { |
| 75 | + const rect = document.querySelector(selector)!.getBoundingClientRect() |
| 76 | + return { width: rect.width, height: rect.height } |
| 77 | + }, |
| 78 | + }), |
| 79 | + ], |
| 80 | +}) |
| 81 | + |
| 82 | +channel.callEvent('flash', 'scanning…') // fans out to every connected panel |
| 83 | +channel.events.on('panel:connected', panel => console.log(panel.id)) |
| 84 | +channel.events.on('panel:disconnected', () => pauseWorkIfNobodyWatches()) |
| 85 | +``` |
| 86 | + |
| 87 | +`callEvent` on the page script is 1:N — it fans out to every connected panel, and panels that don't implement the function ignore it. Request/response *to* a panel goes through an explicit peer handle: `channel.panels[0].call('flash', '…')`. |
| 88 | + |
| 89 | +## The panel endpoint |
| 90 | + |
| 91 | +```ts |
| 92 | +import type { MyChannelProtocol } from '../shared/protocol' |
| 93 | +// spa/main.ts — the devtools SPA (dock iframe, popup, or PiP) |
| 94 | +import { connectPanelChannel } from 'devframe/in-page-channel' |
| 95 | +import { MY_CHANNEL } from '../shared/protocol' |
| 96 | + |
| 97 | +const channel = connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL }) |
| 98 | + |
| 99 | +channel.callEvent('highlight', '.hero') // buffered until connected |
| 100 | +const size = await channel.call('measure', '.hero') |
| 101 | +``` |
| 102 | + |
| 103 | +## Shared state |
| 104 | + |
| 105 | +The channel's shared-state layer mirrors [`rpc.sharedState`](/guide/shared-state) — 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. |
| 106 | + |
| 107 | +```ts |
| 108 | +// Page script — the authority: |
| 109 | +const state = await channel.sharedState.get('state', { initialValue: { selections: [] } }) |
| 110 | +state.mutate((draft) => { |
| 111 | + draft.selections.push('.hero') |
| 112 | +}) |
| 113 | + |
| 114 | +// Panel — a live mirror: |
| 115 | +const state = await channel.sharedState.get('state') |
| 116 | +state.on('updated', fullState => render(fullState)) |
| 117 | +state.value() // Immutable<T> snapshot |
| 118 | +``` |
| 119 | + |
| 120 | +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. |
| 121 | + |
| 122 | +## Errors and fallbacks |
| 123 | + |
| 124 | +Every failure mode is a coded `InPageChannelError` (`error.code`) with a message that explains itself: |
| 125 | + |
| 126 | +| Code | When | What to do | |
| 127 | +|------|------|------------| |
| 128 | +| `timeout` | A call outlived `callTimeoutMs` (default 15s), or `whenConnected(ms)` expired | The message carries the endpoint status — `connecting` usually means the page script isn't loaded in this context | |
| 129 | +| `closed` | The endpoint was closed with calls pending | Expected during teardown | |
| 130 | +| `not-serializable` | A `jsonSerializable: true` payload contained a non-JSON value | The message names the offending path (e.g. `its arguments[0].nodes[2]` is a Map) | |
| 131 | +| `not-cloneable` | The port refused to clone a payload (`DataCloneError`) | Strip functions/DOM nodes/reactivity proxies — or declare `jsonSerializable: true` for the precise error above | |
| 132 | +| `invalid-args` | Incoming arguments failed their Standard-Schema validation | The message lists the schema issues | |
| 133 | +| `state-uninitialized` | The page script read a shared state before providing its `initialValue` | Initialize on first access | |
| 134 | + |
| 135 | +The panel endpoint's connection lifecycle is explicit, so a panel renders a useful fallback instead of hanging: |
| 136 | + |
| 137 | +- `channel.status` is `connecting` → `connected` → (`connecting` on port loss) → `closed`, with `events.on('status:updated', …)` for reactivity. |
| 138 | +- While `connecting`, `call()` is queued (and still subject to its deadline) and `callEvent()` is buffered (up to `eventBufferLimit`, oldest dropped with a warning) — both flush on connect. |
| 139 | +- 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: |
| 140 | + |
| 141 | +```ts |
| 142 | +try { |
| 143 | + await channel.whenConnected(3000) |
| 144 | +} |
| 145 | +catch { |
| 146 | + renderEmptyState('Add the page script to your app to see live data.') |
| 147 | +} |
| 148 | +``` |
| 149 | + |
| 150 | +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. |
| 151 | + |
| 152 | +## Reactivity and serialization |
| 153 | + |
| 154 | +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: |
| 155 | + |
| 156 | +```ts |
| 157 | +import { toRaw } from 'vue' |
| 158 | + |
| 159 | +const channel = connectPanelChannel<MyChannelProtocol>({ |
| 160 | + name: MY_CHANNEL, |
| 161 | + serialize: value => toRawDeep(value), // applied to every outgoing argument and result |
| 162 | +}) |
| 163 | +``` |
| 164 | + |
| 165 | +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. |
| 166 | + |
| 167 | +## Multiple tabs |
| 168 | + |
| 169 | +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: |
| 170 | + |
| 171 | +```ts |
| 172 | +connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL, instanceId }) |
| 173 | +``` |
| 174 | + |
| 175 | +## Custom transports |
| 176 | + |
| 177 | +Both endpoints accept a pre-established `MessagePort`, bypassing the handshake — for custom topologies and tests: |
| 178 | + |
| 179 | +```ts |
| 180 | +const { port1, port2 } = new MessageChannel() |
| 181 | +pageScript.addPanelPort(port1) |
| 182 | +const panel = connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL, transport: port2 }) |
| 183 | +``` |
| 184 | + |
| 185 | +## When to use the in-page channel vs RPC |
| 186 | + |
| 187 | +| Use the in-page channel for | Use [RPC](/guide/rpc) for | |
| 188 | +|----------------------------|---------------------------| |
| 189 | +| Page script ↔ panel loops (highlight, scan, measure) | Anything involving the node side (files, processes, storage) | |
| 190 | +| Working identically in dev and static builds | Data that must survive the tab (server owns it) | |
| 191 | +| Same-tab, same-origin surfaces | Cross-origin external viewers, remote panels | |
| 192 | + |
| 193 | +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. |
0 commit comments