From e141e51046a1445db639eb0ca60eb0f80251c8b0 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Tue, 8 Sep 2026 12:53:25 +0200 Subject: [PATCH 01/14] fix: separate in-page channel events from functions --- docs/content/1.guide/12.in-page-channel.md | 45 ++-- docs/content/6.errors/DF0077.md | 15 +- docs/content/6.errors/index.md | 2 +- docs/content/8.references/5.browser-api.md | 2 + .../src/in-page-channel/diagnostics.ts | 4 +- .../src/in-page-channel/events.test-d.ts | 92 ++++++++ .../in-page-channel/in-page-channel.test.ts | 196 ++++++++++++++++-- .../devframe/src/in-page-channel/internal.ts | 20 +- .../src/in-page-channel/page-script.ts | 7 +- .../devframe/src/in-page-channel/panel.ts | 9 +- .../devframe/src/in-page-channel/protocol.ts | 2 +- .../src/in-page-channel/types.test-d.ts | 64 ++++-- .../devframe/src/in-page-channel/types.ts | 88 ++++---- plugins/a11y/app/lib/channel.ts | 1 + plugins/a11y/src/client-script/index.ts | 11 +- plugins/a11y/src/shared/protocol.ts | 51 ++--- skills/devframe/SKILL.md | 2 +- .../devframe/in-page-channel.snapshot.d.ts | 38 ++-- 18 files changed, 480 insertions(+), 169 deletions(-) create mode 100644 packages/devframe/src/in-page-channel/events.test-d.ts diff --git a/docs/content/1.guide/12.in-page-channel.md b/docs/content/1.guide/12.in-page-channel.md index 7209d759a..4da62face 100644 --- a/docs/content/1.guide/12.in-page-channel.md +++ b/docs/content/1.guide/12.in-page-channel.md @@ -37,14 +37,15 @@ 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 */ - pageScript: { - highlight: (selector: string) => void - measure: (selector: string) => { width: number, height: number } + functions: { + pageScript: { + measure: (selector: string) => { width: number, height: number } + reset: () => Promise + } } - /** implemented by panels, callable by the page script */ - panel: { - flash: (message: string) => void + events: { + pageScript: { highlight: (selector: string) => void } + panel: { flash: (message: string) => void } } sharedStates: { state: { selections: string[] } @@ -56,7 +57,9 @@ 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 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. +The required `functions` and `events` options declare every incoming name on the endpoint's protocol side; use `{}` for an empty direction. Functions require a `handler`. Events accept an optional `handler`, and `{}` registers an event for runtime subscriptions through `on()`. Handlers are contextually typed from the shared protocol and support Standard-Schema argument validation and `jsonSerializable` metadata. `defineChannelFunction` retains the named definition shape for lower-level authoring. + +`call()` accepts names from `functions`, including actions returning `void` or `Promise`: callers can await completion and catch errors or timeouts. `emit()`, its deprecated alias `callEvent()`, and `on()` use the names declared in `events`. Function and event names have separate namespaces. ```ts import type { MyChannelProtocol } from '../shared/protocol' @@ -67,11 +70,7 @@ import { MY_CHANNEL } from '../shared/protocol' const pageChannel = createPageScriptChannel({ name: MY_CHANNEL, functions: { - highlight: { - type: 'event', // fire-and-forget - jsonSerializable: true, - handler: selector => drawRing(document.querySelector(selector)), - }, + reset: { type: 'action', handler: async () => clearSelections() }, measure: { // request/response (the default `query` type) handler: (selector) => { const rect = document.querySelector(selector)!.getBoundingClientRect() @@ -79,6 +78,12 @@ const pageChannel = createPageScriptChannel({ }, }, }, + events: { + highlight: { + jsonSerializable: true, + handler: selector => drawRing(document.querySelector(selector)), + }, + }, }) pageChannel.emit('flash', 'scanning…') // received by each panel endpoint @@ -86,7 +91,7 @@ pageChannel.events.on('panel:connected', panel => console.log(panel.id)) pageChannel.events.on('panel:disconnected', () => pauseWorkIfNobodyWatches()) ``` -`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', '…')`. +`emit` on the page-script endpoint fans out to every connected panel endpoint. Functions declared under `functions.panel` are called through a specific `pageChannel.panels[0].call()` peer handle. ## The panel endpoint @@ -98,14 +103,16 @@ import { MY_CHANNEL } from '../shared/protocol' const panelChannel = connectPanelChannel({ name: MY_CHANNEL, - functions: { - flash: { type: 'event' }, + functions: {}, + events: { + flash: {}, }, }) 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') +await panelChannel.call('reset') offFlash() // stop listening ``` @@ -162,6 +169,8 @@ import { toRaw } from 'vue' const channel = connectPanelChannel({ name: MY_CHANNEL, serialize: value => toRawDeep(value), // applied to every outgoing argument and result + functions: {}, + events: { flash: {} }, }) ``` @@ -172,7 +181,7 @@ Declaring a function `jsonSerializable: true` additionally enforces strict JSON The same app open in two tabs means two page scripts on one origin. Each page script carries a per-tab instance id (persisted in `sessionStorage`), and handshakes are targeted `postMessage`, so a dock panel always pairs with its own tab's page script. A panel can also pin explicitly: ```ts -connectPanelChannel({ name: MY_CHANNEL, instanceId }) +connectPanelChannel({ name: MY_CHANNEL, instanceId, functions: {}, events: { flash: {} } }) ``` ## Custom transports @@ -182,7 +191,7 @@ Both endpoints accept a pre-established `MessagePort` that bypasses the handshak ```ts const { port1, port2 } = new MessageChannel() pageScript.addPanelPort(port1) -const panel = connectPanelChannel({ name: MY_CHANNEL, transport: port2 }) +const panel = connectPanelChannel({ name: MY_CHANNEL, transport: port2, functions: {}, events: { flash: {} } }) ``` ## When to use the in-page channel vs RPC diff --git a/docs/content/6.errors/DF0077.md b/docs/content/6.errors/DF0077.md index 823cbd8bd..148e81a30 100644 --- a/docs/content/6.errors/DF0077.md +++ b/docs/content/6.errors/DF0077.md @@ -1,23 +1,24 @@ --- -title: 'DF0077: In-Page Channel Function Not Registered' -description: 'An in-page channel listener names a function that is not registered on its endpoint.' +title: 'DF0077: In-Page Channel Event Not Registered' +description: 'An in-page channel listener names an event that is not registered on its endpoint.' --- ## Message -> In-page channel function "{name}" is not registered on this endpoint. +> In-page channel event "{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`. +`channel.on(name, listener)` received a name absent from that endpoint's required `events` option. A page-script endpoint subscribes to events declared under `events.pageScript`; a panel endpoint subscribes to events declared under `events.panel`. ## Example ```ts const channel = connectPanelChannel({ name: MY_CHANNEL, - functions: { - notify: { type: 'event' }, + functions: {}, + events: { + notify: {}, }, }) @@ -26,7 +27,7 @@ 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()`. +Declare the event in the endpoint's protocol side and `events` option, then pass that declared name to `on()`. ## Source diff --git a/docs/content/6.errors/index.md b/docs/content/6.errors/index.md index 3bd55a16f..535636cd3 100644 --- a/docs/content/6.errors/index.md +++ b/docs/content/6.errors/index.md @@ -83,7 +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 | +| [DF0077](/errors/DF0077) | error | In-Page Channel Event Not Registered | ## Hub: context & lifecycle (DF80xx) diff --git a/docs/content/8.references/5.browser-api.md b/docs/content/8.references/5.browser-api.md index ad46d400f..f3acf05ff 100644 --- a/docs/content/8.references/5.browser-api.md +++ b/docs/content/8.references/5.browser-api.md @@ -50,6 +50,8 @@ The values of `rpc.status`: [Handling connection and auth errors](/guide/client# 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. +`InPageChannelProtocol` separates `functions` and `events`. Each section has optional `pageScript` and `panel` maps naming the receiving direction. Endpoint options require a complete `functions` map with handlers and a complete `events` map with optional handlers; use `{}` for empty maps. `call()` uses function names regardless of return type, while `emit()`, `callEvent()` (deprecated), and `on()` use event names. A function returning `void` or `Promise` remains an awaitable request/response call. + | 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. | diff --git a/packages/devframe/src/in-page-channel/diagnostics.ts b/packages/devframe/src/in-page-channel/diagnostics.ts index a9a1663ce..9f8dfd28d 100644 --- a/packages/devframe/src/in-page-channel/diagnostics.ts +++ b/packages/devframe/src/in-page-channel/diagnostics.ts @@ -4,8 +4,8 @@ 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()`.', + why: (p: { name: string }) => `In-page channel event "${p.name}" is not registered on this endpoint.`, + fix: 'Declare the event in this endpoint\'s `events` option before subscribing with `on()`.', }, }, }) diff --git a/packages/devframe/src/in-page-channel/events.test-d.ts b/packages/devframe/src/in-page-channel/events.test-d.ts new file mode 100644 index 000000000..79ff8702a --- /dev/null +++ b/packages/devframe/src/in-page-channel/events.test-d.ts @@ -0,0 +1,92 @@ +import type { ConnectPanelChannelOptions, CreatePageScriptChannelOptions, PageScriptChannel, PanelChannel } from './types' +import { expectTypeOf, it } from 'vitest' + +interface Protocol { + functions: { + pageScript: { save: (value: string) => void, reset: () => Promise } + panel: { save: (value: string) => void, reset: () => Promise } + } + events: { + pageScript: { note: (value: string, count?: number) => void } + panel: { notify: (message: string) => void } + } +} + +declare const pageScript: PageScriptChannel +declare const panel: PanelChannel + +it('distinguishes void actions from declared events in both directions', () => { + expectTypeOf(panel.call('save', 'draft')).toEqualTypeOf>() + expectTypeOf(panel.call('reset')).toEqualTypeOf>() + const peer = pageScript.panels[0]! + expectTypeOf(peer.call('save', 'draft')).toEqualTypeOf>() + expectTypeOf(peer.call('reset')).toEqualTypeOf>() + expectTypeOf(panel.emit('note', 'hello', 2)).toEqualTypeOf() + expectTypeOf(pageScript.emit('notify', 'hello')).toEqualTypeOf() + expectTypeOf(pageScript.on('note', (value, count) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(count).toEqualTypeOf() + })).toEqualTypeOf<() => void>() + // @ts-expect-error Events cannot be called as functions. + panel.call('note', 'hello') + // @ts-expect-error Events cannot be called on panel peers. + peer.call('notify', 'hello') + // @ts-expect-error A void action is still a function. + panel.emit('save', 'draft') + // @ts-expect-error An asynchronous void action is still a function. + panel.emit('reset') + // @ts-expect-error The deprecated alias has the same restriction. + panel.callEvent('save', 'draft') + // @ts-expect-error A panel void action is still a function. + pageScript.emit('save', 'draft') + // @ts-expect-error A panel asynchronous void action is still a function. + pageScript.callEvent('reset') + // @ts-expect-error Functions cannot receive event listeners. + pageScript.on('save', () => {}) + // @ts-expect-error Functions cannot receive event listeners. + panel.on('reset', () => {}) +}) + +it('requires function handlers and separate event declarations', () => { + const options: CreatePageScriptChannelOptions = { + name: 'test', + functions: { + save: { type: 'action', handler: (value) => { + expectTypeOf(value).toEqualTypeOf() + } }, + reset: { type: 'action', handler: async () => {} }, + }, + events: { note: {} }, + } + // @ts-expect-error Actions require handlers even when returning void. + options.functions.save = { type: 'action' } + // @ts-expect-error Functions cannot be declared as events. + options.functions.reset = { type: 'event' } + // @ts-expect-error Events belong in the events option. + options.functions.note = { handler: () => {} } + // @ts-expect-error Event declarations must be complete. + options.events = {} + // @ts-expect-error Functions belong in the functions option. + options.events.save = {} + options.events.note = { handler: (value, count) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(count).toEqualTypeOf() + } } + // @ts-expect-error Event handlers must match the declared arguments. + options.events.note = { handler: (value: number) => void value } + // @ts-expect-error Event declarations cannot be actions. + options.events.note = { type: 'action', handler: () => {} } +}) + +it('supports omitted protocol sections without widening their keys', () => { + interface FunctionsOnly { functions: { pageScript: { run: () => void } } } + interface EventsOnly { events: { panel: { ready: () => void } } } + const options: ConnectPanelChannelOptions = { name: 'test', functions: {}, events: {} } + // @ts-expect-error This direction declares no events. + options.events.ready = {} + // @ts-expect-error This direction declares no functions. + options.functions.run = { handler: () => {} } + expectTypeOf['emit']>[0]>().toEqualTypeOf() + expectTypeOf['call']>[0]>().toEqualTypeOf() + expectTypeOf['on']>[0]>().toEqualTypeOf() +}) 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 838e1384f..6bf051cd3 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,21 +1,25 @@ import type { ConnectPanelChannelOptions, CreatePageScriptChannelOptions, InPageChannelProtocol, PageScriptChannel, PanelChannel } from './types' import { describe, expect, it, vi } from 'vitest' -import { InPageChannelError } from './internal' +import { channelMethod, createLocalFunctionRegistry, InPageChannelError } from './internal' import { createPageScriptChannel } from './page-script' import { connectPanelChannel } from './panel' import { IN_PAGE_CHANNEL_TAG, IN_PAGE_CHANNEL_VERSION } from './protocol' interface TestProtocol extends InPageChannelProtocol { - pageScript: { - echo: (value: string) => string - sum: (a: number, b: number) => number - boom: () => void - strict: (payload: unknown) => unknown - note: (value: string) => void + functions: { + pageScript: { + echo: (value: string) => string + sum: (a: number, b: number) => number + boom: () => void + strict: (payload: unknown) => unknown + } + panel: { + 'ping-panel': (value: string) => string + } } - panel: { - 'ping-panel': (value: string) => string - 'notify': (value: string) => void + events: { + pageScript: { note: (value: string) => void } + panel: { notify: (value: string) => void } } sharedStates: { doc: { count: number, label?: string } @@ -43,12 +47,10 @@ const defaultPageScriptFunctions: NonNullable a + b }, boom: { handler: () => {} }, strict: { handler: payload => payload }, - note: { type: 'event' }, } const defaultPanelFunctions: NonNullable['functions']> = { 'ping-panel': { handler: value => `pong:${value}` }, - 'notify': { type: 'event' }, } function createLinkedPair(options?: { @@ -57,6 +59,7 @@ function createLinkedPair(options?: { }): { pageScript: PageScriptChannel, panel: PanelChannel, dispose: () => void } { const { port1, port2 } = new MessageChannel() const pageScript = createPageScriptChannel({ + events: { note: {} }, name: 'devframes:test', ...noHandshake, functions: { @@ -70,6 +73,7 @@ function createLinkedPair(options?: { }) pageScript.addPanelPort(port1) const panel = connectPanelChannel({ + events: { notify: {} }, name: 'devframes:test', ...noHandshake, transport: port2, @@ -87,6 +91,129 @@ function createLinkedPair(options?: { } describe('in-page channel over bring-your-own ports', () => { + it('validates and deserializes events before invoking handlers and listeners', async () => { + const { s } = await import('devframe/utils/simple-schema') + const registry = createLocalFunctionRegistry({ + deserialize: value => typeof value === 'string' ? value.toUpperCase() : value, + }) + const handler = vi.fn(() => new Map()) + const listener = vi.fn() + registry.register({ name: 'note', type: 'event', args: [s.string()], returns: undefined, jsonSerializable: true, handler }) + registry.on('note', listener) + const receive = registry.resolve(channelMethod('event', 'note'))! + await expect(receive(42)).rejects.toMatchObject({ code: 'invalid-args' }) + await expect(receive(new Map())).rejects.toMatchObject({ code: 'not-serializable' }) + expect(handler).not.toHaveBeenCalled() + expect(listener).not.toHaveBeenCalled() + await expect(receive('hello')).resolves.toBeUndefined() + expect(handler).toHaveBeenCalledWith('HELLO') + expect(listener).toHaveBeenCalledWith('HELLO') + }) + + it('awaits void actions, propagates their errors, and times them out', async ({ onTestFinished }) => { + interface Protocol { + functions: { pageScript: { save: () => void, reset: () => Promise, fail: () => Promise, hang: () => Promise } } + } + let completed = false + const pageScript = createPageScriptChannel({ + name: 'test', + ...noHandshake, + events: {}, + functions: { + save: { type: 'action', jsonSerializable: true, handler: () => {} }, + reset: { type: 'action', handler: async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + completed = true + } }, + fail: { type: 'action', handler: async () => { throw new Error('save failed') } }, + hang: { type: 'action', handler: () => new Promise(() => {}) }, + }, + }) + const { port1, port2 } = new MessageChannel() + pageScript.addPanelPort(port1) + const panel = connectPanelChannel({ + name: 'test', + ...noHandshake, + functions: {}, + events: {}, + transport: port2, + callTimeoutMs: 100, + }) + onTestFinished(() => { + panel.close() + pageScript.close() + }) + await expect(panel.call('save')).resolves.toBeUndefined() + await expect(panel.call('reset')).resolves.toBeUndefined() + expect(completed).toBe(true) + await expect(panel.call('fail')).rejects.toThrow('save failed') + await expect(panel.call('hang')).rejects.toMatchObject({ code: 'timeout' }) + }) + + it('keeps same-named functions and events independent in both directions', async ({ onTestFinished }) => { + interface Protocol { + functions: { pageScript: { save: () => void }, panel: { save: () => void } } + events: { pageScript: { save: (value: string) => void }, panel: { save: (value: string) => void } } + } + const pageAction = vi.fn() + const panelAction = vi.fn() + const pageEvent = vi.fn() + const panelEvent = vi.fn() + const pageListener = vi.fn() + const panelListener = vi.fn() + const pageScript = createPageScriptChannel({ + name: 'test', + ...noHandshake, + functions: { save: { type: 'action', handler: pageAction } }, + events: { save: { handler: pageEvent } }, + }) + const { port1, port2 } = new MessageChannel() + const peer = pageScript.addPanelPort(port1) + const panel = connectPanelChannel({ + name: 'test', + ...noHandshake, + transport: port2, + functions: { save: { type: 'action', handler: panelAction } }, + events: { save: { handler: panelEvent } }, + }) + onTestFinished(() => { + panel.close() + pageScript.close() + }) + const offPage = pageScript.on('save', pageListener) + const offPanel = panel.on('save', panelListener) + await panel.call('save') + await peer.call('save') + expect(pageEvent).not.toHaveBeenCalled() + expect(panelEvent).not.toHaveBeenCalled() + expect(pageListener).not.toHaveBeenCalled() + expect(panelListener).not.toHaveBeenCalled() + panel.emit('save', 'draft') + pageScript.callEvent('save', 'saved') + await until(() => pageListener.mock.calls.length === 1 && panelListener.mock.calls.length === 1) + expect(pageEvent).toHaveBeenCalledWith('draft') + expect(panelEvent).toHaveBeenCalledWith('saved') + offPage() + offPanel() + panel.callEvent('save', 'again') + pageScript.emit('save', 'again') + await until(() => pageEvent.mock.calls.length === 2 && panelEvent.mock.calls.length === 2) + expect(pageListener).toHaveBeenCalledOnce() + expect(panelListener).toHaveBeenCalledOnce() + expect(pageAction).toHaveBeenCalledOnce() + expect(panelAction).toHaveBeenCalledOnce() + }) + + it('rejects subscriptions to functions even when they return void', ({ onTestFinished }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { pageScript, dispose } = createLinkedPair() + onTestFinished(() => { + dispose() + warn.mockRestore() + }) + expect(() => pageScript.on('boom' as any, () => {})).toThrowError(expect.objectContaining({ name: 'DF0077' })) + }) + it('round-trips calls, arguments, and results', async () => { const { pageScript, panel, dispose } = createLinkedPair() try { @@ -120,7 +247,7 @@ describe('in-page channel over bring-your-own ports', () => { } }) - it('reports and rejects listeners for unknown functions', ({ onTestFinished }) => { + it('reports and rejects listeners for undeclared events', ({ onTestFinished }) => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { panel, dispose } = createLinkedPair() onTestFinished(() => { @@ -167,27 +294,29 @@ describe('in-page channel over bring-your-own ports', () => { const { s } = await import('devframe/utils/simple-schema') const { port1, port2 } = new MessageChannel() const pageScript = createPageScriptChannel({ + events: { note: {} }, name: 'devframes:test', ...noHandshake, functions: { ...defaultPageScriptFunctions, - note: { + echo: { args: [s.string()] as const, - returns: s.void(), - handler: () => {}, + returns: s.string(), + handler: value => value, }, }, }) pageScript.addPanelPort(port1) const panel = connectPanelChannel({ + events: { notify: {} }, name: 'devframes:test', ...noHandshake, transport: port2, functions: defaultPanelFunctions, }) try { - await expect(panel.call('note', 'fine')).resolves.toBeUndefined() - const rejection = await panel.call('note', 42 as any).catch(error => error) + await expect(panel.call('echo', 'fine')).resolves.toBe('fine') + const rejection = await panel.call('echo', 42 as any).catch(error => error) expect(rejection.message).toContain('rejected argument 0') } finally { @@ -200,6 +329,7 @@ describe('in-page channel over bring-your-own ports', () => { const a = new MessageChannel() const b = new MessageChannel() const pageScript = createPageScriptChannel({ + events: { note: {} }, name: 'devframes:test', ...noHandshake, functions: defaultPageScriptFunctions, @@ -208,6 +338,7 @@ describe('in-page channel over bring-your-own ports', () => { pageScript.addPanelPort(b.port1) const received: string[] = [] const panelA = connectPanelChannel({ + events: { notify: {} }, name: 'devframes:test', ...noHandshake, transport: a.port2, @@ -219,6 +350,7 @@ describe('in-page channel over bring-your-own ports', () => { const offNotify = panelA.on('notify', value => received.push(`a:${value}`)) // Panel B deliberately has no listener for this event. const panelB = connectPanelChannel({ + events: {}, name: 'devframes:test', ...noHandshake, transport: b.port2, @@ -244,12 +376,14 @@ describe('in-page channel over bring-your-own ports', () => { it('lets the page script call one panel through its peer handle', async () => { const { port1, port2 } = new MessageChannel() const pageScript = createPageScriptChannel({ + events: { note: {} }, name: 'devframes:test', ...noHandshake, functions: defaultPageScriptFunctions, }) pageScript.addPanelPort(port1) const panel = connectPanelChannel({ + events: { notify: {} }, name: 'devframes:test', ...noHandshake, transport: port2, @@ -268,12 +402,14 @@ describe('in-page channel over bring-your-own ports', () => { it('applies serialize/deserialize hooks to arguments and results', async () => { const { port1, port2 } = new MessageChannel() const pageScript = createPageScriptChannel({ + events: { note: {} }, name: 'devframes:test', ...noHandshake, functions: defaultPageScriptFunctions, }) pageScript.addPanelPort(port1) const panel = connectPanelChannel({ + events: { notify: {} }, name: 'devframes:test', ...noHandshake, transport: port2, @@ -296,6 +432,7 @@ describe('in-page channel over bring-your-own ports', () => { it('notifies the page script of panel lifecycle', async () => { const { port1, port2 } = new MessageChannel() const pageScript = createPageScriptChannel({ + events: { note: {} }, name: 'devframes:test', ...noHandshake, functions: defaultPageScriptFunctions, @@ -306,6 +443,7 @@ describe('in-page channel over bring-your-own ports', () => { pageScript.events.on('panel:disconnected', peer => disconnected.push(peer.id)) pageScript.addPanelPort(port1) const panel = connectPanelChannel({ + events: { notify: {} }, name: 'devframes:test', ...noHandshake, transport: port2, @@ -387,14 +525,15 @@ describe('in-page channel shared state', () => { const a = new MessageChannel() const b = new MessageChannel() const pageScript = createPageScriptChannel({ + events: { note: {} }, name: 'devframes:test', ...noHandshake, functions: defaultPageScriptFunctions, }) pageScript.addPanelPort(a.port1) pageScript.addPanelPort(b.port1) - const panelA = connectPanelChannel({ name: 'devframes:test', ...noHandshake, transport: a.port2, functions: defaultPanelFunctions }) - const panelB = connectPanelChannel({ name: 'devframes:test', ...noHandshake, transport: b.port2, functions: defaultPanelFunctions }) + const panelA = connectPanelChannel({ events: { notify: {} }, name: 'devframes:test', ...noHandshake, transport: a.port2, functions: defaultPanelFunctions }) + const panelB = connectPanelChannel({ events: { notify: {} }, name: 'devframes:test', ...noHandshake, transport: b.port2, functions: defaultPanelFunctions }) try { const authority = await pageScript.sharedState.get('doc', { initialValue: { count: 0 } }) const mirrorA = await panelA.sharedState.get('doc') @@ -415,7 +554,7 @@ describe('in-page channel shared state', () => { it('seeds a late-joining panel with the current value', async () => { const { port1, port2 } = new MessageChannel() - const pageScript = createPageScriptChannel({ name: 'devframes:test', ...noHandshake, functions: defaultPageScriptFunctions }) + const pageScript = createPageScriptChannel({ events: { note: {} }, name: 'devframes:test', ...noHandshake, functions: defaultPageScriptFunctions }) const authority = await pageScript.sharedState.get('doc', { initialValue: { count: 0 } }) authority.mutate((draft) => { draft.count = 41 @@ -425,7 +564,7 @@ describe('in-page channel shared state', () => { }) pageScript.addPanelPort(port1) - const panel = connectPanelChannel({ name: 'devframes:test', ...noHandshake, transport: port2, functions: defaultPanelFunctions }) + const panel = connectPanelChannel({ events: { notify: {} }, name: 'devframes:test', ...noHandshake, transport: port2, functions: defaultPanelFunctions }) try { const mirror = await panel.sharedState.get('doc') expect(mirror.value()).toEqual({ count: 42 }) @@ -519,12 +658,14 @@ describe('in-page channel handshake', () => { it('connects a panel to the page script and survives page-script restarts', async () => { const { hostWin, panelWin } = createWindowPair() const pageScript = createPageScriptChannel({ + events: { note: {} }, name: 'devframes:test', window: asWindow(hostWin), heartbeat: false, functions: defaultPageScriptFunctions, }) const panel = connectPanelChannel({ + events: { notify: {} }, name: 'devframes:test', window: asWindow(panelWin), targets: [asWindow(hostWin)], @@ -542,6 +683,7 @@ describe('in-page channel handshake', () => { // … and a fresh one boots in the same window: the panel re-handshakes. const revived = createPageScriptChannel({ + events: { note: {} }, name: 'devframes:test', window: asWindow(hostWin), heartbeat: false, @@ -568,6 +710,7 @@ describe('in-page channel handshake', () => { const { hostWin, panelWin } = createWindowPair() const noted: string[] = [] const panel = connectPanelChannel({ + events: { notify: {} }, name: 'devframes:test', window: asWindow(panelWin), targets: [asWindow(hostWin)], @@ -578,6 +721,7 @@ describe('in-page channel handshake', () => { panel.emit('note', 'buffered') const pageScript = createPageScriptChannel({ + events: { note: {} }, name: 'devframes:test', window: asWindow(hostWin), heartbeat: false, @@ -599,6 +743,7 @@ describe('in-page channel handshake', () => { const { hostWin, panelWin } = createWindowPair() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const pageScript = createPageScriptChannel({ + events: { note: {} }, name: 'devframes:test-origin', window: asWindow(hostWin), heartbeat: false, @@ -630,6 +775,7 @@ describe('in-page channel handshake', () => { const { hostWin, panelWin } = createWindowPair() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const pageScript = createPageScriptChannel({ + events: { note: {} }, name: 'devframes:test-version', window: asWindow(hostWin), heartbeat: false, @@ -660,12 +806,14 @@ describe('in-page channel handshake', () => { it('honors an instance pin', async () => { const { hostWin, panelWin } = createWindowPair() const pageScript = createPageScriptChannel({ + events: { note: {} }, name: 'devframes:test', window: asWindow(hostWin), heartbeat: false, functions: defaultPageScriptFunctions, }) const pinnedElsewhere = connectPanelChannel({ + events: { notify: {} }, name: 'devframes:test', window: asWindow(panelWin), targets: [asWindow(hostWin)], @@ -677,6 +825,7 @@ describe('in-page channel handshake', () => { await expect(pinnedElsewhere.whenConnected(100)).rejects.toMatchObject({ code: 'timeout' }) const pinnedHere = connectPanelChannel({ + events: { notify: {} }, name: 'devframes:test', window: asWindow(panelWin), targets: [asWindow(hostWin)], @@ -700,6 +849,7 @@ describe('in-page channel handshake', () => { it('stays connecting and warns when the panel has nowhere to handshake', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const lonely = connectPanelChannel({ + events: { notify: {} }, name: `devframes:test-lonely-${Math.random()}`, window: false, heartbeat: false, @@ -718,6 +868,7 @@ describe('in-page channel handshake', () => { it('rejects buffered calls with a status-aware timeout', async () => { const lonely = connectPanelChannel({ + events: { notify: {} }, name: `devframes:test-lonely-${Math.random()}`, window: false, heartbeat: false, @@ -737,6 +888,7 @@ describe('in-page channel handshake', () => { it('rejects pending work when the channel closes', async () => { const lonely = connectPanelChannel({ + events: { notify: {} }, name: `devframes:test-lonely-${Math.random()}`, window: false, heartbeat: false, diff --git a/packages/devframe/src/in-page-channel/internal.ts b/packages/devframe/src/in-page-channel/internal.ts index e7bbf5b55..fa2f16be1 100644 --- a/packages/devframe/src/in-page-channel/internal.ts +++ b/packages/devframe/src/in-page-channel/internal.ts @@ -158,6 +158,11 @@ export function deserializeResult(codec: InPageChannelSerialization, result: unk return codec.deserialize && result !== undefined ? codec.deserialize(result) : result } +/** Keep user functions, user events, and internal methods in separate wire namespaces. */ +export function channelMethod(kind: 'function' | 'event', name: string): string { + return JSON.stringify([kind, name]) +} + /** * An endpoint's local function table, resolved by name when the remote side * calls in. Each handler is wrapped with the receive pipeline: deserialize @@ -173,21 +178,22 @@ export function createLocalFunctionRegistry(codec: InPageChannelSerialization): const listeners = new Map void>>() return { register(definition) { - definitions.set(definition.name, definition) + definitions.set(channelMethod(definition.type === 'event' ? 'event' : 'function', definition.name), definition) }, on(name, listener) { - if (!definitions.has(name)) + const key = channelMethod('event', name) + if (!definitions.has(key)) throw diagnostics.DF0077({ name }) - let registered = listeners.get(name) + let registered = listeners.get(key) if (!registered) { registered = new Set() - listeners.set(name, registered) + listeners.set(key, registered) } registered.add(listener) return () => { registered.delete(listener) if (registered.size === 0) - listeners.delete(name) + listeners.delete(key) } }, resolve(name) { @@ -204,7 +210,9 @@ export function createLocalFunctionRegistry(codec: InPageChannelSerialization): const result = await definition?.handler?.(...args) for (const listener of [...(listeners.get(name) ?? [])]) listener(...args) - if (definition?.jsonSerializable) + if (definition?.type === 'event') + return undefined + if (definition?.jsonSerializable && result !== undefined) assertJsonSerializable(result, 'its return value', definition.name) return codec.serialize && result !== undefined ? codec.serialize(result) : result } diff --git a/packages/devframe/src/in-page-channel/page-script.ts b/packages/devframe/src/in-page-channel/page-script.ts index 6dc245a74..3abd1a95e 100644 --- a/packages/devframe/src/in-page-channel/page-script.ts +++ b/packages/devframe/src/in-page-channel/page-script.ts @@ -10,6 +10,7 @@ import { createEventEmitter } from 'devframe/utils/events' import { nanoid } from 'devframe/utils/nanoid' import { attachChannelPort, + channelMethod, createLocalFunctionRegistry, DEFAULT_CALL_TIMEOUT_MS, deserializeResult, @@ -65,6 +66,8 @@ export function createPageScriptChannel

( const registry = createLocalFunctionRegistry(codec) for (const [fnName, definition] of Object.entries(options.functions)) registry.register({ ...definition, name: fnName }) + for (const [eventName, definition] of Object.entries(options.events)) + registry.register({ ...definition, name: eventName, type: 'event' }) const stateHost = createPageScriptStateHost

(function* () { for (const peer of peers.values()) { @@ -113,7 +116,7 @@ export function createPageScriptChannel

( internal.peer = { id, call: (fnName, ...args) => withCallDeadline( - internal.attached.rpc.$call(fnName, ...serializeArgs(codec, args)).then(result => deserializeResult(codec, result)) as Promise, + internal.attached.rpc.$call(channelMethod('function', fnName), ...serializeArgs(codec, args)).then(result => deserializeResult(codec, result)) as Promise, callTimeoutMs, () => `in-page channel "${name}": call "${fnName}" to panel "${id}" timed out after ${callTimeoutMs}ms`, ), @@ -178,7 +181,7 @@ export function createPageScriptChannel

( const wireArgs = serializeArgs(codec, args) for (const peer of peers.values()) { void peer.attached.rpc.$callRaw({ - method: fnName, + method: channelMethod('event', fnName), args: wireArgs, event: true, optional: true, diff --git a/packages/devframe/src/in-page-channel/panel.ts b/packages/devframe/src/in-page-channel/panel.ts index 374197d19..ce4aca1db 100644 --- a/packages/devframe/src/in-page-channel/panel.ts +++ b/packages/devframe/src/in-page-channel/panel.ts @@ -10,6 +10,7 @@ import { createEventEmitter } from 'devframe/utils/events' import { nanoid } from 'devframe/utils/nanoid' import { attachChannelPort, + channelMethod, createLocalFunctionRegistry, DEFAULT_CALL_TIMEOUT_MS, deserializeResult, @@ -64,6 +65,8 @@ export function connectPanelChannel

( const registry = createLocalFunctionRegistry(codec) for (const [fnName, definition] of Object.entries(options.functions)) registry.register({ ...definition, name: fnName }) + for (const [eventName, definition] of Object.entries(options.events)) + registry.register({ ...definition, name: eventName, type: 'event' }) let status: InPageChannelStatus = 'connecting' let attached: AttachedChannelPort | undefined @@ -283,9 +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)), + call: (fnName, ...args) => enqueueCall(channelMethod('function', fnName), serializeArgs(codec, args)) as Promise, + emit: (fnName, ...args) => sendEvent(channelMethod('event', fnName), serializeArgs(codec, args)), + callEvent: (fnName, ...args) => sendEvent(channelMethod('event', fnName), serializeArgs(codec, args)), on: (fnName, listener) => registry.on(fnName, listener as (...args: unknown[]) => void), sharedState: stateHost, close: () => { diff --git a/packages/devframe/src/in-page-channel/protocol.ts b/packages/devframe/src/in-page-channel/protocol.ts index 70ddb2628..2bf6cde0d 100644 --- a/packages/devframe/src/in-page-channel/protocol.ts +++ b/packages/devframe/src/in-page-channel/protocol.ts @@ -13,7 +13,7 @@ import { DEVFRAME_EVENTS } from '../events' export const IN_PAGE_CHANNEL_TAG = DEVFRAME_EVENTS.postMessage.inPageChannel /** Envelope version; bump on breaking wire changes. */ -export const IN_PAGE_CHANNEL_VERSION = 1 +export const IN_PAGE_CHANNEL_VERSION = 2 /** * The handshake envelope. A panel posts a `hello` ("grant me a port for 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 418d89c60..4da152921 100644 --- a/packages/devframe/src/in-page-channel/types.test-d.ts +++ b/packages/devframe/src/in-page-channel/types.test-d.ts @@ -4,29 +4,38 @@ import { createPageScriptChannel } from './page-script' import { connectPanelChannel } from './panel' interface TestProtocol { - pageScript: { - echo: (value: string) => string - sum: (a: number, b: number) => number - save: (value: string) => Promise - } - panel: { - notify: (message: string) => void + functions: { + pageScript: { + echo: (value: string) => string + sum: (a: number, b: number) => number + save: (value: string) => Promise + } + panel: { + notify: (message: string) => void + } } + events: { pageScript: { save: (value: string) => void }, panel: { notify: (message: string) => void } } } interface PageScriptOnlyProtocol { - pageScript: { - echo: (value: string) => string + functions: { + pageScript: { + echo: (value: string) => string + } + panel: Record } - panel: Record + events: Record } interface MixedPanelProtocol { - pageScript: Record - panel: { - confirm: (message: string) => boolean - notify: (message: string) => void + functions: { + pageScript: Record + panel: { + confirm: (message: string) => boolean + notify: (message: string) => void + } } + events: { panel: { notify: (message: string) => void } } } describe('Channel function definitions', () => { @@ -47,6 +56,7 @@ describe('Channel function definitions', () => { describe('In-page script channel', () => { const channel = createPageScriptChannel({ + events: { save: {} }, name: 'devframes:test', functions: { echo: { handler: value => value }, @@ -58,6 +68,7 @@ describe('In-page script channel', () => { describe('Function definitions', () => { it('infers handlers from the protocol', () => { createPageScriptChannel({ + events: { save: {} }, name: 'devframes:test', functions: { echo: { @@ -87,6 +98,7 @@ describe('In-page script channel', () => { createPageScriptChannel({ name: 'devframes:test' }) createPageScriptChannel({ + events: { save: {} }, name: 'devframes:test', // @ts-expect-error `sum` and `save` are required. functions: { @@ -97,28 +109,31 @@ describe('In-page script channel', () => { it('allows event declarations to omit their handler', () => { createPageScriptChannel({ + events: { save: {} }, name: 'devframes:test', functions: { echo: { type: 'query', handler: value => value }, sum: { type: 'action', handler: (a, b) => a + b }, - save: { type: 'event' }, + save: { type: 'action', handler: () => {} }, }, }) createPageScriptChannel({ + events: { save: {} }, name: 'devframes:test', functions: { // @ts-expect-error Request/response functions require a handler. echo: { type: 'query' }, // @ts-expect-error Request/response functions require a handler. sum: { type: 'action' }, - save: { type: 'event' }, + save: { type: 'action', handler: () => {} }, }, }) }) it('rejects panel functions', () => { createPageScriptChannel({ + events: { save: {} }, name: 'devframes:test', functions: { echo: { handler: value => value }, @@ -132,6 +147,7 @@ describe('In-page script channel', () => { it('rejects incompatible handlers', () => { createPageScriptChannel({ + events: { save: {} }, name: 'devframes:test', functions: { echo: { @@ -162,6 +178,7 @@ describe('In-page script channel', () => { it('rejects fire-and-forget calls to panel queries', () => { const mixedChannel = createPageScriptChannel({ + events: {}, name: 'devframes:mixed-panel', functions: {}, }) @@ -187,6 +204,7 @@ describe('In-page script channel', () => { it('rejects calls when the protocol declares no panel functions', () => { const pageScriptOnlyChannel = createPageScriptChannel({ + events: {}, name: 'devframes:page-script-only', functions: { echo: { handler: value => value }, @@ -236,6 +254,7 @@ describe('In-page script channel', () => { describe('Panel channel', () => { const channel = connectPanelChannel({ + events: { notify: {} }, name: 'devframes:test', functions: { notify: { handler: () => { } }, @@ -246,6 +265,7 @@ describe('Panel channel', () => { it('infers handlers from the protocol', () => { const { port1 } = new MessageChannel() const inferredChannel = connectPanelChannel({ + events: { notify: {} }, name: 'devframes:test', window: false, transport: port1, @@ -265,6 +285,7 @@ describe('Panel channel', () => { connectPanelChannel({ name: 'devframes:test' }) connectPanelChannel({ + events: { notify: {} }, name: 'devframes:test', // @ts-expect-error `notify` must be declared. functions: {}, @@ -273,13 +294,15 @@ describe('Panel channel', () => { it('allows event declarations to omit their handler', () => { connectPanelChannel({ + events: { notify: {} }, name: 'devframes:test', functions: { - notify: { type: 'event' }, + notify: { handler: () => {} }, }, }) connectPanelChannel({ + events: { notify: {} }, name: 'devframes:test', functions: { // @ts-expect-error Request/response functions require a handler. @@ -290,6 +313,7 @@ describe('Panel channel', () => { it('rejects in-page script functions', () => { connectPanelChannel({ + events: { notify: {} }, name: 'devframes:test', functions: { notify: { handler: () => { } }, @@ -301,6 +325,7 @@ describe('Panel channel', () => { it('rejects incompatible handlers', () => { connectPanelChannel({ + events: { notify: {} }, name: 'devframes:test', functions: { notify: { @@ -313,11 +338,13 @@ describe('Panel channel', () => { it('accepts an explicitly empty panel function map', () => { connectPanelChannel({ + events: {}, name: 'devframes:page-script-only', functions: {}, }) connectPanelChannel({ + events: {}, name: 'devframes:page-script-only', functions: { // @ts-expect-error The protocol has no panel functions. @@ -382,10 +409,11 @@ describe('Panel channel', () => { it('rejects runtime subscriptions to panel queries', () => { const mixedChannel = connectPanelChannel({ + events: { notify: {} }, name: 'devframes:mixed-panel', functions: { confirm: { handler: () => true }, - notify: { type: 'event' }, + notify: { handler: () => {} }, }, }) diff --git a/packages/devframe/src/in-page-channel/types.ts b/packages/devframe/src/in-page-channel/types.ts index 77648a02f..90e763407 100644 --- a/packages/devframe/src/in-page-channel/types.ts +++ b/packages/devframe/src/in-page-channel/types.ts @@ -10,10 +10,18 @@ import type { InferArgsType, InferReturnType } from '../rpc/utils' * channel-name constant declared next to it. */ export interface InPageChannelProtocol { - /** Functions and events received by the page script. */ - pageScript?: Record any> - /** Functions and events received by panels. */ - panel?: Record any> + functions?: { + /** Functions implemented by the page script. */ + pageScript?: Record any> + /** Functions implemented by panels. */ + panel?: Record any> + } + events?: { + /** Events emitted by panels and received by the page script. */ + pageScript?: Record void> + /** Events emitted by the page script and received by panels. */ + panel?: Record void> + } /** * Shared-state slots. The page script is the authority: it owns the * canonical value; panels are seeded on connect and converge through @@ -23,29 +31,24 @@ export interface InPageChannelProtocol { } type SideFunctions = S extends Record any> ? S : Record -type PageScriptFunctions

= SideFunctions> -type PanelFunctions

= SideFunctions> +type SideDeclarations

+ = Kind extends keyof P + ? Side extends keyof NonNullable + ? SideFunctions[Side]>> + : Record + : Record +type PageScriptFunctions

= SideDeclarations +type PanelFunctions

= SideDeclarations type SharedStates

= P['sharedStates'] extends Record ? P['sharedStates'] : Record +type FunctionNames = { [K in keyof T]: [T[K]] extends [never] ? never : K }[keyof T] & string + 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] -} +type PageScriptProtocolEvents

= SideDeclarations +type PanelProtocolEvents

= SideDeclarations /** * Converts a protocol function to its accepted endpoint handler. @@ -161,10 +164,12 @@ interface InPageFunctionOptionBase { } interface InPageEventFunctionOption extends InPageFunctionOptionBase { - type: 'event' + type?: 'event' handler?: ProtocolHandler } +type InPageEventOption = [F] extends [never] ? never : InPageEventFunctionOption + interface InPageQueryFunctionOption extends InPageFunctionOptionBase { type?: 'query' handler: ProtocolHandler @@ -176,8 +181,7 @@ interface InPageActionFunctionOption extends InPageFunctionOptionBase { } type InPageFunctionOption - = | InPageEventFunctionOption - | InPageQueryFunctionOption + = | InPageQueryFunctionOption | InPageActionFunctionOption /** @@ -247,8 +251,10 @@ interface InPageChannelCommonOptions { /** Options for {@link createPageScriptChannel}. */ export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions { - /** Every page-script function declaration; event handlers may use `channel.on()`. */ + /** Every page-script function declaration, with a required handler. */ functions: CreatePageScriptChannelOptionsFunctions + /** Every incoming event declaration; handlers may subscribe through `channel.on()`. */ + events: { [NAME in keyof PageScriptProtocolEvents & string]: InPageEventOption[NAME]> } /** * Window whose `message` events carry panel hellos. Defaults to the * global `window`; pass `false` to skip the handshake listener entirely @@ -259,8 +265,10 @@ export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions { - /** Every panel function declaration; event handlers may use `channel.on()`. */ + /** Every panel function declaration, with a required handler. */ functions: ConnectPanelChannelOptionsFunctions + /** Every incoming event declaration; handlers may subscribe through `channel.on()`. */ + events: { [NAME in keyof PanelProtocolEvents & string]: InPageEventOption[NAME]> } /** * The panel's own window (listens for the handshake grant). Defaults to * the global `window`; pass `false` with `transport` to skip the handshake. @@ -320,7 +328,7 @@ export interface PanelPeer

{ /** Unique id of the panel endpoint (stable across its lifetime, not reloads). */ readonly id: string /** Call one panel's function and await the result. */ - call: & string>( + call: >>( name: K, ...args: FnArgs[K]> ) => Promise[K]>> @@ -345,19 +353,19 @@ export interface PageScriptChannel

{ readonly panels: readonly PanelPeer

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

@@ -394,7 +402,7 @@ export interface PanelChannel

{ * the call is buffered and sent on connect; it rejects with code * `timeout` when `callTimeoutMs` elapses first. */ - call: & string>( + call: >>( name: K, ...args: FnArgs[K]> ) => Promise[K]>> @@ -402,19 +410,19 @@ 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: >>( name: K, - ...args: FnArgs[K]> + ...args: FnArgs[K]> ) => void /** @deprecated Use `emit()` instead. */ - callEvent: & string>( + callEvent: >>( name: K, - ...args: FnArgs[K]> + ...args: FnArgs[K]> ) => void /** Subscribe to an event emitted by the page script. Returns an unsubscribe function. */ - on: & string>( + on: >>( 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/plugins/a11y/app/lib/channel.ts b/plugins/a11y/app/lib/channel.ts index 3b2b2289b..233781c22 100644 --- a/plugins/a11y/app/lib/channel.ts +++ b/plugins/a11y/app/lib/channel.ts @@ -52,6 +52,7 @@ export function createA11yChannel(): A11yChannel { const channel = connectPanelChannel({ name: A11Y_CHANNEL, functions: {}, + events: {}, }) channel.events.on('status:updated', status => setPageScriptReady(status === 'connected')) diff --git a/plugins/a11y/src/client-script/index.ts b/plugins/a11y/src/client-script/index.ts index a639486a9..ae2661001 100644 --- a/plugins/a11y/src/client-script/index.ts +++ b/plugins/a11y/src/client-script/index.ts @@ -96,9 +96,9 @@ async function start(context?: A11yPageScriptContext): Promise { const channel = createPageScriptChannel({ name: A11Y_CHANNEL, - functions: { + functions: {}, + events: { 'highlight': { - type: 'event', jsonSerializable: true, handler: (nodeId: string, target: string[]) => { const el = document.querySelector(`[${A11Y_NODE_ATTR}="${CSS.escape(nodeId)}"]`) @@ -115,11 +115,9 @@ async function start(context?: A11yPageScriptContext): Promise { }, }, 'clear-highlight': { - type: 'event', handler: () => overlay.clearPreview(), }, 'set-pins': { - type: 'event', jsonSerializable: true, handler: (pins: PinTarget[]) => { const infos: PinInfo[] = [] @@ -132,15 +130,12 @@ async function start(context?: A11yPageScriptContext): Promise { }, }, 'rescan': { - type: 'event', handler: () => void runScan(), }, 'set-config': { - type: 'event', handler: (next: PageScriptConfig) => applyConfig(next), }, 'set-autoscan': { - type: 'event', jsonSerializable: true, handler: (enabled: boolean) => { config.autoScan = enabled @@ -151,7 +146,6 @@ async function start(context?: A11yPageScriptContext): Promise { }, }, 'clear-route': { - type: 'event', jsonSerializable: true, handler: (route: string) => { routes.delete(route) @@ -161,7 +155,6 @@ async function start(context?: A11yPageScriptContext): Promise { }, }, 'clear-all': { - type: 'event', handler: () => { routes.clear() loggedRules.clear() diff --git a/plugins/a11y/src/shared/protocol.ts b/plugins/a11y/src/shared/protocol.ts index f0d3118c1..8ea11b49e 100644 --- a/plugins/a11y/src/shared/protocol.ts +++ b/plugins/a11y/src/shared/protocol.ts @@ -157,35 +157,36 @@ export interface PageScriptConfig { } /** - * The a11y inspector's in-page channel contract: every function the page - * script implements for its panels, and the shared {@link A11yState} - * aggregate the page script owns. All functions are fire-and-forget events; + * The a11y inspector's in-page channel contract: every event the page + * script receives from its panels, and the shared {@link A11yState} + * aggregate the page script owns. Events are fire-and-forget; * results flow back through the shared state. */ export interface A11yChannelProtocol { - pageScript: { - /** - * Draw the transient hover-preview ring around a node's element. - * `nodeId` is a {@link ViolationNode.id} (mirrored to - * {@link A11Y_NODE_ATTR}); `target` is the axe selector fallback. - */ - 'highlight': (nodeId: string, target: string[]) => void - /** Clear the transient hover-preview ring. */ - 'clear-highlight': () => void - /** Replace the pinned (numbered) highlight set, drawn in the given order. */ - 'set-pins': (pins: PinTarget[]) => void - /** Re-run the scan. */ - 'rescan': () => void - /** Forward runtime configuration (resolved from the `get-config` RPC). */ - 'set-config': (config: PageScriptConfig) => void - /** Toggle the interaction-driven auto-scan. */ - 'set-autoscan': (enabled: boolean) => void - /** Drop one route's tracked history. */ - 'clear-route': (route: string) => void - /** Drop the whole tracked-route history. */ - 'clear-all': () => void + events: { + pageScript: { + /** + * Draw the transient hover-preview ring around a node's element. + * `nodeId` is a {@link ViolationNode.id} (mirrored to + * {@link A11Y_NODE_ATTR}); `target` is the axe selector fallback. + */ + 'highlight': (nodeId: string, target: string[]) => void + /** Clear the transient hover-preview ring. */ + 'clear-highlight': () => void + /** Replace the pinned (numbered) highlight set, drawn in the given order. */ + 'set-pins': (pins: PinTarget[]) => void + /** Re-run the scan. */ + 'rescan': () => void + /** Forward runtime configuration (resolved from the `get-config` RPC). */ + 'set-config': (config: PageScriptConfig) => void + /** Toggle the interaction-driven auto-scan. */ + 'set-autoscan': (enabled: boolean) => void + /** Drop one route's tracked history. */ + 'clear-route': (route: string) => void + /** Drop the whole tracked-route history. */ + 'clear-all': () => void + } } - panel: Record sharedStates: { /** The authoritative route → report aggregate the page script owns. */ state: A11yState diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index c637aaae2..0c42b2499 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -438,7 +438,7 @@ Use `my.rpc.sharedState(key)` for observable state, `my.rpc.register(...)` to re ### In-page channel (page script ↔ panel, server-free) -For a live inspect-the-page loop, `devframe/in-page-channel` connects a devframe's **page script** (in the user app's page) to its **panels** entirely in the browser — no server, so it works identically in dev and static builds. Declare one shared protocol type; the page script is `createPageScriptChannel

({ name, functions })`, each panel `connectPanelChannel

({ name })`. Functions use `defineChannelFunction` (the `defineRpcFunction` shape: `type: 'event'` fans out to every panel, `query`/`action` are request/response); `channel.sharedState.get(key)` mirrors `rpc.sharedState` with the page script as authority (panels get automatic replay). The handshake is panel-initiated with retry — boot order and reloads don't matter — and panels expose `status`/`whenConnected(ms)` for "page script not loaded" fallbacks. Channel names follow `devframes:plugin:`. The a11y inspector's scan/highlight loop is the reference use. +For a live inspect-the-page loop, `devframe/in-page-channel` connects a devframe's **page script** (in the user app's page) to its **panels** entirely in the browser, in both dev and static builds. Declare a shared protocol type with separate `functions` and `events` sections, each with `pageScript` and `panel` maps naming the receiving direction. Both `createPageScriptChannel

()` and `connectPanelChannel

()` require `{ name, functions, events }`: function declarations require handlers; event declarations accept optional handlers or `{}` for dynamic `channel.on()` subscriptions. `call()` awaits functions, including void actions; `emit()` sends declared events. `channel.sharedState.get(key)` mirrors `rpc.sharedState` with the page script as authority and automatic replay to panels. The handshake retries across boot order and reloads; panels expose `status`/`whenConnected(ms)` for page-script availability fallbacks. Channel names follow `devframes:plugin:`. The a11y inspector's scan/highlight loop is the reference use. ## The Hub 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 118b8ce4a..f99459be4 100644 --- a/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts @@ -4,6 +4,7 @@ // #region Interfaces export interface ConnectPanelChannelOptions extends InPageChannelCommonOptions { functions: ConnectPanelChannelOptionsFunctions; + events: { [NAME in keyof PanelProtocolEvents & string]: InPageEventOption[NAME]>; }; window?: Window | false; targets?: Window[]; transport?: MessagePort; @@ -13,11 +14,18 @@ export interface ConnectPanelChannelOptions extends InPageChannelCommonOptions { functions: CreatePageScriptChannelOptionsFunctions; + events: { [NAME in keyof PageScriptProtocolEvents & string]: InPageEventOption[NAME]>; }; window?: Window | false; } export interface InPageChannelProtocol { - pageScript?: Record any>; - panel?: Record any>; + functions?: { + pageScript?: Record any>; + panel?: Record any>; + }; + events?: { + pageScript?: Record void>; + panel?: Record void>; + }; sharedStates?: Record; } export interface PageScriptChannel

{ @@ -25,9 +33,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; + emit: >>(_: K, ..._: FnArgs[K]>) => void; + callEvent: >>(_: K, ..._: FnArgs[K]>) => void; + on: >>(_: K, _: (..._: FnArgs[K]>) => void) => () => void; readonly sharedState: InPageSharedStateHost

; addPanelPort: (_: MessagePort) => PanelPeer

; close: () => void; @@ -40,16 +48,16 @@ export interface PanelChannel

{ } | undefined; 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; + call: >>(_: K, ..._: FnArgs[K]>) => Promise[K]>>; + emit: >>(_: K, ..._: FnArgs[K]>) => void; + callEvent: >>(_: K, ..._: FnArgs[K]>) => void; + on: >>(_: K, _: (..._: FnArgs[K]>) => void) => () => void; readonly sharedState: InPageSharedStateHost

; close: () => void; } export interface PanelPeer

{ readonly id: string; - call: & string>(_: K, ..._: FnArgs[K]>) => Promise[K]>>; + call: >>(_: K, ..._: FnArgs[K]>) => Promise[K]>>; close: () => void; } // #endregion @@ -86,6 +94,7 @@ type ConnectPanelChannelOptionsFunctions

= { [N 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; +type FunctionNames = { [K in keyof T]: [T[K]] extends [never] ? never : K; }[keyof T] & string; interface InPageChannelCommonOptions { name: string; allowedOrigins?: string[]; @@ -97,6 +106,7 @@ interface InPageChannelCommonOptions { serialize?: (_: unknown) => unknown; deserialize?: (_: unknown) => unknown; } +type InPageEventOption = [F] extends [never] ? never : InPageEventFunctionOption; 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] ? { @@ -116,11 +126,11 @@ interface PageScriptChannelEvents

{ 'panel:connected': (_: PanelPeer

) => void; 'panel:disconnected': (_: PanelPeer

) => void; } -type PageScriptFunctions

= SideFunctions>; -type PageScriptFunctionsEvents

= { [K in keyof PageScriptFunctions

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

[K]; }; +type PageScriptFunctions

= SideDeclarations; +type PageScriptProtocolEvents

= SideDeclarations; interface PanelChannelEvents { 'status:updated': (_: InPageChannelStatus) => void; } -type PanelFunctions

= SideFunctions>; -type PanelFunctionsEvents

= { [K in keyof PanelFunctions

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

[K]; }; +type PanelFunctions

= SideDeclarations; +type PanelProtocolEvents

= SideDeclarations; // #endregion \ No newline at end of file From b05f0095234353be037f17878609f8f6ae5b8c59 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Tue, 8 Sep 2026 15:22:18 +0200 Subject: [PATCH 02/14] Update channelMethod to use template string for namespace Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/devframe/src/in-page-channel/internal.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/devframe/src/in-page-channel/internal.ts b/packages/devframe/src/in-page-channel/internal.ts index fa2f16be1..377b29c3b 100644 --- a/packages/devframe/src/in-page-channel/internal.ts +++ b/packages/devframe/src/in-page-channel/internal.ts @@ -158,9 +158,9 @@ export function deserializeResult(codec: InPageChannelSerialization, result: unk return codec.deserialize && result !== undefined ? codec.deserialize(result) : result } -/** Keep user functions, user events, and internal methods in separate wire namespaces. */ export function channelMethod(kind: 'function' | 'event', name: string): string { - return JSON.stringify([kind, name]) + // Keep user functions, user events, and internal methods in separate wire namespaces. + return `devframe:in-page:${kind}:${name}` } /** From 4dad3151dd1e1085df643ec18822f65f43f9dd34 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Tue, 8 Sep 2026 16:16:36 +0200 Subject: [PATCH 03/14] docs: comments --- docs/content/1.guide/12.in-page-channel.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/content/1.guide/12.in-page-channel.md b/docs/content/1.guide/12.in-page-channel.md index 4da62face..02a0f4b61 100644 --- a/docs/content/1.guide/12.in-page-channel.md +++ b/docs/content/1.guide/12.in-page-channel.md @@ -38,13 +38,20 @@ export const MY_CHANNEL = 'devframes:plugin:my-tool' export interface MyChannelProtocol extends InPageChannelProtocol { functions: { + /** implemented by the page script, callable by panels */ pageScript: { measure: (selector: string) => { width: number, height: number } reset: () => Promise } + /** implemented by panels, callable by the page script */ + panel: { + echo: (message: string) => Promise + } } events: { + /** listened to by the page script, emitted by panels */ pageScript: { highlight: (selector: string) => void } + /** listened to by panels, emitted by the page script */ panel: { flash: (message: string) => void } } sharedStates: { From c2f2bf5d52da7aa0611893d483ec8a7754fb917e Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 9 Sep 2026 14:23:15 +0200 Subject: [PATCH 04/14] refactor: make events optional --- docs/content/6.errors/DF0077.md | 34 ----------------- docs/content/6.errors/index.md | 1 - .../src/in-page-channel/diagnostics.ts | 11 ------ .../src/in-page-channel/events.test-d.ts | 38 +------------------ .../in-page-channel/in-page-channel.test.ts | 30 +++------------ .../devframe/src/in-page-channel/internal.ts | 3 -- .../src/in-page-channel/page-script.ts | 2 +- .../devframe/src/in-page-channel/panel.ts | 2 +- .../src/in-page-channel/types.test-d.ts | 4 -- .../devframe/src/in-page-channel/types.ts | 8 ++-- 10 files changed, 13 insertions(+), 120 deletions(-) delete mode 100644 docs/content/6.errors/DF0077.md delete 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 deleted file mode 100644 index 148e81a30..000000000 --- a/docs/content/6.errors/DF0077.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: 'DF0077: In-Page Channel Event Not Registered' -description: 'An in-page channel listener names an event that is not registered on its endpoint.' ---- - -## Message - -> In-page channel event "{name}" is not registered on this endpoint. - -## Cause - -`channel.on(name, listener)` received a name absent from that endpoint's required `events` option. A page-script endpoint subscribes to events declared under `events.pageScript`; a panel endpoint subscribes to events declared under `events.panel`. - -## Example - -```ts -const channel = connectPanelChannel({ - name: MY_CHANNEL, - functions: {}, - events: { - notify: {}, - }, -}) - -channel.on('missing' as any, () => {}) // ✗ throws DF0077 -``` - -## Fix - -Declare the event in the endpoint's protocol side and `events` 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 535636cd3..b68d769cd 100644 --- a/docs/content/6.errors/index.md +++ b/docs/content/6.errors/index.md @@ -83,7 +83,6 @@ 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 Event 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 deleted file mode 100644 index 9f8dfd28d..000000000 --- a/packages/devframe/src/in-page-channel/diagnostics.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 event "${p.name}" is not registered on this endpoint.`, - fix: 'Declare the event in this endpoint\'s `events` option before subscribing with `on()`.', - }, - }, -}) diff --git a/packages/devframe/src/in-page-channel/events.test-d.ts b/packages/devframe/src/in-page-channel/events.test-d.ts index 79ff8702a..28be5f4df 100644 --- a/packages/devframe/src/in-page-channel/events.test-d.ts +++ b/packages/devframe/src/in-page-channel/events.test-d.ts @@ -1,4 +1,4 @@ -import type { ConnectPanelChannelOptions, CreatePageScriptChannelOptions, PageScriptChannel, PanelChannel } from './types' +import type { PageScriptChannel, PanelChannel } from './types' import { expectTypeOf, it } from 'vitest' interface Protocol { @@ -47,45 +47,9 @@ it('distinguishes void actions from declared events in both directions', () => { panel.on('reset', () => {}) }) -it('requires function handlers and separate event declarations', () => { - const options: CreatePageScriptChannelOptions = { - name: 'test', - functions: { - save: { type: 'action', handler: (value) => { - expectTypeOf(value).toEqualTypeOf() - } }, - reset: { type: 'action', handler: async () => {} }, - }, - events: { note: {} }, - } - // @ts-expect-error Actions require handlers even when returning void. - options.functions.save = { type: 'action' } - // @ts-expect-error Functions cannot be declared as events. - options.functions.reset = { type: 'event' } - // @ts-expect-error Events belong in the events option. - options.functions.note = { handler: () => {} } - // @ts-expect-error Event declarations must be complete. - options.events = {} - // @ts-expect-error Functions belong in the functions option. - options.events.save = {} - options.events.note = { handler: (value, count) => { - expectTypeOf(value).toEqualTypeOf() - expectTypeOf(count).toEqualTypeOf() - } } - // @ts-expect-error Event handlers must match the declared arguments. - options.events.note = { handler: (value: number) => void value } - // @ts-expect-error Event declarations cannot be actions. - options.events.note = { type: 'action', handler: () => {} } -}) - it('supports omitted protocol sections without widening their keys', () => { interface FunctionsOnly { functions: { pageScript: { run: () => void } } } interface EventsOnly { events: { panel: { ready: () => void } } } - const options: ConnectPanelChannelOptions = { name: 'test', functions: {}, events: {} } - // @ts-expect-error This direction declares no events. - options.events.ready = {} - // @ts-expect-error This direction declares no functions. - options.functions.run = { handler: () => {} } expectTypeOf['emit']>[0]>().toEqualTypeOf() expectTypeOf['call']>[0]>().toEqualTypeOf() expectTypeOf['on']>[0]>().toEqualTypeOf() 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 6bf051cd3..c41b8ac7a 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 @@ -59,7 +59,6 @@ function createLinkedPair(options?: { }): { pageScript: PageScriptChannel, panel: PanelChannel, dispose: () => void } { const { port1, port2 } = new MessageChannel() const pageScript = createPageScriptChannel({ - events: { note: {} }, name: 'devframes:test', ...noHandshake, functions: { @@ -73,7 +72,6 @@ function createLinkedPair(options?: { }) pageScript.addPanelPort(port1) const panel = connectPanelChannel({ - events: { notify: {} }, name: 'devframes:test', ...noHandshake, transport: port2, @@ -118,7 +116,6 @@ describe('in-page channel over bring-your-own ports', () => { const pageScript = createPageScriptChannel({ name: 'test', ...noHandshake, - events: {}, functions: { save: { type: 'action', jsonSerializable: true, handler: () => {} }, reset: { type: 'action', handler: async () => { @@ -135,7 +132,6 @@ describe('in-page channel over bring-your-own ports', () => { name: 'test', ...noHandshake, functions: {}, - events: {}, transport: port2, callTimeoutMs: 100, }) @@ -204,14 +200,10 @@ describe('in-page channel over bring-your-own ports', () => { expect(panelAction).toHaveBeenCalledOnce() }) - it('rejects subscriptions to functions even when they return void', ({ onTestFinished }) => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + it('keeps untyped runtime subscriptions isolated from functions', ({ onTestFinished }) => { const { pageScript, dispose } = createLinkedPair() - onTestFinished(() => { - dispose() - warn.mockRestore() - }) - expect(() => pageScript.on('boom' as any, () => {})).toThrowError(expect.objectContaining({ name: 'DF0077' })) + onTestFinished(dispose) + expect(() => pageScript.on('boom' as any, () => {})).not.toThrow() }) it('round-trips calls, arguments, and results', async () => { @@ -247,19 +239,10 @@ describe('in-page channel over bring-your-own ports', () => { } }) - it('reports and rejects listeners for undeclared events', ({ onTestFinished }) => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + it('accepts listeners without runtime event declarations', ({ onTestFinished }) => { const { panel, dispose } = createLinkedPair() - onTestFinished(() => { - dispose() - warn.mockRestore() - }) - - expect(() => { - panel.on('missing' as any, () => {}) - }).toThrowError(expect.objectContaining({ name: 'DF0077' })) - expect(warn).toHaveBeenCalledOnce() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('[DF0077]')) + onTestFinished(dispose) + expect(() => panel.on('missing' as any, () => {})).not.toThrow() }) it('enforces jsonSerializable payloads with a coded error', async () => { @@ -350,7 +333,6 @@ describe('in-page channel over bring-your-own ports', () => { const offNotify = panelA.on('notify', value => received.push(`a:${value}`)) // Panel B deliberately has no listener for this event. const panelB = connectPanelChannel({ - events: {}, name: 'devframes:test', ...noHandshake, transport: b.port2, diff --git a/packages/devframe/src/in-page-channel/internal.ts b/packages/devframe/src/in-page-channel/internal.ts index 377b29c3b..5022ec4bd 100644 --- a/packages/devframe/src/in-page-channel/internal.ts +++ b/packages/devframe/src/in-page-channel/internal.ts @@ -4,7 +4,6 @@ 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' /** @@ -182,8 +181,6 @@ export function createLocalFunctionRegistry(codec: InPageChannelSerialization): }, on(name, listener) { const key = channelMethod('event', name) - if (!definitions.has(key)) - throw diagnostics.DF0077({ name }) let registered = listeners.get(key) if (!registered) { registered = new Set() diff --git a/packages/devframe/src/in-page-channel/page-script.ts b/packages/devframe/src/in-page-channel/page-script.ts index 3abd1a95e..8fec16a45 100644 --- a/packages/devframe/src/in-page-channel/page-script.ts +++ b/packages/devframe/src/in-page-channel/page-script.ts @@ -66,7 +66,7 @@ export function createPageScriptChannel

( const registry = createLocalFunctionRegistry(codec) for (const [fnName, definition] of Object.entries(options.functions)) registry.register({ ...definition, name: fnName }) - for (const [eventName, definition] of Object.entries(options.events)) + for (const [eventName, definition] of Object.entries(options.events ?? {})) registry.register({ ...definition, name: eventName, type: 'event' }) const stateHost = createPageScriptStateHost

(function* () { diff --git a/packages/devframe/src/in-page-channel/panel.ts b/packages/devframe/src/in-page-channel/panel.ts index ce4aca1db..1ef2b3adc 100644 --- a/packages/devframe/src/in-page-channel/panel.ts +++ b/packages/devframe/src/in-page-channel/panel.ts @@ -65,7 +65,7 @@ export function connectPanelChannel

( const registry = createLocalFunctionRegistry(codec) for (const [fnName, definition] of Object.entries(options.functions)) registry.register({ ...definition, name: fnName }) - for (const [eventName, definition] of Object.entries(options.events)) + for (const [eventName, definition] of Object.entries(options.events ?? {})) registry.register({ ...definition, name: eventName, type: 'event' }) let status: InPageChannelStatus = 'connecting' 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 4da152921..4704144b0 100644 --- a/packages/devframe/src/in-page-channel/types.test-d.ts +++ b/packages/devframe/src/in-page-channel/types.test-d.ts @@ -178,7 +178,6 @@ describe('In-page script channel', () => { it('rejects fire-and-forget calls to panel queries', () => { const mixedChannel = createPageScriptChannel({ - events: {}, name: 'devframes:mixed-panel', functions: {}, }) @@ -204,7 +203,6 @@ describe('In-page script channel', () => { it('rejects calls when the protocol declares no panel functions', () => { const pageScriptOnlyChannel = createPageScriptChannel({ - events: {}, name: 'devframes:page-script-only', functions: { echo: { handler: value => value }, @@ -338,13 +336,11 @@ describe('Panel channel', () => { it('accepts an explicitly empty panel function map', () => { connectPanelChannel({ - events: {}, name: 'devframes:page-script-only', functions: {}, }) connectPanelChannel({ - events: {}, name: 'devframes:page-script-only', functions: { // @ts-expect-error The protocol has no panel functions. diff --git a/packages/devframe/src/in-page-channel/types.ts b/packages/devframe/src/in-page-channel/types.ts index 90e763407..ec3e044ef 100644 --- a/packages/devframe/src/in-page-channel/types.ts +++ b/packages/devframe/src/in-page-channel/types.ts @@ -253,8 +253,8 @@ interface InPageChannelCommonOptions { export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions { /** Every page-script function declaration, with a required handler. */ functions: CreatePageScriptChannelOptionsFunctions - /** Every incoming event declaration; handlers may subscribe through `channel.on()`. */ - events: { [NAME in keyof PageScriptProtocolEvents & string]: InPageEventOption[NAME]> } + /** Optional metadata or handlers for incoming events. Listeners may instead subscribe through `channel.on()`. */ + events?: { [NAME in keyof PageScriptProtocolEvents & string]?: InPageEventOption[NAME]> } /** * Window whose `message` events carry panel hellos. Defaults to the * global `window`; pass `false` to skip the handshake listener entirely @@ -267,8 +267,8 @@ export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions { /** Every panel function declaration, with a required handler. */ functions: ConnectPanelChannelOptionsFunctions - /** Every incoming event declaration; handlers may subscribe through `channel.on()`. */ - events: { [NAME in keyof PanelProtocolEvents & string]: InPageEventOption[NAME]> } + /** Optional metadata or handlers for incoming events. Listeners may instead subscribe through `channel.on()`. */ + events?: { [NAME in keyof PanelProtocolEvents & string]?: InPageEventOption[NAME]> } /** * The panel's own window (listens for the handshake grant). Defaults to * the global `window`; pass `false` with `transport` to skip the handshake. From 003dbacfa279a2f38c2992f14e838ee1cc441ad1 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 9 Sep 2026 14:43:07 +0200 Subject: [PATCH 05/14] chore: add back diagnostic --- docs/content/6.errors/DF0077.md | 26 +++++++++++++++++++ docs/content/6.errors/index.md | 1 + .../src/in-page-channel/diagnostics.ts | 11 ++++++++ .../in-page-channel/in-page-channel.test.ts | 7 +++-- .../devframe/src/in-page-channel/internal.ts | 11 +++++++- 5 files changed, 53 insertions(+), 3 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..e7205b076 --- /dev/null +++ b/docs/content/6.errors/DF0077.md @@ -0,0 +1,26 @@ +--- +title: 'DF0077: In-Page Channel Function Not Registered' +description: 'An in-page channel call names a function that is not registered on its endpoint.' +--- + +## Message + +> In-page channel function "{name}" is not registered on this endpoint. + +## Cause + +An in-page channel call named a function absent from the receiving endpoint's required `functions` option. + +## Example + +```ts +await channel.call('missing' as any) // ✗ throws DF0077 +``` + +## Fix + +Declare the function in the receiving endpoint's protocol side and `functions` option. + +## 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().resolve()` throws this when no local function definition matches the call 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..e5dba6346 --- /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.', + }, + }, +}) 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 c41b8ac7a..7fc4a19e0 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 @@ -229,13 +229,16 @@ describe('in-page channel over bring-your-own ports', () => { } }) - it('rejects calls to unknown functions', async () => { + it('rejects calls to unknown functions with a coded diagnostic', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { panel, dispose } = createLinkedPair() try { - await expect(panel.call('missing' as any)).rejects.toThrow(/not found/) + await expect(panel.call('missing' as any)).rejects.toThrow('In-page channel function "missing" is not registered') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[DF0077]')) } finally { dispose() + warn.mockRestore() } }) diff --git a/packages/devframe/src/in-page-channel/internal.ts b/packages/devframe/src/in-page-channel/internal.ts index 5022ec4bd..664ab2f61 100644 --- a/packages/devframe/src/in-page-channel/internal.ts +++ b/packages/devframe/src/in-page-channel/internal.ts @@ -4,6 +4,7 @@ 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' /** @@ -162,6 +163,8 @@ export function channelMethod(kind: 'function' | 'event', name: string): string return `devframe:in-page:${kind}:${name}` } +const FUNCTION_METHOD_PREFIX = channelMethod('function', '') + /** * An endpoint's local function table, resolved by name when the remote side * calls in. Each handler is wrapped with the receive pipeline: deserialize @@ -196,8 +199,14 @@ export function createLocalFunctionRegistry(codec: InPageChannelSerialization): resolve(name) { const definition = definitions.get(name) const registered = listeners.get(name) - if (!definition && !registered?.size) + if (!definition && !registered?.size) { + if (name.startsWith(FUNCTION_METHOD_PREFIX)) { + return () => { + throw diagnostics.DF0077({ name: name.slice(FUNCTION_METHOD_PREFIX.length) }) + } + } return undefined + } return async (...rawArgs: unknown[]) => { const args = codec.deserialize ? rawArgs.map(codec.deserialize) : rawArgs if (definition?.jsonSerializable) From d00bf1b0f6be67cc96f4465a7fb377e28afed248 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 9 Sep 2026 14:53:58 +0200 Subject: [PATCH 06/14] refactor: simlpify --- .../in-page-channel/in-page-channel.test.ts | 72 +++---------------- .../devframe/src/in-page-channel/internal.ts | 7 +- .../src/in-page-channel/types.test-d.ts | 8 --- 3 files changed, 15 insertions(+), 72 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 7fc4a19e0..ae04f4bbb 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 @@ -146,64 +146,38 @@ describe('in-page channel over bring-your-own ports', () => { await expect(panel.call('hang')).rejects.toMatchObject({ code: 'timeout' }) }) - it('keeps same-named functions and events independent in both directions', async ({ onTestFinished }) => { + it('keeps same-named functions and events independent', async ({ onTestFinished }) => { interface Protocol { - functions: { pageScript: { save: () => void }, panel: { save: () => void } } - events: { pageScript: { save: (value: string) => void }, panel: { save: (value: string) => void } } + functions: { pageScript: { save: () => void } } + events: { pageScript: { save: (value: string) => void } } } const pageAction = vi.fn() - const panelAction = vi.fn() - const pageEvent = vi.fn() - const panelEvent = vi.fn() const pageListener = vi.fn() - const panelListener = vi.fn() const pageScript = createPageScriptChannel({ name: 'test', ...noHandshake, functions: { save: { type: 'action', handler: pageAction } }, - events: { save: { handler: pageEvent } }, }) const { port1, port2 } = new MessageChannel() - const peer = pageScript.addPanelPort(port1) + pageScript.addPanelPort(port1) const panel = connectPanelChannel({ name: 'test', ...noHandshake, transport: port2, - functions: { save: { type: 'action', handler: panelAction } }, - events: { save: { handler: panelEvent } }, + functions: {}, }) onTestFinished(() => { panel.close() pageScript.close() }) const offPage = pageScript.on('save', pageListener) - const offPanel = panel.on('save', panelListener) await panel.call('save') - await peer.call('save') - expect(pageEvent).not.toHaveBeenCalled() - expect(panelEvent).not.toHaveBeenCalled() expect(pageListener).not.toHaveBeenCalled() - expect(panelListener).not.toHaveBeenCalled() panel.emit('save', 'draft') - pageScript.callEvent('save', 'saved') - await until(() => pageListener.mock.calls.length === 1 && panelListener.mock.calls.length === 1) - expect(pageEvent).toHaveBeenCalledWith('draft') - expect(panelEvent).toHaveBeenCalledWith('saved') + await until(() => pageListener.mock.calls.length === 1) + expect(pageListener).toHaveBeenCalledWith('draft') offPage() - offPanel() - panel.callEvent('save', 'again') - pageScript.emit('save', 'again') - await until(() => pageEvent.mock.calls.length === 2 && panelEvent.mock.calls.length === 2) - expect(pageListener).toHaveBeenCalledOnce() - expect(panelListener).toHaveBeenCalledOnce() expect(pageAction).toHaveBeenCalledOnce() - expect(panelAction).toHaveBeenCalledOnce() - }) - - it('keeps untyped runtime subscriptions isolated from functions', ({ onTestFinished }) => { - const { pageScript, dispose } = createLinkedPair() - onTestFinished(dispose) - expect(() => pageScript.on('boom' as any, () => {})).not.toThrow() }) it('round-trips calls, arguments, and results', async () => { @@ -280,7 +254,6 @@ describe('in-page channel over bring-your-own ports', () => { const { s } = await import('devframe/utils/simple-schema') const { port1, port2 } = new MessageChannel() const pageScript = createPageScriptChannel({ - events: { note: {} }, name: 'devframes:test', ...noHandshake, functions: { @@ -294,7 +267,6 @@ describe('in-page channel over bring-your-own ports', () => { }) pageScript.addPanelPort(port1) const panel = connectPanelChannel({ - events: { notify: {} }, name: 'devframes:test', ...noHandshake, transport: port2, @@ -315,7 +287,6 @@ describe('in-page channel over bring-your-own ports', () => { const a = new MessageChannel() const b = new MessageChannel() const pageScript = createPageScriptChannel({ - events: { note: {} }, name: 'devframes:test', ...noHandshake, functions: defaultPageScriptFunctions, @@ -324,7 +295,6 @@ describe('in-page channel over bring-your-own ports', () => { pageScript.addPanelPort(b.port1) const received: string[] = [] const panelA = connectPanelChannel({ - events: { notify: {} }, name: 'devframes:test', ...noHandshake, transport: a.port2, @@ -361,14 +331,12 @@ describe('in-page channel over bring-your-own ports', () => { it('lets the page script call one panel through its peer handle', async () => { const { port1, port2 } = new MessageChannel() const pageScript = createPageScriptChannel({ - events: { note: {} }, name: 'devframes:test', ...noHandshake, functions: defaultPageScriptFunctions, }) pageScript.addPanelPort(port1) const panel = connectPanelChannel({ - events: { notify: {} }, name: 'devframes:test', ...noHandshake, transport: port2, @@ -387,14 +355,12 @@ describe('in-page channel over bring-your-own ports', () => { it('applies serialize/deserialize hooks to arguments and results', async () => { const { port1, port2 } = new MessageChannel() const pageScript = createPageScriptChannel({ - events: { note: {} }, name: 'devframes:test', ...noHandshake, functions: defaultPageScriptFunctions, }) pageScript.addPanelPort(port1) const panel = connectPanelChannel({ - events: { notify: {} }, name: 'devframes:test', ...noHandshake, transport: port2, @@ -417,7 +383,6 @@ describe('in-page channel over bring-your-own ports', () => { it('notifies the page script of panel lifecycle', async () => { const { port1, port2 } = new MessageChannel() const pageScript = createPageScriptChannel({ - events: { note: {} }, name: 'devframes:test', ...noHandshake, functions: defaultPageScriptFunctions, @@ -428,7 +393,6 @@ describe('in-page channel over bring-your-own ports', () => { pageScript.events.on('panel:disconnected', peer => disconnected.push(peer.id)) pageScript.addPanelPort(port1) const panel = connectPanelChannel({ - events: { notify: {} }, name: 'devframes:test', ...noHandshake, transport: port2, @@ -510,15 +474,14 @@ describe('in-page channel shared state', () => { const a = new MessageChannel() const b = new MessageChannel() const pageScript = createPageScriptChannel({ - events: { note: {} }, name: 'devframes:test', ...noHandshake, functions: defaultPageScriptFunctions, }) pageScript.addPanelPort(a.port1) pageScript.addPanelPort(b.port1) - const panelA = connectPanelChannel({ events: { notify: {} }, name: 'devframes:test', ...noHandshake, transport: a.port2, functions: defaultPanelFunctions }) - const panelB = connectPanelChannel({ events: { notify: {} }, name: 'devframes:test', ...noHandshake, transport: b.port2, functions: defaultPanelFunctions }) + const panelA = connectPanelChannel({ name: 'devframes:test', ...noHandshake, transport: a.port2, functions: defaultPanelFunctions }) + const panelB = connectPanelChannel({ name: 'devframes:test', ...noHandshake, transport: b.port2, functions: defaultPanelFunctions }) try { const authority = await pageScript.sharedState.get('doc', { initialValue: { count: 0 } }) const mirrorA = await panelA.sharedState.get('doc') @@ -539,7 +502,7 @@ describe('in-page channel shared state', () => { it('seeds a late-joining panel with the current value', async () => { const { port1, port2 } = new MessageChannel() - const pageScript = createPageScriptChannel({ events: { note: {} }, name: 'devframes:test', ...noHandshake, functions: defaultPageScriptFunctions }) + const pageScript = createPageScriptChannel({ name: 'devframes:test', ...noHandshake, functions: defaultPageScriptFunctions }) const authority = await pageScript.sharedState.get('doc', { initialValue: { count: 0 } }) authority.mutate((draft) => { draft.count = 41 @@ -549,7 +512,7 @@ describe('in-page channel shared state', () => { }) pageScript.addPanelPort(port1) - const panel = connectPanelChannel({ events: { notify: {} }, name: 'devframes:test', ...noHandshake, transport: port2, functions: defaultPanelFunctions }) + const panel = connectPanelChannel({ name: 'devframes:test', ...noHandshake, transport: port2, functions: defaultPanelFunctions }) try { const mirror = await panel.sharedState.get('doc') expect(mirror.value()).toEqual({ count: 42 }) @@ -643,14 +606,12 @@ describe('in-page channel handshake', () => { it('connects a panel to the page script and survives page-script restarts', async () => { const { hostWin, panelWin } = createWindowPair() const pageScript = createPageScriptChannel({ - events: { note: {} }, name: 'devframes:test', window: asWindow(hostWin), heartbeat: false, functions: defaultPageScriptFunctions, }) const panel = connectPanelChannel({ - events: { notify: {} }, name: 'devframes:test', window: asWindow(panelWin), targets: [asWindow(hostWin)], @@ -668,7 +629,6 @@ describe('in-page channel handshake', () => { // … and a fresh one boots in the same window: the panel re-handshakes. const revived = createPageScriptChannel({ - events: { note: {} }, name: 'devframes:test', window: asWindow(hostWin), heartbeat: false, @@ -695,7 +655,6 @@ describe('in-page channel handshake', () => { const { hostWin, panelWin } = createWindowPair() const noted: string[] = [] const panel = connectPanelChannel({ - events: { notify: {} }, name: 'devframes:test', window: asWindow(panelWin), targets: [asWindow(hostWin)], @@ -706,7 +665,6 @@ describe('in-page channel handshake', () => { panel.emit('note', 'buffered') const pageScript = createPageScriptChannel({ - events: { note: {} }, name: 'devframes:test', window: asWindow(hostWin), heartbeat: false, @@ -728,7 +686,6 @@ describe('in-page channel handshake', () => { const { hostWin, panelWin } = createWindowPair() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const pageScript = createPageScriptChannel({ - events: { note: {} }, name: 'devframes:test-origin', window: asWindow(hostWin), heartbeat: false, @@ -760,7 +717,6 @@ describe('in-page channel handshake', () => { const { hostWin, panelWin } = createWindowPair() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const pageScript = createPageScriptChannel({ - events: { note: {} }, name: 'devframes:test-version', window: asWindow(hostWin), heartbeat: false, @@ -791,14 +747,12 @@ describe('in-page channel handshake', () => { it('honors an instance pin', async () => { const { hostWin, panelWin } = createWindowPair() const pageScript = createPageScriptChannel({ - events: { note: {} }, name: 'devframes:test', window: asWindow(hostWin), heartbeat: false, functions: defaultPageScriptFunctions, }) const pinnedElsewhere = connectPanelChannel({ - events: { notify: {} }, name: 'devframes:test', window: asWindow(panelWin), targets: [asWindow(hostWin)], @@ -810,7 +764,6 @@ describe('in-page channel handshake', () => { await expect(pinnedElsewhere.whenConnected(100)).rejects.toMatchObject({ code: 'timeout' }) const pinnedHere = connectPanelChannel({ - events: { notify: {} }, name: 'devframes:test', window: asWindow(panelWin), targets: [asWindow(hostWin)], @@ -834,7 +787,6 @@ describe('in-page channel handshake', () => { it('stays connecting and warns when the panel has nowhere to handshake', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const lonely = connectPanelChannel({ - events: { notify: {} }, name: `devframes:test-lonely-${Math.random()}`, window: false, heartbeat: false, @@ -853,7 +805,6 @@ describe('in-page channel handshake', () => { it('rejects buffered calls with a status-aware timeout', async () => { const lonely = connectPanelChannel({ - events: { notify: {} }, name: `devframes:test-lonely-${Math.random()}`, window: false, heartbeat: false, @@ -873,7 +824,6 @@ describe('in-page channel handshake', () => { it('rejects pending work when the channel closes', async () => { const lonely = connectPanelChannel({ - events: { notify: {} }, name: `devframes:test-lonely-${Math.random()}`, window: false, heartbeat: false, diff --git a/packages/devframe/src/in-page-channel/internal.ts b/packages/devframe/src/in-page-channel/internal.ts index 664ab2f61..c5f283bfe 100644 --- a/packages/devframe/src/in-page-channel/internal.ts +++ b/packages/devframe/src/in-page-channel/internal.ts @@ -2,7 +2,7 @@ import type { StandardSchemaV1 } from '@standard-schema/spec' import type { BirpcReturn } from 'birpc' import type { RpcArgsSchema } from '../rpc/types' import type { InPageChannelControlFrame } from './protocol' -import type { InPageFunctionDefinitionAny } from './types' +import type { InPageFunctionDefinitionAny, InPageFunctionType } from './types' import { createBirpc } from 'birpc' import { diagnostics } from './diagnostics' import { isControlFrame } from './protocol' @@ -158,8 +158,9 @@ export function deserializeResult(codec: InPageChannelSerialization, result: unk return codec.deserialize && result !== undefined ? codec.deserialize(result) : result } -export function channelMethod(kind: 'function' | 'event', name: string): string { +export function channelMethod(type: InPageFunctionType | 'function' | undefined, name: string): string { // Keep user functions, user events, and internal methods in separate wire namespaces. + const kind = type === 'event' ? 'event' : 'function' return `devframe:in-page:${kind}:${name}` } @@ -180,7 +181,7 @@ export function createLocalFunctionRegistry(codec: InPageChannelSerialization): const listeners = new Map void>>() return { register(definition) { - definitions.set(channelMethod(definition.type === 'event' ? 'event' : 'function', definition.name), definition) + definitions.set(channelMethod(definition.type, definition.name), definition) }, on(name, listener) { const key = channelMethod('event', name) 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 4704144b0..16d718baa 100644 --- a/packages/devframe/src/in-page-channel/types.test-d.ts +++ b/packages/devframe/src/in-page-channel/types.test-d.ts @@ -252,7 +252,6 @@ describe('In-page script channel', () => { describe('Panel channel', () => { const channel = connectPanelChannel({ - events: { notify: {} }, name: 'devframes:test', functions: { notify: { handler: () => { } }, @@ -263,7 +262,6 @@ describe('Panel channel', () => { it('infers handlers from the protocol', () => { const { port1 } = new MessageChannel() const inferredChannel = connectPanelChannel({ - events: { notify: {} }, name: 'devframes:test', window: false, transport: port1, @@ -283,7 +281,6 @@ describe('Panel channel', () => { connectPanelChannel({ name: 'devframes:test' }) connectPanelChannel({ - events: { notify: {} }, name: 'devframes:test', // @ts-expect-error `notify` must be declared. functions: {}, @@ -292,7 +289,6 @@ describe('Panel channel', () => { it('allows event declarations to omit their handler', () => { connectPanelChannel({ - events: { notify: {} }, name: 'devframes:test', functions: { notify: { handler: () => {} }, @@ -300,7 +296,6 @@ describe('Panel channel', () => { }) connectPanelChannel({ - events: { notify: {} }, name: 'devframes:test', functions: { // @ts-expect-error Request/response functions require a handler. @@ -311,7 +306,6 @@ describe('Panel channel', () => { it('rejects in-page script functions', () => { connectPanelChannel({ - events: { notify: {} }, name: 'devframes:test', functions: { notify: { handler: () => { } }, @@ -323,7 +317,6 @@ describe('Panel channel', () => { it('rejects incompatible handlers', () => { connectPanelChannel({ - events: { notify: {} }, name: 'devframes:test', functions: { notify: { @@ -405,7 +398,6 @@ describe('Panel channel', () => { it('rejects runtime subscriptions to panel queries', () => { const mixedChannel = connectPanelChannel({ - events: { notify: {} }, name: 'devframes:mixed-panel', functions: { confirm: { handler: () => true }, From c8dcb6b17ea9fe274beee3ac2985dcbbc6610190 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 9 Sep 2026 15:03:09 +0200 Subject: [PATCH 07/14] refactor: simplify in-page channel event types --- .../src/in-page-channel/events.test-d.ts | 8 ------- .../devframe/src/in-page-channel/types.ts | 18 +++++++--------- .../devframe/in-page-channel.snapshot.d.ts | 21 +++++++++---------- 3 files changed, 18 insertions(+), 29 deletions(-) diff --git a/packages/devframe/src/in-page-channel/events.test-d.ts b/packages/devframe/src/in-page-channel/events.test-d.ts index 28be5f4df..44a149b28 100644 --- a/packages/devframe/src/in-page-channel/events.test-d.ts +++ b/packages/devframe/src/in-page-channel/events.test-d.ts @@ -46,11 +46,3 @@ it('distinguishes void actions from declared events in both directions', () => { // @ts-expect-error Functions cannot receive event listeners. panel.on('reset', () => {}) }) - -it('supports omitted protocol sections without widening their keys', () => { - interface FunctionsOnly { functions: { pageScript: { run: () => void } } } - interface EventsOnly { events: { panel: { ready: () => void } } } - expectTypeOf['emit']>[0]>().toEqualTypeOf() - expectTypeOf['call']>[0]>().toEqualTypeOf() - expectTypeOf['on']>[0]>().toEqualTypeOf() -}) diff --git a/packages/devframe/src/in-page-channel/types.ts b/packages/devframe/src/in-page-channel/types.ts index ec3e044ef..3a72652a1 100644 --- a/packages/devframe/src/in-page-channel/types.ts +++ b/packages/devframe/src/in-page-channel/types.ts @@ -42,8 +42,6 @@ type PanelFunctions

= SideDeclarations = P['sharedStates'] extends Record ? P['sharedStates'] : Record -type FunctionNames = { [K in keyof T]: [T[K]] extends [never] ? never : K }[keyof T] & string - type FnArgs = F extends (...args: infer A) => any ? A : never type FnReturn = F extends (...args: any[]) => infer R ? Awaited : never @@ -328,7 +326,7 @@ export interface PanelPeer

{ /** Unique id of the panel endpoint (stable across its lifetime, not reloads). */ readonly id: string /** Call one panel's function and await the result. */ - call: >>( + call: & string>( name: K, ...args: FnArgs[K]> ) => Promise[K]>> @@ -353,17 +351,17 @@ export interface PageScriptChannel

{ readonly panels: readonly PanelPeer

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

{ * the call is buffered and sent on connect; it rejects with code * `timeout` when `callTimeoutMs` elapses first. */ - call: >>( + call: & string>( name: K, ...args: FnArgs[K]> ) => Promise[K]>> @@ -410,17 +408,17 @@ export interface PanelChannel

{ * Emit an event to the page script. While `connecting` the event is buffered * (up to `eventBufferLimit`) and flushed on connect. */ - emit: >>( + emit: & string>( name: K, ...args: FnArgs[K]> ) => void /** @deprecated Use `emit()` instead. */ - callEvent: >>( + callEvent: & string>( name: K, ...args: FnArgs[K]> ) => void /** Subscribe to an event emitted by the page script. Returns an unsubscribe function. */ - on: >>( + on: & string>( name: K, listener: (...args: FnArgs[K]>) => void, ) => () => void 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 f99459be4..65c7216eb 100644 --- a/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts @@ -4,7 +4,7 @@ // #region Interfaces export interface ConnectPanelChannelOptions extends InPageChannelCommonOptions { functions: ConnectPanelChannelOptionsFunctions; - events: { [NAME in keyof PanelProtocolEvents & string]: InPageEventOption[NAME]>; }; + events?: { [NAME in keyof PanelProtocolEvents & string]?: InPageEventOption[NAME]>; }; window?: Window | false; targets?: Window[]; transport?: MessagePort; @@ -14,7 +14,7 @@ export interface ConnectPanelChannelOptions extends InPageChannelCommonOptions { functions: CreatePageScriptChannelOptionsFunctions; - events: { [NAME in keyof PageScriptProtocolEvents & string]: InPageEventOption[NAME]>; }; + events?: { [NAME in keyof PageScriptProtocolEvents & string]?: InPageEventOption[NAME]>; }; window?: Window | false; } export interface InPageChannelProtocol { @@ -33,9 +33,9 @@ export interface PageScriptChannel

{ readonly instanceId: string; readonly panels: readonly PanelPeer

[]; readonly events: Pick>, 'on' | 'once'>; - emit: >>(_: K, ..._: FnArgs[K]>) => void; - callEvent: >>(_: K, ..._: FnArgs[K]>) => void; - on: >>(_: K, _: (..._: FnArgs[K]>) => void) => () => 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

; close: () => void; @@ -48,16 +48,16 @@ export interface PanelChannel

{ } | undefined; readonly events: Pick, 'on' | 'once'>; whenConnected: (_?: number) => Promise; - call: >>(_: K, ..._: FnArgs[K]>) => Promise[K]>>; - emit: >>(_: K, ..._: FnArgs[K]>) => void; - callEvent: >>(_: K, ..._: FnArgs[K]>) => void; - on: >>(_: K, _: (..._: FnArgs[K]>) => void) => () => void; + 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; } export interface PanelPeer

{ readonly id: string; - call: >>(_: K, ..._: FnArgs[K]>) => Promise[K]>>; + call: & string>(_: K, ..._: FnArgs[K]>) => Promise[K]>>; close: () => void; } // #endregion @@ -94,7 +94,6 @@ type ConnectPanelChannelOptionsFunctions

= { [N 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; -type FunctionNames = { [K in keyof T]: [T[K]] extends [never] ? never : K; }[keyof T] & string; interface InPageChannelCommonOptions { name: string; allowedOrigins?: string[]; From 8f02d5c9522670d0ab62f088d51a1f7bd6c06b6f Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 9 Sep 2026 15:07:27 +0200 Subject: [PATCH 08/14] refactor: simplify --- .../devframe/src/in-page-channel/types.ts | 32 +++++++++---------- .../devframe/in-page-channel.snapshot.d.ts | 20 ++++++------ 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/devframe/src/in-page-channel/types.ts b/packages/devframe/src/in-page-channel/types.ts index 3a72652a1..1da339e0d 100644 --- a/packages/devframe/src/in-page-channel/types.ts +++ b/packages/devframe/src/in-page-channel/types.ts @@ -45,8 +45,8 @@ type SharedStates

type FnArgs = F extends (...args: infer A) => any ? A : never type FnReturn = F extends (...args: any[]) => infer R ? Awaited : never -type PageScriptProtocolEvents

= SideDeclarations -type PanelProtocolEvents

= SideDeclarations +type PageScriptEvents

= SideDeclarations +type PanelEvents

= SideDeclarations /** * Converts a protocol function to its accepted endpoint handler. @@ -252,7 +252,7 @@ export interface CreatePageScriptChannelOptions /** Optional metadata or handlers for incoming events. Listeners may instead subscribe through `channel.on()`. */ - events?: { [NAME in keyof PageScriptProtocolEvents & string]?: InPageEventOption[NAME]> } + events?: { [NAME in keyof PageScriptEvents & string]?: InPageEventOption[NAME]> } /** * Window whose `message` events carry panel hellos. Defaults to the * global `window`; pass `false` to skip the handshake listener entirely @@ -266,7 +266,7 @@ export interface ConnectPanelChannelOptions /** Optional metadata or handlers for incoming events. Listeners may instead subscribe through `channel.on()`. */ - events?: { [NAME in keyof PanelProtocolEvents & string]?: InPageEventOption[NAME]> } + events?: { [NAME in keyof PanelEvents & string]?: InPageEventOption[NAME]> } /** * The panel's own window (listens for the handshake grant). Defaults to * the global `window`; pass `false` with `transport` to skip the handshake. @@ -351,19 +351,19 @@ 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>( + 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

@@ -408,19 +408,19 @@ 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>( + 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 65c7216eb..5c8b85f6a 100644 --- a/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts @@ -4,7 +4,7 @@ // #region Interfaces export interface ConnectPanelChannelOptions extends InPageChannelCommonOptions { functions: ConnectPanelChannelOptionsFunctions; - events?: { [NAME in keyof PanelProtocolEvents & string]?: InPageEventOption[NAME]>; }; + events?: { [NAME in keyof PanelEvents & string]?: InPageEventOption[NAME]>; }; window?: Window | false; targets?: Window[]; transport?: MessagePort; @@ -14,7 +14,7 @@ export interface ConnectPanelChannelOptions extends InPageChannelCommonOptions { functions: CreatePageScriptChannelOptionsFunctions; - events?: { [NAME in keyof PageScriptProtocolEvents & string]?: InPageEventOption[NAME]>; }; + events?: { [NAME in keyof PageScriptEvents & string]?: InPageEventOption[NAME]>; }; window?: Window | false; } export interface InPageChannelProtocol { @@ -33,9 +33,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; + 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; @@ -49,9 +49,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; + emit: & string>(_: K, ..._: FnArgs[K]>) => void; + callEvent: & string>(_: K, ..._: FnArgs[K]>) => void; + on: & string>(_: K, _: (..._: FnArgs[K]>) => void) => () => void; readonly sharedState: InPageSharedStateHost

; close: () => void; } @@ -125,11 +125,11 @@ interface PageScriptChannelEvents

{ 'panel:connected': (_: PanelPeer

) => void; 'panel:disconnected': (_: PanelPeer

) => void; } +type PageScriptEvents

= SideDeclarations; type PageScriptFunctions

= SideDeclarations; -type PageScriptProtocolEvents

= SideDeclarations; interface PanelChannelEvents { 'status:updated': (_: InPageChannelStatus) => void; } +type PanelEvents

= SideDeclarations; type PanelFunctions

= SideDeclarations; -type PanelProtocolEvents

= SideDeclarations; // #endregion \ No newline at end of file From 9894d40c6e1711c3edab8977c984f7a874e7a82e Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 9 Sep 2026 15:10:43 +0200 Subject: [PATCH 09/14] docs: clarify unregistered in-page functions --- docs/content/6.errors/DF0077.md | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/content/6.errors/DF0077.md b/docs/content/6.errors/DF0077.md index e7205b076..fb14329f4 100644 --- a/docs/content/6.errors/DF0077.md +++ b/docs/content/6.errors/DF0077.md @@ -9,17 +9,43 @@ description: 'An in-page channel call names a function that is not registered on ## Cause -An in-page channel call named a function absent from the receiving endpoint's required `functions` option. +The two endpoints disagree about their channel contract. The calling endpoint names a function that the receiving endpoint did not register in its `functions` option. This usually means the page script and panel use different protocol declarations or incompatible devframe versions. ## Example ```ts -await channel.call('missing' as any) // ✗ throws DF0077 +import { connectPanelChannel, createPageScriptChannel } from 'devframe/in-page-channel' + +interface PanelProtocol { + functions: { + pageScript: { + inspect: () => void + } + } +} + +const { port1, port2 } = new MessageChannel() + +const pageScript = createPageScriptChannel({ + name: 'devframes:example', + window: false, + functions: {}, +}) +pageScript.addPanelPort(port1) + +const panel = connectPanelChannel({ + name: 'devframes:example', + window: false, + transport: port2, + functions: {}, +}) + +await panel.call('inspect') // ✗ The page script did not register `inspect`. ``` ## Fix -Declare the function in the receiving endpoint's protocol side and `functions` option. +Import one shared protocol declaration into both endpoints, then register every function from the receiving side of that protocol in its `functions` option. ## Source From 206ac8e1588ab3330a981130100c65499a3cb447 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 9 Sep 2026 15:53:47 +0200 Subject: [PATCH 10/14] Fix formatting in page script endpoint documentation [skip ci] Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/content/1.guide/12.in-page-channel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/1.guide/12.in-page-channel.md b/docs/content/1.guide/12.in-page-channel.md index 02a0f4b61..44f9f0b95 100644 --- a/docs/content/1.guide/12.in-page-channel.md +++ b/docs/content/1.guide/12.in-page-channel.md @@ -64,7 +64,7 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names ## The page script endpoint -The required `functions` and `events` options declare every incoming name on the endpoint's protocol side; use `{}` for an empty direction. Functions require a `handler`. Events accept an optional `handler`, and `{}` registers an event for runtime subscriptions through `on()`. Handlers are contextually typed from the shared protocol and support Standard-Schema argument validation and `jsonSerializable` metadata. `defineChannelFunction` retains the named definition shape for lower-level authoring. +The required `functions` option and optional `events` option declare every incoming name on the endpoint's protocol side; use `{}` for an empty direction. Functions require a `handler`. Events accept an optional `handler`, and `{}` registers an event for runtime subscriptions through `on()`. Handlers are contextually typed from the shared protocol and support Standard-Schema argument validation and `jsonSerializable` metadata. `defineChannelFunction` retains the named definition shape for lower-level authoring. `call()` accepts names from `functions`, including actions returning `void` or `Promise`: callers can await completion and catch errors or timeouts. `emit()`, its deprecated alias `callEvent()`, and `on()` use the names declared in `events`. Function and event names have separate namespaces. From fbccf2882351c3fbc207af791f3708314ced596c Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 9 Sep 2026 15:54:33 +0200 Subject: [PATCH 11/14] Update InPageChannelProtocol description for clarity [skip ci] Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- 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 f3acf05ff..38dc018e9 100644 --- a/docs/content/8.references/5.browser-api.md +++ b/docs/content/8.references/5.browser-api.md @@ -50,7 +50,7 @@ The values of `rpc.status`: [Handling connection and auth errors](/guide/client# 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. -`InPageChannelProtocol` separates `functions` and `events`. Each section has optional `pageScript` and `panel` maps naming the receiving direction. Endpoint options require a complete `functions` map with handlers and a complete `events` map with optional handlers; use `{}` for empty maps. `call()` uses function names regardless of return type, while `emit()`, `callEvent()` (deprecated), and `on()` use event names. A function returning `void` or `Promise` remains an awaitable request/response call. +`InPageChannelProtocol` separates `functions` and `events`. Each section has optional `pageScript` and `panel` maps naming the receiving direction. Endpoint options require a complete `functions` map with handlers; `events` is optional, and when provided can include optional handlers (use `{}` to declare an event without a handler for `channel.on()`). `call()` uses function names regardless of return type, while `emit()`, `callEvent()` (deprecated), and `on()` use event names. A function returning `void` or `Promise` remains an awaitable request/response call. | Method or property | Page-script endpoint | Panel endpoint | |--------------------|-------------|-------| From ecb850eacf798f5a4b344d002b27699b6e7c8a51 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 9 Sep 2026 15:54:53 +0200 Subject: [PATCH 12/14] Clarify in-page channel documentation Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- skills/devframe/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index 0c42b2499..2e50fb483 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -438,7 +438,7 @@ Use `my.rpc.sharedState(key)` for observable state, `my.rpc.register(...)` to re ### In-page channel (page script ↔ panel, server-free) -For a live inspect-the-page loop, `devframe/in-page-channel` connects a devframe's **page script** (in the user app's page) to its **panels** entirely in the browser, in both dev and static builds. Declare a shared protocol type with separate `functions` and `events` sections, each with `pageScript` and `panel` maps naming the receiving direction. Both `createPageScriptChannel

()` and `connectPanelChannel

()` require `{ name, functions, events }`: function declarations require handlers; event declarations accept optional handlers or `{}` for dynamic `channel.on()` subscriptions. `call()` awaits functions, including void actions; `emit()` sends declared events. `channel.sharedState.get(key)` mirrors `rpc.sharedState` with the page script as authority and automatic replay to panels. The handshake retries across boot order and reloads; panels expose `status`/`whenConnected(ms)` for page-script availability fallbacks. Channel names follow `devframes:plugin:`. The a11y inspector's scan/highlight loop is the reference use. +For a live inspect-the-page loop, `devframe/in-page-channel` connects a devframe's **page script** (in the user app's page) to its **panels** entirely in the browser, in both dev and static builds. Declare a shared protocol type with separate `functions` and `events` sections, each with `pageScript` and `panel` maps naming the receiving direction. Both `createPageScriptChannel

()` and `connectPanelChannel

()` require `{ name, functions }` and accept optional `events`: function declarations require handlers; event declarations accept optional handlers or `{}` for dynamic `channel.on()` subscriptions. `call()` awaits functions, including void actions; `emit()` sends declared events. `channel.sharedState.get(key)` mirrors `rpc.sharedState` with the page script as authority and automatic replay to panels. The handshake retries across boot order and reloads; panels expose `status`/`whenConnected(ms)` for page-script availability fallbacks. Channel names follow `devframes:plugin:`. The a11y inspector's scan/highlight loop is the reference use. ## The Hub From ce7ddbd0e54a359f73c9ef22b84402e731ed53ef Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 9 Sep 2026 16:10:04 +0200 Subject: [PATCH 13/14] docs: improve comment [skip ci] --- docs/content/1.guide/12.in-page-channel.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/content/1.guide/12.in-page-channel.md b/docs/content/1.guide/12.in-page-channel.md index 44f9f0b95..339f7998c 100644 --- a/docs/content/1.guide/12.in-page-channel.md +++ b/docs/content/1.guide/12.in-page-channel.md @@ -117,7 +117,8 @@ const panelChannel = connectPanelChannel({ }) const offFlash = panelChannel.on('flash', message => showFlash(message)) -panelChannel.emit('highlight', '.hero') // received by the page-script endpoint +// defined and received by the page-script endpoint +panelChannel.emit('highlight', '.hero') const size = await panelChannel.call('measure', '.hero') await panelChannel.call('reset') From a20931f1fc822913a787cfdd898a862a640ca45a Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 9 Sep 2026 16:30:22 +0200 Subject: [PATCH 14/14] docs: simplify errors docs --- docs/content/6.errors/DF0077.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/content/6.errors/DF0077.md b/docs/content/6.errors/DF0077.md index fb14329f4..f709c6a78 100644 --- a/docs/content/6.errors/DF0077.md +++ b/docs/content/6.errors/DF0077.md @@ -24,19 +24,14 @@ interface PanelProtocol { } } -const { port1, port2 } = new MessageChannel() - const pageScript = createPageScriptChannel({ name: 'devframes:example', - window: false, functions: {}, }) pageScript.addPanelPort(port1) const panel = connectPanelChannel({ name: 'devframes:example', - window: false, - transport: port2, functions: {}, })