From 474a0faad680d21ef4506555a4dd5747dcf7b2fd Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Thu, 27 Aug 2026 17:56:05 +0200 Subject: [PATCH 1/9] feat: type in-page channel functions from protocol --- docs/content/1.guide/12.in-page-channel.md | 16 ++-- .../in-page-channel/in-page-channel.test.ts | 6 +- .../devframe/src/in-page-channel/index.ts | 36 +++++++-- .../src/in-page-channel/page-script.ts | 2 +- .../devframe/src/in-page-channel/panel.ts | 2 +- .../src/in-page-channel/types.test-d.ts | 79 +++++++++++++++++++ .../devframe/src/in-page-channel/types.ts | 47 ++++++++++- packages/devframe/vitest.config.ts | 14 ++++ 8 files changed, 179 insertions(+), 23 deletions(-) create mode 100644 packages/devframe/src/in-page-channel/types.test-d.ts create mode 100644 packages/devframe/vitest.config.ts diff --git a/docs/content/1.guide/12.in-page-channel.md b/docs/content/1.guide/12.in-page-channel.md index bf2ca5f9..6f7b8aca 100644 --- a/docs/content/1.guide/12.in-page-channel.md +++ b/docs/content/1.guide/12.in-page-channel.md @@ -54,30 +54,30 @@ 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 shape as `defineRpcFunction` (`name`, `type`, Standard-Schema `args`/`returns`, `jsonSerializable`, `handler`), narrowed to the browser. Each inline definition is contextually typed from its `name` and the corresponding function in the protocol. `defineChannelFunction` provides the same shape when defining a function outside an endpoint's options. 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', type: 'event', // fire-and-forget jsonSerializable: true, - handler: (selector: string) => drawRing(document.querySelector(selector)), - }), - defineChannelFunction({ + handler: selector => drawRing(document.querySelector(selector)), + }, + { name: 'measure', // request/response (the default `query` type) - handler: (selector: string) => { + handler: (selector) => { const rect = document.querySelector(selector)!.getBoundingClientRect() return { width: rect.width, height: rect.height } }, - }), + }, ], }) 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..df9d92f0 100644 --- a/packages/devframe/src/in-page-channel/in-page-channel.test.ts +++ b/packages/devframe/src/in-page-channel/in-page-channel.test.ts @@ -1,4 +1,4 @@ -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' @@ -40,8 +40,8 @@ function until(predicate: () => boolean, timeoutMs = 2000): Promise { const noHandshake = { window: false as const, heartbeat: false as const } 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({ diff --git a/packages/devframe/src/in-page-channel/index.ts b/packages/devframe/src/in-page-channel/index.ts index f053b940..f1d25433 100644 --- a/packages/devframe/src/in-page-channel/index.ts +++ b/packages/devframe/src/in-page-channel/index.ts @@ -17,6 +17,7 @@ export type { InPageChannelProtocol, InPageChannelStatus, InPageFunctionDefinition, + InPageFunctionDefinitionFor, PageScriptChannel, PanelChannel, PanelPeer, @@ -31,14 +32,37 @@ export type { * constant. */ export function defineChannelFunction< - NAME extends string, - TYPE extends InPageFunctionType, + const NAME extends string, ARGS extends any[], RETURN = void, - const AS extends RpcArgsSchema | undefined = undefined, - const RS extends RpcReturnSchema | undefined = undefined, + TYPE extends InPageFunctionType = 'query', >( - definition: InPageFunctionDefinition, -): InPageFunctionDefinition { + definition: { + name: NAME + type?: TYPE + args?: undefined + returns?: undefined + jsonSerializable?: boolean + handler: (...args: ARGS) => RETURN + }, +): InPageFunctionDefinition +export function defineChannelFunction< + const NAME extends string, + const AS extends RpcArgsSchema, + const RS extends RpcReturnSchema, + TYPE extends InPageFunctionType = 'query', +>( + definition: { + name: NAME + type?: TYPE + args: AS + returns: RS + jsonSerializable?: boolean + handler: InPageFunctionDefinition['handler'] + }, +): InPageFunctionDefinition +export function defineChannelFunction( + definition: InPageFunctionDefinition, +): InPageFunctionDefinition { return definition } diff --git a/packages/devframe/src/in-page-channel/page-script.ts b/packages/devframe/src/in-page-channel/page-script.ts index 871ea536..2c3dcbc4 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 diff --git a/packages/devframe/src/in-page-channel/panel.ts b/packages/devframe/src/in-page-channel/panel.ts index 4bf17af2..ec64e1a2 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 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..bb2fa4c5 --- /dev/null +++ b/packages/devframe/src/in-page-channel/types.test-d.ts @@ -0,0 +1,79 @@ +import type { InPageChannelProtocol } from './types' +import { describe, it } from 'vitest' +import { createPageScriptChannel } from './page-script' +import { connectPanelChannel } from './panel' + +interface TestProtocol extends InPageChannelProtocol { + pageScript: { + echo: (value: string) => string + sum: (a: number, b: number) => number + save: (value: string) => Promise + } + panel: { + notify: (message: string) => void + } +} + +describe('in-page channel function definitions', () => { + it('infers page-script handlers from the protocol name', () => { + createPageScriptChannel({ + name: 'devframes:test', + functions: [ + { + name: 'echo', + handler: value => value.toUpperCase(), + }, + { + name: 'sum', + handler: (a, b) => a + b, + }, + { + name: 'save', + handler: () => {}, + }, + ], + }) + }) + + it('infers panel handlers from the protocol name', () => { + const { port1 } = new MessageChannel() + const channel = connectPanelChannel({ + name: 'devframes:test', + window: false, + transport: port1, + functions: [{ + name: 'notify', + handler: (message) => { + void message + }, + }], + }) + channel.close() + }) + + it('rejects names from the remote side', () => { + createPageScriptChannel({ + name: 'devframes:test', + functions: [ + { + // @ts-expect-error `notify` is implemented by panels. + name: 'notify', + handler: (message: string) => void message, + }, + ], + }) + }) + + it('rejects handlers incompatible with the named protocol function', () => { + createPageScriptChannel({ + name: 'devframes:test', + functions: [ + // @ts-expect-error `echo` accepts and returns a string. + { + name: 'echo', + handler: (value: number) => value, + }, + ], + }) + }) +}) diff --git a/packages/devframe/src/in-page-channel/types.ts b/packages/devframe/src/in-page-channel/types.ts index 59965dd5..599f6c53 100644 --- a/packages/devframe/src/in-page-channel/types.ts +++ b/packages/devframe/src/in-page-channel/types.ts @@ -30,6 +30,9 @@ type SharedStates

type FnArgs = F extends (...args: infer A) => any ? A : never type FnReturn = F extends (...args: any[]) => infer R ? Awaited : never +type ProtocolHandler = F extends (...args: any[]) => any + ? (...args: FnArgs) => Thenable> + : never /** * Types of an in-page channel function — `RpcFunctionType` minus the @@ -78,6 +81,40 @@ export type InPageFunctionDefinition< /** Loosely-typed definition — the registration unit both endpoints accept. */ export type InPageFunctionDefinitionAny = InPageFunctionDefinition +type ProtocolSide = 'pageScript' | 'panel' +type ProtocolSideFunctions< + P extends InPageChannelProtocol, + SIDE extends ProtocolSide, +> = SideFunctions> + +/** + * A function definition constrained by one side of an in-page channel + * protocol. The `name` discriminant selects the matching protocol function, + * which contextually types the handler's arguments and return value. + */ +export type InPageFunctionDefinitionFor< + P extends InPageChannelProtocol, + SIDE extends ProtocolSide, +> = { + [NAME in keyof ProtocolSideFunctions & string]: { + name: NAME + 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[NAME]> + } +}[keyof ProtocolSideFunctions & string] + +type InPageFunctionOption< + P extends InPageChannelProtocol, + SIDE extends ProtocolSide, +> = InPageChannelProtocol extends P + ? InPageFunctionDefinitionAny + : InPageFunctionDefinitionFor + /** * Connection lifecycle of a panel endpoint: `connecting` (handshake retry * loop running, outgoing traffic buffered) → `connected` → back to @@ -91,8 +128,6 @@ interface InPageChannelCommonOptions { * (e.g. `devframes:plugin:a11y`). Both endpoints must use the same name. */ name: string - /** Implementations of this endpoint's side of the protocol. */ - functions?: readonly InPageFunctionDefinitionAny[] /** * Origins accepted during the handshake (and used as `targetOrigin` when * posting handshake messages). The in-page channel is same-origin by @@ -123,7 +158,9 @@ interface InPageChannelCommonOptions { } /** Options for {@link createPageScriptChannel}. */ -export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions { +export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions { + /** Implementations of the protocol's page-script functions. */ + functions?: readonly InPageFunctionOption[] /** * Window whose `message` events carry panel hellos. Defaults to the * global `window`; pass `false` to skip the handshake listener entirely @@ -133,7 +170,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?: readonly InPageFunctionOption[] /** * 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', + }, + }, +}) From 23b3cedf09e6f1ad05b4091133d574de2834f163 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Fri, 28 Aug 2026 10:01:53 +0200 Subject: [PATCH 2/9] refactor: require in-page channel function maps --- docs/content/1.guide/12.in-page-channel.md | 12 ++-- .../in-page-channel/in-page-channel.test.ts | 71 +++++++++++-------- .../src/in-page-channel/page-script.ts | 4 +- .../devframe/src/in-page-channel/panel.ts | 4 +- .../src/in-page-channel/types.test-d.ts | 61 +++++++++------- .../devframe/src/in-page-channel/types.ts | 15 ++-- plugins/a11y/src/inject/index.ts | 46 +++++------- 7 files changed, 114 insertions(+), 99 deletions(-) diff --git a/docs/content/1.guide/12.in-page-channel.md b/docs/content/1.guide/12.in-page-channel.md index 6f7b8aca..6916a7a4 100644 --- a/docs/content/1.guide/12.in-page-channel.md +++ b/docs/content/1.guide/12.in-page-channel.md @@ -54,7 +54,7 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names ## The page script endpoint -Functions use the same authoring shape as `defineRpcFunction` (`name`, `type`, Standard-Schema `args`/`returns`, `jsonSerializable`, `handler`), narrowed to the browser. Each inline definition is contextually typed from its `name` and the corresponding function in the protocol. `defineChannelFunction` provides the same shape when defining a function outside an endpoint's options. 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 `functions` object's keys are the function names, and every function on that endpoint's protocol side is required when the object is provided. 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' @@ -64,21 +64,19 @@ import { MY_CHANNEL } from '../shared/protocol' const channel = createPageScriptChannel({ name: MY_CHANNEL, - functions: [ - { - name: 'highlight', + functions: { + highlight: { type: 'event', // fire-and-forget jsonSerializable: true, handler: selector => drawRing(document.querySelector(selector)), }, - { - name: 'measure', // request/response (the default `query` type) + 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 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 df9d92f0..15134c2a 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 { 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,6 +38,19 @@ 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> panel?: Partial> @@ -47,14 +59,13 @@ function createLinkedPair(options?: { 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) @@ -142,14 +153,14 @@ 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({ @@ -182,11 +193,12 @@ 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({ @@ -218,9 +230,7 @@ describe('in-page channel over bring-your-own ports', () => { 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,9 +247,7 @@ 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({ @@ -443,7 +451,7 @@ 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', @@ -465,7 +473,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) @@ -497,12 +508,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') diff --git a/packages/devframe/src/in-page-channel/page-script.ts b/packages/devframe/src/in-page-channel/page-script.ts index 2c3dcbc4..2756d22d 100644 --- a/packages/devframe/src/in-page-channel/page-script.ts +++ b/packages/devframe/src/in-page-channel/page-script.ts @@ -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 ec64e1a2..91f46a86 100644 --- a/packages/devframe/src/in-page-channel/panel.ts +++ b/packages/devframe/src/in-page-channel/panel.ts @@ -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 index bb2fa4c5..a46ae384 100644 --- a/packages/devframe/src/in-page-channel/types.test-d.ts +++ b/packages/devframe/src/in-page-channel/types.test-d.ts @@ -18,20 +18,17 @@ describe('in-page channel function definitions', () => { it('infers page-script handlers from the protocol name', () => { createPageScriptChannel({ name: 'devframes:test', - functions: [ - { - name: 'echo', + functions: { + echo: { handler: value => value.toUpperCase(), }, - { - name: 'sum', + sum: { handler: (a, b) => a + b, }, - { - name: 'save', + save: { handler: () => {}, }, - ], + }, }) }) @@ -41,39 +38,51 @@ describe('in-page channel function definitions', () => { name: 'devframes:test', window: false, transport: port1, - functions: [{ - name: 'notify', - handler: (message) => { - void message + functions: { + notify: { + handler: (message) => { + void message + }, }, - }], + }, }) channel.close() }) - it('rejects names from the remote side', () => { + it('requires every function from the local protocol side', () => { createPageScriptChannel({ name: 'devframes:test', - functions: [ - { - // @ts-expect-error `notify` is implemented by panels. - name: 'notify', - handler: (message: string) => void message, - }, - ], + // @ts-expect-error `sum` and `save` are required. + functions: { + echo: { handler: value => value }, + }, + }) + }) + + it('rejects keys from the remote side', () => { + 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 handlers incompatible with the named protocol function', () => { createPageScriptChannel({ name: 'devframes:test', - functions: [ - // @ts-expect-error `echo` accepts and returns a string. - { - name: 'echo', + functions: { + echo: { + // @ts-expect-error `echo` accepts and returns a string. handler: (value: number) => value, }, - ], + sum: { handler: (a, b) => a + b }, + save: { handler: () => {} }, + }, }) }) }) diff --git a/packages/devframe/src/in-page-channel/types.ts b/packages/devframe/src/in-page-channel/types.ts index 599f6c53..905e6545 100644 --- a/packages/devframe/src/in-page-channel/types.ts +++ b/packages/devframe/src/in-page-channel/types.ts @@ -108,12 +108,17 @@ export type InPageFunctionDefinitionFor< } }[keyof ProtocolSideFunctions & string] -type InPageFunctionOption< +type InPageFunctionOptions< P extends InPageChannelProtocol, SIDE extends ProtocolSide, > = InPageChannelProtocol extends P - ? InPageFunctionDefinitionAny - : InPageFunctionDefinitionFor + ? Record> + : { + [NAME in keyof ProtocolSideFunctions & string]: Omit< + Extract, { name: NAME }>, + 'name' + > + } /** * Connection lifecycle of a panel endpoint: `connecting` (handshake retry @@ -160,7 +165,7 @@ interface InPageChannelCommonOptions { /** Options for {@link createPageScriptChannel}. */ export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions { /** Implementations of the protocol's page-script functions. */ - functions?: readonly InPageFunctionOption[] + functions?: InPageFunctionOptions /** * Window whose `message` events carry panel hellos. Defaults to the * global `window`; pass `false` to skip the handshake listener entirely @@ -172,7 +177,7 @@ export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions { /** Implementations of the protocol's panel functions. */ - functions?: readonly InPageFunctionOption[] + functions?: InPageFunctionOptions /** * 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/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 From 6598c40fc4ddf82d46a14474ec5a827d1e8ef5cf Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Fri, 28 Aug 2026 10:28:16 +0200 Subject: [PATCH 3/9] refactor: simplify in-page channel option types --- .../devframe/src/in-page-channel/index.ts | 36 ++------ .../src/in-page-channel/types.test-d.ts | 19 +++-- .../devframe/src/in-page-channel/types.ts | 83 ++++++++++--------- 3 files changed, 66 insertions(+), 72 deletions(-) diff --git a/packages/devframe/src/in-page-channel/index.ts b/packages/devframe/src/in-page-channel/index.ts index f1d25433..f053b940 100644 --- a/packages/devframe/src/in-page-channel/index.ts +++ b/packages/devframe/src/in-page-channel/index.ts @@ -17,7 +17,6 @@ export type { InPageChannelProtocol, InPageChannelStatus, InPageFunctionDefinition, - InPageFunctionDefinitionFor, PageScriptChannel, PanelChannel, PanelPeer, @@ -32,37 +31,14 @@ export type { * constant. */ export function defineChannelFunction< - const NAME extends string, + NAME extends string, + TYPE extends InPageFunctionType, ARGS extends any[], RETURN = void, - TYPE extends InPageFunctionType = 'query', + const AS extends RpcArgsSchema | undefined = undefined, + const RS extends RpcReturnSchema | undefined = undefined, >( - definition: { - name: NAME - type?: TYPE - args?: undefined - returns?: undefined - jsonSerializable?: boolean - handler: (...args: ARGS) => RETURN - }, -): InPageFunctionDefinition -export function defineChannelFunction< - const NAME extends string, - const AS extends RpcArgsSchema, - const RS extends RpcReturnSchema, - TYPE extends InPageFunctionType = 'query', ->( - definition: { - name: NAME - type?: TYPE - args: AS - returns: RS - jsonSerializable?: boolean - handler: InPageFunctionDefinition['handler'] - }, -): InPageFunctionDefinition -export function defineChannelFunction( - definition: InPageFunctionDefinition, -): InPageFunctionDefinition { + definition: InPageFunctionDefinition, +): InPageFunctionDefinition { return definition } 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 a46ae384..1b884874 100644 --- a/packages/devframe/src/in-page-channel/types.test-d.ts +++ b/packages/devframe/src/in-page-channel/types.test-d.ts @@ -1,5 +1,5 @@ import type { InPageChannelProtocol } from './types' -import { describe, it } from 'vitest' +import { describe, expectTypeOf, it } from 'vitest' import { createPageScriptChannel } from './page-script' import { connectPanelChannel } from './panel' @@ -20,13 +20,22 @@ describe('in-page channel function definitions', () => { name: 'devframes:test', functions: { echo: { - handler: value => value.toUpperCase(), + handler: (value) => { + expectTypeOf(value).toEqualTypeOf() + return value.toUpperCase() + }, }, sum: { - handler: (a, b) => a + b, + handler: (a, b) => { + expectTypeOf(a).toEqualTypeOf() + expectTypeOf(b).toEqualTypeOf() + return a + b + }, }, save: { - handler: () => {}, + handler: (value) => { + expectTypeOf(value).toEqualTypeOf() + }, }, }, }) @@ -41,7 +50,7 @@ describe('in-page channel function definitions', () => { functions: { notify: { handler: (message) => { - void message + expectTypeOf(message).toEqualTypeOf() }, }, }, diff --git a/packages/devframe/src/in-page-channel/types.ts b/packages/devframe/src/in-page-channel/types.ts index 905e6545..5230c995 100644 --- a/packages/devframe/src/in-page-channel/types.ts +++ b/packages/devframe/src/in-page-channel/types.ts @@ -30,6 +30,12 @@ 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 @@ -78,47 +84,45 @@ 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 -type ProtocolSide = 'pageScript' | 'panel' -type ProtocolSideFunctions< - P extends InPageChannelProtocol, - SIDE extends ProtocolSide, -> = SideFunctions> +/** + * 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 +} /** - * A function definition constrained by one side of an in-page channel - * protocol. The `name` discriminant selects the matching protocol function, - * which contextually types the handler's arguments and return value. + * Functions implemented by {@link createPageScriptChannel}. + * + * @internal */ -export type InPageFunctionDefinitionFor< - P extends InPageChannelProtocol, - SIDE extends ProtocolSide, -> = { - [NAME in keyof ProtocolSideFunctions & string]: { - name: NAME - 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[NAME]> - } -}[keyof ProtocolSideFunctions & string] +type CreatePageScriptChannelOptionsFunctions

= { + [NAME in keyof PageScriptFunctions

& string]: InPageFunctionOption[NAME]> +} -type InPageFunctionOptions< - P extends InPageChannelProtocol, - SIDE extends ProtocolSide, -> = InPageChannelProtocol extends P - ? Record> - : { - [NAME in keyof ProtocolSideFunctions & string]: Omit< - Extract, { name: NAME }>, - '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 @@ -127,6 +131,11 @@ type InPageFunctionOptions< */ export type InPageChannelStatus = 'connecting' | 'connected' | 'closed' +/** + * Options shared by both in-page channel endpoints. + * + * @internal + */ interface InPageChannelCommonOptions { /** * Channel name, namespaced with the devframe id by convention @@ -165,7 +174,7 @@ interface InPageChannelCommonOptions { /** Options for {@link createPageScriptChannel}. */ export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions { /** Implementations of the protocol's page-script functions. */ - functions?: InPageFunctionOptions + functions?: CreatePageScriptChannelOptionsFunctions /** * Window whose `message` events carry panel hellos. Defaults to the * global `window`; pass `false` to skip the handshake listener entirely @@ -177,7 +186,7 @@ export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions { /** Implementations of the protocol's panel functions. */ - functions?: InPageFunctionOptions + 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. From 4e1634b5e7b6ac9dd98e1e811b30404763120a6f Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Fri, 28 Aug 2026 10:35:18 +0200 Subject: [PATCH 4/9] test: cover in-page channel endpoint types --- .../src/in-page-channel/types.test-d.ts | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) 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 1b884874..9f1f9389 100644 --- a/packages/devframe/src/in-page-channel/types.test-d.ts +++ b/packages/devframe/src/in-page-channel/types.test-d.ts @@ -95,3 +95,101 @@ describe('in-page channel function definitions', () => { }) }) }) + +describe('page-script channel types', () => { + it('types calls to panel functions', () => { + const channel = createPageScriptChannel({ name: 'devframes:test' }) + + expectTypeOf(channel.callEvent('notify', 'ready')).toEqualTypeOf() + + // @ts-expect-error 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 connected panel peers and their calls', () => { + const channel = createPageScriptChannel({ name: 'devframes:test' }) + const unsubscribe = channel.events.on('panel:connected', (panel) => { + expectTypeOf(panel.id).toEqualTypeOf() + expectTypeOf(panel.call('notify', 'ready')).toEqualTypeOf>() + expectTypeOf(panel.close()).toEqualTypeOf() + + // @ts-expect-error 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) + }) + + expectTypeOf(unsubscribe).toEqualTypeOf<() => void>() + }) + + it('types disconnected panel peers and one-time listeners', () => { + const channel = createPageScriptChannel({ name: 'devframes:test' }) + const unsubscribe = channel.events.once('panel:disconnected', (panel) => { + expectTypeOf(panel.id).toEqualTypeOf() + expectTypeOf(panel.call('notify', 'bye')).toEqualTypeOf>() + }) + + expectTypeOf(unsubscribe).toEqualTypeOf<() => void>() + + // @ts-expect-error Unknown page-script channel lifecycle event. + channel.events.on('status:updated', () => {}) + }) +}) + +describe('panel channel types', () => { + it('types calls and their resolved results', () => { + const channel = connectPanelChannel({ name: 'devframes:test' }) + + 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 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 page-script functions', () => { + const channel = connectPanelChannel({ name: 'devframes:test' }) + + 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 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 status listeners and channel state', () => { + const channel = connectPanelChannel({ name: 'devframes:test' }) + const unsubscribe = channel.events.on('status:updated', (status) => { + expectTypeOf(status).toEqualTypeOf<'connecting' | 'connected' | 'closed'>() + }) + + expectTypeOf(unsubscribe).toEqualTypeOf<() => void>() + expectTypeOf(channel.status).toEqualTypeOf<'connecting' | 'connected' | 'closed'>() + expectTypeOf(channel.pageScript).toEqualTypeOf<{ instanceId: string } | undefined>() + expectTypeOf(channel.whenConnected()).toEqualTypeOf>() + expectTypeOf(channel.whenConnected(1_000)).toEqualTypeOf>() + + // @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) + }) +}) From 91178a51b4879e4bcbc3ad2ed531121671958fb9 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Fri, 28 Aug 2026 10:39:45 +0200 Subject: [PATCH 5/9] refactor: require in-page channel functions --- docs/content/1.guide/12.in-page-channel.md | 11 +++++-- .../in-page-channel/in-page-channel.test.ts | 31 +++++++++++++++---- .../src/in-page-channel/types.test-d.ts | 30 ++++++++++++------ .../devframe/src/in-page-channel/types.ts | 4 +-- plugins/a11y/src/spa/lib/channel.ts | 5 ++- 5 files changed, 61 insertions(+), 20 deletions(-) diff --git a/docs/content/1.guide/12.in-page-channel.md b/docs/content/1.guide/12.in-page-channel.md index 6916a7a4..c7120ff3 100644 --- a/docs/content/1.guide/12.in-page-channel.md +++ b/docs/content/1.guide/12.in-page-channel.md @@ -54,7 +54,7 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names ## The page script endpoint -Functions use the same authoring metadata as `defineRpcFunction` (`type`, Standard-Schema `args`/`returns`, `jsonSerializable`, `handler`), narrowed to the browser. The `functions` object's keys are the function names, and every function on that endpoint's protocol side is required when the object is provided. 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. +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' @@ -94,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 15134c2a..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 @@ -73,6 +73,7 @@ function createLinkedPair(options?: { name: 'devframes:test', ...noHandshake, transport: port2, + functions: defaultPanelFunctions, ...options?.panel, }) return { @@ -167,6 +168,7 @@ describe('in-page channel over bring-your-own ports', () => { name: 'devframes:test', ...noHandshake, transport: port2, + functions: defaultPanelFunctions, }) try { await expect(panel.call('note', 'fine')).resolves.toBeUndefined() @@ -185,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) @@ -200,11 +203,12 @@ describe('in-page channel over bring-your-own ports', () => { } }, }, }) - // 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) @@ -224,6 +228,7 @@ describe('in-page channel over bring-your-own ports', () => { const pageScript = createPageScriptChannel({ name: 'devframes:test', ...noHandshake, + functions: defaultPageScriptFunctions, }) pageScript.addPanelPort(port1) const panel = connectPanelChannel({ @@ -254,6 +259,7 @@ describe('in-page channel over bring-your-own ports', () => { 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 @@ -274,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[] = [] @@ -284,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) @@ -324,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') @@ -349,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 @@ -359,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 }) @@ -458,6 +467,7 @@ describe('in-page channel handshake', () => { window: panelWin as unknown as Window, targets: [hostWin as unknown as Window], ...fastHello, + functions: defaultPanelFunctions, }) try { await panel.whenConnected(2000) @@ -500,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') @@ -533,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({ @@ -563,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({ @@ -592,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', @@ -599,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' }) @@ -609,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) @@ -629,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') @@ -647,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) @@ -664,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/types.test-d.ts b/packages/devframe/src/in-page-channel/types.test-d.ts index 9f1f9389..79a326cf 100644 --- a/packages/devframe/src/in-page-channel/types.test-d.ts +++ b/packages/devframe/src/in-page-channel/types.test-d.ts @@ -59,6 +59,11 @@ describe('in-page channel function definitions', () => { }) it('requires every function from the local protocol side', () => { + // @ts-expect-error `functions` is required. + createPageScriptChannel({ name: 'devframes:test' }) + // @ts-expect-error `functions` is required. + connectPanelChannel({ name: 'devframes:test' }) + createPageScriptChannel({ name: 'devframes:test', // @ts-expect-error `sum` and `save` are required. @@ -97,9 +102,16 @@ describe('in-page channel function definitions', () => { }) describe('page-script channel types', () => { - it('types calls to panel functions', () => { - const channel = createPageScriptChannel({ name: 'devframes:test' }) + const channel = createPageScriptChannel({ + name: 'devframes:test', + functions: { + echo: { handler: value => value }, + sum: { handler: (a, b) => a + b }, + save: { handler: () => {} }, + }, + }) + it('types calls to panel functions', () => { expectTypeOf(channel.callEvent('notify', 'ready')).toEqualTypeOf() // @ts-expect-error Page-script functions cannot be called on panels. @@ -113,7 +125,6 @@ describe('page-script channel types', () => { }) it('types connected panel peers and their calls', () => { - const channel = createPageScriptChannel({ name: 'devframes:test' }) const unsubscribe = channel.events.on('panel:connected', (panel) => { expectTypeOf(panel.id).toEqualTypeOf() expectTypeOf(panel.call('notify', 'ready')).toEqualTypeOf>() @@ -129,7 +140,6 @@ describe('page-script channel types', () => { }) it('types disconnected panel peers and one-time listeners', () => { - const channel = createPageScriptChannel({ name: 'devframes:test' }) const unsubscribe = channel.events.once('panel:disconnected', (panel) => { expectTypeOf(panel.id).toEqualTypeOf() expectTypeOf(panel.call('notify', 'bye')).toEqualTypeOf>() @@ -143,9 +153,14 @@ describe('page-script channel types', () => { }) describe('panel channel types', () => { - it('types calls and their resolved results', () => { - const channel = connectPanelChannel({ name: 'devframes:test' }) + const channel = connectPanelChannel({ + name: 'devframes:test', + functions: { + notify: { handler: () => {} }, + }, + }) + 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>() @@ -161,8 +176,6 @@ describe('panel channel types', () => { }) it('types fire-and-forget calls to page-script functions', () => { - const channel = connectPanelChannel({ name: 'devframes:test' }) - expectTypeOf(channel.callEvent('echo', 'hello')).toEqualTypeOf() expectTypeOf(channel.callEvent('sum', 1, 2)).toEqualTypeOf() expectTypeOf(channel.callEvent('save', 'draft')).toEqualTypeOf() @@ -176,7 +189,6 @@ describe('panel channel types', () => { }) it('types status listeners and channel state', () => { - const channel = connectPanelChannel({ name: 'devframes:test' }) const unsubscribe = channel.events.on('status:updated', (status) => { expectTypeOf(status).toEqualTypeOf<'connecting' | 'connected' | 'closed'>() }) diff --git a/packages/devframe/src/in-page-channel/types.ts b/packages/devframe/src/in-page-channel/types.ts index 5230c995..43e1f669 100644 --- a/packages/devframe/src/in-page-channel/types.ts +++ b/packages/devframe/src/in-page-channel/types.ts @@ -174,7 +174,7 @@ interface InPageChannelCommonOptions { /** Options for {@link createPageScriptChannel}. */ export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions { /** Implementations of the protocol's page-script functions. */ - functions?: CreatePageScriptChannelOptionsFunctions + functions: CreatePageScriptChannelOptionsFunctions /** * Window whose `message` events carry panel hellos. Defaults to the * global `window`; pass `false` to skip the handshake listener entirely @@ -186,7 +186,7 @@ export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions { /** Implementations of the protocol's panel functions. */ - functions?: ConnectPanelChannelOptionsFunctions + 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/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) => { From 1c06428f34422ed325ffa9485cf3e89ee234e148 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Fri, 28 Aug 2026 10:42:02 +0200 Subject: [PATCH 6/9] test: organize in-page channel type coverage --- .../src/in-page-channel/types.test-d.ts | 341 ++++++++++-------- 1 file changed, 196 insertions(+), 145 deletions(-) diff --git a/packages/devframe/src/in-page-channel/types.test-d.ts b/packages/devframe/src/in-page-channel/types.test-d.ts index 79a326cf..d97be263 100644 --- a/packages/devframe/src/in-page-channel/types.test-d.ts +++ b/packages/devframe/src/in-page-channel/types.test-d.ts @@ -14,145 +14,134 @@ interface TestProtocol extends InPageChannelProtocol { } } -describe('in-page channel function definitions', () => { - it('infers page-script handlers from the protocol name', () => { - createPageScriptChannel({ - name: 'devframes:test', - functions: { - echo: { - handler: (value) => { - expectTypeOf(value).toEqualTypeOf() - return value.toUpperCase() +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 + sum: { + handler: (a, b) => { + expectTypeOf(a).toEqualTypeOf() + expectTypeOf(b).toEqualTypeOf() + return a + b + }, }, - }, - save: { - handler: (value) => { - expectTypeOf(value).toEqualTypeOf() + save: { + handler: (value) => { + expectTypeOf(value).toEqualTypeOf() + }, }, }, - }, + }) }) - }) - it('infers panel handlers from the protocol name', () => { - const { port1 } = new MessageChannel() - const channel = connectPanelChannel({ - name: 'devframes:test', - window: false, - transport: port1, - functions: { - notify: { - handler: (message) => { - expectTypeOf(message).toEqualTypeOf() - }, - }, - }, - }) - channel.close() - }) + it('requires every in-page script function', () => { + // @ts-expect-error `functions` is required. + createPageScriptChannel({ name: 'devframes:test' }) - it('requires every function from the local protocol side', () => { - // @ts-expect-error `functions` is required. - createPageScriptChannel({ name: 'devframes:test' }) - // @ts-expect-error `functions` is required. - connectPanelChannel({ name: 'devframes:test' }) - - createPageScriptChannel({ - name: 'devframes:test', - // @ts-expect-error `sum` and `save` are required. - functions: { - echo: { handler: value => value }, - }, + createPageScriptChannel({ + name: 'devframes:test', + // @ts-expect-error `sum` and `save` are required. + functions: { + echo: { handler: value => value }, + }, + }) }) - }) - it('rejects keys from the remote side', () => { - 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 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 handlers incompatible with the named protocol function', () => { - createPageScriptChannel({ - name: 'devframes:test', - functions: { - echo: { - // @ts-expect-error `echo` accepts and returns a string. - handler: (value: number) => value, + 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: () => {} }, }, - sum: { handler: (a, b) => a + b }, - save: { handler: () => {} }, - }, + }) }) }) -}) -describe('page-script channel types', () => { - const channel = createPageScriptChannel({ - name: 'devframes:test', - functions: { - echo: { handler: value => 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() - it('types calls to panel functions', () => { - expectTypeOf(channel.callEvent('notify', 'ready')).toEqualTypeOf() - - // @ts-expect-error 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') - }) + // @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]! - it('types connected panel peers and their calls', () => { - const unsubscribe = channel.events.on('panel:connected', (panel) => { - expectTypeOf(panel.id).toEqualTypeOf() expectTypeOf(panel.call('notify', 'ready')).toEqualTypeOf>() expectTypeOf(panel.close()).toEqualTypeOf() - // @ts-expect-error Page-script functions cannot be called on a panel peer. + // @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) }) - - expectTypeOf(unsubscribe).toEqualTypeOf<() => void>() }) - it('types disconnected panel peers and one-time listeners', () => { - const unsubscribe = channel.events.once('panel:disconnected', (panel) => { - expectTypeOf(panel.id).toEqualTypeOf() - expectTypeOf(panel.call('notify', 'bye')).toEqualTypeOf>() - }) + 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(unsubscribe).toEqualTypeOf<() => void>() + expectTypeOf(unsubscribeConnected).toEqualTypeOf<() => void>() + expectTypeOf(unsubscribeDisconnected).toEqualTypeOf<() => void>() + }) - // @ts-expect-error Unknown page-script channel lifecycle event. - channel.events.on('status:updated', () => {}) + it('rejects panel channel events', () => { + // @ts-expect-error Unknown in-page script channel lifecycle event. + channel.events.on('status:updated', () => {}) + }) }) }) -describe('panel channel types', () => { +describe('Panel channel', () => { const channel = connectPanelChannel({ name: 'devframes:test', functions: { @@ -160,48 +149,110 @@ describe('panel channel types', () => { }, }) - 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 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') - }) + 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('types fire-and-forget calls to 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 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('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('types status listeners and channel state', () => { - const unsubscribe = channel.events.on('status:updated', (status) => { - expectTypeOf(status).toEqualTypeOf<'connecting' | 'connected' | 'closed'>() + 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') }) - expectTypeOf(unsubscribe).toEqualTypeOf<() => void>() - expectTypeOf(channel.status).toEqualTypeOf<'connecting' | 'connected' | 'closed'>() - expectTypeOf(channel.pageScript).toEqualTypeOf<{ instanceId: string } | undefined>() - expectTypeOf(channel.whenConnected()).toEqualTypeOf>() - expectTypeOf(channel.whenConnected(1_000)).toEqualTypeOf>() + 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) + }) - // @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) + 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) + }) }) }) From 3947113ccdba3dc6b0b9b03bb60f00b889e9b546 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Fri, 28 Aug 2026 11:45:30 +0200 Subject: [PATCH 7/9] test: update in-page channel API snapshot --- .../tsnapi/devframe/in-page-channel.snapshot.d.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 From 9541c9a5a0cdd3cde29c751d232ba74a2c237fba Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Fri, 28 Aug 2026 12:04:51 +0200 Subject: [PATCH 8/9] fix: keep omitted channel sides strict --- .../src/in-page-channel/page-script.ts | 3 +- .../devframe/src/in-page-channel/panel.ts | 3 +- .../src/in-page-channel/types.test-d.ts | 33 +++++++++++++++++++ .../devframe/src/in-page-channel/types.ts | 18 +++++++--- 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/packages/devframe/src/in-page-channel/page-script.ts b/packages/devframe/src/in-page-channel/page-script.ts index 2756d22d..73d639eb 100644 --- a/packages/devframe/src/in-page-channel/page-script.ts +++ b/packages/devframe/src/in-page-channel/page-script.ts @@ -2,6 +2,7 @@ import type { AttachedChannelPort } from './internal' import type { CreatePageScriptChannelOptions, InPageChannelProtocol, + InPageFunctionDefinitionAny, PageScriptChannel, PageScriptChannelEvents, PanelPeer, @@ -63,7 +64,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) as [string, Omit][]) registry.register({ ...definition, name: fnName }) const stateHost = createPageScriptStateHost

(function* () { diff --git a/packages/devframe/src/in-page-channel/panel.ts b/packages/devframe/src/in-page-channel/panel.ts index 91f46a86..1a0488ec 100644 --- a/packages/devframe/src/in-page-channel/panel.ts +++ b/packages/devframe/src/in-page-channel/panel.ts @@ -3,6 +3,7 @@ import type { ConnectPanelChannelOptions, InPageChannelProtocol, InPageChannelStatus, + InPageFunctionDefinitionAny, PanelChannel, PanelChannelEvents, } from './types' @@ -62,7 +63,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) as [string, Omit][]) registry.register({ ...definition, name: fnName }) let status: InPageChannelStatus = 'connecting' diff --git a/packages/devframe/src/in-page-channel/types.test-d.ts b/packages/devframe/src/in-page-channel/types.test-d.ts index d97be263..8dbac397 100644 --- a/packages/devframe/src/in-page-channel/types.test-d.ts +++ b/packages/devframe/src/in-page-channel/types.test-d.ts @@ -14,6 +14,12 @@ interface TestProtocol extends InPageChannelProtocol { } } +interface PageScriptOnlyProtocol extends InPageChannelProtocol { + pageScript: { + echo: (value: string) => string + } +} + describe('In-page script channel', () => { const channel = createPageScriptChannel({ name: 'devframes:test', @@ -117,6 +123,18 @@ describe('In-page script channel', () => { // @ts-expect-error `notify` requires a string. panel.call('notify', false) }) + + it('rejects calls when the protocol omits 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', () => { @@ -200,6 +218,21 @@ describe('Panel channel', () => { }, }) }) + + it('rejects definitions when the protocol omits panel functions', () => { + 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', () => { diff --git a/packages/devframe/src/in-page-channel/types.ts b/packages/devframe/src/in-page-channel/types.ts index 43e1f669..628f13a6 100644 --- a/packages/devframe/src/in-page-channel/types.ts +++ b/packages/devframe/src/in-page-channel/types.ts @@ -22,9 +22,13 @@ export interface InPageChannelProtocol { sharedStates?: Record } -type SideFunctions = S extends Record any> ? S : Record -type PageScriptFunctions

= SideFunctions> -type PanelFunctions

= SideFunctions> +type SideFunctions = InPageChannelProtocol extends P + ? Record any> + : S extends Record any> + ? string extends keyof S ? Record : S + : Record +type PageScriptFunctions

= SideFunctions> +type PanelFunctions

= SideFunctions> type SharedStates

= P['sharedStates'] extends Record ? P['sharedStates'] : Record @@ -113,7 +117,9 @@ interface InPageFunctionOption { */ type CreatePageScriptChannelOptionsFunctions

= { [NAME in keyof PageScriptFunctions

& string]: InPageFunctionOption[NAME]> -} +} extends infer FUNCTIONS + ? keyof FUNCTIONS extends never ? Record : FUNCTIONS + : never /** * Functions implemented by {@link connectPanelChannel}. @@ -122,7 +128,9 @@ type CreatePageScriptChannelOptionsFunctions

= */ type ConnectPanelChannelOptionsFunctions

= { [NAME in keyof PanelFunctions

& string]: InPageFunctionOption[NAME]> -} +} extends infer FUNCTIONS + ? keyof FUNCTIONS extends never ? Record : FUNCTIONS + : never /** * Connection lifecycle of a panel endpoint: `connecting` (handshake retry From 28503b5c0421b357112e5caabe69d8adc614b8c7 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Fri, 28 Aug 2026 12:08:46 +0200 Subject: [PATCH 9/9] fix: declare empty a11y panel protocol --- .../src/in-page-channel/page-script.ts | 3 +-- packages/devframe/src/in-page-channel/panel.ts | 3 +-- .../src/in-page-channel/types.test-d.ts | 10 +++++----- packages/devframe/src/in-page-channel/types.ts | 18 +++++------------- plugins/a11y/src/shared/protocol.ts | 5 ++--- 5 files changed, 14 insertions(+), 25 deletions(-) diff --git a/packages/devframe/src/in-page-channel/page-script.ts b/packages/devframe/src/in-page-channel/page-script.ts index 73d639eb..2756d22d 100644 --- a/packages/devframe/src/in-page-channel/page-script.ts +++ b/packages/devframe/src/in-page-channel/page-script.ts @@ -2,7 +2,6 @@ import type { AttachedChannelPort } from './internal' import type { CreatePageScriptChannelOptions, InPageChannelProtocol, - InPageFunctionDefinitionAny, PageScriptChannel, PageScriptChannelEvents, PanelPeer, @@ -64,7 +63,7 @@ export function createPageScriptChannel

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

(function* () { diff --git a/packages/devframe/src/in-page-channel/panel.ts b/packages/devframe/src/in-page-channel/panel.ts index 1a0488ec..91f46a86 100644 --- a/packages/devframe/src/in-page-channel/panel.ts +++ b/packages/devframe/src/in-page-channel/panel.ts @@ -3,7 +3,6 @@ import type { ConnectPanelChannelOptions, InPageChannelProtocol, InPageChannelStatus, - InPageFunctionDefinitionAny, PanelChannel, PanelChannelEvents, } from './types' @@ -63,7 +62,7 @@ export function connectPanelChannel

( const events = createEventEmitter() const registry = createLocalFunctionRegistry(codec) - for (const [fnName, definition] of Object.entries(options.functions) as [string, Omit][]) + for (const [fnName, definition] of Object.entries(options.functions ?? {})) registry.register({ ...definition, name: fnName }) let status: InPageChannelStatus = 'connecting' diff --git a/packages/devframe/src/in-page-channel/types.test-d.ts b/packages/devframe/src/in-page-channel/types.test-d.ts index 8dbac397..d3962036 100644 --- a/packages/devframe/src/in-page-channel/types.test-d.ts +++ b/packages/devframe/src/in-page-channel/types.test-d.ts @@ -1,9 +1,8 @@ -import type { InPageChannelProtocol } from './types' import { describe, expectTypeOf, it } from 'vitest' import { createPageScriptChannel } from './page-script' import { connectPanelChannel } from './panel' -interface TestProtocol extends InPageChannelProtocol { +interface TestProtocol { pageScript: { echo: (value: string) => string sum: (a: number, b: number) => number @@ -14,10 +13,11 @@ interface TestProtocol extends InPageChannelProtocol { } } -interface PageScriptOnlyProtocol extends InPageChannelProtocol { +interface PageScriptOnlyProtocol { pageScript: { echo: (value: string) => string } + panel: Record } describe('In-page script channel', () => { @@ -124,7 +124,7 @@ describe('In-page script channel', () => { panel.call('notify', false) }) - it('rejects calls when the protocol omits panel functions', () => { + it('rejects calls when the protocol declares no panel functions', () => { const pageScriptOnlyChannel = createPageScriptChannel({ name: 'devframes:page-script-only', functions: { @@ -219,7 +219,7 @@ describe('Panel channel', () => { }) }) - it('rejects definitions when the protocol omits panel functions', () => { + it('accepts an explicitly empty panel function map', () => { connectPanelChannel({ name: 'devframes:page-script-only', functions: {}, diff --git a/packages/devframe/src/in-page-channel/types.ts b/packages/devframe/src/in-page-channel/types.ts index 628f13a6..43e1f669 100644 --- a/packages/devframe/src/in-page-channel/types.ts +++ b/packages/devframe/src/in-page-channel/types.ts @@ -22,13 +22,9 @@ export interface InPageChannelProtocol { sharedStates?: Record } -type SideFunctions = InPageChannelProtocol extends P - ? Record any> - : S extends Record any> - ? string extends keyof S ? Record : S - : Record -type PageScriptFunctions

= SideFunctions> -type PanelFunctions

= SideFunctions> +type SideFunctions = S extends Record any> ? S : Record +type PageScriptFunctions

= SideFunctions> +type PanelFunctions

= SideFunctions> type SharedStates

= P['sharedStates'] extends Record ? P['sharedStates'] : Record @@ -117,9 +113,7 @@ interface InPageFunctionOption { */ type CreatePageScriptChannelOptionsFunctions

= { [NAME in keyof PageScriptFunctions

& string]: InPageFunctionOption[NAME]> -} extends infer FUNCTIONS - ? keyof FUNCTIONS extends never ? Record : FUNCTIONS - : never +} /** * Functions implemented by {@link connectPanelChannel}. @@ -128,9 +122,7 @@ type CreatePageScriptChannelOptionsFunctions

= */ type ConnectPanelChannelOptionsFunctions

= { [NAME in keyof PanelFunctions

& string]: InPageFunctionOption[NAME]> -} extends infer FUNCTIONS - ? keyof FUNCTIONS extends never ? Record : FUNCTIONS - : never +} /** * Connection lifecycle of a panel endpoint: `connecting` (handshake retry 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