diff --git a/docs/content/1.guide/12.in-page-channel.md b/docs/content/1.guide/12.in-page-channel.md index 9f0403613..7209d759a 100644 --- a/docs/content/1.guide/12.in-page-channel.md +++ b/docs/content/1.guide/12.in-page-channel.md @@ -37,11 +37,13 @@ import type { InPageChannelProtocol } from 'devframe/in-page-channel' export const MY_CHANNEL = 'devframes:plugin:my-tool' export interface MyChannelProtocol extends InPageChannelProtocol { - pageScript: { // implemented by the page script, called by panels + /** implemented by the page script, callable by panels */ + pageScript: { highlight: (selector: string) => void measure: (selector: string) => { width: number, height: number } } - panel: { // implemented by panels, called by the page script + /** implemented by panels, callable by the page script */ + panel: { flash: (message: string) => void } sharedStates: { @@ -54,7 +56,7 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names ## The page script endpoint -Functions use the same authoring metadata as `defineRpcFunction` (`type`, Standard-Schema `args`/`returns`, `jsonSerializable`, `handler`), narrowed to the browser. The required `functions` object's keys are the function names, and it implements every function on that endpoint's protocol side. Each handler is contextually typed from its key and the corresponding function in the protocol. `defineChannelFunction` retains the named definition shape for lower-level authoring. Define each side's functions in that side's source files; the shared protocol file carries only types. +The required `functions` object declares every function on that endpoint's protocol side, preserving a compile-time completeness check. Request/response declarations require a `handler`; an event declaration uses `type: 'event'`, and the receiving endpoint may provide an optional `handler` or subscribe at runtime with `on()`. Functions use the same Standard-Schema `args`/`returns` and `jsonSerializable` metadata as `defineRpcFunction`, narrowed to the browser. Each handler is contextually typed from its key and the corresponding protocol function. `defineChannelFunction` retains the named definition shape for lower-level authoring. Define each side's functions in that side's source files; the shared protocol file carries only types. ```ts import type { MyChannelProtocol } from '../shared/protocol' @@ -62,7 +64,7 @@ import type { MyChannelProtocol } from '../shared/protocol' import { createPageScriptChannel } from 'devframe/in-page-channel' import { MY_CHANNEL } from '../shared/protocol' -const channel = createPageScriptChannel({ +const pageChannel = createPageScriptChannel({ name: MY_CHANNEL, functions: { highlight: { @@ -79,12 +81,12 @@ const channel = createPageScriptChannel({ }, }) -channel.callEvent('flash', 'scanning…') // fans out to every connected panel -channel.events.on('panel:connected', panel => console.log(panel.id)) -channel.events.on('panel:disconnected', () => pauseWorkIfNobodyWatches()) +pageChannel.emit('flash', 'scanning…') // received by each panel endpoint +pageChannel.events.on('panel:connected', panel => console.log(panel.id)) +pageChannel.events.on('panel:disconnected', () => pauseWorkIfNobodyWatches()) ``` -`callEvent` on the page script is 1:N: it fans out to every connected panel, and panels that don't implement the function ignore it. Request/response *to* a panel goes through an explicit peer handle: `channel.panels[0].call('flash', '…')`. +`emit` on the page-script endpoint is 1:N: it fans out to every connected panel endpoint. Request/response *to* a panel goes through an explicit peer handle: `pageChannel.panels[0].call('flash', '…')`. ## The panel endpoint @@ -94,19 +96,22 @@ import type { MyChannelProtocol } from '../shared/protocol' import { connectPanelChannel } from 'devframe/in-page-channel' import { MY_CHANNEL } from '../shared/protocol' -const channel = connectPanelChannel({ +const panelChannel = connectPanelChannel({ name: MY_CHANNEL, functions: { - flash: { - handler: message => showFlash(message), - }, + flash: { type: 'event' }, }, }) -channel.callEvent('highlight', '.hero') // buffered until connected -const size = await channel.call('measure', '.hero') +const offFlash = panelChannel.on('flash', message => showFlash(message)) +panelChannel.emit('highlight', '.hero') // received by the page-script endpoint +const size = await panelChannel.call('measure', '.hero') + +offFlash() // stop listening ``` +The snippets form one channel pair: `pageChannel.emit('flash', …)` invokes `panelChannel.on('flash', …)`. In the other direction, `panelChannel.emit('highlight', …)` invokes the page-script endpoint's `highlight` handler and any matching `pageChannel.on()` listeners. An endpoint never receives its own emission. + ## Shared state The channel's shared-state layer mirrors [`rpc.sharedState`](/guide/shared-state) (same `SharedState` handle, same accessor), with the page script playing the server's role as rendezvous and authority. Its first `get` of a key must provide the initial value; panels are seeded automatically on connect (including late joiners and re-connects) and converge through syncId-deduplicated patches. @@ -133,7 +138,7 @@ Every failure mode is a coded `InPageChannelError` (`error.code`) with a message The panel endpoint's connection lifecycle is explicit, so a panel renders a useful fallback instead of hanging: - `channel.status` is `connecting` → `connected` → (`connecting` on port loss) → `closed`, with `events.on('status:updated', …)` for reactivity. -- While `connecting`, `call()` is queued (and still subject to its deadline) and `callEvent()` is buffered (up to `eventBufferLimit`, oldest dropped with a warning); both flush on connect. +- While `connecting`, `call()` is queued (and still subject to its deadline) and `emit()` is buffered (up to `eventBufferLimit`, oldest dropped with a warning); both flush on connect. - A page script may legitimately never appear (the panel opened standalone, the user app not instrumented). Race `whenConnected(timeoutMs)` to show a "load the page script" empty state: ```ts diff --git a/docs/content/6.errors/DF0077.md b/docs/content/6.errors/DF0077.md new file mode 100644 index 000000000..823cbd8bd --- /dev/null +++ b/docs/content/6.errors/DF0077.md @@ -0,0 +1,33 @@ +--- +title: 'DF0077: In-Page Channel Function Not Registered' +description: 'An in-page channel listener names a function that is not registered on its endpoint.' +--- + +## Message + +> In-page channel function "{name}" is not registered on this endpoint. + +## Cause + +`channel.on(name, listener)` received a name absent from that endpoint's required `functions` option. A page-script endpoint subscribes to functions declared under `pageScript`; a panel endpoint subscribes to functions declared under `panel`. + +## Example + +```ts +const channel = connectPanelChannel({ + name: MY_CHANNEL, + functions: { + notify: { type: 'event' }, + }, +}) + +channel.on('missing' as any, () => {}) // ✗ throws DF0077 +``` + +## Fix + +Declare the event in the endpoint's protocol side and `functions` option, then pass that declared name to `on()`. + +## Source + +- [`packages/devframe/src/in-page-channel/internal.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/in-page-channel/internal.ts): `createLocalFunctionRegistry().on()` throws this when no local definition matches the listener name. diff --git a/docs/content/6.errors/index.md b/docs/content/6.errors/index.md index b68d769cd..3bd55a16f 100644 --- a/docs/content/6.errors/index.md +++ b/docs/content/6.errors/index.md @@ -83,6 +83,7 @@ Emitted by `devframe`: the framework-neutral host, RPC, streaming, assets, servi | [DF0074](/errors/DF0074) | error | JSON-Render Schema Is Asynchronous | | [DF0075](/errors/DF0075) | warn | No RPC Transport On This Runtime | | [DF0076](/errors/DF0076) | error | WebSocket Upgrade Unsupported On This Runtime | +| [DF0077](/errors/DF0077) | error | In-Page Channel Function Not Registered | ## Hub: context & lifecycle (DF80xx) diff --git a/docs/content/8.references/5.browser-api.md b/docs/content/8.references/5.browser-api.md index c7e43f8d0..2a27be0e6 100644 --- a/docs/content/8.references/5.browser-api.md +++ b/docs/content/8.references/5.browser-api.md @@ -2,7 +2,7 @@ title: 'Browser-Side API' navigation: icon: i-lucide-globe -description: 'Lookup tables for the browser side: connectDevframe options, RPC client events, connection statuses, and in-page channel error codes.' +description: 'Lookup tables for the browser side: connectDevframe options, RPC client events, connection statuses, and in-page channels error codes.' --- Lookup tables for a devframe's browser side. Each section links the guide page that teaches the concept. @@ -45,6 +45,18 @@ The values of `rpc.status`: [Handling connection and auth errors](/guide/client# | `disconnected` | Socket closed (dropped mid-session or never opened). | | `error` | Fatal: the socket errored or connection meta couldn't load. | +## In-page channel endpoints + +The browser-only endpoint methods of the [in-page channel](/guide/in-page-channel). `emit()` sends to the opposite endpoint; `on()` handles events arriving from that endpoint. + +| Method or property | Page-script endpoint | Panel endpoint | +|--------------------|-------------|-------| +| `emit(name, ...args)` | Fans an event out to every connected panel. | Sends an event to the page script, buffering while connecting. | +| `on(name, listener)` | Subscribes to events emitted by a panel. Returns an unsubscribe function. | Subscribes to events emitted by the page script. Returns an unsubscribe function. | +| `call(name, ...args)` | Available through a specific `PanelPeer`. | Calls a page-script function and awaits its result. | +| `events` | Local `panel:connected` / `panel:disconnected` lifecycle events. | Local `status:updated` lifecycle event. | +| `sharedState` | Owns the authoritative state. | Mirrors the page-script state. | + ## In-page channel error codes The `error.code` values of `InPageChannelError`: [Errors and fallbacks](/guide/in-page-channel#errors-and-fallbacks). diff --git a/packages/devframe/src/in-page-channel/diagnostics.ts b/packages/devframe/src/in-page-channel/diagnostics.ts new file mode 100644 index 000000000..a9a1663ce --- /dev/null +++ b/packages/devframe/src/in-page-channel/diagnostics.ts @@ -0,0 +1,11 @@ +import { defineDiagnostics } from 'devframe/utils/nostics' + +export const diagnostics = /* #__PURE__ */ defineDiagnostics({ + docsBase: 'https://devfra.me/errors', + codes: { + DF0077: { + why: (p: { name: string }) => `In-page channel function "${p.name}" is not registered on this endpoint.`, + fix: 'Declare the function in this endpoint\'s `functions` option before subscribing with `on()`.', + }, + }, +}) diff --git a/packages/devframe/src/in-page-channel/in-page-channel.test.ts b/packages/devframe/src/in-page-channel/in-page-channel.test.ts index 5dff9e502..d225c2eb8 100644 --- a/packages/devframe/src/in-page-channel/in-page-channel.test.ts +++ b/packages/devframe/src/in-page-channel/in-page-channel.test.ts @@ -43,12 +43,12 @@ const defaultPageScriptFunctions: NonNullable a + b }, boom: { handler: () => {} }, strict: { handler: payload => payload }, - note: { type: 'event', handler: () => {} }, + note: { type: 'event' }, } const defaultPanelFunctions: NonNullable['functions']> = { 'ping-panel': { handler: value => `pong:${value}` }, - 'notify': { type: 'event', handler: () => {} }, + 'notify': { type: 'event' }, } function createLinkedPair(options?: { @@ -120,6 +120,21 @@ describe('in-page channel over bring-your-own ports', () => { } }) + it('reports and rejects listeners for unknown functions', ({ onTestFinished }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + 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]')) + }) + it('enforces jsonSerializable payloads with a coded error', async () => { const { panel, dispose } = createLinkedPair() try { @@ -181,7 +196,7 @@ describe('in-page channel over bring-your-own ports', () => { } }) - it('fans events out to every panel; panels without the handler ignore them', async () => { + it('fans events out to runtime panel listeners and supports unsubscribing', async () => { const a = new MessageChannel() const b = new MessageChannel() const pageScript = createPageScriptChannel({ @@ -196,14 +211,13 @@ describe('in-page channel over bring-your-own ports', () => { name: 'devframes:test', ...noHandshake, transport: a.port2, - functions: { - ...defaultPanelFunctions, - notify: { type: 'event', handler: (value) => { - received.push(`a:${value}`) - } }, - }, + functions: defaultPanelFunctions, }) - // Panel B deliberately has no local functions in its protocol. + pageScript.emit('notify', 'before-listener') + await new Promise(resolve => setTimeout(resolve, 20)) + expect(received).toEqual([]) + const offNotify = panelA.on('notify', value => received.push(`a:${value}`)) + // Panel B deliberately has no listener for this event. const panelB = connectPanelChannel({ name: 'devframes:test', ...noHandshake, @@ -212,9 +226,13 @@ describe('in-page channel over bring-your-own ports', () => { }) try { expect(pageScript.panels).toHaveLength(2) - pageScript.callEvent('notify', 'scan') + pageScript.emit('notify', 'scan') await until(() => received.length === 1) expect(received).toEqual(['a:scan']) + offNotify() + pageScript.emit('notify', 'ignored') + await new Promise(resolve => setTimeout(resolve, 20)) + expect(received).toEqual(['a:scan']) } finally { panelA.close() @@ -518,19 +536,15 @@ describe('in-page channel handshake', () => { functions: defaultPanelFunctions, }) const early = panel.call('echo', 'early') - panel.callEvent('note', 'buffered') + panel.emit('note', 'buffered') const pageScript = createPageScriptChannel({ name: 'devframes:test', window: asWindow(hostWin), heartbeat: false, - functions: { - ...defaultPageScriptFunctions, - note: { type: 'event', handler: (value) => { - noted.push(value) - } }, - }, + functions: defaultPageScriptFunctions, }) + pageScript.on('note', value => noted.push(value)) try { await expect(early).resolves.toBe('early') await until(() => noted.length === 1) diff --git a/packages/devframe/src/in-page-channel/internal.ts b/packages/devframe/src/in-page-channel/internal.ts index 3a832894a..e7bbf5b55 100644 --- a/packages/devframe/src/in-page-channel/internal.ts +++ b/packages/devframe/src/in-page-channel/internal.ts @@ -4,13 +4,13 @@ import type { RpcArgsSchema } from '../rpc/types' import type { InPageChannelControlFrame } from './protocol' import type { InPageFunctionDefinitionAny } from './types' import { createBirpc } from 'birpc' +import { diagnostics } from './diagnostics' import { isControlFrame } from './protocol' /** - * Shared internals of the two endpoints: the coded error surface (browser - * code, so plain coded `Error`s, since `nostics` diagnostics are node-side only), - * the local function table with its receive pipeline, and the birpc wiring - * of one `MessagePort`. + * Shared internals of the two endpoints: the coded error surface, the local + * function table with its receive pipeline, and the birpc wiring of one + * `MessagePort`. */ export const DEFAULT_CALL_TIMEOUT_MS = 15_000 @@ -166,24 +166,49 @@ export function deserializeResult(codec: InPageChannelSerialization, result: unk */ export function createLocalFunctionRegistry(codec: InPageChannelSerialization): { register: (definition: InPageFunctionDefinitionAny) => void + on: (name: string, listener: (...args: unknown[]) => void) => () => void resolve: (name: string) => ((...args: unknown[]) => unknown) | undefined } { - const wrapped = new Map unknown>() + const definitions = new Map() + const listeners = new Map void>>() return { register(definition) { - wrapped.set(definition.name, async (...rawArgs: unknown[]) => { + definitions.set(definition.name, definition) + }, + on(name, listener) { + if (!definitions.has(name)) + throw diagnostics.DF0077({ name }) + let registered = listeners.get(name) + if (!registered) { + registered = new Set() + listeners.set(name, registered) + } + registered.add(listener) + return () => { + registered.delete(listener) + if (registered.size === 0) + listeners.delete(name) + } + }, + resolve(name) { + const definition = definitions.get(name) + const registered = listeners.get(name) + if (!definition && !registered?.size) + return undefined + return async (...rawArgs: unknown[]) => { const args = codec.deserialize ? rawArgs.map(codec.deserialize) : rawArgs - if (definition.jsonSerializable) + if (definition?.jsonSerializable) assertJsonSerializable(args, 'its arguments', definition.name) - if (definition.args?.length) + if (definition?.args?.length) await validateArgs(definition.name, definition.args, args) - const result = await definition.handler(...args) - if (definition.jsonSerializable) + const result = await definition?.handler?.(...args) + for (const listener of [...(listeners.get(name) ?? [])]) + listener(...args) + if (definition?.jsonSerializable) assertJsonSerializable(result, 'its return value', definition.name) return codec.serialize && result !== undefined ? codec.serialize(result) : result - }) + } }, - resolve: name => wrapped.get(name), } } diff --git a/packages/devframe/src/in-page-channel/page-script.ts b/packages/devframe/src/in-page-channel/page-script.ts index 806daa01e..6dc245a74 100644 --- a/packages/devframe/src/in-page-channel/page-script.ts +++ b/packages/devframe/src/in-page-channel/page-script.ts @@ -63,7 +63,7 @@ export function createPageScriptChannel

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

(function* () { @@ -174,6 +174,18 @@ export function createPageScriptChannel

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

['emit'] = (fnName, ...args) => { + const wireArgs = serializeArgs(codec, args) + for (const peer of peers.values()) { + void peer.attached.rpc.$callRaw({ + method: fnName, + args: wireArgs, + event: true, + optional: true, + }).catch(() => {}) + } + } + return { name, instanceId, @@ -181,17 +193,9 @@ export function createPageScriptChannel

( return [...peers.values()].map(peer => peer.peer) }, events: { on: events.on, once: events.once }, - callEvent: (fnName, ...args) => { - const wireArgs = serializeArgs(codec, args) - for (const peer of peers.values()) { - void peer.attached.rpc.$callRaw({ - method: fnName, - args: wireArgs, - event: true, - optional: true, - }).catch(() => {}) - } - }, + emit, + callEvent: emit, + on: (fnName, listener) => registry.on(fnName, listener as (...args: unknown[]) => void), sharedState: stateHost, addPanelPort: port => addPeer(port, `transport:${nanoid(8)}`), close: () => { diff --git a/packages/devframe/src/in-page-channel/panel.ts b/packages/devframe/src/in-page-channel/panel.ts index e9273c0e5..374197d19 100644 --- a/packages/devframe/src/in-page-channel/panel.ts +++ b/packages/devframe/src/in-page-channel/panel.ts @@ -62,7 +62,7 @@ export function connectPanelChannel

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

( }) }, call: (fnName, ...args) => enqueueCall(fnName, serializeArgs(codec, args)) as Promise, + emit: (fnName, ...args) => sendEvent(fnName, serializeArgs(codec, args)), callEvent: (fnName, ...args) => sendEvent(fnName, serializeArgs(codec, args)), + on: (fnName, listener) => registry.on(fnName, listener as (...args: unknown[]) => void), sharedState: stateHost, close: () => { if (status === 'closed') diff --git a/packages/devframe/src/in-page-channel/types.test-d.ts b/packages/devframe/src/in-page-channel/types.test-d.ts index d3962036b..418d89c60 100644 --- a/packages/devframe/src/in-page-channel/types.test-d.ts +++ b/packages/devframe/src/in-page-channel/types.test-d.ts @@ -1,4 +1,5 @@ import { describe, expectTypeOf, it } from 'vitest' +import { defineChannelFunction } from './index' import { createPageScriptChannel } from './page-script' import { connectPanelChannel } from './panel' @@ -20,13 +21,37 @@ interface PageScriptOnlyProtocol { panel: Record } +interface MixedPanelProtocol { + pageScript: Record + panel: { + confirm: (message: string) => boolean + notify: (message: string) => void + } +} + +describe('Channel function definitions', () => { + it('distinguishes event, query, and action definitions', () => { + defineChannelFunction({ name: 'notify', type: 'event' }) + defineChannelFunction({ name: 'load', handler: () => 'value' }) + defineChannelFunction({ name: 'load', type: 'query', handler: () => 'value' }) + defineChannelFunction({ name: 'save', type: 'action', handler: () => { } }) + }) + + it('requires handlers for request/response functions', () => { + // @ts-expect-error Query functions require a handler. + defineChannelFunction({ name: 'load', type: 'query' }) + // @ts-expect-error Action functions require a handler. + defineChannelFunction({ name: 'save', type: 'action' }) + }) +}) + describe('In-page script channel', () => { const channel = createPageScriptChannel({ name: 'devframes:test', functions: { echo: { handler: value => value }, sum: { handler: (a, b) => a + b }, - save: { handler: () => {} }, + save: { handler: () => { } }, }, }) @@ -70,13 +95,35 @@ describe('In-page script channel', () => { }) }) + it('allows event declarations to omit their handler', () => { + createPageScriptChannel({ + name: 'devframes:test', + functions: { + echo: { type: 'query', handler: value => value }, + sum: { type: 'action', handler: (a, b) => a + b }, + save: { type: 'event' }, + }, + }) + + createPageScriptChannel({ + name: 'devframes:test', + functions: { + // @ts-expect-error Request/response functions require a handler. + echo: { type: 'query' }, + // @ts-expect-error Request/response functions require a handler. + sum: { type: 'action' }, + save: { type: 'event' }, + }, + }) + }) + it('rejects panel functions', () => { createPageScriptChannel({ name: 'devframes:test', functions: { echo: { handler: value => value }, sum: { handler: (a, b) => a + b }, - save: { handler: () => {} }, + save: { handler: () => { } }, // @ts-expect-error `notify` is implemented by panels. notify: { handler: (message: string) => void message }, }, @@ -92,7 +139,7 @@ describe('In-page script channel', () => { handler: (value: number) => value, }, sum: { handler: (a, b) => a + b }, - save: { handler: () => {} }, + save: { handler: () => { } }, }, }) }) @@ -100,16 +147,30 @@ describe('In-page script channel', () => { describe('Function calling', () => { it('types fire-and-forget calls to panel functions', () => { + expectTypeOf(channel.emit('notify', 'ready')).toEqualTypeOf() expectTypeOf(channel.callEvent('notify', 'ready')).toEqualTypeOf() // @ts-expect-error In-page script functions cannot be called on panels. - channel.callEvent('echo', 'ready') + channel.emit('echo', 'ready') // @ts-expect-error `notify` requires a string. - channel.callEvent('notify', 42) + channel.emit('notify', 42) // @ts-expect-error `notify` requires one argument. - channel.callEvent('notify') + channel.emit('notify') // @ts-expect-error `notify` accepts one argument. - channel.callEvent('notify', 'ready', 'extra') + channel.emit('notify', 'ready', 'extra') + }) + + it('rejects fire-and-forget calls to panel queries', () => { + const mixedChannel = createPageScriptChannel({ + name: 'devframes:mixed-panel', + functions: {}, + }) + + mixedChannel.emit('notify', 'ready') + // @ts-expect-error Queries cannot be emitted as events. + mixedChannel.emit('confirm', 'continue?') + // @ts-expect-error The deprecated alias has the same event-only contract. + mixedChannel.callEvent('confirm', 'continue?') }) it('types calls to connected panels', () => { @@ -133,11 +194,25 @@ describe('In-page script channel', () => { }) // @ts-expect-error The protocol has no panel functions. - pageScriptOnlyChannel.callEvent('notify', 'ready') + pageScriptOnlyChannel.emit('notify', 'ready') }) }) describe('Event checking', () => { + it('types runtime subscriptions to page-script events', () => { + const unsubscribe = channel.on('save', (value) => { + expectTypeOf(value).toEqualTypeOf() + }) + + expectTypeOf(unsubscribe).toEqualTypeOf<() => void>() + // @ts-expect-error Panel functions cannot be handled by the page script. + channel.on('notify', () => { }) + // @ts-expect-error Query functions cannot be handled as events. + channel.on('echo', () => { }) + // @ts-expect-error `save` listeners receive a string. + channel.on('save', (value: number) => void value) + }) + it('types panel connection events', () => { const unsubscribeConnected = channel.events.on('panel:connected', (panel) => { expectTypeOf(panel.id).toEqualTypeOf() @@ -154,7 +229,7 @@ describe('In-page script channel', () => { it('rejects panel channel events', () => { // @ts-expect-error Unknown in-page script channel lifecycle event. - channel.events.on('status:updated', () => {}) + channel.events.on('status:updated', () => { }) }) }) }) @@ -163,7 +238,7 @@ describe('Panel channel', () => { const channel = connectPanelChannel({ name: 'devframes:test', functions: { - notify: { handler: () => {} }, + notify: { handler: () => { } }, }, }) @@ -185,22 +260,39 @@ describe('Panel channel', () => { inferredChannel.close() }) - it('requires every panel function', () => { + it('requires every panel function declaration', () => { // @ts-expect-error `functions` is required. connectPanelChannel({ name: 'devframes:test' }) connectPanelChannel({ name: 'devframes:test', - // @ts-expect-error `notify` is required. + // @ts-expect-error `notify` must be declared. functions: {}, }) }) + it('allows event declarations to omit their handler', () => { + connectPanelChannel({ + name: 'devframes:test', + functions: { + notify: { type: 'event' }, + }, + }) + + connectPanelChannel({ + name: 'devframes:test', + functions: { + // @ts-expect-error Request/response functions require a handler. + notify: { type: 'action' }, + }, + }) + }) + it('rejects in-page script functions', () => { connectPanelChannel({ name: 'devframes:test', functions: { - notify: { handler: () => {} }, + notify: { handler: () => { } }, // @ts-expect-error `echo` is implemented by the in-page script. echo: { handler: (value: string) => value }, }, @@ -229,7 +321,7 @@ describe('Panel channel', () => { name: 'devframes:page-script-only', functions: { // @ts-expect-error The protocol has no panel functions. - notify: { handler: () => {} }, + notify: { handler: () => { } }, }, }) }) @@ -252,16 +344,19 @@ describe('Panel channel', () => { }) it('types fire-and-forget calls to in-page script functions', () => { - expectTypeOf(channel.callEvent('echo', 'hello')).toEqualTypeOf() - expectTypeOf(channel.callEvent('sum', 1, 2)).toEqualTypeOf() + expectTypeOf(channel.emit('save', 'draft')).toEqualTypeOf() expectTypeOf(channel.callEvent('save', 'draft')).toEqualTypeOf() // @ts-expect-error Panel functions cannot be emitted to the in-page script. - channel.callEvent('notify', 'hello') - // @ts-expect-error `echo` requires a string. - channel.callEvent('echo', false) - // @ts-expect-error `sum` requires two arguments. - channel.callEvent('sum', 1) + channel.emit('notify', 'hello') + // @ts-expect-error Queries cannot be emitted as events. + channel.emit('echo', 'hello') + // @ts-expect-error Queries cannot be emitted as events. + channel.emit('sum', 1, 2) + // @ts-expect-error `save` requires a string. + channel.emit('save', false) + // @ts-expect-error The deprecated alias has the same event-only contract. + channel.callEvent('echo', 'hello') }) it('types channel state', () => { @@ -273,6 +368,32 @@ describe('Panel channel', () => { }) describe('Event checking', () => { + it('types runtime subscriptions to panel functions', () => { + const unsubscribe = channel.on('notify', (message) => { + expectTypeOf(message).toEqualTypeOf() + }) + + expectTypeOf(unsubscribe).toEqualTypeOf<() => void>() + // @ts-expect-error Page-script functions cannot be handled by the panel. + channel.on('echo', () => { }) + // @ts-expect-error `notify` listeners receive a string. + channel.on('notify', (message: number) => void message) + }) + + it('rejects runtime subscriptions to panel queries', () => { + const mixedChannel = connectPanelChannel({ + name: 'devframes:mixed-panel', + functions: { + confirm: { handler: () => true }, + notify: { type: 'event' }, + }, + }) + + mixedChannel.on('notify', () => { }) + // @ts-expect-error Query functions cannot be handled as events. + mixedChannel.on('confirm', () => { }) + }) + it('types status events', () => { const unsubscribe = channel.events.on('status:updated', (status) => { expectTypeOf(status).toEqualTypeOf<'connecting' | 'connected' | 'closed'>() @@ -283,7 +404,7 @@ describe('Panel channel', () => { it('rejects in-page script channel events and incompatible listeners', () => { // @ts-expect-error Unknown panel channel lifecycle event. - channel.events.on('panel:connected', () => {}) + channel.events.on('panel:connected', () => { }) // @ts-expect-error `status:updated` listeners receive the status. channel.events.on('status:updated', (status: number) => void status) }) diff --git a/packages/devframe/src/in-page-channel/types.ts b/packages/devframe/src/in-page-channel/types.ts index c89e12623..77648a02f 100644 --- a/packages/devframe/src/in-page-channel/types.ts +++ b/packages/devframe/src/in-page-channel/types.ts @@ -10,9 +10,9 @@ import type { InferArgsType, InferReturnType } from '../rpc/utils' * channel-name constant declared next to it. */ export interface InPageChannelProtocol { - /** Functions implemented by the page script, callable by panels. */ + /** Functions and events received by the page script. */ pageScript?: Record any> - /** Functions implemented by panels, callable by the page script. */ + /** Functions and events received by panels. */ panel?: Record any> /** * Shared-state slots. The page script is the authority: it owns the @@ -31,6 +31,22 @@ type SharedStates

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

= { + [K in keyof PageScriptFunctions

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

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

= { + [K in keyof PanelFunctions

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

[K] +} + /** * Converts a protocol function to its accepted endpoint handler. * @@ -47,6 +63,60 @@ type ProtocolHandler = F extends (...args: any[]) => any */ export type InPageFunctionType = 'action' | 'event' | 'query' +interface InPageFunctionDefinitionBase { + name: NAME + jsonSerializable?: boolean +} + +interface InPageEventFunctionDefinition extends InPageFunctionDefinitionBase { + type: 'event' + handler?: HANDLER +} + +interface InPageQueryFunctionDefinition extends InPageFunctionDefinitionBase { + type?: 'query' + handler: HANDLER +} + +interface InPageActionFunctionDefinition extends InPageFunctionDefinitionBase { + type: 'action' + handler: HANDLER +} + +type InPageFunctionDefinitionForType< + NAME extends string, + TYPE extends InPageFunctionType, + HANDLER, +> = TYPE extends 'event' + ? InPageEventFunctionDefinition + : TYPE extends 'action' + ? InPageActionFunctionDefinition + : InPageQueryFunctionDefinition + +type InPageFunctionDefinitionSchemas< + AS extends RpcArgsSchema | undefined, + RS extends RpcReturnSchema | undefined, +> = [AS, RS] extends [undefined, undefined] + ? { + args?: AS + returns?: RS + } + : { + /** Standard Schema array validating (and typing) the arguments. */ + args: AS + /** Standard Schema typing the resolved return value. */ + returns: RS + } + +type InPageFunctionDefinitionHandler< + ARGS extends any[], + RETURN, + AS extends RpcArgsSchema | undefined, + RS extends RpcReturnSchema | undefined, +> = [AS, RS] extends [undefined, undefined] + ? (...args: ARGS) => RETURN + : (...args: InferArgsType) => Thenable> + /** * An in-page channel function definition: the `defineRpcFunction` authoring * shape (`name`, `type`, Standard-Schema `args`/`returns`, @@ -54,7 +124,8 @@ export type InPageFunctionType = 'action' | 'event' | 'query' * `dump`/`snapshot`/`cacheable`/`agent`. When `jsonSerializable` is `true`, * payloads are strictly validated at the receiving endpoint and misshapen * values reject the call with a descriptive `InPageChannelError` instead of - * a cryptic `DataCloneError` in the port. + * a cryptic `DataCloneError` in the port. Event definitions may omit their + * handler when runtime listeners subscribe through `channel.on()`. */ export type InPageFunctionDefinition< NAME extends string, @@ -63,26 +134,11 @@ export type InPageFunctionDefinition< RETURN = void, AS extends RpcArgsSchema | undefined = undefined, RS extends RpcReturnSchema | undefined = undefined, -> - = [AS, RS] extends [undefined, undefined] - ? { - name: NAME - type?: TYPE - args?: AS - returns?: RS - jsonSerializable?: boolean - handler: (...args: ARGS) => RETURN - } - : { - name: NAME - type?: TYPE - /** Standard Schema array validating (and typing) the arguments. */ - args: AS - /** Standard Schema typing the resolved return value. */ - returns: RS - jsonSerializable?: boolean - handler: (...args: InferArgsType) => Thenable> - } +> = InPageFunctionDefinitionForType< + NAME, + TYPE, + InPageFunctionDefinitionHandler +> & InPageFunctionDefinitionSchemas /** * Loosely-typed definition used by the internal function registry. @@ -96,16 +152,34 @@ export type InPageFunctionDefinitionAny = InPageFunctionDefinition { - type?: InPageFunctionType +interface InPageFunctionOptionBase { /** Optional Standard Schema array validating the arguments. */ args?: RpcArgsSchema /** Optional Standard Schema validating the resolved return value. */ returns?: RpcReturnSchema jsonSerializable?: boolean +} + +interface InPageEventFunctionOption extends InPageFunctionOptionBase { + type: 'event' + handler?: ProtocolHandler +} + +interface InPageQueryFunctionOption extends InPageFunctionOptionBase { + type?: 'query' handler: ProtocolHandler } +interface InPageActionFunctionOption extends InPageFunctionOptionBase { + type: 'action' + handler: ProtocolHandler +} + +type InPageFunctionOption + = | InPageEventFunctionOption + | InPageQueryFunctionOption + | InPageActionFunctionOption + /** * Functions implemented by {@link createPageScriptChannel}. * @@ -173,7 +247,7 @@ interface InPageChannelCommonOptions { /** Options for {@link createPageScriptChannel}. */ export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions { - /** Implementations of the protocol's page-script functions. */ + /** Every page-script function declaration; event handlers may use `channel.on()`. */ functions: CreatePageScriptChannelOptionsFunctions /** * Window whose `message` events carry panel hellos. Defaults to the @@ -185,7 +259,7 @@ export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions { - /** Implementations of the protocol's panel functions. */ + /** Every panel function declaration; event handlers may use `channel.on()`. */ functions: ConnectPanelChannelOptionsFunctions /** * The panel's own window (listens for the handshake grant). Defaults to @@ -215,7 +289,7 @@ export interface ConnectPanelChannelOptions { /** Currently connected panels. */ readonly panels: readonly PanelPeer

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

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

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

/** Tear the endpoint down permanently. */ diff --git a/plugins/a11y/app/lib/channel.ts b/plugins/a11y/app/lib/channel.ts index 7b416fe89..3b2b2289b 100644 --- a/plugins/a11y/app/lib/channel.ts +++ b/plugins/a11y/app/lib/channel.ts @@ -71,16 +71,16 @@ export function createA11yChannel(): A11yChannel { pageScriptReady, scanning: () => state()?.scanning || localScanning(), activeRoute: () => state()?.activeRoute ?? null, - preview: node => channel.callEvent('highlight', node.id, node.target), - clearPreview: () => channel.callEvent('clear-highlight'), - setPins: pins => channel.callEvent('set-pins', pins), + preview: node => channel.emit('highlight', node.id, node.target), + clearPreview: () => channel.emit('clear-highlight'), + setPins: pins => channel.emit('set-pins', pins), rescan: () => { setLocalScanning(true) - channel.callEvent('rescan') + channel.emit('rescan') }, - sendConfig: config => channel.callEvent('set-config', config), - setAutoScan: enabled => channel.callEvent('set-autoscan', enabled), - clearRoute: route => channel.callEvent('clear-route', route), - clearAll: () => channel.callEvent('clear-all'), + sendConfig: config => channel.emit('set-config', config), + setAutoScan: enabled => channel.emit('set-autoscan', enabled), + clearRoute: route => channel.emit('clear-route', route), + clearAll: () => channel.emit('clear-all'), } } diff --git a/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts index 217e64918..118b8ce4a 100644 --- a/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts @@ -25,7 +25,9 @@ export interface PageScriptChannel

{ readonly instanceId: string; readonly panels: readonly PanelPeer

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

; addPanelPort: (_: MessagePort) => PanelPeer

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

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

; close: () => void; } @@ -58,21 +62,7 @@ export type InPageChannelErrorCode = 'timeout' | 'invalid-args' | 'state-uninitialized'; export type InPageChannelStatus = 'connecting' | 'connected' | 'closed'; -export type InPageFunctionDefinition = [AS, RS] extends [undefined, undefined] ? { - name: NAME; - type?: TYPE; - args?: AS; - returns?: RS; - jsonSerializable?: boolean; - handler: (...args: ARGS) => RETURN; -} : { - name: NAME; - type?: TYPE; - args: AS; - returns: RS; - jsonSerializable?: boolean; - handler: (...args: InferArgsType) => Thenable>; -}; +export type InPageFunctionDefinition = InPageFunctionDefinitionForType> & InPageFunctionDefinitionSchemas; // #endregion // #region Classes @@ -107,6 +97,15 @@ interface InPageChannelCommonOptions { serialize?: (_: unknown) => unknown; deserialize?: (_: unknown) => unknown; } +type InPageFunctionDefinitionForType = TYPE extends 'event' ? InPageEventFunctionDefinition : TYPE extends 'action' ? InPageActionFunctionDefinition : InPageQueryFunctionDefinition; +type InPageFunctionDefinitionHandler = [AS, RS] extends [undefined, undefined] ? (...args: ARGS) => RETURN : (...args: InferArgsType) => Thenable>; +type InPageFunctionDefinitionSchemas = [AS, RS] extends [undefined, undefined] ? { + args?: AS; + returns?: RS; +} : { + args: AS; + returns: RS; +}; type InPageFunctionType = 'action' | 'event' | 'query'; interface InPageSharedStateHost

{ get: & string>(_: K, _?: { @@ -118,8 +117,10 @@ interface PageScriptChannelEvents

{ 'panel:disconnected': (_: PanelPeer

) => void; } type PageScriptFunctions

= SideFunctions>; +type PageScriptFunctionsEvents

= { [K in keyof PageScriptFunctions

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

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

= SideFunctions>; +type PanelFunctionsEvents

= { [K in keyof PanelFunctions

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

[K]; }; // #endregion \ No newline at end of file