diff --git a/docs/content/1.guide/12.in-page-channel.md b/docs/content/1.guide/12.in-page-channel.md index bf2ca5f9..c7120ff3 100644 --- a/docs/content/1.guide/12.in-page-channel.md +++ b/docs/content/1.guide/12.in-page-channel.md @@ -54,31 +54,29 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names ## The page script endpoint -Functions are defined with `defineChannelFunction` — the same authoring shape as `defineRpcFunction` (`name`, `type`, Standard-Schema `args`/`returns`, `jsonSerializable`, `handler`), narrowed to the browser. Define each side's functions in that side's source files; the shared protocol file carries only types. +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. ```ts import type { MyChannelProtocol } from '../shared/protocol' // inject/index.ts — runs in the user app's page -import { createPageScriptChannel, defineChannelFunction } from 'devframe/in-page-channel' +import { createPageScriptChannel } from 'devframe/in-page-channel' import { MY_CHANNEL } from '../shared/protocol' const channel = createPageScriptChannel({ name: MY_CHANNEL, - functions: [ - defineChannelFunction({ - name: 'highlight', + functions: { + highlight: { type: 'event', // fire-and-forget jsonSerializable: true, - handler: (selector: string) => drawRing(document.querySelector(selector)), - }), - defineChannelFunction({ - name: 'measure', // request/response (the default `query` type) - handler: (selector: string) => { + handler: selector => drawRing(document.querySelector(selector)), + }, + measure: { // request/response (the default `query` type) + handler: (selector) => { const rect = document.querySelector(selector)!.getBoundingClientRect() return { width: rect.width, height: rect.height } }, - }), - ], + }, + }, }) channel.callEvent('flash', 'scanning…') // fans out to every connected panel @@ -96,7 +94,14 @@ import type { MyChannelProtocol } from '../shared/protocol' import { connectPanelChannel } from 'devframe/in-page-channel' import { MY_CHANNEL } from '../shared/protocol' -const channel = connectPanelChannel({ name: MY_CHANNEL }) +const channel = connectPanelChannel({ + name: MY_CHANNEL, + functions: { + flash: { + handler: message => showFlash(message), + }, + }, +}) channel.callEvent('highlight', '.hero') // buffered until connected const size = await channel.call('measure', '.hero') 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 173e9351..0f170955 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,6 +1,5 @@ -import type { InPageChannelProtocol, PageScriptChannel, PanelChannel } from './types' +import type { ConnectPanelChannelOptions, CreatePageScriptChannelOptions, InPageChannelProtocol, PageScriptChannel, PanelChannel } from './types' import { describe, expect, it, vi } from 'vitest' -import { defineChannelFunction } from './index' import { InPageChannelError } from './internal' import { createPageScriptChannel } from './page-script' import { connectPanelChannel } from './panel' @@ -39,22 +38,34 @@ function until(predicate: () => boolean, timeoutMs = 2000): Promise { const noHandshake = { window: false as const, heartbeat: false as const } +const defaultPageScriptFunctions: NonNullable['functions']> = { + echo: { handler: value => value }, + sum: { handler: (a, b) => a + b }, + boom: { handler: () => {} }, + strict: { handler: payload => payload }, + note: { type: 'event', handler: () => {} }, +} + +const defaultPanelFunctions: NonNullable['functions']> = { + 'ping-panel': { handler: value => `pong:${value}` }, + 'notify': { type: 'event', handler: () => {} }, +} + function createLinkedPair(options?: { - pageScript?: Partial[0]> - panel?: Partial[0]> + pageScript?: Partial> + panel?: Partial> }): { pageScript: PageScriptChannel, panel: PanelChannel, dispose: () => void } { const { port1, port2 } = new MessageChannel() const pageScript = createPageScriptChannel({ name: 'devframes:test', ...noHandshake, - functions: [ - defineChannelFunction({ name: 'echo', handler: (value: string) => value }), - defineChannelFunction({ name: 'sum', type: 'query', handler: (a: number, b: number) => a + b }), - defineChannelFunction({ name: 'boom', handler: () => { + functions: { + ...defaultPageScriptFunctions, + boom: { handler: () => { throw new Error('exploded') - } }), - defineChannelFunction({ name: 'strict', jsonSerializable: true, handler: (payload: unknown) => payload }), - ], + } }, + strict: { jsonSerializable: true, handler: payload => payload }, + }, ...options?.pageScript, }) pageScript.addPanelPort(port1) @@ -62,6 +73,7 @@ function createLinkedPair(options?: { name: 'devframes:test', ...noHandshake, transport: port2, + functions: defaultPanelFunctions, ...options?.panel, }) return { @@ -142,20 +154,21 @@ describe('in-page channel over bring-your-own ports', () => { const pageScript = createPageScriptChannel({ name: 'devframes:test', ...noHandshake, - functions: [ - defineChannelFunction({ - name: 'note', + functions: { + ...defaultPageScriptFunctions, + note: { args: [s.string()] as const, returns: s.void(), handler: () => {}, - }), - ], + }, + }, }) pageScript.addPanelPort(port1) const panel = connectPanelChannel({ name: 'devframes:test', ...noHandshake, transport: port2, + functions: defaultPanelFunctions, }) try { await expect(panel.call('note', 'fine')).resolves.toBeUndefined() @@ -174,6 +187,7 @@ describe('in-page channel over bring-your-own ports', () => { const pageScript = createPageScriptChannel({ name: 'devframes:test', ...noHandshake, + functions: defaultPageScriptFunctions, }) pageScript.addPanelPort(a.port1) pageScript.addPanelPort(b.port1) @@ -182,17 +196,19 @@ describe('in-page channel over bring-your-own ports', () => { name: 'devframes:test', ...noHandshake, transport: a.port2, - functions: [ - defineChannelFunction({ name: 'notify', type: 'event', handler: (value: string) => { + functions: { + ...defaultPanelFunctions, + notify: { type: 'event', handler: (value) => { received.push(`a:${value}`) - } }), - ], + } }, + }, }) - // Panel B deliberately implements nothing. - const panelB = connectPanelChannel({ + // Panel B deliberately has no local functions in its protocol. + const panelB = connectPanelChannel({ name: 'devframes:test', ...noHandshake, transport: b.port2, + functions: {}, }) try { expect(pageScript.panels).toHaveLength(2) @@ -212,15 +228,14 @@ describe('in-page channel over bring-your-own ports', () => { const pageScript = createPageScriptChannel({ name: 'devframes:test', ...noHandshake, + functions: defaultPageScriptFunctions, }) pageScript.addPanelPort(port1) const panel = connectPanelChannel({ name: 'devframes:test', ...noHandshake, transport: port2, - functions: [ - defineChannelFunction({ name: 'ping-panel', handler: (value: string) => `pong:${value}` }), - ], + functions: defaultPanelFunctions, }) try { const peer = pageScript.panels[0]! @@ -237,15 +252,14 @@ describe('in-page channel over bring-your-own ports', () => { const pageScript = createPageScriptChannel({ name: 'devframes:test', ...noHandshake, - functions: [ - defineChannelFunction({ name: 'echo', handler: (value: any) => value }), - ], + functions: defaultPageScriptFunctions, }) pageScript.addPanelPort(port1) const panel = connectPanelChannel({ name: 'devframes:test', ...noHandshake, transport: port2, + functions: defaultPanelFunctions, // Unwrap a fake reactivity wrapper on the way out, tag on the way in. serialize: value => (value && typeof value === 'object' && '__wrapped' in (value as any)) ? (value as any).__wrapped @@ -266,6 +280,7 @@ describe('in-page channel over bring-your-own ports', () => { const pageScript = createPageScriptChannel({ name: 'devframes:test', ...noHandshake, + functions: defaultPageScriptFunctions, }) const connected: string[] = [] const disconnected: string[] = [] @@ -276,6 +291,7 @@ describe('in-page channel over bring-your-own ports', () => { name: 'devframes:test', ...noHandshake, transport: port2, + functions: defaultPanelFunctions, }) try { expect(connected).toHaveLength(1) @@ -316,11 +332,12 @@ describe('in-page channel shared state', () => { const pageScript = createPageScriptChannel({ name: 'devframes:test', ...noHandshake, + functions: defaultPageScriptFunctions, }) pageScript.addPanelPort(a.port1) pageScript.addPanelPort(b.port1) - const panelA = connectPanelChannel({ name: 'devframes:test', ...noHandshake, transport: a.port2 }) - const panelB = connectPanelChannel({ name: 'devframes:test', ...noHandshake, transport: b.port2 }) + 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') @@ -341,7 +358,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 }) + 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 @@ -351,7 +368,7 @@ describe('in-page channel shared state', () => { }) pageScript.addPanelPort(port1) - const panel = connectPanelChannel({ name: 'devframes:test', ...noHandshake, transport: port2 }) + 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 }) @@ -443,13 +460,14 @@ describe('in-page channel handshake', () => { name: 'devframes:test', window: hostWin as unknown as Window, heartbeat: false, - functions: [defineChannelFunction({ name: 'echo', handler: (value: string) => value })], + functions: defaultPageScriptFunctions, }) const panel = connectPanelChannel({ name: 'devframes:test', window: panelWin as unknown as Window, targets: [hostWin as unknown as Window], ...fastHello, + functions: defaultPanelFunctions, }) try { await panel.whenConnected(2000) @@ -465,7 +483,10 @@ describe('in-page channel handshake', () => { name: 'devframes:test', window: hostWin as unknown as Window, heartbeat: false, - functions: [defineChannelFunction({ name: 'echo', handler: (value: string) => `revived:${value}` })], + functions: { + ...defaultPageScriptFunctions, + echo: { handler: value => `revived:${value}` }, + }, }) try { await panel.whenConnected(2000) @@ -489,6 +510,7 @@ describe('in-page channel handshake', () => { window: panelWin as unknown as Window, targets: [hostWin as unknown as Window], ...fastHello, + functions: defaultPanelFunctions, }) const early = panel.call('echo', 'early') panel.callEvent('note', 'buffered') @@ -497,12 +519,12 @@ describe('in-page channel handshake', () => { name: 'devframes:test', window: hostWin as unknown as Window, heartbeat: false, - functions: [ - defineChannelFunction({ name: 'echo', handler: (value: string) => value }), - defineChannelFunction({ name: 'note', type: 'event', handler: (value: string) => { + functions: { + ...defaultPageScriptFunctions, + note: { type: 'event', handler: (value) => { noted.push(value) - } }), - ], + } }, + }, }) try { await expect(early).resolves.toBe('early') @@ -522,6 +544,7 @@ describe('in-page channel handshake', () => { name: 'devframes:test-origin', window: hostWin as unknown as Window, heartbeat: false, + functions: defaultPageScriptFunctions, }) try { hostWin.__dispatch({ @@ -552,6 +575,7 @@ describe('in-page channel handshake', () => { name: 'devframes:test-version', window: hostWin as unknown as Window, heartbeat: false, + functions: defaultPageScriptFunctions, }) try { hostWin.__dispatch({ @@ -581,6 +605,7 @@ describe('in-page channel handshake', () => { name: 'devframes:test', window: hostWin as unknown as Window, heartbeat: false, + functions: defaultPageScriptFunctions, }) const pinnedElsewhere = connectPanelChannel({ name: 'devframes:test', @@ -588,6 +613,7 @@ describe('in-page channel handshake', () => { targets: [hostWin as unknown as Window], instanceId: 'some-other-tab', ...fastHello, + functions: defaultPanelFunctions, }) try { await expect(pinnedElsewhere.whenConnected(100)).rejects.toMatchObject({ code: 'timeout' }) @@ -598,6 +624,7 @@ describe('in-page channel handshake', () => { targets: [hostWin as unknown as Window], instanceId: pageScript.instanceId, ...fastHello, + functions: defaultPanelFunctions, }) try { await pinnedHere.whenConnected(2000) @@ -618,6 +645,7 @@ describe('in-page channel handshake', () => { name: `devframes:test-lonely-${Math.random()}`, window: false, heartbeat: false, + functions: defaultPanelFunctions, }) try { expect(lonely.status).toBe('connecting') @@ -636,6 +664,7 @@ describe('in-page channel handshake', () => { window: false, heartbeat: false, callTimeoutMs: 50, + functions: defaultPanelFunctions, }) try { const rejection = await lonely.call('echo', 'nobody').catch(error => error) @@ -653,6 +682,7 @@ describe('in-page channel handshake', () => { name: `devframes:test-lonely-${Math.random()}`, window: false, heartbeat: false, + functions: defaultPanelFunctions, }) const pending = lonely.call('echo', 'never') const waiting = lonely.whenConnected() diff --git a/packages/devframe/src/in-page-channel/page-script.ts b/packages/devframe/src/in-page-channel/page-script.ts index 871ea536..2756d22d 100644 --- a/packages/devframe/src/in-page-channel/page-script.ts +++ b/packages/devframe/src/in-page-channel/page-script.ts @@ -45,7 +45,7 @@ interface PeerInternal

{ * handshake. No server is involved at any point. */ export function createPageScriptChannel

( - options: CreatePageScriptChannelOptions, + options: CreatePageScriptChannelOptions

, ): PageScriptChannel

{ const { name } = options const callTimeoutMs = options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS @@ -63,8 +63,8 @@ export function createPageScriptChannel

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

(function* () { for (const peer of peers.values()) { diff --git a/packages/devframe/src/in-page-channel/panel.ts b/packages/devframe/src/in-page-channel/panel.ts index 4bf17af2..91f46a86 100644 --- a/packages/devframe/src/in-page-channel/panel.ts +++ b/packages/devframe/src/in-page-channel/panel.ts @@ -45,7 +45,7 @@ const DEFAULT_EVENT_BUFFER_LIMIT = 64 * the UI can key a fallback state off `status` / `whenConnected()`. */ export function connectPanelChannel

( - options: ConnectPanelChannelOptions, + options: ConnectPanelChannelOptions

, ): PanelChannel

{ const { name } = options const callTimeoutMs = options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS @@ -62,8 +62,8 @@ export function connectPanelChannel

( const events = createEventEmitter() const registry = createLocalFunctionRegistry(codec) - for (const definition of options.functions ?? []) - registry.register(definition) + for (const [fnName, definition] of Object.entries(options.functions ?? {})) + registry.register({ ...definition, name: fnName }) let status: InPageChannelStatus = 'connecting' let attached: AttachedChannelPort | undefined diff --git a/packages/devframe/src/in-page-channel/types.test-d.ts b/packages/devframe/src/in-page-channel/types.test-d.ts new file mode 100644 index 00000000..d3962036 --- /dev/null +++ b/packages/devframe/src/in-page-channel/types.test-d.ts @@ -0,0 +1,291 @@ +import { describe, expectTypeOf, it } from 'vitest' +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 + } +} + +interface PageScriptOnlyProtocol { + pageScript: { + echo: (value: string) => string + } + panel: Record +} + +describe('In-page script channel', () => { + const channel = createPageScriptChannel({ + name: 'devframes:test', + functions: { + echo: { handler: value => value }, + sum: { handler: (a, b) => a + b }, + save: { handler: () => {} }, + }, + }) + + describe('Function definitions', () => { + it('infers handlers from the protocol', () => { + createPageScriptChannel({ + name: 'devframes:test', + functions: { + echo: { + handler: (value) => { + expectTypeOf(value).toEqualTypeOf() + return value.toUpperCase() + }, + }, + sum: { + handler: (a, b) => { + expectTypeOf(a).toEqualTypeOf() + expectTypeOf(b).toEqualTypeOf() + return a + b + }, + }, + save: { + handler: (value) => { + expectTypeOf(value).toEqualTypeOf() + }, + }, + }, + }) + }) + + it('requires every in-page script function', () => { + // @ts-expect-error `functions` is required. + createPageScriptChannel({ name: 'devframes:test' }) + + createPageScriptChannel({ + name: 'devframes:test', + // @ts-expect-error `sum` and `save` are required. + functions: { + echo: { handler: value => value }, + }, + }) + }) + + it('rejects panel functions', () => { + createPageScriptChannel({ + name: 'devframes:test', + functions: { + echo: { handler: value => value }, + sum: { handler: (a, b) => a + b }, + save: { handler: () => {} }, + // @ts-expect-error `notify` is implemented by panels. + notify: { handler: (message: string) => void message }, + }, + }) + }) + + it('rejects incompatible handlers', () => { + createPageScriptChannel({ + name: 'devframes:test', + functions: { + echo: { + // @ts-expect-error `echo` accepts and returns a string. + handler: (value: number) => value, + }, + sum: { handler: (a, b) => a + b }, + save: { handler: () => {} }, + }, + }) + }) + }) + + describe('Function calling', () => { + it('types fire-and-forget calls to panel functions', () => { + expectTypeOf(channel.callEvent('notify', 'ready')).toEqualTypeOf() + + // @ts-expect-error In-page script functions cannot be called on panels. + channel.callEvent('echo', 'ready') + // @ts-expect-error `notify` requires a string. + channel.callEvent('notify', 42) + // @ts-expect-error `notify` requires one argument. + channel.callEvent('notify') + // @ts-expect-error `notify` accepts one argument. + channel.callEvent('notify', 'ready', 'extra') + }) + + it('types calls to connected panels', () => { + const panel = channel.panels[0]! + + expectTypeOf(panel.call('notify', 'ready')).toEqualTypeOf>() + expectTypeOf(panel.close()).toEqualTypeOf() + + // @ts-expect-error In-page script functions cannot be called on a panel peer. + panel.call('sum', 1, 2) + // @ts-expect-error `notify` requires a string. + panel.call('notify', false) + }) + + it('rejects calls when the protocol declares no panel functions', () => { + const pageScriptOnlyChannel = createPageScriptChannel({ + name: 'devframes:page-script-only', + functions: { + echo: { handler: value => value }, + }, + }) + + // @ts-expect-error The protocol has no panel functions. + pageScriptOnlyChannel.callEvent('notify', 'ready') + }) + }) + + describe('Event checking', () => { + it('types panel connection events', () => { + const unsubscribeConnected = channel.events.on('panel:connected', (panel) => { + expectTypeOf(panel.id).toEqualTypeOf() + expectTypeOf(panel.call('notify', 'ready')).toEqualTypeOf>() + }) + const unsubscribeDisconnected = channel.events.on('panel:disconnected', (panel) => { + expectTypeOf(panel.id).toEqualTypeOf() + expectTypeOf(panel.call('notify', 'bye')).toEqualTypeOf>() + }) + + expectTypeOf(unsubscribeConnected).toEqualTypeOf<() => void>() + expectTypeOf(unsubscribeDisconnected).toEqualTypeOf<() => void>() + }) + + it('rejects panel channel events', () => { + // @ts-expect-error Unknown in-page script channel lifecycle event. + channel.events.on('status:updated', () => {}) + }) + }) +}) + +describe('Panel channel', () => { + const channel = connectPanelChannel({ + name: 'devframes:test', + functions: { + notify: { handler: () => {} }, + }, + }) + + describe('Function definitions', () => { + it('infers handlers from the protocol', () => { + const { port1 } = new MessageChannel() + const inferredChannel = connectPanelChannel({ + name: 'devframes:test', + window: false, + transport: port1, + functions: { + notify: { + handler: (message) => { + expectTypeOf(message).toEqualTypeOf() + }, + }, + }, + }) + inferredChannel.close() + }) + + it('requires every panel function', () => { + // @ts-expect-error `functions` is required. + connectPanelChannel({ name: 'devframes:test' }) + + connectPanelChannel({ + name: 'devframes:test', + // @ts-expect-error `notify` is required. + functions: {}, + }) + }) + + it('rejects in-page script functions', () => { + connectPanelChannel({ + name: 'devframes:test', + functions: { + notify: { handler: () => {} }, + // @ts-expect-error `echo` is implemented by the in-page script. + echo: { handler: (value: string) => value }, + }, + }) + }) + + it('rejects incompatible handlers', () => { + connectPanelChannel({ + name: 'devframes:test', + functions: { + notify: { + // @ts-expect-error `notify` accepts a string. + handler: (message: number) => void message, + }, + }, + }) + }) + + it('accepts an explicitly empty panel function map', () => { + connectPanelChannel({ + name: 'devframes:page-script-only', + functions: {}, + }) + + connectPanelChannel({ + name: 'devframes:page-script-only', + functions: { + // @ts-expect-error The protocol has no panel functions. + notify: { handler: () => {} }, + }, + }) + }) + }) + + describe('Function calling', () => { + it('types calls and their resolved results', () => { + expectTypeOf(channel.call('echo', 'hello')).toEqualTypeOf>() + expectTypeOf(channel.call('sum', 1, 2)).toEqualTypeOf>() + expectTypeOf(channel.call('save', 'draft')).toEqualTypeOf>() + + // @ts-expect-error Panel functions cannot be called on the in-page script. + channel.call('notify', 'hello') + // @ts-expect-error `echo` requires a string. + channel.call('echo', 42) + // @ts-expect-error `sum` requires two arguments. + channel.call('sum', 1) + // @ts-expect-error `save` accepts one argument. + channel.call('save', 'draft', 'extra') + }) + + it('types fire-and-forget calls to in-page script functions', () => { + expectTypeOf(channel.callEvent('echo', 'hello')).toEqualTypeOf() + expectTypeOf(channel.callEvent('sum', 1, 2)).toEqualTypeOf() + expectTypeOf(channel.callEvent('save', 'draft')).toEqualTypeOf() + + // @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) + }) + + it('types channel state', () => { + expectTypeOf(channel.status).toEqualTypeOf<'connecting' | 'connected' | 'closed'>() + expectTypeOf(channel.pageScript).toEqualTypeOf<{ instanceId: string } | undefined>() + expectTypeOf(channel.whenConnected()).toEqualTypeOf>() + expectTypeOf(channel.whenConnected(1_000)).toEqualTypeOf>() + }) + }) + + describe('Event checking', () => { + it('types status events', () => { + const unsubscribe = channel.events.on('status:updated', (status) => { + expectTypeOf(status).toEqualTypeOf<'connecting' | 'connected' | 'closed'>() + }) + + expectTypeOf(unsubscribe).toEqualTypeOf<() => void>() + }) + + it('rejects in-page script channel events and incompatible listeners', () => { + // @ts-expect-error Unknown panel channel lifecycle event. + 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 59965dd5..43e1f669 100644 --- a/packages/devframe/src/in-page-channel/types.ts +++ b/packages/devframe/src/in-page-channel/types.ts @@ -31,6 +31,15 @@ type SharedStates

type FnArgs = F extends (...args: infer A) => any ? A : never type FnReturn = F extends (...args: any[]) => infer R ? Awaited : never +/** + * Converts a protocol function to its accepted endpoint handler. + * + * @internal + */ +type ProtocolHandler = F extends (...args: any[]) => any + ? (...args: FnArgs) => Thenable> + : never + /** * Types of an in-page channel function — `RpcFunctionType` minus the * server-only `static`: `event` is fire-and-forget (the only type valid for @@ -75,9 +84,46 @@ export type InPageFunctionDefinition< handler: (...args: InferArgsType) => Thenable> } -/** Loosely-typed definition — the registration unit both endpoints accept. */ +/** + * Loosely-typed definition used by the internal function registry. + * + * @internal + */ export type InPageFunctionDefinitionAny = InPageFunctionDefinition +/** + * Function metadata with its handler constrained by a protocol function. + * + * @internal + */ +interface InPageFunctionOption { + type?: InPageFunctionType + /** Optional Standard Schema array validating the arguments. */ + args?: RpcArgsSchema + /** Optional Standard Schema validating the resolved return value. */ + returns?: RpcReturnSchema + jsonSerializable?: boolean + handler: ProtocolHandler +} + +/** + * Functions implemented by {@link createPageScriptChannel}. + * + * @internal + */ +type CreatePageScriptChannelOptionsFunctions

= { + [NAME in keyof PageScriptFunctions

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

= { + [NAME in keyof PanelFunctions

& string]: InPageFunctionOption[NAME]> +} + /** * Connection lifecycle of a panel endpoint: `connecting` (handshake retry * loop running, outgoing traffic buffered) → `connected` → back to @@ -85,14 +131,17 @@ export type InPageFunctionDefinitionAny = InPageFunctionDefinition extends InPageChannelCommonOptions { + /** Implementations of the protocol's page-script functions. */ + functions: CreatePageScriptChannelOptionsFunctions /** * Window whose `message` events carry panel hellos. Defaults to the * global `window`; pass `false` to skip the handshake listener entirely @@ -133,7 +184,9 @@ export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptio } /** Options for {@link connectPanelChannel}. */ -export interface ConnectPanelChannelOptions extends InPageChannelCommonOptions { +export interface ConnectPanelChannelOptions extends InPageChannelCommonOptions { + /** Implementations of the protocol's panel functions. */ + functions: ConnectPanelChannelOptionsFunctions /** * The panel's own window (listens for the handshake grant). Defaults to * the global `window`; pass `false` with `transport` to skip the handshake. diff --git a/packages/devframe/vitest.config.ts b/packages/devframe/vitest.config.ts new file mode 100644 index 00000000..b7d4f6e2 --- /dev/null +++ b/packages/devframe/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config' +import { alias } from '../../alias' + +export default defineConfig({ + resolve: { alias }, + test: { + name: 'devframe', + testTimeout: 10_000, + typecheck: { + enabled: true, + tsconfig: './tsconfig.json', + }, + }, +}) diff --git a/plugins/a11y/src/inject/index.ts b/plugins/a11y/src/inject/index.ts index 73c7c4a5..d80dd270 100644 --- a/plugins/a11y/src/inject/index.ts +++ b/plugins/a11y/src/inject/index.ts @@ -18,7 +18,7 @@ import type { A11yChannelProtocol, PageScriptConfig, PinTarget, ScanReport } from '../shared/protocol.ts' import type { A11yPageScriptContext } from './messages.ts' import type { PinInfo } from './overlay.ts' -import { createPageScriptChannel, defineChannelFunction } from 'devframe/in-page-channel' +import { createPageScriptChannel } from 'devframe/in-page-channel' import { A11Y_CHANNEL, A11Y_DEFAULT_DOCK_ID, @@ -96,9 +96,8 @@ async function start(context?: A11yPageScriptContext): Promise { const channel = createPageScriptChannel({ name: A11Y_CHANNEL, - functions: [ - defineChannelFunction({ - name: 'highlight', + functions: { + 'highlight': { type: 'event', jsonSerializable: true, handler: (nodeId: string, target: string[]) => { @@ -114,14 +113,12 @@ async function start(context?: A11yPageScriptContext): Promise { overlay.clearPreview() } }, - }), - defineChannelFunction({ - name: 'clear-highlight', + }, + 'clear-highlight': { type: 'event', handler: () => overlay.clearPreview(), - }), - defineChannelFunction({ - name: 'set-pins', + }, + 'set-pins': { type: 'event', jsonSerializable: true, handler: (pins: PinTarget[]) => { @@ -133,19 +130,16 @@ async function start(context?: A11yPageScriptContext): Promise { }) overlay.setPins(infos) }, - }), - defineChannelFunction({ - name: 'rescan', + }, + 'rescan': { type: 'event', handler: () => void runScan(), - }), - defineChannelFunction({ - name: 'set-config', + }, + 'set-config': { type: 'event', handler: (next: PageScriptConfig) => applyConfig(next), - }), - defineChannelFunction({ - name: 'set-autoscan', + }, + 'set-autoscan': { type: 'event', jsonSerializable: true, handler: (enabled: boolean) => { @@ -155,9 +149,8 @@ async function start(context?: A11yPageScriptContext): Promise { else unbindInteractions() }, - }), - defineChannelFunction({ - name: 'clear-route', + }, + 'clear-route': { type: 'event', jsonSerializable: true, handler: (route: string) => { @@ -166,9 +159,8 @@ async function start(context?: A11yPageScriptContext): Promise { saveRoutes() publishState() }, - }), - defineChannelFunction({ - name: 'clear-all', + }, + 'clear-all': { type: 'event', handler: () => { routes.clear() @@ -176,8 +168,8 @@ async function start(context?: A11yPageScriptContext): Promise { saveRoutes() publishState() }, - }), - ], + }, + }, }) // The page script is the authority for the aggregate; connected panels are diff --git a/plugins/a11y/src/shared/protocol.ts b/plugins/a11y/src/shared/protocol.ts index 8bfc9d2c..4f7de846 100644 --- a/plugins/a11y/src/shared/protocol.ts +++ b/plugins/a11y/src/shared/protocol.ts @@ -19,8 +19,6 @@ * `static` RPC the panel resolves) is forwarded to the page script over the * same channel, keeping the page script itself free of any RPC dependency. */ -import type { InPageChannelProtocol } from 'devframe/in-page-channel' - /** In-page channel name. Namespaced with the devframe id, per convention. */ export const A11Y_CHANNEL = 'devframes:plugin:a11y' @@ -164,7 +162,7 @@ export interface PageScriptConfig { * aggregate the page script owns. All functions are fire-and-forget events — * results flow back through the shared state. */ -export interface A11yChannelProtocol extends InPageChannelProtocol { +export interface A11yChannelProtocol { pageScript: { /** * Draw the transient hover-preview ring around a node's element. @@ -187,6 +185,7 @@ export interface A11yChannelProtocol extends InPageChannelProtocol { /** 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/plugins/a11y/src/spa/lib/channel.ts b/plugins/a11y/src/spa/lib/channel.ts index e34536da..028be635 100644 --- a/plugins/a11y/src/spa/lib/channel.ts +++ b/plugins/a11y/src/spa/lib/channel.ts @@ -49,7 +49,10 @@ export function createA11yChannel(): A11yChannel { // authoritative flag inside `A11yState` takes over on the next update. const [localScanning, setLocalScanning] = createSignal(false) - const channel = connectPanelChannel({ name: A11Y_CHANNEL }) + const channel = connectPanelChannel({ + name: A11Y_CHANNEL, + functions: {}, + }) channel.events.on('status:updated', status => setPageScriptReady(status === 'connected')) void channel.sharedState.get('state').then((shared) => { 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 53ec3f21..a404a7fb 100644 --- a/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts @@ -2,7 +2,8 @@ * Generated by tsnapi — public API snapshot of `devframe/in-page-channel` */ // #region Interfaces -export interface ConnectPanelChannelOptions extends InPageChannelCommonOptions { +export interface ConnectPanelChannelOptions extends InPageChannelCommonOptions { + functions: ConnectPanelChannelOptionsFunctions; window?: Window | false; targets?: Window[]; transport?: MessagePort; @@ -10,7 +11,8 @@ export interface ConnectPanelChannelOptions extends InPageChannelCommonOptions { helloIntervalMs?: number; eventBufferLimit?: number; } -export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions { +export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions { + functions: CreatePageScriptChannelOptionsFunctions; window?: Window | false; } export interface InPageChannelProtocol { @@ -84,7 +86,7 @@ export declare class InPageChannelError extends Error { // #endregion // #region Functions -export declare function connectPanelChannel

(_: ConnectPanelChannelOptions): PanelChannel

; -export declare function createPageScriptChannel

(_: CreatePageScriptChannelOptions): PageScriptChannel

; +export declare function connectPanelChannel

(_: ConnectPanelChannelOptions

): PanelChannel

; +export declare function createPageScriptChannel

(_: CreatePageScriptChannelOptions

): PageScriptChannel

; export declare function defineChannelFunction(_: InPageFunctionDefinition): InPageFunctionDefinition; // #endregion \ No newline at end of file