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
55 changes: 36 additions & 19 deletions docs/content/1.guide/12.in-page-channel.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,22 @@ import type { InPageChannelProtocol } from 'devframe/in-page-channel'
export const MY_CHANNEL = 'devframes:plugin:my-tool'

export interface MyChannelProtocol extends InPageChannelProtocol {
/** implemented by the page script, callable by panels */
pageScript: {
highlight: (selector: string) => void
measure: (selector: string) => { width: number, height: number }
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>
}
}
/** implemented by panels, callable by the page script */
panel: {
flash: (message: string) => void
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[] }
Expand All @@ -56,7 +64,9 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names

## The page script endpoint

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

```ts
import type { MyChannelProtocol } from '../shared/protocol'
Expand All @@ -67,26 +77,28 @@ import { MY_CHANNEL } from '../shared/protocol'
const pageChannel = createPageScriptChannel<MyChannelProtocol>({
name: MY_CHANNEL,
functions: {
highlight: {
type: 'event', // fire-and-forget
jsonSerializable: true,
handler: selector => drawRing(document.querySelector(selector)),
},
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 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', '…')`.
`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

Expand All @@ -98,14 +110,17 @@ import { MY_CHANNEL } from '../shared/protocol'

const panelChannel = connectPanelChannel<MyChannelProtocol>({
name: MY_CHANNEL,
functions: {
flash: { type: 'event' },
functions: {},
events: {
flash: {},
Comment thread
posva marked this conversation as resolved.
},
})

const offFlash = panelChannel.on('flash', message => showFlash(message))
panelChannel.emit('highlight', '.hero') // received by the page-script endpoint
// 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
```
Expand Down Expand Up @@ -162,6 +177,8 @@ 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: {} },

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this should just be optional

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right now used to diagnose missing events when emitted, so I feel like it can be useful to diagnose wrong names https://github.com/devframes/devframe/pull/371/changes#diff-c8bbf4963ef4b0c4ae6cc7164bfa1da31233ea66fdb9cda6b4b1c98568f62aa5R7

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ended up making it optional, i feel like it makes more sense for events and they are already type safe (although we do accept any string with & string)

})
```

Expand All @@ -172,7 +189,7 @@ Declaring a function `jsonSerializable: true` additionally enforces strict JSON
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 })
connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL, instanceId, functions: {}, events: { flash: {} } })
```

## Custom transports
Expand All @@ -182,7 +199,7 @@ Both endpoints accept a pre-established `MessagePort` that bypasses the handshak
```ts
const { port1, port2 } = new MessageChannel()
pageScript.addPanelPort(port1)
const panel = connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL, transport: port2 })
const panel = connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL, transport: port2, functions: {}, events: { flash: {} } })
```

## When to use the in-page channel vs RPC
Expand Down
32 changes: 23 additions & 9 deletions docs/content/6.errors/DF0077.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: 'DF0077: In-Page Channel Function Not Registered'
description: 'An in-page channel listener names a function that is not registered on its endpoint.'
description: 'An in-page channel call names a function that is not registered on its endpoint.'
---

## Message
Expand All @@ -9,25 +9,39 @@ description: 'An in-page channel listener names a function that is not registere

## 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`.
The two endpoints disagree about their channel contract. The calling endpoint names a function that the receiving endpoint did not register in its `functions` option. This usually means the page script and panel use different protocol declarations or incompatible devframe versions.

## Example

```ts
const channel = connectPanelChannel<MyProtocol>({
name: MY_CHANNEL,
import { connectPanelChannel, createPageScriptChannel } from 'devframe/in-page-channel'

interface PanelProtocol {
functions: {
notify: { type: 'event' },
},
pageScript: {
inspect: () => void
}
}
}

const pageScript = createPageScriptChannel({
name: 'devframes:example',
functions: {},
})
pageScript.addPanelPort(port1)

const panel = connectPanelChannel<PanelProtocol>({
name: 'devframes:example',
functions: {},
})

channel.on('missing' as any, () => {}) // ✗ throws DF0077
await panel.call('inspect') // ✗ The page script did not register `inspect`.
```

## Fix

Declare the event in the endpoint's protocol side and `functions` option, then pass that declared name to `on()`.
Import one shared protocol declaration into both endpoints, then register every function from the receiving side of that protocol in its `functions` option.

## 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.
- [`packages/devframe/src/in-page-channel/internal.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/in-page-channel/internal.ts): `createLocalFunctionRegistry().resolve()` throws this when no local function definition matches the call name.
2 changes: 2 additions & 0 deletions docs/content/8.references/5.browser-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ The values of `rpc.status`: [Handling connection and auth errors](/guide/client#

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.

`InPageChannelProtocol` separates `functions` and `events`. Each section has optional `pageScript` and `panel` maps naming the receiving direction. Endpoint options require a complete `functions` map with handlers; `events` is optional, and when provided can include optional handlers (use `{}` to declare an event without a handler for `channel.on()`). `call()` uses function names regardless of return type, while `emit()`, `callEvent()` (deprecated), and `on()` use event names. A function returning `void` or `Promise<void>` remains an awaitable request/response call.

| 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. |
Expand Down
2 changes: 1 addition & 1 deletion packages/devframe/src/in-page-channel/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export const diagnostics = /* #__PURE__ */ defineDiagnostics({
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()`.',
fix: 'Declare the function in this endpoint\'s `functions` option.',
},
},
})
48 changes: 48 additions & 0 deletions packages/devframe/src/in-page-channel/events.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { PageScriptChannel, PanelChannel } from './types'
import { expectTypeOf, it } from 'vitest'

interface Protocol {
functions: {
pageScript: { save: (value: string) => void, reset: () => Promise<void> }
panel: { save: (value: string) => void, reset: () => Promise<void> }
}
events: {
pageScript: { note: (value: string, count?: number) => void }
panel: { notify: (message: string) => void }
}
}

declare const pageScript: PageScriptChannel<Protocol>
declare const panel: PanelChannel<Protocol>

it('distinguishes void actions from declared events in both directions', () => {
expectTypeOf(panel.call('save', 'draft')).toEqualTypeOf<Promise<void>>()
expectTypeOf(panel.call('reset')).toEqualTypeOf<Promise<void>>()
const peer = pageScript.panels[0]!
expectTypeOf(peer.call('save', 'draft')).toEqualTypeOf<Promise<void>>()
expectTypeOf(peer.call('reset')).toEqualTypeOf<Promise<void>>()
expectTypeOf(panel.emit('note', 'hello', 2)).toEqualTypeOf<void>()
expectTypeOf(pageScript.emit('notify', 'hello')).toEqualTypeOf<void>()
expectTypeOf(pageScript.on('note', (value, count) => {
expectTypeOf(value).toEqualTypeOf<string>()
expectTypeOf(count).toEqualTypeOf<number | undefined>()
})).toEqualTypeOf<() => void>()
// @ts-expect-error Events cannot be called as functions.
panel.call('note', 'hello')
// @ts-expect-error Events cannot be called on panel peers.
peer.call('notify', 'hello')
// @ts-expect-error A void action is still a function.
panel.emit('save', 'draft')
// @ts-expect-error An asynchronous void action is still a function.
panel.emit('reset')
// @ts-expect-error The deprecated alias has the same restriction.
panel.callEvent('save', 'draft')
// @ts-expect-error A panel void action is still a function.
pageScript.emit('save', 'draft')
// @ts-expect-error A panel asynchronous void action is still a function.
pageScript.callEvent('reset')
// @ts-expect-error Functions cannot receive event listeners.
pageScript.on('save', () => {})
// @ts-expect-error Functions cannot receive event listeners.
panel.on('reset', () => {})
})
Loading
Loading