Skip to content

Commit e141e51

Browse files
committed
fix: separate in-page channel events from functions
1 parent 5f6d5be commit e141e51

18 files changed

Lines changed: 480 additions & 169 deletions

File tree

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

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,15 @@ 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+
pageScript: {
42+
measure: (selector: string) => { width: number, height: number }
43+
reset: () => Promise<void>
44+
}
4445
}
45-
/** implemented by panels, callable by the page script */
46-
panel: {
47-
flash: (message: string) => void
46+
events: {
47+
pageScript: { highlight: (selector: string) => void }
48+
panel: { flash: (message: string) => void }
4849
}
4950
sharedStates: {
5051
state: { selections: string[] }
@@ -56,7 +57,9 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names
5657

5758
## The page script endpoint
5859

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.
60+
The required `functions` and `events` options 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.
61+
62+
`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.
6063

6164
```ts
6265
import type { MyChannelProtocol } from '../shared/protocol'
@@ -67,26 +70,28 @@ import { MY_CHANNEL } from '../shared/protocol'
6770
const pageChannel = createPageScriptChannel<MyChannelProtocol>({
6871
name: MY_CHANNEL,
6972
functions: {
70-
highlight: {
71-
type: 'event', // fire-and-forget
72-
jsonSerializable: true,
73-
handler: selector => drawRing(document.querySelector(selector)),
74-
},
73+
reset: { type: 'action', handler: async () => clearSelections() },
7574
measure: { // request/response (the default `query` type)
7675
handler: (selector) => {
7776
const rect = document.querySelector(selector)!.getBoundingClientRect()
7877
return { width: rect.width, height: rect.height }
7978
},
8079
},
8180
},
81+
events: {
82+
highlight: {
83+
jsonSerializable: true,
84+
handler: selector => drawRing(document.querySelector(selector)),
85+
},
86+
},
8287
})
8388

8489
pageChannel.emit('flash', 'scanning…') // received by each panel endpoint
8590
pageChannel.events.on('panel:connected', panel => console.log(panel.id))
8691
pageChannel.events.on('panel:disconnected', () => pauseWorkIfNobodyWatches())
8792
```
8893

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

9196
## The panel endpoint
9297

@@ -98,14 +103,16 @@ import { MY_CHANNEL } from '../shared/protocol'
98103

99104
const panelChannel = connectPanelChannel<MyChannelProtocol>({
100105
name: MY_CHANNEL,
101-
functions: {
102-
flash: { type: 'event' },
106+
functions: {},
107+
events: {
108+
flash: {},
103109
},
104110
})
105111

106112
const offFlash = panelChannel.on('flash', message => showFlash(message))
107113
panelChannel.emit('highlight', '.hero') // received by the page-script endpoint
108114
const size = await panelChannel.call('measure', '.hero')
115+
await panelChannel.call('reset')
109116

110117
offFlash() // stop listening
111118
```
@@ -162,6 +169,8 @@ import { toRaw } from 'vue'
162169
const channel = connectPanelChannel<MyChannelProtocol>({
163170
name: MY_CHANNEL,
164171
serialize: value => toRawDeep(value), // applied to every outgoing argument and result
172+
functions: {},
173+
events: { flash: {} },
165174
})
166175
```
167176

@@ -172,7 +181,7 @@ Declaring a function `jsonSerializable: true` additionally enforces strict JSON
172181
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:
173182

174183
```ts
175-
connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL, instanceId })
184+
connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL, instanceId, functions: {}, events: { flash: {} } })
176185
```
177186

178187
## Custom transports
@@ -182,7 +191,7 @@ Both endpoints accept a pre-established `MessagePort` that bypasses the handshak
182191
```ts
183192
const { port1, port2 } = new MessageChannel()
184193
pageScript.addPanelPort(port1)
185-
const panel = connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL, transport: port2 })
194+
const panel = connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL, transport: port2, functions: {}, events: { flash: {} } })
186195
```
187196

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

‎docs/content/6.errors/DF0077.md‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
11
---
2-
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.'
2+
title: 'DF0077: In-Page Channel Event Not Registered'
3+
description: 'An in-page channel listener names an event that is not registered on its endpoint.'
44
---
55

66
## Message
77

8-
> In-page channel function "{name}" is not registered on this endpoint.
8+
> In-page channel event "{name}" is not registered on this endpoint.
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+
`channel.on(name, listener)` received a name absent from that endpoint's required `events` option. A page-script endpoint subscribes to events declared under `events.pageScript`; a panel endpoint subscribes to events declared under `events.panel`.
1313

1414
## Example
1515

1616
```ts
1717
const channel = connectPanelChannel<MyProtocol>({
1818
name: MY_CHANNEL,
19-
functions: {
20-
notify: { type: 'event' },
19+
functions: {},
20+
events: {
21+
notify: {},
2122
},
2223
})
2324

@@ -26,7 +27,7 @@ channel.on('missing' as any, () => {}) // ✗ throws DF0077
2627

2728
## Fix
2829

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

3132
## Source
3233

‎docs/content/6.errors/index.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ Emitted by `devframe`: the framework-neutral host, RPC, streaming, assets, servi
8383
| [DF0074](/errors/DF0074) | error | JSON-Render Schema Is Asynchronous |
8484
| [DF0075](/errors/DF0075) | warn | No RPC Transport On This Runtime |
8585
| [DF0076](/errors/DF0076) | error | WebSocket Upgrade Unsupported On This Runtime |
86-
| [DF0077](/errors/DF0077) | error | In-Page Channel Function Not Registered |
86+
| [DF0077](/errors/DF0077) | error | In-Page Channel Event Not Registered |
8787

8888
## Hub: context & lifecycle (DF80xx)
8989

‎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 and a complete `events` map with optional handlers; use `{}` for empty maps. `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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ export const diagnostics = /* #__PURE__ */ defineDiagnostics({
44
docsBase: 'https://devfra.me/errors',
55
codes: {
66
DF0077: {
7-
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()`.',
7+
why: (p: { name: string }) => `In-page channel event "${p.name}" is not registered on this endpoint.`,
8+
fix: 'Declare the event in this endpoint\'s `events` option before subscribing with `on()`.',
99
},
1010
},
1111
})
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { ConnectPanelChannelOptions, CreatePageScriptChannelOptions, 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+
})
49+
50+
it('requires function handlers and separate event declarations', () => {
51+
const options: CreatePageScriptChannelOptions<Protocol> = {
52+
name: 'test',
53+
functions: {
54+
save: { type: 'action', handler: (value) => {
55+
expectTypeOf(value).toEqualTypeOf<string>()
56+
} },
57+
reset: { type: 'action', handler: async () => {} },
58+
},
59+
events: { note: {} },
60+
}
61+
// @ts-expect-error Actions require handlers even when returning void.
62+
options.functions.save = { type: 'action' }
63+
// @ts-expect-error Functions cannot be declared as events.
64+
options.functions.reset = { type: 'event' }
65+
// @ts-expect-error Events belong in the events option.
66+
options.functions.note = { handler: () => {} }
67+
// @ts-expect-error Event declarations must be complete.
68+
options.events = {}
69+
// @ts-expect-error Functions belong in the functions option.
70+
options.events.save = {}
71+
options.events.note = { handler: (value, count) => {
72+
expectTypeOf(value).toEqualTypeOf<string>()
73+
expectTypeOf(count).toEqualTypeOf<number | undefined>()
74+
} }
75+
// @ts-expect-error Event handlers must match the declared arguments.
76+
options.events.note = { handler: (value: number) => void value }
77+
// @ts-expect-error Event declarations cannot be actions.
78+
options.events.note = { type: 'action', handler: () => {} }
79+
})
80+
81+
it('supports omitted protocol sections without widening their keys', () => {
82+
interface FunctionsOnly { functions: { pageScript: { run: () => void } } }
83+
interface EventsOnly { events: { panel: { ready: () => void } } }
84+
const options: ConnectPanelChannelOptions<FunctionsOnly> = { name: 'test', functions: {}, events: {} }
85+
// @ts-expect-error This direction declares no events.
86+
options.events.ready = {}
87+
// @ts-expect-error This direction declares no functions.
88+
options.functions.run = { handler: () => {} }
89+
expectTypeOf<Parameters<PanelChannel<FunctionsOnly>['emit']>[0]>().toEqualTypeOf<never>()
90+
expectTypeOf<Parameters<PanelChannel<EventsOnly>['call']>[0]>().toEqualTypeOf<never>()
91+
expectTypeOf<Parameters<PageScriptChannel<EventsOnly>['on']>[0]>().toEqualTypeOf<never>()
92+
})

0 commit comments

Comments
 (0)