Skip to content
Merged
21 changes: 11 additions & 10 deletions docs/content/1.guide/12.in-page-channel.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ import type { InPageChannelProtocol } from 'devframe/in-page-channel'
export const MY_CHANNEL = 'devframes:plugin:my-tool'

export interface MyChannelProtocol extends InPageChannelProtocol {
pageScript: { // implemented by the page script, called by panels
pageScript: { // functions and events received by the page script
highlight: (selector: string) => void
measure: (selector: string) => { width: number, height: number }
}
panel: { // implemented by panels, called by the page script
panel: { // functions and events received by panels
flash: (message: string) => void
}
sharedStates: {
Expand All @@ -54,7 +54,7 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names

## The page script endpoint

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.
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 may receive events through either its optional `handler` or runtime `channel.on()` listeners. 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.

```ts
import type { MyChannelProtocol } from '../shared/protocol'
Expand All @@ -79,12 +79,12 @@ const channel = createPageScriptChannel<MyChannelProtocol>({
},
})

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

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

## The panel endpoint

Expand All @@ -97,14 +97,15 @@ import { MY_CHANNEL } from '../shared/protocol'
const channel = connectPanelChannel<MyChannelProtocol>({
name: MY_CHANNEL,
functions: {
flash: {
handler: message => showFlash(message),
},
flash: { type: 'event' },
},
})

channel.callEvent('highlight', '.hero') // buffered until connected
const offFlash = channel.on('flash', message => showFlash(message))
channel.emit('highlight', '.hero') // buffered until connected
Comment thread
posva marked this conversation as resolved.
Outdated
const size = await channel.call('measure', '.hero')

offFlash() // stop listening
```

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

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

```ts
Expand Down
14 changes: 13 additions & 1 deletion docs/content/8.references/5.browser-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: 'Browser-Side API'
navigation:
icon: i-lucide-globe
description: 'Lookup tables for the browser side: connectDevframe options, RPC client events, connection statuses, and in-page channel error codes.'
description: 'Lookup tables for the browser side: connectDevframe options, RPC client events, connection statuses, and in-page channels.'

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.

so many useless edits on this one... I'm reverting them

---

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

## In-page channel endpoints

The browser-only endpoint methods of the [in-page channel](/guide/in-page-channel).

| Method or property | Page script | Panel |
|--------------------|-------------|-------|
| `emit(name, ...args)` | Fans an event out to every connected panel. | Sends an event to the page script, buffering while connecting. |
| `on(name, listener)` | Subscribes to events emitted by a panel. | Subscribes to events emitted by the page script. Returns an unsubscribe function. |
| `call(name, ...args)` | Available through a specific `PanelPeer`. | Calls a page-script function and awaits its result. |
Comment thread
posva marked this conversation as resolved.
| `events` | Local `panel:connected` / `panel:disconnected` lifecycle events. | Local `status:updated` lifecycle event. |
| `sharedState` | Owns the authoritative state. | Mirrors the page-script state. |

## In-page channel error codes

The `error.code` values of `InPageChannelError`: [Errors and fallbacks](/guide/in-page-channel#errors-and-fallbacks).
Expand Down
35 changes: 17 additions & 18 deletions packages/devframe/src/in-page-channel/in-page-channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,12 @@ const defaultPageScriptFunctions: NonNullable<CreatePageScriptChannelOptions<Tes
sum: { handler: (a, b) => a + b },
boom: { handler: () => {} },
strict: { handler: payload => payload },
note: { type: 'event', handler: () => {} },
note: { type: 'event' },
}

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

function createLinkedPair(options?: {
Expand Down Expand Up @@ -181,7 +181,7 @@ describe('in-page channel over bring-your-own ports', () => {
}
})

it('fans events out to every panel; panels without the handler ignore them', async () => {
it('fans events out to runtime panel listeners and supports unsubscribing', async () => {
const a = new MessageChannel()
const b = new MessageChannel()
const pageScript = createPageScriptChannel<TestProtocol>({
Expand All @@ -196,14 +196,13 @@ describe('in-page channel over bring-your-own ports', () => {
name: 'devframes:test',
...noHandshake,
transport: a.port2,
functions: {
...defaultPanelFunctions,
notify: { type: 'event', handler: (value) => {
received.push(`a:${value}`)
} },
},
functions: defaultPanelFunctions,
})
// Panel B deliberately has no local functions in its protocol.
pageScript.emit('notify', 'before-listener')
await new Promise(resolve => setTimeout(resolve, 20))
expect(received).toEqual([])
const offNotify = panelA.on('notify', value => received.push(`a:${value}`))
// Panel B deliberately has no listener for this event.
const panelB = connectPanelChannel<InPageChannelProtocol>({
name: 'devframes:test',
...noHandshake,
Expand All @@ -212,9 +211,13 @@ describe('in-page channel over bring-your-own ports', () => {
})
try {
expect(pageScript.panels).toHaveLength(2)
pageScript.callEvent('notify', 'scan')
pageScript.emit('notify', 'scan')
await until(() => received.length === 1)
expect(received).toEqual(['a:scan'])
offNotify()
pageScript.emit('notify', 'ignored')
await new Promise(resolve => setTimeout(resolve, 20))
expect(received).toEqual(['a:scan'])
}
finally {
panelA.close()
Expand Down Expand Up @@ -518,19 +521,15 @@ describe('in-page channel handshake', () => {
functions: defaultPanelFunctions,
})
const early = panel.call('echo', 'early')
panel.callEvent('note', 'buffered')
panel.emit('note', 'buffered')

const pageScript = createPageScriptChannel<TestProtocol>({
name: 'devframes:test',
window: asWindow(hostWin),
heartbeat: false,
functions: {
...defaultPageScriptFunctions,
note: { type: 'event', handler: (value) => {
noted.push(value)
} },
},
functions: defaultPageScriptFunctions,
})
pageScript.on('note', value => noted.push(value))
try {
await expect(early).resolves.toBe('early')
await until(() => noted.length === 1)
Expand Down
2 changes: 1 addition & 1 deletion packages/devframe/src/in-page-channel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export type {
export function defineChannelFunction<
NAME extends string,
TYPE extends InPageFunctionType,
ARGS extends any[],
ARGS extends any[] = [],

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.

needed because handler is optional if type is event

RETURN = void,
const AS extends RpcArgsSchema | undefined = undefined,
const RS extends RpcReturnSchema | undefined = undefined,
Expand Down
39 changes: 31 additions & 8 deletions packages/devframe/src/in-page-channel/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,24 +166,47 @@ export function deserializeResult(codec: InPageChannelSerialization, result: unk
*/
export function createLocalFunctionRegistry(codec: InPageChannelSerialization): {
register: (definition: InPageFunctionDefinitionAny) => void
on: (name: string, listener: (...args: unknown[]) => void) => () => void
resolve: (name: string) => ((...args: unknown[]) => unknown) | undefined
} {
const wrapped = new Map<string, (...args: unknown[]) => unknown>()
const definitions = new Map<string, InPageFunctionDefinitionAny>()
const listeners = new Map<string, Set<(...args: unknown[]) => void>>()
return {
register(definition) {
wrapped.set(definition.name, async (...rawArgs: unknown[]) => {
definitions.set(definition.name, definition)
},
on(name, listener) {
let registered = listeners.get(name)
if (!registered) {
registered = new Set()
listeners.set(name, registered)
}
registered.add(listener)
return () => {
Comment on lines +178 to +187
registered.delete(listener)
if (registered.size === 0)
listeners.delete(name)
}
},
resolve(name) {
const definition = definitions.get(name)
const registered = listeners.get(name)
if (!definition && !registered?.size)
return undefined
return async (...rawArgs: unknown[]) => {
const args = codec.deserialize ? rawArgs.map(codec.deserialize) : rawArgs
if (definition.jsonSerializable)
if (definition?.jsonSerializable)
assertJsonSerializable(args, 'its arguments', definition.name)
if (definition.args?.length)
if (definition?.args?.length)
await validateArgs(definition.name, definition.args, args)
const result = await definition.handler(...args)
if (definition.jsonSerializable)
const result = await definition?.handler?.(...args)
for (const listener of [...(listeners.get(name) ?? [])])
listener(...args)
Comment on lines +202 to +206
if (definition?.jsonSerializable)
assertJsonSerializable(result, 'its return value', definition.name)
return codec.serialize && result !== undefined ? codec.serialize(result) : result
})
}
},
resolve: name => wrapped.get(name),
}
}

Expand Down
28 changes: 16 additions & 12 deletions packages/devframe/src/in-page-channel/page-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export function createPageScriptChannel<P extends InPageChannelProtocol>(
let heartbeatTimer: ReturnType<typeof setInterval> | undefined

const registry = createLocalFunctionRegistry(codec)
for (const [fnName, definition] of Object.entries(options.functions ?? {}))
for (const [fnName, definition] of Object.entries(options.functions))
registry.register({ ...definition, name: fnName })
Comment on lines 65 to 67

const stateHost = createPageScriptStateHost<P>(function* () {
Expand Down Expand Up @@ -174,24 +174,28 @@ export function createPageScriptChannel<P extends InPageChannelProtocol>(

win?.addEventListener('message', onWindowMessage)

const emit: PageScriptChannel<P>['emit'] = (fnName, ...args) => {
const wireArgs = serializeArgs(codec, args)
for (const peer of peers.values()) {
void peer.attached.rpc.$callRaw({
method: fnName,
args: wireArgs,
event: true,
optional: true,
}).catch(() => {})
}
}

return {
name,
instanceId,
get panels() {
return [...peers.values()].map(peer => peer.peer)
},
events: { on: events.on, once: events.once },
callEvent: (fnName, ...args) => {
const wireArgs = serializeArgs(codec, args)
for (const peer of peers.values()) {
void peer.attached.rpc.$callRaw({
method: fnName,
args: wireArgs,
event: true,
optional: true,
}).catch(() => {})
}
},
emit,
callEvent: emit,
on: (fnName, listener) => registry.on(fnName, listener as (...args: unknown[]) => void),
sharedState: stateHost,
addPanelPort: port => addPeer(port, `transport:${nanoid(8)}`),
close: () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/devframe/src/in-page-channel/panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function connectPanelChannel<P extends InPageChannelProtocol>(

const events = createEventEmitter<PanelChannelEvents>()
const registry = createLocalFunctionRegistry(codec)
for (const [fnName, definition] of Object.entries(options.functions ?? {}))
for (const [fnName, definition] of Object.entries(options.functions))
registry.register({ ...definition, name: fnName })
Comment on lines +65 to 66

let status: InPageChannelStatus = 'connecting'
Expand Down Expand Up @@ -284,7 +284,9 @@ export function connectPanelChannel<P extends InPageChannelProtocol>(
})
},
call: (fnName, ...args) => enqueueCall(fnName, serializeArgs(codec, args)) as Promise<any>,
emit: (fnName, ...args) => sendEvent(fnName, serializeArgs(codec, args)),
callEvent: (fnName, ...args) => sendEvent(fnName, serializeArgs(codec, args)),
on: (fnName, listener) => registry.on(fnName, listener as (...args: unknown[]) => void),
sharedState: stateHost,
close: () => {
if (status === 'closed')
Expand Down
Loading
Loading