From 0ed9101df57766e0dad520db44b46c5a7fea2a60 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Fri, 4 Sep 2026 17:15:07 +0200 Subject: [PATCH 01/15] feat: add runtime in-page channel events --- docs/content/1.guide/12.in-page-channel.md | 22 ++++---- docs/content/8.references/5.browser-api.md | 14 ++++- .../in-page-channel/in-page-channel.test.ts | 30 +++++----- .../devframe/src/in-page-channel/internal.ts | 39 ++++++++++--- .../src/in-page-channel/page-script.ts | 32 ++++++----- .../devframe/src/in-page-channel/panel.ts | 8 ++- .../src/in-page-channel/types.test-d.ts | 56 +++++++++++++------ .../devframe/src/in-page-channel/types.ts | 51 +++++++++++------ plugins/a11y/app/lib/channel.ts | 16 +++--- .../devframe/in-page-channel.snapshot.d.ts | 12 ++-- 10 files changed, 182 insertions(+), 98 deletions(-) diff --git a/docs/content/1.guide/12.in-page-channel.md b/docs/content/1.guide/12.in-page-channel.md index 9f0403613..7eb93a24f 100644 --- a/docs/content/1.guide/12.in-page-channel.md +++ b/docs/content/1.guide/12.in-page-channel.md @@ -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: { @@ -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. +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. ```ts import type { MyChannelProtocol } from '../shared/protocol' @@ -79,12 +79,12 @@ const channel = createPageScriptChannel({ }, }) -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 @@ -96,15 +96,13 @@ import { MY_CHANNEL } from '../shared/protocol' const channel = connectPanelChannel({ name: MY_CHANNEL, - functions: { - flash: { - handler: message => showFlash(message), - }, - }, }) -channel.callEvent('highlight', '.hero') // buffered until connected +const offFlash = channel.on('flash', message => showFlash(message)) +channel.emit('highlight', '.hero') // buffered until connected const size = await channel.call('measure', '.hero') + +offFlash() // stop listening ``` ## Shared state @@ -133,7 +131,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 diff --git a/docs/content/8.references/5.browser-api.md b/docs/content/8.references/5.browser-api.md index c7e43f8d0..3d24a23c4 100644 --- a/docs/content/8.references/5.browser-api.md +++ b/docs/content/8.references/5.browser-api.md @@ -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.' --- 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# | `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. | +| `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). diff --git a/packages/devframe/src/in-page-channel/in-page-channel.test.ts b/packages/devframe/src/in-page-channel/in-page-channel.test.ts index 5dff9e502..512800aef 100644 --- a/packages/devframe/src/in-page-channel/in-page-channel.test.ts +++ b/packages/devframe/src/in-page-channel/in-page-channel.test.ts @@ -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({ @@ -197,24 +197,28 @@ describe('in-page channel over bring-your-own ports', () => { ...noHandshake, transport: a.port2, functions: { - ...defaultPanelFunctions, - notify: { type: 'event', handler: (value) => { - received.push(`a:${value}`) - } }, + 'ping-panel': defaultPanelFunctions['ping-panel'], }, }) - // 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({ name: 'devframes:test', ...noHandshake, transport: b.port2, - functions: {}, }) 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() @@ -518,19 +522,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({ 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) diff --git a/packages/devframe/src/in-page-channel/internal.ts b/packages/devframe/src/in-page-channel/internal.ts index 3a832894a..70acae2d0 100644 --- a/packages/devframe/src/in-page-channel/internal.ts +++ b/packages/devframe/src/in-page-channel/internal.ts @@ -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 unknown>() + const definitions = new Map() + const listeners = new Map 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 () => { + 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) + if (definition?.jsonSerializable) assertJsonSerializable(result, 'its return value', definition.name) return codec.serialize && result !== undefined ? codec.serialize(result) : result - }) + } }, - resolve: name => wrapped.get(name), } } diff --git a/packages/devframe/src/in-page-channel/page-script.ts b/packages/devframe/src/in-page-channel/page-script.ts index 806daa01e..b3cf8575f 100644 --- a/packages/devframe/src/in-page-channel/page-script.ts +++ b/packages/devframe/src/in-page-channel/page-script.ts @@ -63,8 +63,10 @@ export function createPageScriptChannel

( let heartbeatTimer: ReturnType | undefined const registry = createLocalFunctionRegistry(codec) - for (const [fnName, definition] of Object.entries(options.functions ?? {})) - registry.register({ ...definition, name: fnName }) + for (const [fnName, definition] of Object.entries(options.functions ?? {})) { + if (definition) + registry.register({ ...definition, name: fnName }) + } const stateHost = createPageScriptStateHost

(function* () { for (const peer of peers.values()) { @@ -174,6 +176,18 @@ export function createPageScriptChannel

( win?.addEventListener('message', onWindowMessage) + const emit: PageScriptChannel

['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, @@ -181,17 +195,9 @@ export function createPageScriptChannel

( 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: () => { diff --git a/packages/devframe/src/in-page-channel/panel.ts b/packages/devframe/src/in-page-channel/panel.ts index e9273c0e5..46fddddd8 100644 --- a/packages/devframe/src/in-page-channel/panel.ts +++ b/packages/devframe/src/in-page-channel/panel.ts @@ -62,8 +62,10 @@ export function connectPanelChannel

( const events = createEventEmitter() const registry = createLocalFunctionRegistry(codec) - for (const [fnName, definition] of Object.entries(options.functions ?? {})) - registry.register({ ...definition, name: fnName }) + for (const [fnName, definition] of Object.entries(options.functions ?? {})) { + if (definition) + registry.register({ ...definition, name: fnName }) + } let status: InPageChannelStatus = 'connecting' let attached: AttachedChannelPort | undefined @@ -284,7 +286,9 @@ export function connectPanelChannel

( }) }, call: (fnName, ...args) => enqueueCall(fnName, serializeArgs(codec, args)) as Promise, + 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') diff --git a/packages/devframe/src/in-page-channel/types.test-d.ts b/packages/devframe/src/in-page-channel/types.test-d.ts index d3962036b..111039f6f 100644 --- a/packages/devframe/src/in-page-channel/types.test-d.ts +++ b/packages/devframe/src/in-page-channel/types.test-d.ts @@ -57,13 +57,11 @@ describe('In-page script channel', () => { }) }) - it('requires every in-page script function', () => { - // @ts-expect-error `functions` is required. + it('accepts runtime-only and partial function implementations', () => { createPageScriptChannel({ name: 'devframes:test' }) createPageScriptChannel({ name: 'devframes:test', - // @ts-expect-error `sum` and `save` are required. functions: { echo: { handler: value => value }, }, @@ -100,16 +98,16 @@ describe('In-page script channel', () => { describe('Function calling', () => { it('types fire-and-forget calls to panel functions', () => { - expectTypeOf(channel.callEvent('notify', 'ready')).toEqualTypeOf() + expectTypeOf(channel.emit('notify', 'ready')).toEqualTypeOf() // @ts-expect-error In-page script functions cannot be called on panels. - channel.callEvent('echo', 'ready') + channel.emit('echo', 'ready') // @ts-expect-error `notify` requires a string. - channel.callEvent('notify', 42) + channel.emit('notify', 42) // @ts-expect-error `notify` requires one argument. - channel.callEvent('notify') + channel.emit('notify') // @ts-expect-error `notify` accepts one argument. - channel.callEvent('notify', 'ready', 'extra') + channel.emit('notify', 'ready', 'extra') }) it('types calls to connected panels', () => { @@ -133,11 +131,23 @@ describe('In-page script channel', () => { }) // @ts-expect-error The protocol has no panel functions. - pageScriptOnlyChannel.callEvent('notify', 'ready') + pageScriptOnlyChannel.emit('notify', 'ready') }) }) describe('Event checking', () => { + it('types runtime subscriptions to page-script functions', () => { + const unsubscribe = channel.on('echo', (value) => { + expectTypeOf(value).toEqualTypeOf() + }) + + expectTypeOf(unsubscribe).toEqualTypeOf<() => void>() + // @ts-expect-error Panel functions cannot be handled by the page script. + channel.on('notify', () => {}) + // @ts-expect-error `echo` listeners receive a string. + channel.on('echo', (value: number) => void value) + }) + it('types panel connection events', () => { const unsubscribeConnected = channel.events.on('panel:connected', (panel) => { expectTypeOf(panel.id).toEqualTypeOf() @@ -185,13 +195,11 @@ describe('Panel channel', () => { inferredChannel.close() }) - it('requires every panel function', () => { - // @ts-expect-error `functions` is required. + it('accepts runtime-only and partial function implementations', () => { connectPanelChannel({ name: 'devframes:test' }) connectPanelChannel({ name: 'devframes:test', - // @ts-expect-error `notify` is required. functions: {}, }) }) @@ -252,16 +260,16 @@ describe('Panel channel', () => { }) it('types fire-and-forget calls to in-page script functions', () => { - expectTypeOf(channel.callEvent('echo', 'hello')).toEqualTypeOf() - expectTypeOf(channel.callEvent('sum', 1, 2)).toEqualTypeOf() - expectTypeOf(channel.callEvent('save', 'draft')).toEqualTypeOf() + expectTypeOf(channel.emit('echo', 'hello')).toEqualTypeOf() + expectTypeOf(channel.emit('sum', 1, 2)).toEqualTypeOf() + expectTypeOf(channel.emit('save', 'draft')).toEqualTypeOf() // @ts-expect-error Panel functions cannot be emitted to the in-page script. - channel.callEvent('notify', 'hello') + channel.emit('notify', 'hello') // @ts-expect-error `echo` requires a string. - channel.callEvent('echo', false) + channel.emit('echo', false) // @ts-expect-error `sum` requires two arguments. - channel.callEvent('sum', 1) + channel.emit('sum', 1) }) it('types channel state', () => { @@ -273,6 +281,18 @@ describe('Panel channel', () => { }) describe('Event checking', () => { + it('types runtime subscriptions to panel functions', () => { + const unsubscribe = channel.on('notify', (message) => { + expectTypeOf(message).toEqualTypeOf() + }) + + expectTypeOf(unsubscribe).toEqualTypeOf<() => void>() + // @ts-expect-error Page-script functions cannot be handled by the panel. + channel.on('echo', () => {}) + // @ts-expect-error `notify` listeners receive a string. + channel.on('notify', (message: number) => void message) + }) + it('types status events', () => { const unsubscribe = channel.events.on('status:updated', (status) => { expectTypeOf(status).toEqualTypeOf<'connecting' | 'connected' | 'closed'>() diff --git a/packages/devframe/src/in-page-channel/types.ts b/packages/devframe/src/in-page-channel/types.ts index c89e12623..93a539aed 100644 --- a/packages/devframe/src/in-page-channel/types.ts +++ b/packages/devframe/src/in-page-channel/types.ts @@ -10,9 +10,9 @@ import type { InferArgsType, InferReturnType } from '../rpc/utils' * channel-name constant declared next to it. */ export interface InPageChannelProtocol { - /** Functions implemented by the page script, callable by panels. */ + /** Functions and events received by the page script. */ pageScript?: Record any> - /** Functions implemented by panels, callable by the page script. */ + /** Functions and events received by panels. */ panel?: Record any> /** * Shared-state slots. The page script is the authority: it owns the @@ -111,18 +111,18 @@ interface InPageFunctionOption { * * @internal */ -type CreatePageScriptChannelOptionsFunctions

= { +type CreatePageScriptChannelOptionsFunctions

= Partial<{ [NAME in keyof PageScriptFunctions

& string]: InPageFunctionOption[NAME]> -} +}> /** * Functions implemented by {@link connectPanelChannel}. * * @internal */ -type ConnectPanelChannelOptionsFunctions

= { +type ConnectPanelChannelOptionsFunctions

= Partial<{ [NAME in keyof PanelFunctions

& string]: InPageFunctionOption[NAME]> -} +}> /** * Connection lifecycle of a panel endpoint: `connecting` (handshake retry @@ -173,8 +173,8 @@ interface InPageChannelCommonOptions { /** Options for {@link createPageScriptChannel}. */ export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions { - /** Implementations of the protocol's page-script functions. */ - functions: CreatePageScriptChannelOptionsFunctions + /** Initial page-script handlers. Event listeners may also use `channel.on()`. */ + functions?: CreatePageScriptChannelOptionsFunctions /** * Window whose `message` events carry panel hellos. Defaults to the * global `window`; pass `false` to skip the handshake listener entirely @@ -185,8 +185,8 @@ export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions { - /** Implementations of the protocol's panel functions. */ - functions: ConnectPanelChannelOptionsFunctions + /** Initial panel handlers. Event listeners may also use `channel.on()`. */ + functions?: ConnectPanelChannelOptionsFunctions /** * The panel's own window (listens for the handshake grant). Defaults to * the global `window`; pass `false` with `transport` to skip the handshake. @@ -215,7 +215,7 @@ export interface ConnectPanelChannelOptions { /** Currently connected panels. */ readonly panels: readonly PanelPeer

[] readonly events: Pick>, 'on' | 'once'> - /** - * Fan a fire-and-forget event out to every connected panel; panels that - * don't implement the function ignore it. - */ + /** Fan an event out to every connected panel. */ + emit: & string>( + name: K, + ...args: FnArgs[K]> + ) => void + /** @deprecated Use `emit()` instead. */ callEvent: & string>( name: K, ...args: FnArgs[K]> ) => void + /** Subscribe to an event emitted by a panel. Returns an unsubscribe function. */ + on: & string>( + name: K, + listener: (...args: FnArgs[K]>) => void, + ) => () => void /** Page-script-authoritative shared states, replayed to joining panels. */ readonly sharedState: InPageSharedStateHost

/** Adopt a pre-established port as a panel peer (bring-your-own transport). */ @@ -318,13 +325,23 @@ export interface PanelChannel

{ ...args: FnArgs[K]> ) => Promise[K]>> /** - * Fire-and-forget to the page script. While `connecting` the event is - * buffered (up to `eventBufferLimit`) and flushed on connect. + * Emit an event to the page script. While `connecting` the event is buffered + * (up to `eventBufferLimit`) and flushed on connect. */ + emit: & string>( + name: K, + ...args: FnArgs[K]> + ) => void + /** @deprecated Use `emit()` instead. */ callEvent: & string>( name: K, ...args: FnArgs[K]> ) => void + /** Subscribe to an event emitted by the page script. Returns an unsubscribe function. */ + on: & string>( + name: K, + listener: (...args: FnArgs[K]>) => void, + ) => () => void /** Shared states mirrored from the page-script authority. */ readonly sharedState: InPageSharedStateHost

/** Tear the endpoint down permanently. */ diff --git a/plugins/a11y/app/lib/channel.ts b/plugins/a11y/app/lib/channel.ts index 7b416fe89..3b2b2289b 100644 --- a/plugins/a11y/app/lib/channel.ts +++ b/plugins/a11y/app/lib/channel.ts @@ -71,16 +71,16 @@ export function createA11yChannel(): A11yChannel { pageScriptReady, scanning: () => state()?.scanning || localScanning(), activeRoute: () => state()?.activeRoute ?? null, - preview: node => channel.callEvent('highlight', node.id, node.target), - clearPreview: () => channel.callEvent('clear-highlight'), - setPins: pins => channel.callEvent('set-pins', pins), + preview: node => channel.emit('highlight', node.id, node.target), + clearPreview: () => channel.emit('clear-highlight'), + setPins: pins => channel.emit('set-pins', pins), rescan: () => { setLocalScanning(true) - channel.callEvent('rescan') + channel.emit('rescan') }, - sendConfig: config => channel.callEvent('set-config', config), - setAutoScan: enabled => channel.callEvent('set-autoscan', enabled), - clearRoute: route => channel.callEvent('clear-route', route), - clearAll: () => channel.callEvent('clear-all'), + sendConfig: config => channel.emit('set-config', config), + setAutoScan: enabled => channel.emit('set-autoscan', enabled), + clearRoute: route => channel.emit('clear-route', route), + clearAll: () => channel.emit('clear-all'), } } diff --git a/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts index 217e64918..523b77bf2 100644 --- a/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts @@ -3,7 +3,7 @@ */ // #region Interfaces export interface ConnectPanelChannelOptions extends InPageChannelCommonOptions { - functions: ConnectPanelChannelOptionsFunctions; + functions?: ConnectPanelChannelOptionsFunctions; window?: Window | false; targets?: Window[]; transport?: MessagePort; @@ -12,7 +12,7 @@ export interface ConnectPanelChannelOptions extends InPageChannelCommonOptions { - functions: CreatePageScriptChannelOptionsFunctions; + functions?: CreatePageScriptChannelOptionsFunctions; window?: Window | false; } export interface InPageChannelProtocol { @@ -25,7 +25,9 @@ export interface PageScriptChannel

{ readonly instanceId: string; readonly panels: readonly PanelPeer

[]; readonly events: Pick>, 'on' | 'once'>; + emit: & string>(_: K, ..._: FnArgs[K]>) => void; callEvent: & string>(_: K, ..._: FnArgs[K]>) => void; + on: & string>(_: K, _: (..._: FnArgs[K]>) => void) => () => void; readonly sharedState: InPageSharedStateHost

; addPanelPort: (_: MessagePort) => PanelPeer

; close: () => void; @@ -39,7 +41,9 @@ export interface PanelChannel

{ readonly events: Pick, 'on' | 'once'>; whenConnected: (_?: number) => Promise; call: & string>(_: K, ..._: FnArgs[K]>) => Promise[K]>>; + emit: & string>(_: K, ..._: FnArgs[K]>) => void; callEvent: & string>(_: K, ..._: FnArgs[K]>) => void; + on: & string>(_: K, _: (..._: FnArgs[K]>) => void) => () => void; readonly sharedState: InPageSharedStateHost

; close: () => void; } @@ -92,8 +96,8 @@ export declare function defineChannelFunction = { [NAME in keyof PanelFunctions

& string]: InPageFunctionOption[NAME]>; }; -type CreatePageScriptChannelOptionsFunctions

= { [NAME in keyof PageScriptFunctions

& string]: InPageFunctionOption[NAME]>; }; +type ConnectPanelChannelOptionsFunctions

= Partial<{ [NAME in keyof PanelFunctions

& string]: InPageFunctionOption[NAME]>; }>; +type CreatePageScriptChannelOptionsFunctions

= Partial<{ [NAME in keyof PageScriptFunctions

& string]: InPageFunctionOption[NAME]>; }>; type FnArgs = F extends ((...args: infer A) => any) ? A : never; type FnReturn = F extends ((...args: any[]) => infer R) ? Awaited : never; interface InPageChannelCommonOptions { From c5337a8ae3a72fbb1b671cecca6cdf0fc0191be3 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Fri, 4 Sep 2026 17:26:33 +0200 Subject: [PATCH 02/15] fix: preserve required in-page function declarations --- docs/content/1.guide/12.in-page-channel.md | 5 +- .../in-page-channel/in-page-channel.test.ts | 9 ++- .../devframe/src/in-page-channel/index.ts | 2 +- .../devframe/src/in-page-channel/internal.ts | 2 +- .../src/in-page-channel/page-script.ts | 6 +- .../devframe/src/in-page-channel/panel.ts | 6 +- .../src/in-page-channel/types.test-d.ts | 60 ++++++++++++++++++- .../devframe/src/in-page-channel/types.ts | 47 +++++++++------ .../devframe/in-page-channel.snapshot.d.ts | 22 ++++--- 9 files changed, 115 insertions(+), 44 deletions(-) diff --git a/docs/content/1.guide/12.in-page-channel.md b/docs/content/1.guide/12.in-page-channel.md index 7eb93a24f..4a0b4e5e6 100644 --- a/docs/content/1.guide/12.in-page-channel.md +++ b/docs/content/1.guide/12.in-page-channel.md @@ -54,7 +54,7 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names ## The page script endpoint -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. +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' @@ -96,6 +96,9 @@ import { MY_CHANNEL } from '../shared/protocol' const channel = connectPanelChannel({ name: MY_CHANNEL, + functions: { + flash: { type: 'event' }, + }, }) const offFlash = channel.on('flash', message => showFlash(message)) diff --git a/packages/devframe/src/in-page-channel/in-page-channel.test.ts b/packages/devframe/src/in-page-channel/in-page-channel.test.ts index 512800aef..8a6b50224 100644 --- a/packages/devframe/src/in-page-channel/in-page-channel.test.ts +++ b/packages/devframe/src/in-page-channel/in-page-channel.test.ts @@ -43,12 +43,12 @@ const defaultPageScriptFunctions: NonNullable a + b }, boom: { handler: () => {} }, strict: { handler: payload => payload }, - note: { type: 'event', handler: () => {} }, + note: { type: 'event' }, } const defaultPanelFunctions: NonNullable['functions']> = { 'ping-panel': { handler: value => `pong:${value}` }, - 'notify': { type: 'event', handler: () => {} }, + 'notify': { type: 'event' }, } function createLinkedPair(options?: { @@ -196,9 +196,7 @@ describe('in-page channel over bring-your-own ports', () => { name: 'devframes:test', ...noHandshake, transport: a.port2, - functions: { - 'ping-panel': defaultPanelFunctions['ping-panel'], - }, + functions: defaultPanelFunctions, }) pageScript.emit('notify', 'before-listener') await new Promise(resolve => setTimeout(resolve, 20)) @@ -209,6 +207,7 @@ describe('in-page channel over bring-your-own ports', () => { name: 'devframes:test', ...noHandshake, transport: b.port2, + functions: {}, }) try { expect(pageScript.panels).toHaveLength(2) diff --git a/packages/devframe/src/in-page-channel/index.ts b/packages/devframe/src/in-page-channel/index.ts index 1b2e08dd5..f9e886b9f 100644 --- a/packages/devframe/src/in-page-channel/index.ts +++ b/packages/devframe/src/in-page-channel/index.ts @@ -33,7 +33,7 @@ export type { export function defineChannelFunction< NAME extends string, TYPE extends InPageFunctionType, - ARGS extends any[], + ARGS extends any[] = [], RETURN = void, const AS extends RpcArgsSchema | undefined = undefined, const RS extends RpcReturnSchema | undefined = undefined, diff --git a/packages/devframe/src/in-page-channel/internal.ts b/packages/devframe/src/in-page-channel/internal.ts index 70acae2d0..6cc39d33d 100644 --- a/packages/devframe/src/in-page-channel/internal.ts +++ b/packages/devframe/src/in-page-channel/internal.ts @@ -199,7 +199,7 @@ export function createLocalFunctionRegistry(codec: InPageChannelSerialization): assertJsonSerializable(args, 'its arguments', definition.name) if (definition?.args?.length) await validateArgs(definition.name, definition.args, args) - const result = await definition?.handler(...args) + const result = await definition?.handler?.(...args) for (const listener of [...(listeners.get(name) ?? [])]) listener(...args) if (definition?.jsonSerializable) diff --git a/packages/devframe/src/in-page-channel/page-script.ts b/packages/devframe/src/in-page-channel/page-script.ts index b3cf8575f..6dc245a74 100644 --- a/packages/devframe/src/in-page-channel/page-script.ts +++ b/packages/devframe/src/in-page-channel/page-script.ts @@ -63,10 +63,8 @@ export function createPageScriptChannel

( let heartbeatTimer: ReturnType | undefined const registry = createLocalFunctionRegistry(codec) - for (const [fnName, definition] of Object.entries(options.functions ?? {})) { - if (definition) - registry.register({ ...definition, name: fnName }) - } + for (const [fnName, definition] of Object.entries(options.functions)) + registry.register({ ...definition, name: fnName }) const stateHost = createPageScriptStateHost

(function* () { for (const peer of peers.values()) { diff --git a/packages/devframe/src/in-page-channel/panel.ts b/packages/devframe/src/in-page-channel/panel.ts index 46fddddd8..374197d19 100644 --- a/packages/devframe/src/in-page-channel/panel.ts +++ b/packages/devframe/src/in-page-channel/panel.ts @@ -62,10 +62,8 @@ export function connectPanelChannel

( const events = createEventEmitter() const registry = createLocalFunctionRegistry(codec) - for (const [fnName, definition] of Object.entries(options.functions ?? {})) { - if (definition) - registry.register({ ...definition, name: fnName }) - } + for (const [fnName, definition] of Object.entries(options.functions)) + registry.register({ ...definition, name: fnName }) let status: InPageChannelStatus = 'connecting' let attached: AttachedChannelPort | undefined diff --git a/packages/devframe/src/in-page-channel/types.test-d.ts b/packages/devframe/src/in-page-channel/types.test-d.ts index 111039f6f..0ad636afe 100644 --- a/packages/devframe/src/in-page-channel/types.test-d.ts +++ b/packages/devframe/src/in-page-channel/types.test-d.ts @@ -1,4 +1,5 @@ import { describe, expectTypeOf, it } from 'vitest' +import { defineChannelFunction } from './index' import { createPageScriptChannel } from './page-script' import { connectPanelChannel } from './panel' @@ -20,6 +21,19 @@ interface PageScriptOnlyProtocol { panel: Record } +describe('Channel function definitions', () => { + it('allows events without handlers', () => { + defineChannelFunction({ name: 'notify', type: 'event' }) + }) + + it('requires handlers for request/response functions', () => { + // @ts-expect-error Query functions require a handler. + defineChannelFunction({ name: 'load', type: 'query' }) + // @ts-expect-error Action functions require a handler. + defineChannelFunction({ name: 'save', type: 'action' }) + }) +}) + describe('In-page script channel', () => { const channel = createPageScriptChannel({ name: 'devframes:test', @@ -57,17 +71,40 @@ describe('In-page script channel', () => { }) }) - it('accepts runtime-only and partial function implementations', () => { + it('requires every page-script function declaration', () => { + // @ts-expect-error `functions` is required. createPageScriptChannel({ name: 'devframes:test' }) createPageScriptChannel({ name: 'devframes:test', + // @ts-expect-error `sum` and `save` must be declared. functions: { echo: { handler: value => value }, }, }) }) + it('allows event declarations to omit their handler', () => { + createPageScriptChannel({ + name: 'devframes:test', + functions: { + echo: { handler: value => value }, + sum: { handler: (a, b) => a + b }, + save: { type: 'event' }, + }, + }) + + createPageScriptChannel({ + name: 'devframes:test', + functions: { + // @ts-expect-error Request/response functions require a handler. + echo: { type: 'query' }, + sum: { handler: (a, b) => a + b }, + save: { type: 'event' }, + }, + }) + }) + it('rejects panel functions', () => { createPageScriptChannel({ name: 'devframes:test', @@ -195,15 +232,34 @@ describe('Panel channel', () => { inferredChannel.close() }) - it('accepts runtime-only and partial function implementations', () => { + it('requires every panel function declaration', () => { + // @ts-expect-error `functions` is required. connectPanelChannel({ name: 'devframes:test' }) connectPanelChannel({ name: 'devframes:test', + // @ts-expect-error `notify` must be declared. functions: {}, }) }) + it('allows event declarations to omit their handler', () => { + connectPanelChannel({ + name: 'devframes:test', + functions: { + notify: { type: 'event' }, + }, + }) + + connectPanelChannel({ + name: 'devframes:test', + functions: { + // @ts-expect-error Request/response functions require a handler. + notify: { type: 'action' }, + }, + }) + }) + it('rejects in-page script functions', () => { connectPanelChannel({ name: 'devframes:test', diff --git a/packages/devframe/src/in-page-channel/types.ts b/packages/devframe/src/in-page-channel/types.ts index 93a539aed..f3c88ebfa 100644 --- a/packages/devframe/src/in-page-channel/types.ts +++ b/packages/devframe/src/in-page-channel/types.ts @@ -54,7 +54,8 @@ export type InPageFunctionType = 'action' | 'event' | 'query' * `dump`/`snapshot`/`cacheable`/`agent`. When `jsonSerializable` is `true`, * payloads are strictly validated at the receiving endpoint and misshapen * values reject the call with a descriptive `InPageChannelError` instead of - * a cryptic `DataCloneError` in the port. + * a cryptic `DataCloneError` in the port. Event definitions may omit their + * handler when runtime listeners subscribe through `channel.on()`. */ export type InPageFunctionDefinition< NAME extends string, @@ -65,15 +66,16 @@ export type InPageFunctionDefinition< RS extends RpcReturnSchema | undefined = undefined, > = [AS, RS] extends [undefined, undefined] - ? { + ? ({ name: NAME type?: TYPE args?: AS returns?: RS jsonSerializable?: boolean - handler: (...args: ARGS) => RETURN - } - : { + } & (TYPE extends 'event' + ? { handler?: (...args: ARGS) => RETURN } + : { handler: (...args: ARGS) => RETURN })) + : ({ name: NAME type?: TYPE /** Standard Schema array validating (and typing) the arguments. */ @@ -81,8 +83,9 @@ export type InPageFunctionDefinition< /** Standard Schema typing the resolved return value. */ returns: RS jsonSerializable?: boolean - handler: (...args: InferArgsType) => Thenable> - } + } & (TYPE extends 'event' + ? { handler?: (...args: InferArgsType) => Thenable> } + : { handler: (...args: InferArgsType) => Thenable> })) /** * Loosely-typed definition used by the internal function registry. @@ -96,33 +99,41 @@ export type InPageFunctionDefinitionAny = InPageFunctionDefinition { - type?: InPageFunctionType +interface InPageFunctionOptionBase { /** Optional Standard Schema array validating the arguments. */ args?: RpcArgsSchema /** Optional Standard Schema validating the resolved return value. */ returns?: RpcReturnSchema jsonSerializable?: boolean - handler: ProtocolHandler } +type InPageFunctionOption + = | (InPageFunctionOptionBase & { + type: 'event' + handler?: ProtocolHandler + }) + | (InPageFunctionOptionBase & { + type?: Exclude + handler: ProtocolHandler + }) + /** * Functions implemented by {@link createPageScriptChannel}. * * @internal */ -type CreatePageScriptChannelOptionsFunctions

= Partial<{ +type CreatePageScriptChannelOptionsFunctions

= { [NAME in keyof PageScriptFunctions

& string]: InPageFunctionOption[NAME]> -}> +} /** * Functions implemented by {@link connectPanelChannel}. * * @internal */ -type ConnectPanelChannelOptionsFunctions

= Partial<{ +type ConnectPanelChannelOptionsFunctions

= { [NAME in keyof PanelFunctions

& string]: InPageFunctionOption[NAME]> -}> +} /** * Connection lifecycle of a panel endpoint: `connecting` (handshake retry @@ -173,8 +184,8 @@ interface InPageChannelCommonOptions { /** Options for {@link createPageScriptChannel}. */ export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions { - /** Initial page-script handlers. Event listeners may also use `channel.on()`. */ - functions?: CreatePageScriptChannelOptionsFunctions + /** Every page-script function declaration; event handlers may use `channel.on()`. */ + functions: CreatePageScriptChannelOptionsFunctions /** * Window whose `message` events carry panel hellos. Defaults to the * global `window`; pass `false` to skip the handshake listener entirely @@ -185,8 +196,8 @@ export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions { - /** Initial panel handlers. Event listeners may also use `channel.on()`. */ - functions?: ConnectPanelChannelOptionsFunctions + /** Every panel function declaration; event handlers may use `channel.on()`. */ + functions: ConnectPanelChannelOptionsFunctions /** * The panel's own window (listens for the handshake grant). Defaults to * the global `window`; pass `false` with `transport` to skip the handshake. diff --git a/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts index 523b77bf2..45bc7c343 100644 --- a/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts @@ -3,7 +3,7 @@ */ // #region Interfaces export interface ConnectPanelChannelOptions extends InPageChannelCommonOptions { - functions?: ConnectPanelChannelOptionsFunctions; + functions: ConnectPanelChannelOptionsFunctions; window?: Window | false; targets?: Window[]; transport?: MessagePort; @@ -12,7 +12,7 @@ export interface ConnectPanelChannelOptions extends InPageChannelCommonOptions { - functions?: CreatePageScriptChannelOptionsFunctions; + functions: CreatePageScriptChannelOptionsFunctions; window?: Window | false; } export interface InPageChannelProtocol { @@ -62,21 +62,27 @@ export type InPageChannelErrorCode = 'timeout' | 'invalid-args' | 'state-uninitialized'; export type InPageChannelStatus = 'connecting' | 'connected' | 'closed'; -export type InPageFunctionDefinition = [AS, RS] extends [undefined, undefined] ? { +export type InPageFunctionDefinition = [AS, RS] extends [undefined, undefined] ? ({ name: NAME; type?: TYPE; args?: AS; returns?: RS; jsonSerializable?: boolean; - handler: (...args: ARGS) => RETURN; +} & (TYPE extends 'event' ? { + handler?: (...args: ARGS) => RETURN; } : { + handler: (...args: ARGS) => RETURN; +})) : ({ name: NAME; type?: TYPE; args: AS; returns: RS; jsonSerializable?: boolean; +} & (TYPE extends 'event' ? { + handler?: (...args: InferArgsType) => Thenable>; +} : { handler: (...args: InferArgsType) => Thenable>; -}; +})); // #endregion // #region Classes @@ -92,12 +98,12 @@ export declare class InPageChannelError extends Error { // #region Functions export declare function connectPanelChannel

(_: ConnectPanelChannelOptions

): PanelChannel

; export declare function createPageScriptChannel

(_: CreatePageScriptChannelOptions

): PageScriptChannel

; -export declare function defineChannelFunction(_: InPageFunctionDefinition): InPageFunctionDefinition; +export declare function defineChannelFunction(_: InPageFunctionDefinition): InPageFunctionDefinition; // #endregion // #region Referenced (internal) -type ConnectPanelChannelOptionsFunctions

= Partial<{ [NAME in keyof PanelFunctions

& string]: InPageFunctionOption[NAME]>; }>; -type CreatePageScriptChannelOptionsFunctions

= Partial<{ [NAME in keyof PageScriptFunctions

& string]: InPageFunctionOption[NAME]>; }>; +type ConnectPanelChannelOptionsFunctions

= { [NAME in keyof PanelFunctions

& string]: InPageFunctionOption[NAME]>; }; +type CreatePageScriptChannelOptionsFunctions

= { [NAME in keyof PageScriptFunctions

& string]: InPageFunctionOption[NAME]>; }; type FnArgs = F extends ((...args: infer A) => any) ? A : never; type FnReturn = F extends ((...args: any[]) => infer R) ? Awaited : never; interface InPageChannelCommonOptions { From 3eb32b9b5bf85483e1caf34598cedc8509c8f187 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Fri, 4 Sep 2026 17:36:41 +0200 Subject: [PATCH 03/15] docs: clarify in-page event direction --- docs/content/1.guide/12.in-page-channel.md | 22 ++++++++++++---------- docs/content/8.references/5.browser-api.md | 4 ++-- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/content/1.guide/12.in-page-channel.md b/docs/content/1.guide/12.in-page-channel.md index 4a0b4e5e6..f09bd1a8a 100644 --- a/docs/content/1.guide/12.in-page-channel.md +++ b/docs/content/1.guide/12.in-page-channel.md @@ -54,7 +54,7 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names ## The page script endpoint -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. +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. ```ts import type { MyChannelProtocol } from '../shared/protocol' @@ -62,7 +62,7 @@ import type { MyChannelProtocol } from '../shared/protocol' import { createPageScriptChannel } from 'devframe/in-page-channel' import { MY_CHANNEL } from '../shared/protocol' -const channel = createPageScriptChannel({ +const pageChannel = createPageScriptChannel({ name: MY_CHANNEL, functions: { highlight: { @@ -79,12 +79,12 @@ const channel = createPageScriptChannel({ }, }) -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()) +pageChannel.emit('flash', 'scanning…') // received by each panel endpoint +pageChannel.events.on('panel:connected', panel => console.log(panel.id)) +pageChannel.events.on('panel:disconnected', () => pauseWorkIfNobodyWatches()) ``` -`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', '…')`. +`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', '…')`. ## The panel endpoint @@ -94,20 +94,22 @@ import type { MyChannelProtocol } from '../shared/protocol' import { connectPanelChannel } from 'devframe/in-page-channel' import { MY_CHANNEL } from '../shared/protocol' -const channel = connectPanelChannel({ +const panelChannel = connectPanelChannel({ name: MY_CHANNEL, functions: { flash: { type: 'event' }, }, }) -const offFlash = channel.on('flash', message => showFlash(message)) -channel.emit('highlight', '.hero') // buffered until connected -const size = await channel.call('measure', '.hero') +const offFlash = panelChannel.on('flash', message => showFlash(message)) +panelChannel.emit('highlight', '.hero') // received by the page-script endpoint +const size = await panelChannel.call('measure', '.hero') offFlash() // stop listening ``` +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. + ## Shared state The channel's shared-state layer mirrors [`rpc.sharedState`](/guide/shared-state) (same `SharedState` 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. diff --git a/docs/content/8.references/5.browser-api.md b/docs/content/8.references/5.browser-api.md index 3d24a23c4..0d5debf98 100644 --- a/docs/content/8.references/5.browser-api.md +++ b/docs/content/8.references/5.browser-api.md @@ -47,9 +47,9 @@ The values of `rpc.status`: [Handling connection and auth errors](/guide/client# ## In-page channel endpoints -The browser-only endpoint methods of the [in-page channel](/guide/in-page-channel). +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. -| Method or property | Page script | Panel | +| Method or property | Page-script endpoint | Panel endpoint | |--------------------|-------------|-------| | `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. | From 5c026a032fde8fa455b08eabe90cce5a9af7e631 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Fri, 4 Sep 2026 18:23:10 +0200 Subject: [PATCH 04/15] refactor: split in-page function option types --- .../devframe/src/in-page-channel/index.ts | 2 +- .../src/in-page-channel/types.test-d.ts | 12 +- .../devframe/src/in-page-channel/types.ts | 106 +++++++++++++----- .../devframe/in-page-channel.snapshot.d.ts | 33 ++---- 4 files changed, 97 insertions(+), 56 deletions(-) diff --git a/packages/devframe/src/in-page-channel/index.ts b/packages/devframe/src/in-page-channel/index.ts index f9e886b9f..1b2e08dd5 100644 --- a/packages/devframe/src/in-page-channel/index.ts +++ b/packages/devframe/src/in-page-channel/index.ts @@ -33,7 +33,7 @@ export type { export function defineChannelFunction< NAME extends string, TYPE extends InPageFunctionType, - ARGS extends any[] = [], + ARGS extends any[], RETURN = void, const AS extends RpcArgsSchema | undefined = undefined, const RS extends RpcReturnSchema | undefined = undefined, diff --git a/packages/devframe/src/in-page-channel/types.test-d.ts b/packages/devframe/src/in-page-channel/types.test-d.ts index 0ad636afe..ffc813fa0 100644 --- a/packages/devframe/src/in-page-channel/types.test-d.ts +++ b/packages/devframe/src/in-page-channel/types.test-d.ts @@ -22,8 +22,11 @@ interface PageScriptOnlyProtocol { } describe('Channel function definitions', () => { - it('allows events without handlers', () => { + it('distinguishes event, query, and action definitions', () => { defineChannelFunction({ name: 'notify', type: 'event' }) + defineChannelFunction({ name: 'load', handler: () => 'value' }) + defineChannelFunction({ name: 'load', type: 'query', handler: () => 'value' }) + defineChannelFunction({ name: 'save', type: 'action', handler: () => {} }) }) it('requires handlers for request/response functions', () => { @@ -88,8 +91,8 @@ describe('In-page script channel', () => { createPageScriptChannel({ name: 'devframes:test', functions: { - echo: { handler: value => value }, - sum: { handler: (a, b) => a + b }, + echo: { type: 'query', handler: value => value }, + sum: { type: 'action', handler: (a, b) => a + b }, save: { type: 'event' }, }, }) @@ -99,7 +102,8 @@ describe('In-page script channel', () => { functions: { // @ts-expect-error Request/response functions require a handler. echo: { type: 'query' }, - sum: { handler: (a, b) => a + b }, + // @ts-expect-error Request/response functions require a handler. + sum: { type: 'action' }, save: { type: 'event' }, }, }) diff --git a/packages/devframe/src/in-page-channel/types.ts b/packages/devframe/src/in-page-channel/types.ts index f3c88ebfa..1724755e5 100644 --- a/packages/devframe/src/in-page-channel/types.ts +++ b/packages/devframe/src/in-page-channel/types.ts @@ -47,6 +47,60 @@ type ProtocolHandler = F extends (...args: any[]) => any */ export type InPageFunctionType = 'action' | 'event' | 'query' +interface InPageFunctionDefinitionBase { + name: NAME + jsonSerializable?: boolean +} + +interface InPageEventFunctionDefinition extends InPageFunctionDefinitionBase { + type: 'event' + handler?: HANDLER +} + +interface InPageQueryFunctionDefinition extends InPageFunctionDefinitionBase { + type?: 'query' + handler: HANDLER +} + +interface InPageActionFunctionDefinition extends InPageFunctionDefinitionBase { + type: 'action' + handler: HANDLER +} + +type InPageFunctionDefinitionForType< + NAME extends string, + TYPE extends InPageFunctionType, + HANDLER, +> = TYPE extends 'event' + ? InPageEventFunctionDefinition + : TYPE extends 'action' + ? InPageActionFunctionDefinition + : InPageQueryFunctionDefinition + +type InPageFunctionDefinitionSchemas< + AS extends RpcArgsSchema | undefined, + RS extends RpcReturnSchema | undefined, +> = [AS, RS] extends [undefined, undefined] + ? { + args?: AS + returns?: RS + } + : { + /** Standard Schema array validating (and typing) the arguments. */ + args: AS + /** Standard Schema typing the resolved return value. */ + returns: RS + } + +type InPageFunctionDefinitionHandler< + ARGS extends any[], + RETURN, + AS extends RpcArgsSchema | undefined, + RS extends RpcReturnSchema | undefined, +> = [AS, RS] extends [undefined, undefined] + ? (...args: ARGS) => RETURN + : (...args: InferArgsType) => Thenable> + /** * An in-page channel function definition: the `defineRpcFunction` authoring * shape (`name`, `type`, Standard-Schema `args`/`returns`, @@ -65,27 +119,11 @@ export type InPageFunctionDefinition< AS extends RpcArgsSchema | undefined = undefined, RS extends RpcReturnSchema | undefined = undefined, > - = [AS, RS] extends [undefined, undefined] - ? ({ - name: NAME - type?: TYPE - args?: AS - returns?: RS - jsonSerializable?: boolean - } & (TYPE extends 'event' - ? { handler?: (...args: ARGS) => RETURN } - : { handler: (...args: ARGS) => RETURN })) - : ({ - name: NAME - type?: TYPE - /** Standard Schema array validating (and typing) the arguments. */ - args: AS - /** Standard Schema typing the resolved return value. */ - returns: RS - jsonSerializable?: boolean - } & (TYPE extends 'event' - ? { handler?: (...args: InferArgsType) => Thenable> } - : { handler: (...args: InferArgsType) => Thenable> })) + = InPageFunctionDefinitionForType< + NAME, + TYPE, + InPageFunctionDefinitionHandler + > & InPageFunctionDefinitionSchemas /** * Loosely-typed definition used by the internal function registry. @@ -107,15 +145,25 @@ interface InPageFunctionOptionBase { jsonSerializable?: boolean } +interface InPageEventFunctionOption extends InPageFunctionOptionBase { + type: 'event' + handler?: ProtocolHandler +} + +interface InPageQueryFunctionOption extends InPageFunctionOptionBase { + type?: 'query' + handler: ProtocolHandler +} + +interface InPageActionFunctionOption extends InPageFunctionOptionBase { + type: 'action' + handler: ProtocolHandler +} + type InPageFunctionOption - = | (InPageFunctionOptionBase & { - type: 'event' - handler?: ProtocolHandler - }) - | (InPageFunctionOptionBase & { - type?: Exclude - handler: ProtocolHandler - }) + = | InPageEventFunctionOption + | InPageQueryFunctionOption + | InPageActionFunctionOption /** * Functions implemented by {@link createPageScriptChannel}. diff --git a/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts index 45bc7c343..4dea00163 100644 --- a/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts @@ -62,27 +62,7 @@ export type InPageChannelErrorCode = 'timeout' | 'invalid-args' | 'state-uninitialized'; export type InPageChannelStatus = 'connecting' | 'connected' | 'closed'; -export type InPageFunctionDefinition = [AS, RS] extends [undefined, undefined] ? ({ - name: NAME; - type?: TYPE; - args?: AS; - returns?: RS; - jsonSerializable?: boolean; -} & (TYPE extends 'event' ? { - handler?: (...args: ARGS) => RETURN; -} : { - handler: (...args: ARGS) => RETURN; -})) : ({ - name: NAME; - type?: TYPE; - args: AS; - returns: RS; - jsonSerializable?: boolean; -} & (TYPE extends 'event' ? { - handler?: (...args: InferArgsType) => Thenable>; -} : { - handler: (...args: InferArgsType) => Thenable>; -})); +export type InPageFunctionDefinition = InPageFunctionDefinitionForType> & InPageFunctionDefinitionSchemas; // #endregion // #region Classes @@ -98,7 +78,7 @@ export declare class InPageChannelError extends Error { // #region Functions export declare function connectPanelChannel

(_: ConnectPanelChannelOptions

): PanelChannel

; export declare function createPageScriptChannel

(_: CreatePageScriptChannelOptions

): PageScriptChannel

; -export declare function defineChannelFunction(_: InPageFunctionDefinition): InPageFunctionDefinition; +export declare function defineChannelFunction(_: InPageFunctionDefinition): InPageFunctionDefinition; // #endregion // #region Referenced (internal) @@ -117,6 +97,15 @@ interface InPageChannelCommonOptions { serialize?: (_: unknown) => unknown; deserialize?: (_: unknown) => unknown; } +type InPageFunctionDefinitionForType = TYPE extends 'event' ? InPageEventFunctionDefinition : TYPE extends 'action' ? InPageActionFunctionDefinition : InPageQueryFunctionDefinition; +type InPageFunctionDefinitionHandler = [AS, RS] extends [undefined, undefined] ? (...args: ARGS) => RETURN : (...args: InferArgsType) => Thenable>; +type InPageFunctionDefinitionSchemas = [AS, RS] extends [undefined, undefined] ? { + args?: AS; + returns?: RS; +} : { + args: AS; + returns: RS; +}; type InPageFunctionType = 'action' | 'event' | 'query'; interface InPageSharedStateHost

{ get: & string>(_: K, _?: { From a3bea6c0e0c11033d7e9d3fbc6308891340b9a16 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 7 Sep 2026 10:04:24 +0200 Subject: [PATCH 05/15] fix: restrict in-page listeners to events --- .../src/in-page-channel/types.test-d.ts | 32 ++++++++++++++++--- .../devframe/src/in-page-channel/types.ts | 24 +++++++++++--- .../devframe/in-page-channel.snapshot.d.ts | 6 ++-- 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/packages/devframe/src/in-page-channel/types.test-d.ts b/packages/devframe/src/in-page-channel/types.test-d.ts index ffc813fa0..457305669 100644 --- a/packages/devframe/src/in-page-channel/types.test-d.ts +++ b/packages/devframe/src/in-page-channel/types.test-d.ts @@ -21,6 +21,14 @@ interface PageScriptOnlyProtocol { panel: Record } +interface MixedPanelProtocol { + pageScript: Record + panel: { + confirm: (message: string) => boolean + notify: (message: string) => void + } +} + describe('Channel function definitions', () => { it('distinguishes event, query, and action definitions', () => { defineChannelFunction({ name: 'notify', type: 'event' }) @@ -177,16 +185,18 @@ describe('In-page script channel', () => { }) describe('Event checking', () => { - it('types runtime subscriptions to page-script functions', () => { - const unsubscribe = channel.on('echo', (value) => { + it('types runtime subscriptions to page-script events', () => { + const unsubscribe = channel.on('save', (value) => { expectTypeOf(value).toEqualTypeOf() }) expectTypeOf(unsubscribe).toEqualTypeOf<() => void>() // @ts-expect-error Panel functions cannot be handled by the page script. channel.on('notify', () => {}) - // @ts-expect-error `echo` listeners receive a string. - channel.on('echo', (value: number) => void value) + // @ts-expect-error Query functions cannot be handled as events. + channel.on('echo', () => {}) + // @ts-expect-error `save` listeners receive a string. + channel.on('save', (value: number) => void value) }) it('types panel connection events', () => { @@ -353,6 +363,20 @@ describe('Panel channel', () => { channel.on('notify', (message: number) => void message) }) + it('rejects runtime subscriptions to panel queries', () => { + const mixedChannel = connectPanelChannel({ + name: 'devframes:mixed-panel', + functions: { + confirm: { handler: () => true }, + notify: { type: 'event' }, + }, + }) + + mixedChannel.on('notify', () => {}) + // @ts-expect-error Query functions cannot be handled as events. + mixedChannel.on('confirm', () => {}) + }) + it('types status events', () => { const unsubscribe = channel.events.on('status:updated', (status) => { expectTypeOf(status).toEqualTypeOf<'connecting' | 'connected' | 'closed'>() diff --git a/packages/devframe/src/in-page-channel/types.ts b/packages/devframe/src/in-page-channel/types.ts index 1724755e5..1455a1a81 100644 --- a/packages/devframe/src/in-page-channel/types.ts +++ b/packages/devframe/src/in-page-channel/types.ts @@ -31,6 +31,22 @@ type SharedStates

type FnArgs = F extends (...args: infer A) => any ? A : never type FnReturn = F extends (...args: any[]) => infer R ? Awaited : never +/** + * Page-script functions whose resolved return type marks an event. + * @internal + */ +type PageScriptFunctionsEvents

= { + [K in keyof PageScriptFunctions

as FnReturn[K]> extends void ? K : never]: PageScriptFunctions

[K] +} + +/** + * Panel functions whose resolved return type marks an event. + * @internal + */ +type PanelFunctionsEvents

= { + [K in keyof PanelFunctions

as FnReturn[K]> extends void ? K : never]: PanelFunctions

[K] +} + /** * Converts a protocol function to its accepted endpoint handler. * @@ -340,9 +356,9 @@ export interface PageScriptChannel

{ ...args: FnArgs[K]> ) => void /** Subscribe to an event emitted by a panel. Returns an unsubscribe function. */ - on: & string>( + on: & string>( name: K, - listener: (...args: FnArgs[K]>) => void, + listener: (...args: FnArgs[K]>) => void, ) => () => void /** Page-script-authoritative shared states, replayed to joining panels. */ readonly sharedState: InPageSharedStateHost

@@ -397,9 +413,9 @@ export interface PanelChannel

{ ...args: FnArgs[K]> ) => void /** Subscribe to an event emitted by the page script. Returns an unsubscribe function. */ - on: & string>( + on: & string>( name: K, - listener: (...args: FnArgs[K]>) => void, + listener: (...args: FnArgs[K]>) => void, ) => () => void /** Shared states mirrored from the page-script authority. */ readonly sharedState: InPageSharedStateHost

diff --git a/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts index 4dea00163..9a3d8dda4 100644 --- a/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts @@ -27,7 +27,7 @@ export interface PageScriptChannel

{ readonly events: Pick>, 'on' | 'once'>; emit: & string>(_: K, ..._: FnArgs[K]>) => void; callEvent: & string>(_: K, ..._: FnArgs[K]>) => void; - on: & string>(_: K, _: (..._: FnArgs[K]>) => void) => () => void; + on: & string>(_: K, _: (..._: FnArgs[K]>) => void) => () => void; readonly sharedState: InPageSharedStateHost

; addPanelPort: (_: MessagePort) => PanelPeer

; close: () => void; @@ -43,7 +43,7 @@ export interface PanelChannel

{ call: & string>(_: K, ..._: FnArgs[K]>) => Promise[K]>>; emit: & string>(_: K, ..._: FnArgs[K]>) => void; callEvent: & string>(_: K, ..._: FnArgs[K]>) => void; - on: & string>(_: K, _: (..._: FnArgs[K]>) => void) => () => void; + on: & string>(_: K, _: (..._: FnArgs[K]>) => void) => () => void; readonly sharedState: InPageSharedStateHost

; close: () => void; } @@ -117,8 +117,10 @@ interface PageScriptChannelEvents

{ 'panel:disconnected': (_: PanelPeer

) => void; } type PageScriptFunctions

= SideFunctions>; +type PageScriptFunctionsEvents

= { [K in keyof PageScriptFunctions

as FnReturn[K]> extends void ? K : never]: PageScriptFunctions

[K]; }; interface PanelChannelEvents { 'status:updated': (_: InPageChannelStatus) => void; } type PanelFunctions

= SideFunctions>; +type PanelFunctionsEvents

= { [K in keyof PanelFunctions

as FnReturn[K]> extends void ? K : never]: PanelFunctions

[K]; }; // #endregion \ No newline at end of file From 993c3f3ea9ea24ea3036baa3f1a742d9d8d2f000 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 7 Sep 2026 11:10:18 +0200 Subject: [PATCH 06/15] docs: reword --- docs/content/1.guide/12.in-page-channel.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/content/1.guide/12.in-page-channel.md b/docs/content/1.guide/12.in-page-channel.md index f09bd1a8a..1b9a1d891 100644 --- a/docs/content/1.guide/12.in-page-channel.md +++ b/docs/content/1.guide/12.in-page-channel.md @@ -37,11 +37,13 @@ import type { InPageChannelProtocol } from 'devframe/in-page-channel' export const MY_CHANNEL = 'devframes:plugin:my-tool' export interface MyChannelProtocol extends InPageChannelProtocol { - pageScript: { // functions and events received by the page script + // implemented by the page script, callable by panels + pageScript: { highlight: (selector: string) => void measure: (selector: string) => { width: number, height: number } } - panel: { // functions and events received by panels + // implemented by panels, callable by the page script + panel: { flash: (message: string) => void } sharedStates: { From 6be6f9c540cb03813385ed4702b5775b900377ba Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 7 Sep 2026 11:18:13 +0200 Subject: [PATCH 07/15] fix: reject unknown in-page listeners --- docs/content/6.errors/DF0077.md | 33 +++++++++++++++++++ docs/content/6.errors/index.md | 1 + .../src/in-page-channel/diagnostics.ts | 11 +++++++ .../in-page-channel/in-page-channel.test.ts | 23 +++++++++++++ .../devframe/src/in-page-channel/internal.ts | 10 +++--- 5 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 docs/content/6.errors/DF0077.md create mode 100644 packages/devframe/src/in-page-channel/diagnostics.ts diff --git a/docs/content/6.errors/DF0077.md b/docs/content/6.errors/DF0077.md new file mode 100644 index 000000000..823cbd8bd --- /dev/null +++ b/docs/content/6.errors/DF0077.md @@ -0,0 +1,33 @@ +--- +title: 'DF0077: In-Page Channel Function Not Registered' +description: 'An in-page channel listener names a function that is not registered on its endpoint.' +--- + +## Message + +> In-page channel function "{name}" is not registered on this endpoint. + +## Cause + +`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`. + +## Example + +```ts +const channel = connectPanelChannel({ + name: MY_CHANNEL, + functions: { + notify: { type: 'event' }, + }, +}) + +channel.on('missing' as any, () => {}) // ✗ throws DF0077 +``` + +## Fix + +Declare the event in the endpoint's protocol side and `functions` option, then pass that declared name to `on()`. + +## Source + +- [`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. diff --git a/docs/content/6.errors/index.md b/docs/content/6.errors/index.md index b68d769cd..3bd55a16f 100644 --- a/docs/content/6.errors/index.md +++ b/docs/content/6.errors/index.md @@ -83,6 +83,7 @@ Emitted by `devframe`: the framework-neutral host, RPC, streaming, assets, servi | [DF0074](/errors/DF0074) | error | JSON-Render Schema Is Asynchronous | | [DF0075](/errors/DF0075) | warn | No RPC Transport On This Runtime | | [DF0076](/errors/DF0076) | error | WebSocket Upgrade Unsupported On This Runtime | +| [DF0077](/errors/DF0077) | error | In-Page Channel Function Not Registered | ## Hub: context & lifecycle (DF80xx) diff --git a/packages/devframe/src/in-page-channel/diagnostics.ts b/packages/devframe/src/in-page-channel/diagnostics.ts new file mode 100644 index 000000000..9105b6e58 --- /dev/null +++ b/packages/devframe/src/in-page-channel/diagnostics.ts @@ -0,0 +1,11 @@ +import { defineDiagnostics } from 'devframe/utils/nostics' + +export const diagnostics = /*#__PURE__*/ defineDiagnostics({ + docsBase: 'https://devfra.me/errors', + codes: { + DF0077: { + why: (p: { name: string }) => `In-page channel function "${p.name}" is not registered on this endpoint.`, + fix: 'Declare the function in this endpoint\'s `functions` option before subscribing with `on()`.', + }, + }, +}) diff --git a/packages/devframe/src/in-page-channel/in-page-channel.test.ts b/packages/devframe/src/in-page-channel/in-page-channel.test.ts index 8a6b50224..bdef89336 100644 --- a/packages/devframe/src/in-page-channel/in-page-channel.test.ts +++ b/packages/devframe/src/in-page-channel/in-page-channel.test.ts @@ -1,4 +1,5 @@ import type { ConnectPanelChannelOptions, CreatePageScriptChannelOptions, InPageChannelProtocol, PageScriptChannel, PanelChannel } from './types' +import { Diagnostic } from 'devframe/utils/nostics' import { describe, expect, it, vi } from 'vitest' import { InPageChannelError } from './internal' import { createPageScriptChannel } from './page-script' @@ -120,6 +121,28 @@ describe('in-page channel over bring-your-own ports', () => { } }) + it('reports and rejects listeners for unknown functions', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { panel, dispose } = createLinkedPair() + try { + let rejection: unknown + try { + panel.on('missing' as any, () => {}) + } + catch (error) { + rejection = error + } + expect(rejection).toBeInstanceOf(Diagnostic) + expect(rejection).toMatchObject({ name: 'DF0077' }) + expect(warn).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[DF0077]')) + } + finally { + dispose() + warn.mockRestore() + } + }) + it('enforces jsonSerializable payloads with a coded error', async () => { const { panel, dispose } = createLinkedPair() try { diff --git a/packages/devframe/src/in-page-channel/internal.ts b/packages/devframe/src/in-page-channel/internal.ts index 6cc39d33d..e7bbf5b55 100644 --- a/packages/devframe/src/in-page-channel/internal.ts +++ b/packages/devframe/src/in-page-channel/internal.ts @@ -4,13 +4,13 @@ import type { RpcArgsSchema } from '../rpc/types' import type { InPageChannelControlFrame } from './protocol' import type { InPageFunctionDefinitionAny } from './types' import { createBirpc } from 'birpc' +import { diagnostics } from './diagnostics' import { isControlFrame } from './protocol' /** - * Shared internals of the two endpoints: the coded error surface (browser - * code, so plain coded `Error`s, since `nostics` diagnostics are node-side only), - * the local function table with its receive pipeline, and the birpc wiring - * of one `MessagePort`. + * Shared internals of the two endpoints: the coded error surface, the local + * function table with its receive pipeline, and the birpc wiring of one + * `MessagePort`. */ export const DEFAULT_CALL_TIMEOUT_MS = 15_000 @@ -176,6 +176,8 @@ export function createLocalFunctionRegistry(codec: InPageChannelSerialization): definitions.set(definition.name, definition) }, on(name, listener) { + if (!definitions.has(name)) + throw diagnostics.DF0077({ name }) let registered = listeners.get(name) if (!registered) { registered = new Set() From 0b53ab2b5590cb945fde592ac63819f43104c024 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 7 Sep 2026 11:20:25 +0200 Subject: [PATCH 08/15] fix: restrict in-page emits to events --- .../src/in-page-channel/types.test-d.ts | 29 +++++++++++++++---- .../devframe/src/in-page-channel/types.ts | 16 +++++----- .../devframe/in-page-channel.snapshot.d.ts | 8 ++--- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/packages/devframe/src/in-page-channel/types.test-d.ts b/packages/devframe/src/in-page-channel/types.test-d.ts index 457305669..727d1298c 100644 --- a/packages/devframe/src/in-page-channel/types.test-d.ts +++ b/packages/devframe/src/in-page-channel/types.test-d.ts @@ -148,6 +148,7 @@ describe('In-page script channel', () => { describe('Function calling', () => { it('types fire-and-forget calls to panel functions', () => { expectTypeOf(channel.emit('notify', 'ready')).toEqualTypeOf() + expectTypeOf(channel.callEvent('notify', 'ready')).toEqualTypeOf() // @ts-expect-error In-page script functions cannot be called on panels. channel.emit('echo', 'ready') @@ -159,6 +160,19 @@ describe('In-page script channel', () => { channel.emit('notify', 'ready', 'extra') }) + it('rejects fire-and-forget calls to panel queries', () => { + const mixedChannel = createPageScriptChannel({ + name: 'devframes:mixed-panel', + functions: {}, + }) + + mixedChannel.emit('notify', 'ready') + // @ts-expect-error Queries cannot be emitted as events. + mixedChannel.emit('confirm', 'continue?') + // @ts-expect-error The deprecated alias has the same event-only contract. + mixedChannel.callEvent('confirm', 'continue?') + }) + it('types calls to connected panels', () => { const panel = channel.panels[0]! @@ -330,16 +344,19 @@ describe('Panel channel', () => { }) it('types fire-and-forget calls to in-page script functions', () => { - expectTypeOf(channel.emit('echo', 'hello')).toEqualTypeOf() - expectTypeOf(channel.emit('sum', 1, 2)).toEqualTypeOf() expectTypeOf(channel.emit('save', 'draft')).toEqualTypeOf() + expectTypeOf(channel.callEvent('save', 'draft')).toEqualTypeOf() // @ts-expect-error Panel functions cannot be emitted to the in-page script. channel.emit('notify', 'hello') - // @ts-expect-error `echo` requires a string. - channel.emit('echo', false) - // @ts-expect-error `sum` requires two arguments. - channel.emit('sum', 1) + // @ts-expect-error Queries cannot be emitted as events. + channel.emit('echo', 'hello') + // @ts-expect-error Queries cannot be emitted as events. + channel.emit('sum', 1, 2) + // @ts-expect-error `save` requires a string. + channel.emit('save', false) + // @ts-expect-error The deprecated alias has the same event-only contract. + channel.callEvent('echo', 'hello') }) it('types channel state', () => { diff --git a/packages/devframe/src/in-page-channel/types.ts b/packages/devframe/src/in-page-channel/types.ts index 1455a1a81..4d97bd227 100644 --- a/packages/devframe/src/in-page-channel/types.ts +++ b/packages/devframe/src/in-page-channel/types.ts @@ -346,14 +346,14 @@ export interface PageScriptChannel

{ readonly panels: readonly PanelPeer

[] readonly events: Pick>, 'on' | 'once'> /** Fan an event out to every connected panel. */ - emit: & string>( + emit: & string>( name: K, - ...args: FnArgs[K]> + ...args: FnArgs[K]> ) => void /** @deprecated Use `emit()` instead. */ - callEvent: & string>( + callEvent: & string>( name: K, - ...args: FnArgs[K]> + ...args: FnArgs[K]> ) => void /** Subscribe to an event emitted by a panel. Returns an unsubscribe function. */ on: & string>( @@ -403,14 +403,14 @@ export interface PanelChannel

{ * Emit an event to the page script. While `connecting` the event is buffered * (up to `eventBufferLimit`) and flushed on connect. */ - emit: & string>( + emit: & string>( name: K, - ...args: FnArgs[K]> + ...args: FnArgs[K]> ) => void /** @deprecated Use `emit()` instead. */ - callEvent: & string>( + callEvent: & string>( name: K, - ...args: FnArgs[K]> + ...args: FnArgs[K]> ) => void /** Subscribe to an event emitted by the page script. Returns an unsubscribe function. */ on: & string>( diff --git a/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts index 9a3d8dda4..118b8ce4a 100644 --- a/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts @@ -25,8 +25,8 @@ export interface PageScriptChannel

{ readonly instanceId: string; readonly panels: readonly PanelPeer

[]; readonly events: Pick>, 'on' | 'once'>; - emit: & string>(_: K, ..._: FnArgs[K]>) => void; - callEvent: & string>(_: K, ..._: FnArgs[K]>) => void; + emit: & string>(_: K, ..._: FnArgs[K]>) => void; + callEvent: & string>(_: K, ..._: FnArgs[K]>) => void; on: & string>(_: K, _: (..._: FnArgs[K]>) => void) => () => void; readonly sharedState: InPageSharedStateHost

; addPanelPort: (_: MessagePort) => PanelPeer

; @@ -41,8 +41,8 @@ export interface PanelChannel

{ readonly events: Pick, 'on' | 'once'>; whenConnected: (_?: number) => Promise; call: & string>(_: K, ..._: FnArgs[K]>) => Promise[K]>>; - emit: & string>(_: K, ..._: FnArgs[K]>) => void; - callEvent: & string>(_: K, ..._: FnArgs[K]>) => void; + emit: & string>(_: K, ..._: FnArgs[K]>) => void; + callEvent: & string>(_: K, ..._: FnArgs[K]>) => void; on: & string>(_: K, _: (..._: FnArgs[K]>) => void) => () => void; readonly sharedState: InPageSharedStateHost

; close: () => void; From 372e06c357e72aa07940d2893b2fa6d98bbdf088 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 7 Sep 2026 11:21:47 +0200 Subject: [PATCH 09/15] test: simplify unknown listener assertion --- .../src/in-page-channel/in-page-channel.test.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/devframe/src/in-page-channel/in-page-channel.test.ts b/packages/devframe/src/in-page-channel/in-page-channel.test.ts index bdef89336..65e164f72 100644 --- a/packages/devframe/src/in-page-channel/in-page-channel.test.ts +++ b/packages/devframe/src/in-page-channel/in-page-channel.test.ts @@ -1,5 +1,4 @@ import type { ConnectPanelChannelOptions, CreatePageScriptChannelOptions, InPageChannelProtocol, PageScriptChannel, PanelChannel } from './types' -import { Diagnostic } from 'devframe/utils/nostics' import { describe, expect, it, vi } from 'vitest' import { InPageChannelError } from './internal' import { createPageScriptChannel } from './page-script' @@ -125,15 +124,9 @@ describe('in-page channel over bring-your-own ports', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { panel, dispose } = createLinkedPair() try { - let rejection: unknown - try { + expect(() => { panel.on('missing' as any, () => {}) - } - catch (error) { - rejection = error - } - expect(rejection).toBeInstanceOf(Diagnostic) - expect(rejection).toMatchObject({ name: 'DF0077' }) + }).toThrowError(expect.objectContaining({ name: 'DF0077' })) expect(warn).toHaveBeenCalledOnce() expect(warn).toHaveBeenCalledWith(expect.stringContaining('[DF0077]')) } From fe623c693fb517ee4feea005ca55c017ce91928d Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 7 Sep 2026 11:22:34 +0200 Subject: [PATCH 10/15] docs: reword --- docs/content/8.references/5.browser-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/8.references/5.browser-api.md b/docs/content/8.references/5.browser-api.md index 0d5debf98..877dfccbe 100644 --- a/docs/content/8.references/5.browser-api.md +++ b/docs/content/8.references/5.browser-api.md @@ -52,7 +52,7 @@ The browser-only endpoint methods of the [in-page channel](/guide/in-page-channe | Method or property | Page-script endpoint | Panel endpoint | |--------------------|-------------|-------| | `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. | +| `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. | | `call(name, ...args)` | Available through a specific `PanelPeer`. | Calls a page-script function and awaits its result. | | `events` | Local `panel:connected` / `panel:disconnected` lifecycle events. | Local `status:updated` lifecycle event. | | `sharedState` | Owns the authoritative state. | Mirrors the page-script state. | From 03319432780e4f070e7aa2dc3b03d5e52eea9c6b Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 7 Sep 2026 11:22:56 +0200 Subject: [PATCH 11/15] chore: simpler --- packages/devframe/src/in-page-channel/types.test-d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/devframe/src/in-page-channel/types.test-d.ts b/packages/devframe/src/in-page-channel/types.test-d.ts index 727d1298c..b43871e0d 100644 --- a/packages/devframe/src/in-page-channel/types.test-d.ts +++ b/packages/devframe/src/in-page-channel/types.test-d.ts @@ -82,13 +82,13 @@ describe('In-page script channel', () => { }) }) - it('requires every page-script function declaration', () => { + it('requires every in-page-script function', () => { // @ts-expect-error `functions` is required. createPageScriptChannel({ name: 'devframes:test' }) createPageScriptChannel({ name: 'devframes:test', - // @ts-expect-error `sum` and `save` must be declared. + // @ts-expect-error `sum` and `save` are required functions: { echo: { handler: value => value }, }, From 022938d085ebc81228de4cace3111e44be680be0 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 7 Sep 2026 11:24:00 +0200 Subject: [PATCH 12/15] test: word --- .../src/in-page-channel/types.test-d.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/devframe/src/in-page-channel/types.test-d.ts b/packages/devframe/src/in-page-channel/types.test-d.ts index b43871e0d..418d89c60 100644 --- a/packages/devframe/src/in-page-channel/types.test-d.ts +++ b/packages/devframe/src/in-page-channel/types.test-d.ts @@ -34,7 +34,7 @@ describe('Channel function definitions', () => { defineChannelFunction({ name: 'notify', type: 'event' }) defineChannelFunction({ name: 'load', handler: () => 'value' }) defineChannelFunction({ name: 'load', type: 'query', handler: () => 'value' }) - defineChannelFunction({ name: 'save', type: 'action', handler: () => {} }) + defineChannelFunction({ name: 'save', type: 'action', handler: () => { } }) }) it('requires handlers for request/response functions', () => { @@ -51,7 +51,7 @@ describe('In-page script channel', () => { functions: { echo: { handler: value => value }, sum: { handler: (a, b) => a + b }, - save: { handler: () => {} }, + save: { handler: () => { } }, }, }) @@ -82,13 +82,13 @@ describe('In-page script channel', () => { }) }) - it('requires every in-page-script function', () => { + it('requires every in-page script function', () => { // @ts-expect-error `functions` is required. createPageScriptChannel({ name: 'devframes:test' }) createPageScriptChannel({ name: 'devframes:test', - // @ts-expect-error `sum` and `save` are required + // @ts-expect-error `sum` and `save` are required. functions: { echo: { handler: value => value }, }, @@ -123,7 +123,7 @@ describe('In-page script channel', () => { functions: { echo: { handler: value => value }, sum: { handler: (a, b) => a + b }, - save: { handler: () => {} }, + save: { handler: () => { } }, // @ts-expect-error `notify` is implemented by panels. notify: { handler: (message: string) => void message }, }, @@ -139,7 +139,7 @@ describe('In-page script channel', () => { handler: (value: number) => value, }, sum: { handler: (a, b) => a + b }, - save: { handler: () => {} }, + save: { handler: () => { } }, }, }) }) @@ -206,9 +206,9 @@ describe('In-page script channel', () => { expectTypeOf(unsubscribe).toEqualTypeOf<() => void>() // @ts-expect-error Panel functions cannot be handled by the page script. - channel.on('notify', () => {}) + channel.on('notify', () => { }) // @ts-expect-error Query functions cannot be handled as events. - channel.on('echo', () => {}) + channel.on('echo', () => { }) // @ts-expect-error `save` listeners receive a string. channel.on('save', (value: number) => void value) }) @@ -229,7 +229,7 @@ describe('In-page script channel', () => { it('rejects panel channel events', () => { // @ts-expect-error Unknown in-page script channel lifecycle event. - channel.events.on('status:updated', () => {}) + channel.events.on('status:updated', () => { }) }) }) }) @@ -238,7 +238,7 @@ describe('Panel channel', () => { const channel = connectPanelChannel({ name: 'devframes:test', functions: { - notify: { handler: () => {} }, + notify: { handler: () => { } }, }, }) @@ -292,7 +292,7 @@ describe('Panel channel', () => { connectPanelChannel({ name: 'devframes:test', functions: { - notify: { handler: () => {} }, + notify: { handler: () => { } }, // @ts-expect-error `echo` is implemented by the in-page script. echo: { handler: (value: string) => value }, }, @@ -321,7 +321,7 @@ describe('Panel channel', () => { name: 'devframes:page-script-only', functions: { // @ts-expect-error The protocol has no panel functions. - notify: { handler: () => {} }, + notify: { handler: () => { } }, }, }) }) @@ -375,7 +375,7 @@ describe('Panel channel', () => { expectTypeOf(unsubscribe).toEqualTypeOf<() => void>() // @ts-expect-error Page-script functions cannot be handled by the panel. - channel.on('echo', () => {}) + channel.on('echo', () => { }) // @ts-expect-error `notify` listeners receive a string. channel.on('notify', (message: number) => void message) }) @@ -389,9 +389,9 @@ describe('Panel channel', () => { }, }) - mixedChannel.on('notify', () => {}) + mixedChannel.on('notify', () => { }) // @ts-expect-error Query functions cannot be handled as events. - mixedChannel.on('confirm', () => {}) + mixedChannel.on('confirm', () => { }) }) it('types status events', () => { @@ -404,7 +404,7 @@ describe('Panel channel', () => { it('rejects in-page script channel events and incompatible listeners', () => { // @ts-expect-error Unknown panel channel lifecycle event. - channel.events.on('panel:connected', () => {}) + channel.events.on('panel:connected', () => { }) // @ts-expect-error `status:updated` listeners receive the status. channel.events.on('status:updated', (status: number) => void status) }) From c449f955977cec21dd1d4e85632efdaa78b4faca Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 7 Sep 2026 11:40:54 +0200 Subject: [PATCH 13/15] style: lint --- docs/content/1.guide/12.in-page-channel.md | 4 ++-- packages/devframe/src/in-page-channel/diagnostics.ts | 2 +- packages/devframe/src/in-page-channel/types.ts | 11 +++++------ 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/docs/content/1.guide/12.in-page-channel.md b/docs/content/1.guide/12.in-page-channel.md index 1b9a1d891..7209d759a 100644 --- a/docs/content/1.guide/12.in-page-channel.md +++ b/docs/content/1.guide/12.in-page-channel.md @@ -37,12 +37,12 @@ import type { InPageChannelProtocol } from 'devframe/in-page-channel' export const MY_CHANNEL = 'devframes:plugin:my-tool' export interface MyChannelProtocol extends InPageChannelProtocol { - // implemented by the page script, callable by panels + /** implemented by the page script, callable by panels */ pageScript: { highlight: (selector: string) => void measure: (selector: string) => { width: number, height: number } } - // implemented by panels, callable by the page script + /** implemented by panels, callable by the page script */ panel: { flash: (message: string) => void } diff --git a/packages/devframe/src/in-page-channel/diagnostics.ts b/packages/devframe/src/in-page-channel/diagnostics.ts index 9105b6e58..a9a1663ce 100644 --- a/packages/devframe/src/in-page-channel/diagnostics.ts +++ b/packages/devframe/src/in-page-channel/diagnostics.ts @@ -1,6 +1,6 @@ import { defineDiagnostics } from 'devframe/utils/nostics' -export const diagnostics = /*#__PURE__*/ defineDiagnostics({ +export const diagnostics = /* #__PURE__ */ defineDiagnostics({ docsBase: 'https://devfra.me/errors', codes: { DF0077: { diff --git a/packages/devframe/src/in-page-channel/types.ts b/packages/devframe/src/in-page-channel/types.ts index 4d97bd227..77648a02f 100644 --- a/packages/devframe/src/in-page-channel/types.ts +++ b/packages/devframe/src/in-page-channel/types.ts @@ -134,12 +134,11 @@ export type InPageFunctionDefinition< RETURN = void, AS extends RpcArgsSchema | undefined = undefined, RS extends RpcReturnSchema | undefined = undefined, -> - = InPageFunctionDefinitionForType< - NAME, - TYPE, - InPageFunctionDefinitionHandler - > & InPageFunctionDefinitionSchemas +> = InPageFunctionDefinitionForType< + NAME, + TYPE, + InPageFunctionDefinitionHandler +> & InPageFunctionDefinitionSchemas /** * Loosely-typed definition used by the internal function registry. From 723c19f754033c65dfc596244c062fe23ac6e281 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 7 Sep 2026 11:42:29 +0200 Subject: [PATCH 14/15] chore: reword --- docs/content/8.references/5.browser-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/8.references/5.browser-api.md b/docs/content/8.references/5.browser-api.md index 877dfccbe..2a27be0e6 100644 --- a/docs/content/8.references/5.browser-api.md +++ b/docs/content/8.references/5.browser-api.md @@ -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 channels.' +description: 'Lookup tables for the browser side: connectDevframe options, RPC client events, connection statuses, and in-page channels error codes.' --- Lookup tables for a devframe's browser side. Each section links the guide page that teaches the concept. From f37858e5b2eecc4b6aca6a21a49b62e2f06baff8 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 7 Sep 2026 11:46:43 +0200 Subject: [PATCH 15/15] test: simplify unknown listener cleanup --- .../in-page-channel/in-page-channel.test.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/devframe/src/in-page-channel/in-page-channel.test.ts b/packages/devframe/src/in-page-channel/in-page-channel.test.ts index 65e164f72..d225c2eb8 100644 --- a/packages/devframe/src/in-page-channel/in-page-channel.test.ts +++ b/packages/devframe/src/in-page-channel/in-page-channel.test.ts @@ -120,20 +120,19 @@ describe('in-page channel over bring-your-own ports', () => { } }) - it('reports and rejects listeners for unknown functions', () => { + it('reports and rejects listeners for unknown functions', ({ onTestFinished }) => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { panel, dispose } = createLinkedPair() - try { - expect(() => { - panel.on('missing' as any, () => {}) - }).toThrowError(expect.objectContaining({ name: 'DF0077' })) - expect(warn).toHaveBeenCalledOnce() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('[DF0077]')) - } - finally { + onTestFinished(() => { dispose() warn.mockRestore() - } + }) + + expect(() => { + panel.on('missing' as any, () => {}) + }).toThrowError(expect.objectContaining({ name: 'DF0077' })) + expect(warn).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[DF0077]')) }) it('enforces jsonSerializable payloads with a coded error', async () => {