diff --git a/docs/content/1.guide/12.in-page-channel.md b/docs/content/1.guide/12.in-page-channel.md index 7209d759a..339f7998c 100644 --- a/docs/content/1.guide/12.in-page-channel.md +++ b/docs/content/1.guide/12.in-page-channel.md @@ -37,14 +37,22 @@ 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: { + /** 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 + } } - /** implemented by panels, callable by the page script */ - panel: { - flash: (message: string) => void + 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: { state: { selections: string[] } @@ -56,7 +64,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` 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. ```ts import type { MyChannelProtocol } from '../shared/protocol' @@ -67,11 +77,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 +85,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 +98,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 +110,17 @@ 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 +// defined and received by the page-script endpoint +panelChannel.emit('highlight', '.hero') const size = await panelChannel.call('measure', '.hero') +await panelChannel.call('reset') offFlash() // stop listening ``` @@ -162,6 +177,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 +189,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 +199,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..f709c6a78 100644 --- a/docs/content/6.errors/DF0077.md +++ b/docs/content/6.errors/DF0077.md @@ -1,6 +1,6 @@ --- title: 'DF0077: In-Page Channel Function Not Registered' -description: 'An in-page channel listener names a function that is not registered on its endpoint.' +description: 'An in-page channel call names a function that is not registered on its endpoint.' --- ## Message @@ -9,25 +9,39 @@ description: 'An in-page channel listener names a function that is not registere ## 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`. +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 -const channel = connectPanelChannel({ - name: MY_CHANNEL, +import { connectPanelChannel, createPageScriptChannel } from 'devframe/in-page-channel' + +interface PanelProtocol { functions: { - notify: { type: 'event' }, - }, + pageScript: { + inspect: () => void + } + } +} + +const pageScript = createPageScriptChannel({ + name: 'devframes:example', + functions: {}, +}) +pageScript.addPanelPort(port1) + +const panel = connectPanelChannel({ + name: 'devframes:example', + functions: {}, }) -channel.on('missing' as any, () => {}) // ✗ throws DF0077 +await panel.call('inspect') // ✗ The page script did not register `inspect`. ``` ## Fix -Declare the event in the endpoint's protocol side and `functions` option, then pass that declared name to `on()`. +Import one shared protocol declaration into both endpoints, then register every function from the receiving side of that protocol in its `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().on()` throws this when no local definition matches the listener name. +- [`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/8.references/5.browser-api.md b/docs/content/8.references/5.browser-api.md index ad46d400f..38dc018e9 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; `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 | |--------------------|-------------|-------| | `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..e5dba6346 100644 --- a/packages/devframe/src/in-page-channel/diagnostics.ts +++ b/packages/devframe/src/in-page-channel/diagnostics.ts @@ -5,7 +5,7 @@ export const diagnostics = /* #__PURE__ */ defineDiagnostics({ 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()`.', + fix: 'Declare the function in this endpoint\'s `functions` option.', }, }, }) 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..44a149b28 --- /dev/null +++ b/packages/devframe/src/in-page-channel/events.test-d.ts @@ -0,0 +1,48 @@ +import type { 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', () => {}) +}) 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..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 @@ -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?: { @@ -87,6 +89,97 @@ 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, + 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: {}, + 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', async ({ onTestFinished }) => { + interface Protocol { + functions: { pageScript: { save: () => void } } + events: { pageScript: { save: (value: string) => void } } + } + const pageAction = vi.fn() + const pageListener = vi.fn() + const pageScript = createPageScriptChannel({ + name: 'test', + ...noHandshake, + functions: { save: { type: 'action', handler: pageAction } }, + }) + const { port1, port2 } = new MessageChannel() + pageScript.addPanelPort(port1) + const panel = connectPanelChannel({ + name: 'test', + ...noHandshake, + transport: port2, + functions: {}, + }) + onTestFinished(() => { + panel.close() + pageScript.close() + }) + const offPage = pageScript.on('save', pageListener) + await panel.call('save') + expect(pageListener).not.toHaveBeenCalled() + panel.emit('save', 'draft') + await until(() => pageListener.mock.calls.length === 1) + expect(pageListener).toHaveBeenCalledWith('draft') + offPage() + expect(pageAction).toHaveBeenCalledOnce() + }) + it('round-trips calls, arguments, and results', async () => { const { pageScript, panel, dispose } = createLinkedPair() try { @@ -110,29 +203,23 @@ 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() } }) - it('reports and rejects listeners for unknown functions', ({ 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 () => { @@ -171,10 +258,10 @@ describe('in-page channel over bring-your-own ports', () => { ...noHandshake, functions: { ...defaultPageScriptFunctions, - note: { + echo: { args: [s.string()] as const, - returns: s.void(), - handler: () => {}, + returns: s.string(), + handler: value => value, }, }, }) @@ -186,8 +273,8 @@ describe('in-page channel over bring-your-own ports', () => { 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 { diff --git a/packages/devframe/src/in-page-channel/internal.ts b/packages/devframe/src/in-page-channel/internal.ts index e7bbf5b55..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,6 +158,14 @@ export function deserializeResult(codec: InPageChannelSerialization, result: unk return codec.deserialize && result !== undefined ? codec.deserialize(result) : result } +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}` +} + +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 @@ -173,28 +181,33 @@ export function createLocalFunctionRegistry(codec: InPageChannelSerialization): const listeners = new Map void>>() return { register(definition) { - definitions.set(definition.name, definition) + definitions.set(channelMethod(definition.type, definition.name), definition) }, on(name, listener) { - if (!definitions.has(name)) - throw diagnostics.DF0077({ name }) - let registered = listeners.get(name) + const key = channelMethod('event', 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) { 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) @@ -204,7 +217,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..8fec16a45 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..1ef2b3adc 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..16d718baa 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: { @@ -275,7 +291,7 @@ describe('Panel channel', () => { connectPanelChannel({ name: 'devframes:test', functions: { - notify: { type: 'event' }, + notify: { handler: () => {} }, }, }) @@ -385,7 +401,7 @@ describe('Panel channel', () => { 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..1da339e0d 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,22 @@ 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 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 PageScriptEvents

= SideDeclarations +type PanelEvents

= SideDeclarations /** * Converts a protocol function to its accepted endpoint handler. @@ -161,10 +162,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 +179,7 @@ interface InPageActionFunctionOption extends InPageFunctionOptionBase { } type InPageFunctionOption - = | InPageEventFunctionOption - | InPageQueryFunctionOption + = | InPageQueryFunctionOption | InPageActionFunctionOption /** @@ -247,8 +249,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 + /** Optional metadata or handlers for incoming events. Listeners may instead subscribe through `channel.on()`. */ + 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 @@ -259,8 +263,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 + /** Optional metadata or handlers for incoming events. Listeners may instead subscribe through `channel.on()`. */ + 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. @@ -345,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

@@ -402,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/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..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 — 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 }` 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 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..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,6 +4,7 @@ // #region Interfaces export interface ConnectPanelChannelOptions extends InPageChannelCommonOptions { functions: ConnectPanelChannelOptionsFunctions; + events?: { [NAME in keyof PanelEvents & 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 PageScriptEvents & 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: & 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; @@ -41,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; } @@ -97,6 +105,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 +125,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 PageScriptEvents

= SideDeclarations; +type PageScriptFunctions

= 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 PanelEvents

= SideDeclarations; +type PanelFunctions

= SideDeclarations; // #endregion \ No newline at end of file