Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const alias = {
'devframe/node/hub-internals': r('devframe/src/node/hub-internals/index.ts'),
'devframe/node': r('devframe/src/node/index.ts'),
'devframe/internal': r('devframe/src/internal/index.ts'),
'devframe/in-page-channel': r('devframe/src/in-page-channel/index.ts'),
'devframe/constants': r('devframe/src/constants.ts'),
'devframe/utils/agent-tool-name': r('devframe/src/utils/agent-tool-name.ts'),
'devframe/utils/colors': r('devframe/src/utils/colors.ts'),
Expand Down
193 changes: 193 additions & 0 deletions docs/content/1.guide/12.in-page-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
---
title: 'In-Page Channel'
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.'
---

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.

## Overview

```mermaid
flowchart LR
subgraph Host["User app's page"]
PS["Page script<br/>createPageScriptChannel()"]
end
subgraph Dock["Dock iframe"]
PA["Panel<br/>connectPanelChannel()"]
end
subgraph PiP["Popup / Document PiP"]
PB["Panel<br/>connectPanelChannel()"]
end
PA <-->|"MessageChannel port"| PS
PB <-->|"MessageChannel port"| PS
```

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.

## The protocol

Declare the contract once, in a shared file both sides import — a pure type plus the channel-name constant:

```ts
// shared/protocol.ts
import type { InPageChannelProtocol } from 'devframe/in-page-channel'

export const MY_CHANNEL = 'devframes:plugin:my-tool'

export interface MyChannelProtocol extends InPageChannelProtocol {
pageScript: { // implemented by the page script, called by panels
highlight: (selector: string) => void
measure: (selector: string) => { width: number, height: number }
}
panel: { // implemented by panels, called by the page script
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

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.

```ts
import type { MyChannelProtocol } from '../shared/protocol'
// inject/index.ts — runs in the user app's page
import { createPageScriptChannel, defineChannelFunction } from 'devframe/in-page-channel'
import { MY_CHANNEL } from '../shared/protocol'

const channel = createPageScriptChannel<MyChannelProtocol>({
name: MY_CHANNEL,
functions: [
defineChannelFunction({
name: 'highlight',
type: 'event', // fire-and-forget
jsonSerializable: true,
handler: (selector: string) => drawRing(document.querySelector(selector)),
}),
defineChannelFunction({
name: 'measure', // request/response (the default `query` type)
handler: (selector: string) => {
const rect = document.querySelector(selector)!.getBoundingClientRect()
return { width: rect.width, height: rect.height }
},
}),
],
})

channel.callEvent('flash', 'scanning…') // fans out to every connected panel
channel.events.on('panel:connected', panel => console.log(panel.id))
channel.events.on('panel:disconnected', () => pauseWorkIfNobodyWatches())
```

`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', '…')`.

## The panel endpoint

```ts
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 channel = connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL })

channel.callEvent('highlight', '.hero') // buffered until connected
const size = await channel.call('measure', '.hero')
```

## Shared state

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.

```ts
// 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:

| Code | When | What to do |
|------|------|------------|
| `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 |
| `closed` | The endpoint was closed with calls pending | Expected during teardown |
| `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) |
| `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 |
| `invalid-args` | Incoming arguments failed their Standard-Schema validation | The message lists the schema issues |
| `state-uninitialized` | The page script read a shared state before providing its `initialValue` | Initialize on first access |

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 `callEvent()` 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:

```ts
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:

```ts
import { toRaw } from 'vue'

const channel = connectPanelChannel<MyChannelProtocol>({
name: MY_CHANNEL,
serialize: value => toRawDeep(value), // applied to every outgoing argument and result
})
```

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:

```ts
connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL, instanceId })
```

## Custom transports

Both endpoints accept a pre-established `MessagePort`, bypassing the handshake — for custom topologies and tests:

```ts
const { port1, port2 } = new MessageChannel()
pageScript.addPanelPort(port1)
const panel = connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL, transport: port2 })
```

## When to use the in-page channel vs RPC

| Use the in-page channel for | Use [RPC](/guide/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.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ One bundle can serve as both a client script (default export) and, via a globall

## Iframe panels

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

## Shared-iframe soft navigation

Expand Down
6 changes: 3 additions & 3 deletions docs/content/5.plugins/4.a11y.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ Three pieces, two browser-side:

| Piece | Runs in | Role |
|-------|---------|------|
| **Page script** | the user app's page | runs axe-core, broadcasts the report, draws the highlight ring |
| **Page script** | the user app's page | runs axe-core, owns the report state, draws the highlight ring |
| **Panel** | the devtools iframe | Solid SPA: lists violations, highlights on hover |
| **Node side** | the Node process | the `get-config` RPC (impact taxonomy), baked in static builds |

The page script and the panel talk over the in-page channel (a same-origin `BroadcastChannel`), so the loop works live or static. The page script is the author-provided bridge (the panel has no reach into the user app's DOM) — one module script scans, reports, and highlights.
The page script and the panel talk over the [in-page channel](/guide/in-page-channel), so the loop works live or static. The page script is the author-provided bridge (the panel has no reach into the user app's DOM) — one module script scans, reports, and highlights.

## In a hub

Expand Down Expand Up @@ -86,7 +86,7 @@ Namespaced `devframes:plugin:a11y:*`:

| Function | Type | Returns |
|----------|------|---------|
| `get-config` | `static` | The impact taxonomy and the in-page channel's `BroadcastChannel` coordinates. |
| `get-config` | `static` | The impact taxonomy and the in-page channel coordinates. |

## Run the demo

Expand Down
2 changes: 1 addition & 1 deletion docs/content/8.references/1.terms.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,4 @@ Three distinct paths connect the pieces; each has its own name.
|------|---------|-----------|
| **RPC** | browser side ↔ node side | WebSocket or static snapshot, via `connectDevframe()` |
| **client context** | client scripts ↔ client runtime | a shared object inside the host page |
| **in-page channel** | page script ↔ panel | same-origin, entirely in-browser (e.g. a `MessageChannel` or `BroadcastChannel`) |
| **in-page channel** | page script ↔ panel | same-origin, entirely in-browser a handshaken `MessageChannel` port per panel, via [`devframe/in-page-channel`](/guide/in-page-channel) |
16 changes: 15 additions & 1 deletion docs/content/8.references/3.events.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,18 @@ Pushed to subscribed RPC clients, wired by the core node side.
| `devframe:streaming:end` | A streaming terminator (optionally an error). |
| `devframe:streaming:upload-cancel` | Server-side cancel of an in-flight upload. |

Plus one `postMessage` channel, `devframe:remote-assets-error`, posted by the remote-assets fallback page to `window.parent` so an embedding hub UI provider can replace the 502 page.
### In-page channel notifications — page script → panel

Pushed over each panel's [in-page channel](/guide/in-page-channel) port; the paired request methods (`devframe:in-page:page-state:subscribe`/`set`/`patch`) are call endpoints defined at their handlers, not events.

| Name | Carries |
|---|---|
| `devframe:in-page:panel-state:updated` | Full channel shared-state snapshot for a key. |
| `devframe:in-page:panel-state:patch` | Incremental channel shared-state patch for a key. |

### `postMessage` channels

| Name | Posted by | Carries |
|---|---|---|
| `devframe:remote-assets-error` | the remote-assets fallback page, to `window.parent` | The failed package/version/reason, so an embedding hub UI provider can replace the 502 page. |
| `devframe:in-page-channel` | both [in-page channel](/guide/in-page-channel) endpoints, across window boundaries | The versioned handshake envelope (panel hello, page-script port grant). |
2 changes: 1 addition & 1 deletion examples/a11y-messages-playground/src/client/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ async function main() {
const docks = await rpc.sharedState.get<DevframeDockEntry[]>('devframe:docks', { initialValue: [] })

// Keep-alive iframe pool: one iframe per dock, toggled by visibility so the
// a11y panel keeps its BroadcastChannel connection while you switch docks.
// a11y panel keeps its in-page channel connection while you switch docks.
const iframePool = new Map<string, HTMLIFrameElement>()

function ensureIframe(entry: DevframeDockEntry & { url: string }): HTMLIFrameElement {
Expand Down
6 changes: 3 additions & 3 deletions examples/a11y-messages-playground/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ export default defineConfig({
UnoCSS(),
a11yMessagesPlayground({
devframes: [a11yDevframe, messagesDevframe],
// Attach the a11y agent as the a11y dock's client script - served over
// Vite's `/@fs/` so it shares this page's origin (the BroadcastChannel the
// agent and panel talk over rides that origin).
// Attach the a11y page script as the a11y dock's client script - served
// over Vite's `/@fs/` so it shares this page's origin (the in-page
// channel the page script and panel talk over is same-origin).
clientScripts: {
[a11yDevframe.id]: { importFrom: `/@fs/${a11yPageScriptBundlePath}` },
},
Expand Down
2 changes: 1 addition & 1 deletion examples/hub-next/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Open the printed URL. The dock rail on the left lists every mounted tool with it

Selecting a tool loads its SPA in the stage. The bottom drawer mirrors the hub's **Commands**, **Messages**, and **Terminals** subsystems, plus a button that dispatches a command through `hub:commands:execute`, and a **Transport** section showing which RPC transport the connection runs on (`websocket` or `sse`) with a segmented Auto / WS / SSE toggle - the choice rides a `?transport=` URL param and reconnects the whole client runtime on the pinned transport.

The A11y Inspector shows a live axe-core report of this hub's own page: the hub serves the devframe's page-script module (`a11yPageScriptBundlePath`) same-origin inside the hub namespace and attaches it as the a11y dock's `clientScript` (the `{ devframe, dock }` entry form); the hub client runtime - `createDevframeClientRuntime()` booted in `app/page.tsx` - imports it into the page, so the docked panel and the page script share the origin their in-page channel (a `BroadcastChannel`) rides.
The A11y Inspector shows a live axe-core report of this hub's own page: the hub serves the devframe's page-script module (`a11yPageScriptBundlePath`) same-origin inside the hub namespace and attaches it as the a11y dock's `clientScript` (the `{ devframe, dock }` entry form); the hub client runtime - `createDevframeClientRuntime()` booted in `app/page.tsx` - imports it into the page, so the docked panel and the page script share the origin and tab their in-page channel handshakes across.

The **RPC & State Inspector** carries an **Instances** tab that lists every devframe dev server running on your machine. The hub registers itself in the shared registry (`~/.devframe/instances/`) on startup via `registerDevframeInstance()`, so it shows up as "this instance"; start another example (e.g. `pnpm --filter hub-vite dev`, or any `node bin.mjs` CLI example) in a second terminal and it appears there too, each linking to its own SPA.

Expand Down
2 changes: 1 addition & 1 deletion examples/hub-vite/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Open the printed URL. The dock rail on the left lists every mounted tool with it

Selecting a tool loads its SPA in the stage. The bottom drawer mirrors the hub's **Commands**, **Messages**, and **Terminals** subsystems, plus a button that dispatches a command through `hub:commands:execute`, and a **Transport** section showing which RPC transport the connection runs on (`websocket` or `sse`) with a segmented Auto / WS / SSE toggle - the choice rides a `?transport=` URL param and reconnects the whole client runtime on the pinned transport.

The A11y Inspector shows a live axe-core report of this hub's own page. `vite.config.ts` attaches the devframe's page script as the a11y dock's `clientScript` (served via `/@fs/`), and the hub client runtime - `createDevframeClientRuntime()` booted in `src/client/main.ts` - imports it into the host page. Panel and page script share the Vite origin their in-page channel (a `BroadcastChannel`) rides; hover a violation to ring the offending element in the hub UI.
The A11y Inspector shows a live axe-core report of this hub's own page. `vite.config.ts` attaches the devframe's page script as the a11y dock's `clientScript` (served via `/@fs/`), and the hub client runtime - `createDevframeClientRuntime()` booted in `src/client/main.ts` - imports it into the host page. Panel and page script share the Vite origin and tab their in-page channel handshakes across; hover a violation to ring the offending element in the hub UI.

The **RPC & State Inspector** carries an **Instances** tab that lists every devframe dev server running on your machine. The hub registers itself in the shared registry (`~/.devframe/instances/`) on startup via `registerDevframeInstance()`, so it shows up as "this instance"; start another example (`pnpm --filter a11y-messages-playground dev`, or any `node bin.mjs` CLI example) in a second terminal and it appears there too, each linking to its own SPA.

Expand Down
1 change: 1 addition & 0 deletions knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
"src/adapters/{build,cac,dev,embedded,initiate}.ts",
"src/adapters/mcp/index.ts",
"src/client/index.ts",
"src/in-page-channel/index.ts",
"src/internal/index.ts",
"src/node/index.ts",
"src/node/{auth,hub-internals}/index.ts",
Expand Down
1 change: 1 addition & 0 deletions packages/devframe/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"./adapters/mcp": "./dist/adapters/mcp.mjs",
"./client": "./dist/client/index.mjs",
"./constants": "./dist/constants.mjs",
"./in-page-channel": "./dist/in-page-channel/index.mjs",
"./initiate": "./dist/adapters/initiate.mjs",
"./internal": "./dist/internal/index.mjs",
"./node": "./dist/node/index.mjs",
Expand Down
Loading
Loading