Skip to content

Commit 120a5a5

Browse files
authored
feat: add runtime in-page channel events (#358)
1 parent 6ba7c59 commit 120a5a5

13 files changed

Lines changed: 464 additions & 144 deletions

File tree

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

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,13 @@ 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-
pageScript: { // implemented by the page script, called by panels
40+
/** implemented by the page script, callable by panels */
41+
pageScript: {
4142
highlight: (selector: string) => void
4243
measure: (selector: string) => { width: number, height: number }
4344
}
44-
panel: { // implemented by panels, called by the page script
45+
/** implemented by panels, callable by the page script */
46+
panel: {
4547
flash: (message: string) => void
4648
}
4749
sharedStates: {
@@ -54,15 +56,15 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names
5456

5557
## The page script endpoint
5658

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

5961
```ts
6062
import type { MyChannelProtocol } from '../shared/protocol'
6163
// inject/index.ts: runs in the user app's page
6264
import { createPageScriptChannel } from 'devframe/in-page-channel'
6365
import { MY_CHANNEL } from '../shared/protocol'
6466

65-
const channel = createPageScriptChannel<MyChannelProtocol>({
67+
const pageChannel = createPageScriptChannel<MyChannelProtocol>({
6668
name: MY_CHANNEL,
6769
functions: {
6870
highlight: {
@@ -79,12 +81,12 @@ const channel = createPageScriptChannel<MyChannelProtocol>({
7981
},
8082
})
8183

82-
channel.callEvent('flash', 'scanning…') // fans out to every connected panel
83-
channel.events.on('panel:connected', panel => console.log(panel.id))
84-
channel.events.on('panel:disconnected', () => pauseWorkIfNobodyWatches())
84+
pageChannel.emit('flash', 'scanning…') // received by each panel endpoint
85+
pageChannel.events.on('panel:connected', panel => console.log(panel.id))
86+
pageChannel.events.on('panel:disconnected', () => pauseWorkIfNobodyWatches())
8587
```
8688

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

8991
## The panel endpoint
9092

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

97-
const channel = connectPanelChannel<MyChannelProtocol>({
99+
const panelChannel = connectPanelChannel<MyChannelProtocol>({
98100
name: MY_CHANNEL,
99101
functions: {
100-
flash: {
101-
handler: message => showFlash(message),
102-
},
102+
flash: { type: 'event' },
103103
},
104104
})
105105

106-
channel.callEvent('highlight', '.hero') // buffered until connected
107-
const size = await channel.call('measure', '.hero')
106+
const offFlash = panelChannel.on('flash', message => showFlash(message))
107+
panelChannel.emit('highlight', '.hero') // received by the page-script endpoint
108+
const size = await panelChannel.call('measure', '.hero')
109+
110+
offFlash() // stop listening
108111
```
109112

113+
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.
114+
110115
## Shared state
111116

112117
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.
@@ -133,7 +138,7 @@ Every failure mode is a coded `InPageChannelError` (`error.code`) with a message
133138
The panel endpoint's connection lifecycle is explicit, so a panel renders a useful fallback instead of hanging:
134139

135140
- `channel.status` is `connecting` → `connected` → (`connecting` on port loss) → `closed`, with `events.on('status:updated', …)` for reactivity.
136-
- 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.
141+
- 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.
137142
- 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:
138143

139144
```ts

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
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.'
4+
---
5+
6+
## Message
7+
8+
> In-page channel function "{name}" is not registered on this endpoint.
9+
10+
## Cause
11+
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`.
13+
14+
## Example
15+
16+
```ts
17+
const channel = connectPanelChannel<MyProtocol>({
18+
name: MY_CHANNEL,
19+
functions: {
20+
notify: { type: 'event' },
21+
},
22+
})
23+
24+
channel.on('missing' as any, () => {}) // ✗ throws DF0077
25+
```
26+
27+
## Fix
28+
29+
Declare the event in the endpoint's protocol side and `functions` option, then pass that declared name to `on()`.
30+
31+
## Source
32+
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.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +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 |
8687

8788
## Hub: context & lifecycle (DF80xx)
8889

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
title: 'Browser-Side API'
33
navigation:
44
icon: i-lucide-globe
5-
description: 'Lookup tables for the browser side: connectDevframe options, RPC client events, connection statuses, and in-page channel error codes.'
5+
description: 'Lookup tables for the browser side: connectDevframe options, RPC client events, connection statuses, and in-page channels error codes.'
66
---
77

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

48+
## In-page channel endpoints
49+
50+
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.
51+
52+
| Method or property | Page-script endpoint | Panel endpoint |
53+
|--------------------|-------------|-------|
54+
| `emit(name, ...args)` | Fans an event out to every connected panel. | Sends an event to the page script, buffering while connecting. |
55+
| `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. |
56+
| `call(name, ...args)` | Available through a specific `PanelPeer`. | Calls a page-script function and awaits its result. |
57+
| `events` | Local `panel:connected` / `panel:disconnected` lifecycle events. | Local `status:updated` lifecycle event. |
58+
| `sharedState` | Owns the authoritative state. | Mirrors the page-script state. |
59+
4860
## In-page channel error codes
4961

5062
The `error.code` values of `InPageChannelError`: [Errors and fallbacks](/guide/in-page-channel#errors-and-fallbacks).
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { defineDiagnostics } from 'devframe/utils/nostics'
2+
3+
export const diagnostics = /* #__PURE__ */ defineDiagnostics({
4+
docsBase: 'https://devfra.me/errors',
5+
codes: {
6+
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()`.',
9+
},
10+
},
11+
})

‎packages/devframe/src/in-page-channel/in-page-channel.test.ts‎

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,12 @@ const defaultPageScriptFunctions: NonNullable<CreatePageScriptChannelOptions<Tes
4343
sum: { handler: (a, b) => a + b },
4444
boom: { handler: () => {} },
4545
strict: { handler: payload => payload },
46-
note: { type: 'event', handler: () => {} },
46+
note: { type: 'event' },
4747
}
4848

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

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

123+
it('reports and rejects listeners for unknown functions', ({ onTestFinished }) => {
124+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
125+
const { panel, dispose } = createLinkedPair()
126+
onTestFinished(() => {
127+
dispose()
128+
warn.mockRestore()
129+
})
130+
131+
expect(() => {
132+
panel.on('missing' as any, () => {})
133+
}).toThrowError(expect.objectContaining({ name: 'DF0077' }))
134+
expect(warn).toHaveBeenCalledOnce()
135+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('[DF0077]'))
136+
})
137+
123138
it('enforces jsonSerializable payloads with a coded error', async () => {
124139
const { panel, dispose } = createLinkedPair()
125140
try {
@@ -181,7 +196,7 @@ describe('in-page channel over bring-your-own ports', () => {
181196
}
182197
})
183198

184-
it('fans events out to every panel; panels without the handler ignore them', async () => {
199+
it('fans events out to runtime panel listeners and supports unsubscribing', async () => {
185200
const a = new MessageChannel()
186201
const b = new MessageChannel()
187202
const pageScript = createPageScriptChannel<TestProtocol>({
@@ -196,14 +211,13 @@ describe('in-page channel over bring-your-own ports', () => {
196211
name: 'devframes:test',
197212
...noHandshake,
198213
transport: a.port2,
199-
functions: {
200-
...defaultPanelFunctions,
201-
notify: { type: 'event', handler: (value) => {
202-
received.push(`a:${value}`)
203-
} },
204-
},
214+
functions: defaultPanelFunctions,
205215
})
206-
// Panel B deliberately has no local functions in its protocol.
216+
pageScript.emit('notify', 'before-listener')
217+
await new Promise(resolve => setTimeout(resolve, 20))
218+
expect(received).toEqual([])
219+
const offNotify = panelA.on('notify', value => received.push(`a:${value}`))
220+
// Panel B deliberately has no listener for this event.
207221
const panelB = connectPanelChannel<InPageChannelProtocol>({
208222
name: 'devframes:test',
209223
...noHandshake,
@@ -212,9 +226,13 @@ describe('in-page channel over bring-your-own ports', () => {
212226
})
213227
try {
214228
expect(pageScript.panels).toHaveLength(2)
215-
pageScript.callEvent('notify', 'scan')
229+
pageScript.emit('notify', 'scan')
216230
await until(() => received.length === 1)
217231
expect(received).toEqual(['a:scan'])
232+
offNotify()
233+
pageScript.emit('notify', 'ignored')
234+
await new Promise(resolve => setTimeout(resolve, 20))
235+
expect(received).toEqual(['a:scan'])
218236
}
219237
finally {
220238
panelA.close()
@@ -518,19 +536,15 @@ describe('in-page channel handshake', () => {
518536
functions: defaultPanelFunctions,
519537
})
520538
const early = panel.call('echo', 'early')
521-
panel.callEvent('note', 'buffered')
539+
panel.emit('note', 'buffered')
522540

523541
const pageScript = createPageScriptChannel<TestProtocol>({
524542
name: 'devframes:test',
525543
window: asWindow(hostWin),
526544
heartbeat: false,
527-
functions: {
528-
...defaultPageScriptFunctions,
529-
note: { type: 'event', handler: (value) => {
530-
noted.push(value)
531-
} },
532-
},
545+
functions: defaultPageScriptFunctions,
533546
})
547+
pageScript.on('note', value => noted.push(value))
534548
try {
535549
await expect(early).resolves.toBe('early')
536550
await until(() => noted.length === 1)

‎packages/devframe/src/in-page-channel/internal.ts‎

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ import type { RpcArgsSchema } from '../rpc/types'
44
import type { InPageChannelControlFrame } from './protocol'
55
import type { InPageFunctionDefinitionAny } from './types'
66
import { createBirpc } from 'birpc'
7+
import { diagnostics } from './diagnostics'
78
import { isControlFrame } from './protocol'
89

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

1616
export const DEFAULT_CALL_TIMEOUT_MS = 15_000
@@ -166,24 +166,49 @@ export function deserializeResult(codec: InPageChannelSerialization, result: unk
166166
*/
167167
export function createLocalFunctionRegistry(codec: InPageChannelSerialization): {
168168
register: (definition: InPageFunctionDefinitionAny) => void
169+
on: (name: string, listener: (...args: unknown[]) => void) => () => void
169170
resolve: (name: string) => ((...args: unknown[]) => unknown) | undefined
170171
} {
171-
const wrapped = new Map<string, (...args: unknown[]) => unknown>()
172+
const definitions = new Map<string, InPageFunctionDefinitionAny>()
173+
const listeners = new Map<string, Set<(...args: unknown[]) => void>>()
172174
return {
173175
register(definition) {
174-
wrapped.set(definition.name, async (...rawArgs: unknown[]) => {
176+
definitions.set(definition.name, definition)
177+
},
178+
on(name, listener) {
179+
if (!definitions.has(name))
180+
throw diagnostics.DF0077({ name })
181+
let registered = listeners.get(name)
182+
if (!registered) {
183+
registered = new Set()
184+
listeners.set(name, registered)
185+
}
186+
registered.add(listener)
187+
return () => {
188+
registered.delete(listener)
189+
if (registered.size === 0)
190+
listeners.delete(name)
191+
}
192+
},
193+
resolve(name) {
194+
const definition = definitions.get(name)
195+
const registered = listeners.get(name)
196+
if (!definition && !registered?.size)
197+
return undefined
198+
return async (...rawArgs: unknown[]) => {
175199
const args = codec.deserialize ? rawArgs.map(codec.deserialize) : rawArgs
176-
if (definition.jsonSerializable)
200+
if (definition?.jsonSerializable)
177201
assertJsonSerializable(args, 'its arguments', definition.name)
178-
if (definition.args?.length)
202+
if (definition?.args?.length)
179203
await validateArgs(definition.name, definition.args, args)
180-
const result = await definition.handler(...args)
181-
if (definition.jsonSerializable)
204+
const result = await definition?.handler?.(...args)
205+
for (const listener of [...(listeners.get(name) ?? [])])
206+
listener(...args)
207+
if (definition?.jsonSerializable)
182208
assertJsonSerializable(result, 'its return value', definition.name)
183209
return codec.serialize && result !== undefined ? codec.serialize(result) : result
184-
})
210+
}
185211
},
186-
resolve: name => wrapped.get(name),
187212
}
188213
}
189214

0 commit comments

Comments
 (0)