Skip to content
Merged
35 changes: 20 additions & 15 deletions docs/content/1.guide/12.in-page-channel.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,13 @@ 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
/** implemented by the page script, callable by panels */
pageScript: {
highlight: (selector: string) => void
measure: (selector: string) => { width: number, height: number }
}
panel: { // implemented by panels, called by the page script
/** implemented by panels, callable by the page script */
panel: {
flash: (message: string) => void
}
sharedStates: {
Expand All @@ -54,15 +56,15 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names

## The page script endpoint

Functions use the same authoring metadata as `defineRpcFunction` (`type`, Standard-Schema `args`/`returns`, `jsonSerializable`, `handler`), narrowed to the browser. The required `functions` object's keys are the function names, and it implements every function on that endpoint's protocol side. Each handler is contextually typed from its key and the corresponding function in the protocol. `defineChannelFunction` retains the named definition shape for lower-level authoring. Define each side's functions in that side's source files; the shared protocol file carries only types.
The required `functions` object declares every function on that endpoint's protocol side, preserving a compile-time completeness check. Request/response declarations require a `handler`; an event declaration uses `type: 'event'`, and the receiving endpoint may provide an optional `handler` or subscribe at runtime with `on()`. Functions use the same Standard-Schema `args`/`returns` and `jsonSerializable` metadata as `defineRpcFunction`, narrowed to the browser. Each handler is contextually typed from its key and the corresponding protocol function. `defineChannelFunction` retains the named definition shape for lower-level authoring. 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 } from 'devframe/in-page-channel'
import { MY_CHANNEL } from '../shared/protocol'

const channel = createPageScriptChannel<MyChannelProtocol>({
const pageChannel = createPageScriptChannel<MyChannelProtocol>({
name: MY_CHANNEL,
functions: {
highlight: {
Expand All @@ -79,12 +81,12 @@ const channel = createPageScriptChannel<MyChannelProtocol>({
},
})

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

`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', '…')`.
`emit` on the page-script endpoint is 1:N: it fans out to every connected panel endpoint. Request/response *to* a panel goes through an explicit peer handle: `pageChannel.panels[0].call('flash', '…')`.

## The panel endpoint

Expand All @@ -94,19 +96,22 @@ import type { MyChannelProtocol } from '../shared/protocol'
import { connectPanelChannel } from 'devframe/in-page-channel'
import { MY_CHANNEL } from '../shared/protocol'

const channel = connectPanelChannel<MyChannelProtocol>({
const panelChannel = connectPanelChannel<MyChannelProtocol>({
name: MY_CHANNEL,
functions: {
flash: {
handler: message => showFlash(message),
},
flash: { type: 'event' },
},
})

channel.callEvent('highlight', '.hero') // buffered until connected
const size = await channel.call('measure', '.hero')
const offFlash = panelChannel.on('flash', message => showFlash(message))
panelChannel.emit('highlight', '.hero') // received by the page-script endpoint
const size = await panelChannel.call('measure', '.hero')

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`](/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.
Expand All @@ -133,7 +138,7 @@ Every failure mode is a coded `InPageChannelError` (`error.code`) with a message
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.
- 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:

```ts
Expand Down
33 changes: 33 additions & 0 deletions docs/content/6.errors/DF0077.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
title: 'DF0077: In-Page Channel Function Not Registered'
description: 'An in-page channel listener names a function that is not registered on its endpoint.'
---

## Message

> In-page channel function "{name}" is not registered on this endpoint.

## Cause

`channel.on(name, listener)` received a name absent from that endpoint's required `functions` option. A page-script endpoint subscribes to functions declared under `pageScript`; a panel endpoint subscribes to functions declared under `panel`.

## Example

```ts
const channel = connectPanelChannel<MyProtocol>({
name: MY_CHANNEL,
functions: {
notify: { type: 'event' },
},
})

channel.on('missing' as any, () => {}) // ✗ throws DF0077
```

## Fix

Declare the event in the endpoint's protocol side and `functions` option, then pass that declared name to `on()`.

## Source

- [`packages/devframe/src/in-page-channel/internal.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/in-page-channel/internal.ts): `createLocalFunctionRegistry().on()` throws this when no local definition matches the listener name.
1 change: 1 addition & 0 deletions docs/content/6.errors/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Emitted by `devframe`: the framework-neutral host, RPC, streaming, assets, servi
| [DF0074](/errors/DF0074) | error | JSON-Render Schema Is Asynchronous |
| [DF0075](/errors/DF0075) | warn | No RPC Transport On This Runtime |
| [DF0076](/errors/DF0076) | error | WebSocket Upgrade Unsupported On This Runtime |
| [DF0077](/errors/DF0077) | error | In-Page Channel Function Not Registered |

## Hub: context & lifecycle (DF80xx)

Expand Down
14 changes: 13 additions & 1 deletion docs/content/8.references/5.browser-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: 'Browser-Side API'
navigation:
icon: i-lucide-globe
description: 'Lookup tables for the browser side: connectDevframe options, RPC client events, connection statuses, and in-page channel error codes.'
description: 'Lookup tables for the browser side: connectDevframe options, RPC client events, connection statuses, and in-page channels error codes.'
---

Lookup tables for a devframe's browser side. Each section links the guide page that teaches the concept.
Expand Down Expand Up @@ -45,6 +45,18 @@ The values of `rpc.status`: [Handling connection and auth errors](/guide/client#
| `disconnected` | Socket closed (dropped mid-session or never opened). |
| `error` | Fatal: the socket errored or connection meta couldn't load. |

## In-page channel endpoints

The browser-only endpoint methods of the [in-page channel](/guide/in-page-channel). `emit()` sends to the opposite endpoint; `on()` handles events arriving from that endpoint.

| Method or property | Page-script endpoint | Panel endpoint |
|--------------------|-------------|-------|
| `emit(name, ...args)` | Fans an event out to every connected panel. | Sends an event to the page script, buffering while connecting. |
| `on(name, listener)` | Subscribes to events emitted by a panel. Returns an unsubscribe function. | Subscribes to events emitted by the page script. Returns an unsubscribe function. |
| `call(name, ...args)` | Available through a specific `PanelPeer`. | Calls a page-script function and awaits its result. |
| `events` | Local `panel:connected` / `panel:disconnected` lifecycle events. | Local `status:updated` lifecycle event. |
| `sharedState` | Owns the authoritative state. | Mirrors the page-script state. |

## In-page channel error codes

The `error.code` values of `InPageChannelError`: [Errors and fallbacks](/guide/in-page-channel#errors-and-fallbacks).
Expand Down
11 changes: 11 additions & 0 deletions packages/devframe/src/in-page-channel/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineDiagnostics } from 'devframe/utils/nostics'

export const diagnostics = /* #__PURE__ */ defineDiagnostics({
docsBase: 'https://devfra.me/errors',
codes: {
DF0077: {
why: (p: { name: string }) => `In-page channel function "${p.name}" is not registered on this endpoint.`,
fix: 'Declare the function in this endpoint\'s `functions` option before subscribing with `on()`.',
},
},
})
50 changes: 32 additions & 18 deletions packages/devframe/src/in-page-channel/in-page-channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,12 @@ const defaultPageScriptFunctions: NonNullable<CreatePageScriptChannelOptions<Tes
sum: { handler: (a, b) => a + b },
boom: { handler: () => {} },
strict: { handler: payload => payload },
note: { type: 'event', handler: () => {} },
note: { type: 'event' },
}

const defaultPanelFunctions: NonNullable<ConnectPanelChannelOptions<TestProtocol>['functions']> = {
'ping-panel': { handler: value => `pong:${value}` },
'notify': { type: 'event', handler: () => {} },
'notify': { type: 'event' },
}

function createLinkedPair(options?: {
Expand Down Expand Up @@ -120,6 +120,21 @@ describe('in-page channel over bring-your-own ports', () => {
}
})

it('reports and rejects listeners for unknown functions', ({ onTestFinished }) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { panel, dispose } = createLinkedPair()
onTestFinished(() => {
dispose()
warn.mockRestore()
})

expect(() => {
panel.on('missing' as any, () => {})
}).toThrowError(expect.objectContaining({ name: 'DF0077' }))
expect(warn).toHaveBeenCalledOnce()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('[DF0077]'))
})

it('enforces jsonSerializable payloads with a coded error', async () => {
const { panel, dispose } = createLinkedPair()
try {
Expand Down Expand Up @@ -181,7 +196,7 @@ describe('in-page channel over bring-your-own ports', () => {
}
})

it('fans events out to every panel; panels without the handler ignore them', async () => {
it('fans events out to runtime panel listeners and supports unsubscribing', async () => {
const a = new MessageChannel()
const b = new MessageChannel()
const pageScript = createPageScriptChannel<TestProtocol>({
Expand All @@ -196,14 +211,13 @@ describe('in-page channel over bring-your-own ports', () => {
name: 'devframes:test',
...noHandshake,
transport: a.port2,
functions: {
...defaultPanelFunctions,
notify: { type: 'event', handler: (value) => {
received.push(`a:${value}`)
} },
},
functions: defaultPanelFunctions,
})
// Panel B deliberately has no local functions in its protocol.
pageScript.emit('notify', 'before-listener')
await new Promise(resolve => setTimeout(resolve, 20))
expect(received).toEqual([])
const offNotify = panelA.on('notify', value => received.push(`a:${value}`))
// Panel B deliberately has no listener for this event.
const panelB = connectPanelChannel<InPageChannelProtocol>({
name: 'devframes:test',
...noHandshake,
Expand All @@ -212,9 +226,13 @@ describe('in-page channel over bring-your-own ports', () => {
})
try {
expect(pageScript.panels).toHaveLength(2)
pageScript.callEvent('notify', 'scan')
pageScript.emit('notify', 'scan')
await until(() => received.length === 1)
expect(received).toEqual(['a:scan'])
offNotify()
pageScript.emit('notify', 'ignored')
await new Promise(resolve => setTimeout(resolve, 20))
expect(received).toEqual(['a:scan'])
}
finally {
panelA.close()
Expand Down Expand Up @@ -518,19 +536,15 @@ describe('in-page channel handshake', () => {
functions: defaultPanelFunctions,
})
const early = panel.call('echo', 'early')
panel.callEvent('note', 'buffered')
panel.emit('note', 'buffered')

const pageScript = createPageScriptChannel<TestProtocol>({
name: 'devframes:test',
window: asWindow(hostWin),
heartbeat: false,
functions: {
...defaultPageScriptFunctions,
note: { type: 'event', handler: (value) => {
noted.push(value)
} },
},
functions: defaultPageScriptFunctions,
})
pageScript.on('note', value => noted.push(value))
try {
await expect(early).resolves.toBe('early')
await until(() => noted.length === 1)
Expand Down
49 changes: 37 additions & 12 deletions packages/devframe/src/in-page-channel/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import type { RpcArgsSchema } from '../rpc/types'
import type { InPageChannelControlFrame } from './protocol'
import type { InPageFunctionDefinitionAny } from './types'
import { createBirpc } from 'birpc'
import { diagnostics } from './diagnostics'
import { isControlFrame } from './protocol'

/**
* Shared internals of the two endpoints: the coded error surface (browser
* code, so plain coded `Error`s, since `nostics` diagnostics are node-side only),
* the local function table with its receive pipeline, and the birpc wiring
* of one `MessagePort`.
* Shared internals of the two endpoints: the coded error surface, the local
* function table with its receive pipeline, and the birpc wiring of one
* `MessagePort`.
*/

export const DEFAULT_CALL_TIMEOUT_MS = 15_000
Expand Down Expand Up @@ -166,24 +166,49 @@ export function deserializeResult(codec: InPageChannelSerialization, result: unk
*/
export function createLocalFunctionRegistry(codec: InPageChannelSerialization): {
register: (definition: InPageFunctionDefinitionAny) => void
on: (name: string, listener: (...args: unknown[]) => void) => () => void
resolve: (name: string) => ((...args: unknown[]) => unknown) | undefined
} {
const wrapped = new Map<string, (...args: unknown[]) => unknown>()
const definitions = new Map<string, InPageFunctionDefinitionAny>()
const listeners = new Map<string, Set<(...args: unknown[]) => void>>()
return {
register(definition) {
wrapped.set(definition.name, async (...rawArgs: unknown[]) => {
definitions.set(definition.name, definition)
},
on(name, listener) {
if (!definitions.has(name))
throw diagnostics.DF0077({ name })
let registered = listeners.get(name)
if (!registered) {
registered = new Set()
listeners.set(name, registered)
}
registered.add(listener)
return () => {
registered.delete(listener)
if (registered.size === 0)
listeners.delete(name)
}
},
resolve(name) {
const definition = definitions.get(name)
const registered = listeners.get(name)
if (!definition && !registered?.size)
return undefined
return async (...rawArgs: unknown[]) => {
const args = codec.deserialize ? rawArgs.map(codec.deserialize) : rawArgs
if (definition.jsonSerializable)
if (definition?.jsonSerializable)
assertJsonSerializable(args, 'its arguments', definition.name)
if (definition.args?.length)
if (definition?.args?.length)
await validateArgs(definition.name, definition.args, args)
const result = await definition.handler(...args)
if (definition.jsonSerializable)
const result = await definition?.handler?.(...args)
for (const listener of [...(listeners.get(name) ?? [])])
listener(...args)
Comment on lines +202 to +206
if (definition?.jsonSerializable)
assertJsonSerializable(result, 'its return value', definition.name)
return codec.serialize && result !== undefined ? codec.serialize(result) : result
})
}
},
resolve: name => wrapped.get(name),
}
}

Expand Down
Loading
Loading