Skip to content

Commit 87dafe9

Browse files
antfubotantfu
andauthored
feat: add devframe/in-page-channel, server-free page script ↔ panel communication (#302)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent 8d58eb5 commit 87dafe9

47 files changed

Lines changed: 2737 additions & 252 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

alias.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const alias = {
2323
'devframe/node/hub-internals': r('devframe/src/node/hub-internals/index.ts'),
2424
'devframe/node': r('devframe/src/node/index.ts'),
2525
'devframe/internal': r('devframe/src/internal/index.ts'),
26+
'devframe/in-page-channel': r('devframe/src/in-page-channel/index.ts'),
2627
'devframe/constants': r('devframe/src/constants.ts'),
2728
'devframe/utils/agent-tool-name': r('devframe/src/utils/agent-tool-name.ts'),
2829
'devframe/utils/colors': r('devframe/src/utils/colors.ts'),
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
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.
File renamed without changes.
File renamed without changes.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ One bundle can serve as both a client script (default export) and, via a globall
9393

9494
## Iframe panels
9595

96-
Dock iframes are their own documents: the panel calls `connectDevframe()`, discovering `./__connection.json` from its base. A client script and an iframe panel share the node side via RPC and shared state, or a same-origin `BroadcastChannel` for static builds.
96+
Dock iframes are their own documents: the panel calls `connectDevframe()`, discovering `./__connection.json` from its base. A client script and an iframe panel share the node side via RPC and shared state, or talk directly — server-free, static-build-friendly — over the [in-page channel](/guide/in-page-channel).
9797

9898
## Shared-iframe soft navigation
9999

File renamed without changes.

0 commit comments

Comments
 (0)