Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 10 additions & 12 deletions docs/content/1.guide/12.in-page-channel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `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'
// 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<MyChannelProtocol>({
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
Expand Down
77 changes: 44 additions & 33 deletions packages/devframe/src/in-page-channel/in-page-channel.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -39,22 +38,34 @@ function until(predicate: () => boolean, timeoutMs = 2000): Promise<void> {

const noHandshake = { window: false as const, heartbeat: false as const }

const defaultPageScriptFunctions: NonNullable<CreatePageScriptChannelOptions<TestProtocol>['functions']> = {
echo: { handler: value => value },
sum: { handler: (a, b) => a + b },
boom: { handler: () => {} },
strict: { handler: payload => payload },
note: { type: 'event', handler: () => {} },
}

const defaultPanelFunctions: NonNullable<ConnectPanelChannelOptions<TestProtocol>['functions']> = {
'ping-panel': { handler: value => `pong:${value}` },
'notify': { type: 'event', handler: () => {} },
}

function createLinkedPair(options?: {
pageScript?: Partial<Parameters<typeof createPageScriptChannel>[0]>
panel?: Partial<Parameters<typeof connectPanelChannel>[0]>
pageScript?: Partial<CreatePageScriptChannelOptions<TestProtocol>>
panel?: Partial<ConnectPanelChannelOptions<TestProtocol>>
}): { pageScript: PageScriptChannel<TestProtocol>, panel: PanelChannel<TestProtocol>, dispose: () => void } {
const { port1, port2 } = new MessageChannel()
const pageScript = createPageScriptChannel<TestProtocol>({
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)
Expand Down Expand Up @@ -142,14 +153,14 @@ describe('in-page channel over bring-your-own ports', () => {
const pageScript = createPageScriptChannel<TestProtocol>({
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<TestProtocol>({
Expand Down Expand Up @@ -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<TestProtocol>({
Expand Down Expand Up @@ -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]!
Expand All @@ -237,9 +247,7 @@ describe('in-page channel over bring-your-own ports', () => {
const pageScript = createPageScriptChannel<TestProtocol>({
name: 'devframes:test',
...noHandshake,
functions: [
defineChannelFunction({ name: 'echo', handler: (value: any) => value }),
],
functions: defaultPageScriptFunctions,
})
pageScript.addPanelPort(port1)
const panel = connectPanelChannel<TestProtocol>({
Expand Down Expand Up @@ -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<TestProtocol>({
name: 'devframes:test',
Expand All @@ -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)
Expand Down Expand Up @@ -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')
Expand Down
36 changes: 30 additions & 6 deletions packages/devframe/src/in-page-channel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type {
InPageChannelProtocol,
InPageChannelStatus,
InPageFunctionDefinition,
InPageFunctionDefinitionFor,
PageScriptChannel,
PanelChannel,
PanelPeer,
Expand All @@ -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<NAME, TYPE, ARGS, RETURN, AS, RS>,
): InPageFunctionDefinition<NAME, TYPE, ARGS, RETURN, AS, RS> {
definition: {
name: NAME
type?: TYPE
args?: undefined
returns?: undefined
jsonSerializable?: boolean
handler: (...args: ARGS) => RETURN
},
): InPageFunctionDefinition<NAME, TYPE, ARGS, RETURN>
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<NAME, TYPE, never, never, AS, RS>['handler']
},
): InPageFunctionDefinition<NAME, TYPE, never, never, AS, RS>
export function defineChannelFunction(
definition: InPageFunctionDefinition<string, InPageFunctionType, any[], any, any, any>,
): InPageFunctionDefinition<string, InPageFunctionType, any[], any, any, any> {
return definition
}
6 changes: 3 additions & 3 deletions packages/devframe/src/in-page-channel/page-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ interface PeerInternal<P extends InPageChannelProtocol> {
* handshake. No server is involved at any point.
*/
export function createPageScriptChannel<P extends InPageChannelProtocol>(
options: CreatePageScriptChannelOptions,
options: CreatePageScriptChannelOptions<P>,
): PageScriptChannel<P> {
const { name } = options
const callTimeoutMs = options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS
Expand All @@ -63,8 +63,8 @@ export function createPageScriptChannel<P extends InPageChannelProtocol>(
let heartbeatTimer: ReturnType<typeof setInterval> | undefined

const registry = createLocalFunctionRegistry(codec)
for (const definition of options.functions ?? [])
registry.register(definition)
for (const [fnName, definition] of Object.entries(options.functions ?? {}))

@posva posva Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we could use for in, it's a bit faster but goes through inherited properties, which in the case of functions, should be none unless someone pollutes the object prototype. I kept it this way because it shouldn't change much in the end

registry.register({ ...definition, name: fnName })
Comment on lines 65 to +67

const stateHost = createPageScriptStateHost<P>(function* () {
for (const peer of peers.values()) {
Expand Down
6 changes: 3 additions & 3 deletions packages/devframe/src/in-page-channel/panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const DEFAULT_EVENT_BUFFER_LIMIT = 64
* the UI can key a fallback state off `status` / `whenConnected()`.
*/
export function connectPanelChannel<P extends InPageChannelProtocol>(
options: ConnectPanelChannelOptions,
options: ConnectPanelChannelOptions<P>,
): PanelChannel<P> {
const { name } = options
const callTimeoutMs = options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS
Expand All @@ -62,8 +62,8 @@ export function connectPanelChannel<P extends InPageChannelProtocol>(

const events = createEventEmitter<PanelChannelEvents>()
const registry = createLocalFunctionRegistry(codec)
for (const definition of options.functions ?? [])
registry.register(definition)
for (const [fnName, definition] of Object.entries(options.functions ?? {}))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as in page-script.ts

registry.register({ ...definition, name: fnName })
Comment on lines 64 to +66

let status: InPageChannelStatus = 'connecting'
let attached: AttachedChannelPort | undefined
Expand Down
88 changes: 88 additions & 0 deletions packages/devframe/src/in-page-channel/types.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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<void>
}
panel: {
notify: (message: string) => void
}
}

describe('in-page channel function definitions', () => {
it('infers page-script handlers from the protocol name', () => {
createPageScriptChannel<TestProtocol>({
name: 'devframes:test',
functions: {
echo: {
handler: value => value.toUpperCase(),
},
sum: {
handler: (a, b) => a + b,
},
save: {
handler: () => {},
},
},
})
})

it('infers panel handlers from the protocol name', () => {
const { port1 } = new MessageChannel()
const channel = connectPanelChannel<TestProtocol>({
name: 'devframes:test',
window: false,
transport: port1,
functions: {
notify: {
handler: (message) => {
void message
},
},
},
})
channel.close()
})

it('requires every function from the local protocol side', () => {
createPageScriptChannel<TestProtocol>({
name: 'devframes:test',
// @ts-expect-error `sum` and `save` are required.
functions: {
echo: { handler: value => value },
},
})
})

it('rejects keys from the remote side', () => {
createPageScriptChannel<TestProtocol>({
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<TestProtocol>({
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: () => {} },
},
})
})
})
Loading
Loading