Skip to content

Commit 0ed9101

Browse files
committed
feat: add runtime in-page channel events
1 parent 6ba7c59 commit 0ed9101

10 files changed

Lines changed: 182 additions & 98 deletions

File tree

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

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,11 @@ 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+
pageScript: { // functions and events received by the page script
4141
highlight: (selector: string) => void
4242
measure: (selector: string) => { width: number, height: number }
4343
}
44-
panel: { // implemented by panels, called by the page script
44+
panel: { // functions and events received by panels
4545
flash: (message: string) => void
4646
}
4747
sharedStates: {
@@ -54,7 +54,7 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names
5454

5555
## The page script endpoint
5656

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.
57+
Request/response functions use the same authoring metadata as `defineRpcFunction` (`type`, Standard-Schema `args`/`returns`, `jsonSerializable`, `handler`), narrowed to the browser. The optional `functions` object registers initial handlers, while `channel.on()` subscribes event listeners at runtime. 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.
5858

5959
```ts
6060
import type { MyChannelProtocol } from '../shared/protocol'
@@ -79,12 +79,12 @@ const channel = createPageScriptChannel<MyChannelProtocol>({
7979
},
8080
})
8181

82-
channel.callEvent('flash', 'scanning…') // fans out to every connected panel
82+
channel.emit('flash', 'scanning…') // fans out to every connected panel
8383
channel.events.on('panel:connected', panel => console.log(panel.id))
8484
channel.events.on('panel:disconnected', () => pauseWorkIfNobodyWatches())
8585
```
8686

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

8989
## The panel endpoint
9090

@@ -96,15 +96,13 @@ import { MY_CHANNEL } from '../shared/protocol'
9696

9797
const channel = connectPanelChannel<MyChannelProtocol>({
9898
name: MY_CHANNEL,
99-
functions: {
100-
flash: {
101-
handler: message => showFlash(message),
102-
},
103-
},
10499
})
105100

106-
channel.callEvent('highlight', '.hero') // buffered until connected
101+
const offFlash = channel.on('flash', message => showFlash(message))
102+
channel.emit('highlight', '.hero') // buffered until connected
107103
const size = await channel.call('measure', '.hero')
104+
105+
offFlash() // stop listening
108106
```
109107

110108
## Shared state
@@ -133,7 +131,7 @@ Every failure mode is a coded `InPageChannelError` (`error.code`) with a message
133131
The panel endpoint's connection lifecycle is explicit, so a panel renders a useful fallback instead of hanging:
134132

135133
- `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.
134+
- 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.
137135
- 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:
138136

139137
```ts

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.'
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).
51+
52+
| Method or property | Page script | Panel |
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. | 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).

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

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ describe('in-page channel over bring-your-own ports', () => {
181181
}
182182
})
183183

184-
it('fans events out to every panel; panels without the handler ignore them', async () => {
184+
it('fans events out to runtime panel listeners and supports unsubscribing', async () => {
185185
const a = new MessageChannel()
186186
const b = new MessageChannel()
187187
const pageScript = createPageScriptChannel<TestProtocol>({
@@ -197,24 +197,28 @@ describe('in-page channel over bring-your-own ports', () => {
197197
...noHandshake,
198198
transport: a.port2,
199199
functions: {
200-
...defaultPanelFunctions,
201-
notify: { type: 'event', handler: (value) => {
202-
received.push(`a:${value}`)
203-
} },
200+
'ping-panel': defaultPanelFunctions['ping-panel'],
204201
},
205202
})
206-
// Panel B deliberately has no local functions in its protocol.
203+
pageScript.emit('notify', 'before-listener')
204+
await new Promise(resolve => setTimeout(resolve, 20))
205+
expect(received).toEqual([])
206+
const offNotify = panelA.on('notify', value => received.push(`a:${value}`))
207+
// Panel B deliberately has no listener for this event.
207208
const panelB = connectPanelChannel<InPageChannelProtocol>({
208209
name: 'devframes:test',
209210
...noHandshake,
210211
transport: b.port2,
211-
functions: {},
212212
})
213213
try {
214214
expect(pageScript.panels).toHaveLength(2)
215-
pageScript.callEvent('notify', 'scan')
215+
pageScript.emit('notify', 'scan')
216216
await until(() => received.length === 1)
217217
expect(received).toEqual(['a:scan'])
218+
offNotify()
219+
pageScript.emit('notify', 'ignored')
220+
await new Promise(resolve => setTimeout(resolve, 20))
221+
expect(received).toEqual(['a:scan'])
218222
}
219223
finally {
220224
panelA.close()
@@ -518,19 +522,15 @@ describe('in-page channel handshake', () => {
518522
functions: defaultPanelFunctions,
519523
})
520524
const early = panel.call('echo', 'early')
521-
panel.callEvent('note', 'buffered')
525+
panel.emit('note', 'buffered')
522526

523527
const pageScript = createPageScriptChannel<TestProtocol>({
524528
name: 'devframes:test',
525529
window: asWindow(hostWin),
526530
heartbeat: false,
527-
functions: {
528-
...defaultPageScriptFunctions,
529-
note: { type: 'event', handler: (value) => {
530-
noted.push(value)
531-
} },
532-
},
531+
functions: defaultPageScriptFunctions,
533532
})
533+
pageScript.on('note', value => noted.push(value))
534534
try {
535535
await expect(early).resolves.toBe('early')
536536
await until(() => noted.length === 1)

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

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -166,24 +166,47 @@ 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+
let registered = listeners.get(name)
180+
if (!registered) {
181+
registered = new Set()
182+
listeners.set(name, registered)
183+
}
184+
registered.add(listener)
185+
return () => {
186+
registered.delete(listener)
187+
if (registered.size === 0)
188+
listeners.delete(name)
189+
}
190+
},
191+
resolve(name) {
192+
const definition = definitions.get(name)
193+
const registered = listeners.get(name)
194+
if (!definition && !registered?.size)
195+
return undefined
196+
return async (...rawArgs: unknown[]) => {
175197
const args = codec.deserialize ? rawArgs.map(codec.deserialize) : rawArgs
176-
if (definition.jsonSerializable)
198+
if (definition?.jsonSerializable)
177199
assertJsonSerializable(args, 'its arguments', definition.name)
178-
if (definition.args?.length)
200+
if (definition?.args?.length)
179201
await validateArgs(definition.name, definition.args, args)
180-
const result = await definition.handler(...args)
181-
if (definition.jsonSerializable)
202+
const result = await definition?.handler(...args)
203+
for (const listener of [...(listeners.get(name) ?? [])])
204+
listener(...args)
205+
if (definition?.jsonSerializable)
182206
assertJsonSerializable(result, 'its return value', definition.name)
183207
return codec.serialize && result !== undefined ? codec.serialize(result) : result
184-
})
208+
}
185209
},
186-
resolve: name => wrapped.get(name),
187210
}
188211
}
189212

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

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,10 @@ export function createPageScriptChannel<P extends InPageChannelProtocol>(
6363
let heartbeatTimer: ReturnType<typeof setInterval> | undefined
6464

6565
const registry = createLocalFunctionRegistry(codec)
66-
for (const [fnName, definition] of Object.entries(options.functions ?? {}))
67-
registry.register({ ...definition, name: fnName })
66+
for (const [fnName, definition] of Object.entries(options.functions ?? {})) {
67+
if (definition)
68+
registry.register({ ...definition, name: fnName })
69+
}
6870

6971
const stateHost = createPageScriptStateHost<P>(function* () {
7072
for (const peer of peers.values()) {
@@ -174,24 +176,28 @@ export function createPageScriptChannel<P extends InPageChannelProtocol>(
174176

175177
win?.addEventListener('message', onWindowMessage)
176178

179+
const emit: PageScriptChannel<P>['emit'] = (fnName, ...args) => {
180+
const wireArgs = serializeArgs(codec, args)
181+
for (const peer of peers.values()) {
182+
void peer.attached.rpc.$callRaw({
183+
method: fnName,
184+
args: wireArgs,
185+
event: true,
186+
optional: true,
187+
}).catch(() => {})
188+
}
189+
}
190+
177191
return {
178192
name,
179193
instanceId,
180194
get panels() {
181195
return [...peers.values()].map(peer => peer.peer)
182196
},
183197
events: { on: events.on, once: events.once },
184-
callEvent: (fnName, ...args) => {
185-
const wireArgs = serializeArgs(codec, args)
186-
for (const peer of peers.values()) {
187-
void peer.attached.rpc.$callRaw({
188-
method: fnName,
189-
args: wireArgs,
190-
event: true,
191-
optional: true,
192-
}).catch(() => {})
193-
}
194-
},
198+
emit,
199+
callEvent: emit,
200+
on: (fnName, listener) => registry.on(fnName, listener as (...args: unknown[]) => void),
195201
sharedState: stateHost,
196202
addPanelPort: port => addPeer(port, `transport:${nanoid(8)}`),
197203
close: () => {

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,10 @@ export function connectPanelChannel<P extends InPageChannelProtocol>(
6262

6363
const events = createEventEmitter<PanelChannelEvents>()
6464
const registry = createLocalFunctionRegistry(codec)
65-
for (const [fnName, definition] of Object.entries(options.functions ?? {}))
66-
registry.register({ ...definition, name: fnName })
65+
for (const [fnName, definition] of Object.entries(options.functions ?? {})) {
66+
if (definition)
67+
registry.register({ ...definition, name: fnName })
68+
}
6769

6870
let status: InPageChannelStatus = 'connecting'
6971
let attached: AttachedChannelPort | undefined
@@ -284,7 +286,9 @@ export function connectPanelChannel<P extends InPageChannelProtocol>(
284286
})
285287
},
286288
call: (fnName, ...args) => enqueueCall(fnName, serializeArgs(codec, args)) as Promise<any>,
289+
emit: (fnName, ...args) => sendEvent(fnName, serializeArgs(codec, args)),
287290
callEvent: (fnName, ...args) => sendEvent(fnName, serializeArgs(codec, args)),
291+
on: (fnName, listener) => registry.on(fnName, listener as (...args: unknown[]) => void),
288292
sharedState: stateHost,
289293
close: () => {
290294
if (status === 'closed')

0 commit comments

Comments
 (0)