Skip to content
31 changes: 18 additions & 13 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 required `functions` object's keys are the function names, and it implements every function on that endpoint's protocol side. Each handler is contextually typed from its key and the corresponding function in the protocol. `defineChannelFunction` retains the named definition shape for lower-level authoring. Define each side's functions in that side's source files; the shared protocol file carries only types.

```ts
import type { MyChannelProtocol } from '../shared/protocol'
// inject/index.ts — runs in the user app's page
import { createPageScriptChannel, defineChannelFunction } from 'devframe/in-page-channel'
import { createPageScriptChannel } from 'devframe/in-page-channel'
import { MY_CHANNEL } from '../shared/protocol'

const channel = createPageScriptChannel<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 All @@ -96,7 +94,14 @@ import type { MyChannelProtocol } from '../shared/protocol'
import { connectPanelChannel } from 'devframe/in-page-channel'
import { MY_CHANNEL } from '../shared/protocol'

const channel = connectPanelChannel<MyChannelProtocol>({ name: MY_CHANNEL })
const channel = connectPanelChannel<MyChannelProtocol>({
name: MY_CHANNEL,
functions: {
flash: {
handler: message => showFlash(message),
},
},
})

channel.callEvent('highlight', '.hero') // buffered until connected
const size = await channel.call('measure', '.hero')
Expand Down
108 changes: 69 additions & 39 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,29 +38,42 @@ 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)
const panel = connectPanelChannel<TestProtocol>({
name: 'devframes:test',
...noHandshake,
transport: port2,
functions: defaultPanelFunctions,
...options?.panel,
})
return {
Expand Down Expand Up @@ -142,20 +154,21 @@ 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>({
name: 'devframes:test',
...noHandshake,
transport: port2,
functions: defaultPanelFunctions,
})
try {
await expect(panel.call('note', 'fine')).resolves.toBeUndefined()
Expand All @@ -174,6 +187,7 @@ describe('in-page channel over bring-your-own ports', () => {
const pageScript = createPageScriptChannel<TestProtocol>({
name: 'devframes:test',
...noHandshake,
functions: defaultPageScriptFunctions,
})
pageScript.addPanelPort(a.port1)
pageScript.addPanelPort(b.port1)
Expand All @@ -182,17 +196,19 @@ describe('in-page channel over bring-your-own ports', () => {
name: 'devframes:test',
...noHandshake,
transport: a.port2,
functions: [
defineChannelFunction({ name: 'notify', type: 'event', handler: (value: string) => {
functions: {
...defaultPanelFunctions,
notify: { type: 'event', handler: (value) => {
received.push(`a:${value}`)
} }),
],
} },
},
})
// Panel B deliberately implements nothing.
const panelB = connectPanelChannel<TestProtocol>({
// Panel B deliberately has no local functions in its protocol.
const panelB = connectPanelChannel<InPageChannelProtocol>({
name: 'devframes:test',
...noHandshake,
transport: b.port2,
functions: {},
})
try {
expect(pageScript.panels).toHaveLength(2)
Expand All @@ -212,15 +228,14 @@ describe('in-page channel over bring-your-own ports', () => {
const pageScript = createPageScriptChannel<TestProtocol>({
name: 'devframes:test',
...noHandshake,
functions: defaultPageScriptFunctions,
})
pageScript.addPanelPort(port1)
const panel = connectPanelChannel<TestProtocol>({
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,15 +252,14 @@ 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>({
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
Expand All @@ -266,6 +280,7 @@ describe('in-page channel over bring-your-own ports', () => {
const pageScript = createPageScriptChannel<TestProtocol>({
name: 'devframes:test',
...noHandshake,
functions: defaultPageScriptFunctions,
})
const connected: string[] = []
const disconnected: string[] = []
Expand All @@ -276,6 +291,7 @@ describe('in-page channel over bring-your-own ports', () => {
name: 'devframes:test',
...noHandshake,
transport: port2,
functions: defaultPanelFunctions,
})
try {
expect(connected).toHaveLength(1)
Expand Down Expand Up @@ -316,11 +332,12 @@ describe('in-page channel shared state', () => {
const pageScript = createPageScriptChannel<TestProtocol>({
name: 'devframes:test',
...noHandshake,
functions: defaultPageScriptFunctions,
})
pageScript.addPanelPort(a.port1)
pageScript.addPanelPort(b.port1)
const panelA = connectPanelChannel<TestProtocol>({ name: 'devframes:test', ...noHandshake, transport: a.port2 })
const panelB = connectPanelChannel<TestProtocol>({ name: 'devframes:test', ...noHandshake, transport: b.port2 })
const panelA = connectPanelChannel<TestProtocol>({ name: 'devframes:test', ...noHandshake, transport: a.port2, functions: defaultPanelFunctions })
const panelB = connectPanelChannel<TestProtocol>({ 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')
Expand All @@ -341,7 +358,7 @@ describe('in-page channel shared state', () => {

it('seeds a late-joining panel with the current value', async () => {
const { port1, port2 } = new MessageChannel()
const pageScript = createPageScriptChannel<TestProtocol>({ name: 'devframes:test', ...noHandshake })
const pageScript = createPageScriptChannel<TestProtocol>({ name: 'devframes:test', ...noHandshake, functions: defaultPageScriptFunctions })
const authority = await pageScript.sharedState.get('doc', { initialValue: { count: 0 } })
authority.mutate((draft) => {
draft.count = 41
Expand All @@ -351,7 +368,7 @@ describe('in-page channel shared state', () => {
})

pageScript.addPanelPort(port1)
const panel = connectPanelChannel<TestProtocol>({ name: 'devframes:test', ...noHandshake, transport: port2 })
const panel = connectPanelChannel<TestProtocol>({ name: 'devframes:test', ...noHandshake, transport: port2, functions: defaultPanelFunctions })
try {
const mirror = await panel.sharedState.get('doc')
expect(mirror.value()).toEqual({ count: 42 })
Expand Down Expand Up @@ -443,13 +460,14 @@ describe('in-page channel handshake', () => {
name: 'devframes:test',
window: hostWin as unknown as Window,
heartbeat: false,
functions: [defineChannelFunction({ name: 'echo', handler: (value: string) => value })],
functions: defaultPageScriptFunctions,
})
const panel = connectPanelChannel<TestProtocol>({
name: 'devframes:test',
window: panelWin as unknown as Window,
targets: [hostWin as unknown as Window],
...fastHello,
functions: defaultPanelFunctions,
})
try {
await panel.whenConnected(2000)
Expand All @@ -465,7 +483,10 @@ describe('in-page channel handshake', () => {
name: 'devframes:test',
window: hostWin as unknown as Window,
heartbeat: false,
functions: [defineChannelFunction({ name: 'echo', handler: (value: string) => `revived:${value}` })],
functions: {
...defaultPageScriptFunctions,
echo: { handler: value => `revived:${value}` },
},
})
try {
await panel.whenConnected(2000)
Expand All @@ -489,6 +510,7 @@ describe('in-page channel handshake', () => {
window: panelWin as unknown as Window,
targets: [hostWin as unknown as Window],
...fastHello,
functions: defaultPanelFunctions,
})
const early = panel.call('echo', 'early')
panel.callEvent('note', 'buffered')
Expand All @@ -497,12 +519,12 @@ describe('in-page channel handshake', () => {
name: 'devframes:test',
window: hostWin as unknown as Window,
heartbeat: false,
functions: [
defineChannelFunction({ name: 'echo', handler: (value: string) => value }),
defineChannelFunction({ name: 'note', type: 'event', handler: (value: string) => {
functions: {
...defaultPageScriptFunctions,
note: { type: 'event', handler: (value) => {
noted.push(value)
} }),
],
} },
},
})
try {
await expect(early).resolves.toBe('early')
Expand All @@ -522,6 +544,7 @@ describe('in-page channel handshake', () => {
name: 'devframes:test-origin',
window: hostWin as unknown as Window,
heartbeat: false,
functions: defaultPageScriptFunctions,
})
try {
hostWin.__dispatch({
Expand Down Expand Up @@ -552,6 +575,7 @@ describe('in-page channel handshake', () => {
name: 'devframes:test-version',
window: hostWin as unknown as Window,
heartbeat: false,
functions: defaultPageScriptFunctions,
})
try {
hostWin.__dispatch({
Expand Down Expand Up @@ -581,13 +605,15 @@ describe('in-page channel handshake', () => {
name: 'devframes:test',
window: hostWin as unknown as Window,
heartbeat: false,
functions: defaultPageScriptFunctions,
})
const pinnedElsewhere = connectPanelChannel<TestProtocol>({
name: 'devframes:test',
window: panelWin as unknown as Window,
targets: [hostWin as unknown as Window],
instanceId: 'some-other-tab',
...fastHello,
functions: defaultPanelFunctions,
})
try {
await expect(pinnedElsewhere.whenConnected(100)).rejects.toMatchObject({ code: 'timeout' })
Expand All @@ -598,6 +624,7 @@ describe('in-page channel handshake', () => {
targets: [hostWin as unknown as Window],
instanceId: pageScript.instanceId,
...fastHello,
functions: defaultPanelFunctions,
})
try {
await pinnedHere.whenConnected(2000)
Expand All @@ -618,6 +645,7 @@ describe('in-page channel handshake', () => {
name: `devframes:test-lonely-${Math.random()}`,
window: false,
heartbeat: false,
functions: defaultPanelFunctions,
})
try {
expect(lonely.status).toBe('connecting')
Expand All @@ -636,6 +664,7 @@ describe('in-page channel handshake', () => {
window: false,
heartbeat: false,
callTimeoutMs: 50,
functions: defaultPanelFunctions,
})
try {
const rejection = await lonely.call('echo', 'nobody').catch(error => error)
Expand All @@ -653,6 +682,7 @@ describe('in-page channel handshake', () => {
name: `devframes:test-lonely-${Math.random()}`,
window: false,
heartbeat: false,
functions: defaultPanelFunctions,
})
const pending = lonely.call('echo', 'never')
const waiting = lonely.whenConnected()
Expand Down
Loading
Loading