Skip to content

Commit 90dd140

Browse files
committed
fix(hub-ui): bridge tools to the inspected page
Add a transport-neutral in-page channel relay and a dedicated inspected-page endpoint for browser adapters. Route page scripts and action activation to the inspected document while keeping custom renderers in the hub UI provider document. Run action setup before activation and serialize stale activation cleanup so rapid navigation cannot disable a newer action. Cover port routing, lifecycle cleanup, isolation, activation races, and the public API with regression tests and snapshots.
1 parent 6ba7c59 commit 90dd140

16 files changed

Lines changed: 1497 additions & 12 deletions

File tree

docs/content/8.references/3.events.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,16 @@ Used on a `static` backend, where no live server can relay a client's request to
7474
|---|---|---|
7575
| `devframe:docks:activate` | a panel iframe (e.g. the messages panel's activate actions) | The `{ dockId, params? }` activation; the client runtime in the host page switches the dock locally. |
7676

77+
### Hub `postMessage` channels
78+
79+
| Name | Posted by | Carries |
80+
|---|---|---|
81+
| `devframe:inspected-page:connect` | a browser adapter and the standalone hub UI provider | `{ type: 'devframe:inspected-page:connect', session }` with a transferred `MessagePort` for one inspected document. |
82+
83+
A browser adapter opens the standalone hub UI provider with `devframe-inspected-page` set to its session identifier and `devframe-parent-origin` set to the exact parent origin. The hub UI provider transfers a port to that parent; the browser adapter routes the connection to the selected inspected document. The page-side endpoint accepts the connection from its own window and origin. The browser adapter owns tab and document isolation.
84+
85+
The dedicated port carries dock preparation, activation, deactivation, selection updates, and in-page channel traffic. Action scripts and iframe page scripts execute in the inspected document; custom renderers execute in the hub UI provider's document. Connection failures reject the remote operation. Closing the connection deactivates its active action.
86+
7787
## Core devframe events
7888

7989
This map covers notifications only; request/response RPC endpoints (`devframe:rpc:server-state:*`, `devframe:streaming:subscribe`, `anonymous:devframe:auth`, …) are typed in `types/rpc-augments.ts`, not events.

docs/content/8.references/5.browser-api.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,19 @@ The values of `rpc.status`: [Handling connection and auth errors](/guide/client#
4545
| `disconnected` | Socket closed (dropped mid-session or never opened). |
4646
| `error` | Fatal: the socket errored or connection meta couldn't load. |
4747

48+
## In-page channel relay
49+
50+
`createInPageChannelRelay()` from `devframe/in-page-channel` connects existing panels and page scripts through a transport supplied by a hub UI provider: [In-page channel](/guide/in-page-channel).
51+
52+
| Option | Description |
53+
|--------|-------------|
54+
| `role` | `'panel'` in the hub UI provider document containing panel iframes; `'page'` in the inspected document. |
55+
| `window` | Browser window receiving the channel handshake. Defaults to the current window. |
56+
| `transport.postMessage(data)` | Send a relay envelope to the paired relay. Preserve message order and structured-cloneable payloads. |
57+
| `transport.onMessage(handler)` | Subscribe to incoming relay envelopes and return an unsubscribe function. |
58+
59+
The returned function removes listeners and closes relayed ports. The hub UI provider binds the transport to one inspected document and one hub UI provider document, validates the transport's sender, and disposes both relays on navigation or disconnection. The panel relay accepts same-origin descendant-frame handshakes; the page relay grants connections through the existing page script. Channel calls, shared state, and heartbeat messages retain the existing protocol.
60+
4861
## In-page channel error codes
4962

5063
The `error.code` values of `InPageChannelError`: [Errors and fallbacks](/guide/in-page-channel#errors-and-fallbacks).

packages/devframe/src/in-page-channel/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { InPageFunctionDefinition, InPageFunctionType } from './types'
1111
export { InPageChannelError, type InPageChannelErrorCode } from './internal'
1212
export { createPageScriptChannel } from './page-script'
1313
export { connectPanelChannel } from './panel'
14+
export { createInPageChannelRelay, type InPageChannelRelayOptions, type InPageChannelRelayTransport } from './relay'
1415
export type {
1516
ConnectPanelChannelOptions,
1617
CreatePageScriptChannelOptions,
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import type { InPageChannelRelayTransport } from './relay'
2+
import type { InPageChannelProtocol } from './types'
3+
import { afterEach, describe, expect, it, vi } from 'vitest'
4+
import { createPageScriptChannel } from './page-script'
5+
import { connectPanelChannel } from './panel'
6+
import { IN_PAGE_CHANNEL_TAG, IN_PAGE_CHANNEL_VERSION } from './protocol'
7+
import { createInPageChannelRelay } from './relay'
8+
9+
interface Protocol extends InPageChannelProtocol {
10+
pageScript: { highlight: (selector: string) => string }
11+
panel: Record<string, never>
12+
sharedStates: { report: { route: string, count: number } }
13+
}
14+
15+
function fakeWindow(origin = 'https://app.test') {
16+
const listeners = new Set<(event: MessageEvent) => void>()
17+
const storage = new Map<string, string>()
18+
const win = {
19+
location: { origin },
20+
sessionStorage: {
21+
getItem: (key: string) => storage.get(key) ?? null,
22+
setItem: (key: string, value: string) => storage.set(key, value),
23+
},
24+
parent: undefined as Window | undefined,
25+
opener: null,
26+
sender: undefined as Window | undefined,
27+
addEventListener: (_type: string, fn: (event: MessageEvent) => void) => listeners.add(fn),
28+
removeEventListener: (_type: string, fn: (event: MessageEvent) => void) => listeners.delete(fn),
29+
postMessage(data: unknown, _origin: string, ports: MessagePort[] = []) {
30+
win.dispatch({ data, origin, source: win.sender!, ports })
31+
},
32+
dispatch(event: Partial<MessageEvent>) {
33+
queueMicrotask(() => {
34+
for (const listener of listeners)
35+
listener(event as MessageEvent)
36+
})
37+
},
38+
listeners,
39+
}
40+
// eslint-disable-next-line slop/no-chained-type-assertions -- the fake substitutes the browser Window at the public channel boundary
41+
const window = win as unknown as Window
42+
win.parent = window
43+
win.sender = window
44+
return { win, window }
45+
}
46+
47+
function transportPair() {
48+
const leftListeners = new Set<(data: unknown) => void>()
49+
const rightListeners = new Set<(data: unknown) => void>()
50+
function endpoint(local: typeof leftListeners, remote: typeof rightListeners): InPageChannelRelayTransport {
51+
return {
52+
postMessage(data) {
53+
const cloned = structuredClone(data)
54+
queueMicrotask(() => {
55+
for (const listener of remote)
56+
listener(cloned)
57+
})
58+
},
59+
onMessage(handler) {
60+
local.add(handler)
61+
return () => {
62+
local.delete(handler)
63+
}
64+
},
65+
}
66+
}
67+
return {
68+
panel: endpoint(leftListeners, rightListeners),
69+
page: endpoint(rightListeners, leftListeners),
70+
listeners: [leftListeners, rightListeners],
71+
}
72+
}
73+
74+
const cleanup: (() => void)[] = []
75+
afterEach(() => {
76+
for (const dispose of cleanup.splice(0).reverse())
77+
dispose()
78+
})
79+
80+
function session(route: string) {
81+
const viewer = fakeWindow()
82+
const panelWindow = fakeWindow()
83+
const page = fakeWindow()
84+
panelWindow.win.parent = viewer.window
85+
panelWindow.win.sender = viewer.window
86+
viewer.win.sender = panelWindow.window
87+
const transport = transportPair()
88+
const stopPage = createInPageChannelRelay({ role: 'page', window: page.window, transport: transport.page })
89+
const stopPanel = createInPageChannelRelay({ role: 'panel', window: viewer.window, transport: transport.panel })
90+
cleanup.push(stopPage, stopPanel)
91+
const highlight = vi.fn((selector: string) => `${route}:${selector}`)
92+
const pageScript = createPageScriptChannel<Protocol>({
93+
name: 'devframes:relay-test',
94+
window: page.window,
95+
heartbeat: false,
96+
functions: { highlight: { handler: highlight } },
97+
})
98+
const panel = connectPanelChannel<Protocol>({
99+
name: 'devframes:relay-test',
100+
window: panelWindow.window,
101+
heartbeat: false,
102+
helloIntervalMs: 5,
103+
functions: {},
104+
})
105+
cleanup.push(() => pageScript.close(), () => panel.close())
106+
return { viewer, panelWindow, page, transport, stopPanel, stopPage, pageScript, panel, highlight }
107+
}
108+
109+
describe('in-page channel relay', () => {
110+
it('connects existing panels to the inspected document for shared reports and highlighting', async () => {
111+
const s = session('/')
112+
const report = await s.pageScript.sharedState.get('report', { initialValue: { route: '/', count: 2 } })
113+
await vi.waitFor(() => expect(s.panel.status).toBe('connected'))
114+
const remoteReport = await s.panel.sharedState.get('report')
115+
expect(remoteReport.value()).toEqual({ route: '/', count: 2 })
116+
await expect(s.panel.call('highlight', '#submit')).resolves.toBe('/:#submit')
117+
expect(s.highlight).toHaveBeenCalledExactlyOnceWith('#submit')
118+
report.mutate((draft) => {
119+
draft.count = 3
120+
})
121+
await vi.waitFor(() => expect(remoteReport.value().count).toBe(3))
122+
})
123+
124+
it('keeps two inspected sessions on the same origin isolated', async () => {
125+
const first = session('/first')
126+
const second = session('/second')
127+
await vi.waitFor(() => {
128+
expect(first.panel.status).toBe('connected')
129+
expect(second.panel.status).toBe('connected')
130+
})
131+
await expect(first.panel.call('highlight', '#one')).resolves.toBe('/first:#one')
132+
await expect(second.panel.call('highlight', '#two')).resolves.toBe('/second:#two')
133+
expect(first.highlight).toHaveBeenCalledExactlyOnceWith('#one')
134+
expect(second.highlight).toHaveBeenCalledExactlyOnceWith('#two')
135+
})
136+
137+
it('reconnects an existing panel after its page relay is replaced', async () => {
138+
const s = session('/')
139+
await vi.waitFor(() => expect(s.panel.status).toBe('connected'))
140+
s.stopPage()
141+
await vi.waitFor(() => expect(s.panel.status).not.toBe('connected'))
142+
cleanup.push(createInPageChannelRelay({ role: 'page', window: s.page.window, transport: s.transport.page }))
143+
await vi.waitFor(() => expect(s.panel.status).toBe('connected'))
144+
await expect(s.panel.call('highlight', '#after-reconnect')).resolves.toBe('/:#after-reconnect')
145+
expect(s.pageScript.panels).toHaveLength(1)
146+
})
147+
148+
it('can dispose after the external transport has disconnected', async () => {
149+
const s = session('/')
150+
await vi.waitFor(() => expect(s.panel.status).toBe('connected'))
151+
s.transport.panel.postMessage = () => {
152+
throw new Error('transport disconnected')
153+
}
154+
expect(() => s.stopPanel()).not.toThrow()
155+
expect(s.viewer.win.listeners.size).toBe(0)
156+
await vi.waitFor(() => expect(s.panel.status).not.toBe('connected'))
157+
})
158+
159+
it('disconnects real endpoints and removes subscriptions when the relays are disposed', async () => {
160+
const s = session('/')
161+
await vi.waitFor(() => expect(s.panel.status).toBe('connected'))
162+
s.stopPanel()
163+
s.stopPanel()
164+
s.stopPage()
165+
await vi.waitFor(() => {
166+
expect(s.pageScript.panels).toHaveLength(0)
167+
expect(s.panel.status).not.toBe('connected')
168+
})
169+
expect(s.viewer.win.listeners.size).toBe(0)
170+
for (const listeners of s.transport.listeners)
171+
expect(listeners.size).toBe(0)
172+
})
173+
174+
it('ignores unrelated, cross-origin, non-descendant and wrong-version window messages', async () => {
175+
const viewer = fakeWindow()
176+
const child = fakeWindow()
177+
const unrelated = fakeWindow()
178+
child.win.parent = viewer.window
179+
const send = vi.fn()
180+
const stop = createInPageChannelRelay({
181+
role: 'panel',
182+
window: viewer.window,
183+
transport: { postMessage: send, onMessage: () => () => {} },
184+
})
185+
cleanup.push(stop)
186+
const hello = {
187+
channel: IN_PAGE_CHANNEL_TAG,
188+
v: IN_PAGE_CHANNEL_VERSION,
189+
kind: 'hello',
190+
name: 'devframes:relay-test',
191+
panelId: 'panel',
192+
}
193+
viewer.win.dispatch({ data: { arbitrary: true }, origin: 'https://app.test', source: child.window })
194+
viewer.win.dispatch({ data: hello, origin: 'https://other.test', source: child.window })
195+
viewer.win.dispatch({ data: hello, origin: 'https://app.test', source: unrelated.window })
196+
viewer.win.dispatch({ data: { ...hello, v: 99 }, origin: 'https://app.test', source: child.window })
197+
await new Promise(resolve => setTimeout(resolve, 0))
198+
expect(send).not.toHaveBeenCalled()
199+
viewer.win.dispatch({ data: hello, origin: 'https://app.test', source: child.window })
200+
await vi.waitFor(() => expect(send).toHaveBeenCalledOnce())
201+
})
202+
})

0 commit comments

Comments
 (0)