Skip to content

Commit bda5bc0

Browse files
posvaCopilot
andauthored
fix: separate in-page channel events from functions (#371)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 1b71074 commit bda5bc0

17 files changed

Lines changed: 393 additions & 178 deletions

File tree

docs/content/1.guide/12.in-page-channel.md

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,22 @@ import type { InPageChannelProtocol } from 'devframe/in-page-channel'
3737
export const MY_CHANNEL = 'devframes:plugin:my-tool'
3838

3939
export interface MyChannelProtocol extends InPageChannelProtocol {
40-
/** implemented by the page script, callable by panels */
41-
pageScript: {
42-
highlight: (selector: string) => void
43-
measure: (selector: string) => { width: number, height: number }
40+
functions: {
41+
/** implemented by the page script, callable by panels */
42+
pageScript: {
43+
measure: (selector: string) => { width: number, height: number }
44+
reset: () => Promise<void>
45+
}
46+
/** implemented by panels, callable by the page script */
47+
panel: {
48+
echo: (message: string) => Promise<string>
49+
}
4450
}
45-
/** implemented by panels, callable by the page script */
46-
panel: {
47-
flash: (message: string) => void
51+
events: {
52+
/** listened to by the page script, emitted by panels */
53+
pageScript: { highlight: (selector: string) => void }
54+
/** listened to by panels, emitted by the page script */
55+
panel: { flash: (message: string) => void }
4856
}
4957
sharedStates: {
5058
state: { selections: string[] }
@@ -56,7 +64,9 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names
5664

5765
## The page script endpoint
5866

59-
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.
67+
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.
68+
69+
`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.
6070

6171
```ts
6272
import type { MyChannelProtocol } from '../shared/protocol'
@@ -67,26 +77,28 @@ import { MY_CHANNEL } from '../shared/protocol'
6777
const pageChannel = createPageScriptChannel<MyChannelProtocol>({
6878
name: MY_CHANNEL,
6979
functions: {
70-
highlight: {
71-
type: 'event', // fire-and-forget
72-
jsonSerializable: true,
73-
handler: selector => drawRing(document.querySelector(selector)),
74-
},
80+
reset: { type: 'action', handler: async () => clearSelections() },
7581
measure: { // request/response (the default `query` type)
7682
handler: (selector) => {
7783
const rect = document.querySelector(selector)!.getBoundingClientRect()
7884
return { width: rect.width, height: rect.height }
7985
},
8086
},
8187
},
88+
events: {
89+
highlight: {
90+
jsonSerializable: true,
91+
handler: selector => drawRing(document.querySelector(selector)),
92+
},
93+
},
8294
})
8395

8496
pageChannel.emit('flash', 'scanning…') // received by each panel endpoint
8597
pageChannel.events.on('panel:connected', panel => console.log(panel.id))
8698
pageChannel.events.on('panel:disconnected', () => pauseWorkIfNobodyWatches())
8799
```
88100

89-
`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', '…')`.
101+
`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.
90102

91103
## The panel endpoint
92104

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

99111
const panelChannel = connectPanelChannel<MyChannelProtocol>({
100112
name: MY_CHANNEL,
101-
functions: {
102-
flash: { type: 'event' },
113+
functions: {},
114+
events: {
115+
flash: {},
103116
},
104117
})
105118

106119
const offFlash = panelChannel.on('flash', message => showFlash(message))
107-
panelChannel.emit('highlight', '.hero') // received by the page-script endpoint
120+
// defined and received by the page-script endpoint
121+
panelChannel.emit('highlight', '.hero')
108122
const size = await panelChannel.call('measure', '.hero')
123+
await panelChannel.call('reset')
109124

110125
offFlash() // stop listening
111126
```
@@ -162,6 +177,8 @@ import { toRaw } from 'vue'
162177
const channel = connectPanelChannel<MyChannelProtocol>({
163178
name: MY_CHANNEL,
164179
serialize: value => toRawDeep(value), // applied to every outgoing argument and result
180+
functions: {},
181+
events: { flash: {} },
165182
})
166183
```
167184

@@ -174,7 +191,7 @@ Declaring a function `jsonSerializable: true` additionally enforces strict JSON
174191
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:
175192

176193
```ts
177-
connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL, instanceId })
194+
connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL, instanceId, functions: {}, events: { flash: {} } })
178195
```
179196

180197
## Custom transports
@@ -184,7 +201,7 @@ Both endpoints accept a pre-established `MessagePort` that bypasses the handshak
184201
```ts
185202
const { port1, port2 } = new MessageChannel()
186203
pageScript.addPanelPort(port1)
187-
const panel = connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL, transport: port2 })
204+
const panel = connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL, transport: port2, functions: {}, events: { flash: {} } })
188205
```
189206

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

docs/content/6.errors/DF0077.md

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: 'DF0077: In-Page Channel Function Not Registered'
3-
description: 'An in-page channel listener names a function that is not registered on its endpoint.'
3+
description: 'An in-page channel call names a function that is not registered on its endpoint.'
44
---
55

66
## Message
@@ -9,25 +9,39 @@ description: 'An in-page channel listener names a function that is not registere
99
1010
## Cause
1111

12-
`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`.
12+
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.
1313

1414
## Example
1515

1616
```ts
17-
const channel = connectPanelChannel<MyProtocol>({
18-
name: MY_CHANNEL,
17+
import { connectPanelChannel, createPageScriptChannel } from 'devframe/in-page-channel'
18+
19+
interface PanelProtocol {
1920
functions: {
20-
notify: { type: 'event' },
21-
},
21+
pageScript: {
22+
inspect: () => void
23+
}
24+
}
25+
}
26+
27+
const pageScript = createPageScriptChannel({
28+
name: 'devframes:example',
29+
functions: {},
30+
})
31+
pageScript.addPanelPort(port1)
32+
33+
const panel = connectPanelChannel<PanelProtocol>({
34+
name: 'devframes:example',
35+
functions: {},
2236
})
2337

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

2741
## Fix
2842

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

3145
## Source
3246

33-
- [`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.
47+
- [`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.

docs/content/8.references/5.browser-api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ The values of `rpc.status`: [Handling connection and auth errors](/guide/client#
5050

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

53+
`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.
54+
5355
| Method or property | Page-script endpoint | Panel endpoint |
5456
|--------------------|-------------|-------|
5557
| `emit(name, ...args)` | Fans an event out to every connected panel. | Sends an event to the page script, buffering while connecting. |

packages/devframe/src/in-page-channel/diagnostics.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export const diagnostics = /* #__PURE__ */ defineDiagnostics({
55
codes: {
66
DF0077: {
77
why: (p: { name: string }) => `In-page channel function "${p.name}" is not registered on this endpoint.`,
8-
fix: 'Declare the function in this endpoint\'s `functions` option before subscribing with `on()`.',
8+
fix: 'Declare the function in this endpoint\'s `functions` option.',
99
},
1010
},
1111
})
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { PageScriptChannel, PanelChannel } from './types'
2+
import { expectTypeOf, it } from 'vitest'
3+
4+
interface Protocol {
5+
functions: {
6+
pageScript: { save: (value: string) => void, reset: () => Promise<void> }
7+
panel: { save: (value: string) => void, reset: () => Promise<void> }
8+
}
9+
events: {
10+
pageScript: { note: (value: string, count?: number) => void }
11+
panel: { notify: (message: string) => void }
12+
}
13+
}
14+
15+
declare const pageScript: PageScriptChannel<Protocol>
16+
declare const panel: PanelChannel<Protocol>
17+
18+
it('distinguishes void actions from declared events in both directions', () => {
19+
expectTypeOf(panel.call('save', 'draft')).toEqualTypeOf<Promise<void>>()
20+
expectTypeOf(panel.call('reset')).toEqualTypeOf<Promise<void>>()
21+
const peer = pageScript.panels[0]!
22+
expectTypeOf(peer.call('save', 'draft')).toEqualTypeOf<Promise<void>>()
23+
expectTypeOf(peer.call('reset')).toEqualTypeOf<Promise<void>>()
24+
expectTypeOf(panel.emit('note', 'hello', 2)).toEqualTypeOf<void>()
25+
expectTypeOf(pageScript.emit('notify', 'hello')).toEqualTypeOf<void>()
26+
expectTypeOf(pageScript.on('note', (value, count) => {
27+
expectTypeOf(value).toEqualTypeOf<string>()
28+
expectTypeOf(count).toEqualTypeOf<number | undefined>()
29+
})).toEqualTypeOf<() => void>()
30+
// @ts-expect-error Events cannot be called as functions.
31+
panel.call('note', 'hello')
32+
// @ts-expect-error Events cannot be called on panel peers.
33+
peer.call('notify', 'hello')
34+
// @ts-expect-error A void action is still a function.
35+
panel.emit('save', 'draft')
36+
// @ts-expect-error An asynchronous void action is still a function.
37+
panel.emit('reset')
38+
// @ts-expect-error The deprecated alias has the same restriction.
39+
panel.callEvent('save', 'draft')
40+
// @ts-expect-error A panel void action is still a function.
41+
pageScript.emit('save', 'draft')
42+
// @ts-expect-error A panel asynchronous void action is still a function.
43+
pageScript.callEvent('reset')
44+
// @ts-expect-error Functions cannot receive event listeners.
45+
pageScript.on('save', () => {})
46+
// @ts-expect-error Functions cannot receive event listeners.
47+
panel.on('reset', () => {})
48+
})

0 commit comments

Comments
 (0)