Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
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