diff --git a/docs/content/8.references/3.events.md b/docs/content/8.references/3.events.md index 96fb817f8..8bea22e8a 100644 --- a/docs/content/8.references/3.events.md +++ b/docs/content/8.references/3.events.md @@ -74,6 +74,16 @@ Used on a `static` backend, where no live server can relay a client's request to |---|---|---| | `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. | +### Hub `postMessage` channels + +| Name | Posted by | Carries | +|---|---|---| +| `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. | + +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. + +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. + ## Core devframe events 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. diff --git a/docs/content/8.references/5.browser-api.md b/docs/content/8.references/5.browser-api.md index c7e43f8d0..37292c33c 100644 --- a/docs/content/8.references/5.browser-api.md +++ b/docs/content/8.references/5.browser-api.md @@ -45,6 +45,19 @@ The values of `rpc.status`: [Handling connection and auth errors](/guide/client# | `disconnected` | Socket closed (dropped mid-session or never opened). | | `error` | Fatal: the socket errored or connection meta couldn't load. | +## In-page channel relay + +`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). + +| Option | Description | +|--------|-------------| +| `role` | `'panel'` in the hub UI provider document containing panel iframes; `'page'` in the inspected document. | +| `window` | Browser window receiving the channel handshake. Defaults to the current window. | +| `transport.postMessage(data)` | Send a relay envelope to the paired relay. Preserve message order and structured-cloneable payloads. | +| `transport.onMessage(handler)` | Subscribe to incoming relay envelopes and return an unsubscribe function. | + +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. + ## In-page channel error codes The `error.code` values of `InPageChannelError`: [Errors and fallbacks](/guide/in-page-channel#errors-and-fallbacks). diff --git a/packages/devframe/src/in-page-channel/index.ts b/packages/devframe/src/in-page-channel/index.ts index 1b2e08dd5..bce5e62f4 100644 --- a/packages/devframe/src/in-page-channel/index.ts +++ b/packages/devframe/src/in-page-channel/index.ts @@ -11,6 +11,7 @@ import type { InPageFunctionDefinition, InPageFunctionType } from './types' export { InPageChannelError, type InPageChannelErrorCode } from './internal' export { createPageScriptChannel } from './page-script' export { connectPanelChannel } from './panel' +export { createInPageChannelRelay, type InPageChannelRelayOptions, type InPageChannelRelayTransport } from './relay' export type { ConnectPanelChannelOptions, CreatePageScriptChannelOptions, diff --git a/packages/devframe/src/in-page-channel/relay.test.ts b/packages/devframe/src/in-page-channel/relay.test.ts new file mode 100644 index 000000000..dea2a2060 --- /dev/null +++ b/packages/devframe/src/in-page-channel/relay.test.ts @@ -0,0 +1,202 @@ +import type { InPageChannelRelayTransport } from './relay' +import type { InPageChannelProtocol } from './types' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createPageScriptChannel } from './page-script' +import { connectPanelChannel } from './panel' +import { IN_PAGE_CHANNEL_TAG, IN_PAGE_CHANNEL_VERSION } from './protocol' +import { createInPageChannelRelay } from './relay' + +interface Protocol extends InPageChannelProtocol { + pageScript: { highlight: (selector: string) => string } + panel: Record + sharedStates: { report: { route: string, count: number } } +} + +function fakeWindow(origin = 'https://app.test') { + const listeners = new Set<(event: MessageEvent) => void>() + const storage = new Map() + const win = { + location: { origin }, + sessionStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + }, + parent: undefined as Window | undefined, + opener: null, + sender: undefined as Window | undefined, + addEventListener: (_type: string, fn: (event: MessageEvent) => void) => listeners.add(fn), + removeEventListener: (_type: string, fn: (event: MessageEvent) => void) => listeners.delete(fn), + postMessage(data: unknown, _origin: string, ports: MessagePort[] = []) { + win.dispatch({ data, origin, source: win.sender!, ports }) + }, + dispatch(event: Partial) { + queueMicrotask(() => { + for (const listener of listeners) + listener(event as MessageEvent) + }) + }, + listeners, + } + // eslint-disable-next-line slop/no-chained-type-assertions -- the fake substitutes the browser Window at the public channel boundary + const window = win as unknown as Window + win.parent = window + win.sender = window + return { win, window } +} + +function transportPair() { + const leftListeners = new Set<(data: unknown) => void>() + const rightListeners = new Set<(data: unknown) => void>() + function endpoint(local: typeof leftListeners, remote: typeof rightListeners): InPageChannelRelayTransport { + return { + postMessage(data) { + const cloned = structuredClone(data) + queueMicrotask(() => { + for (const listener of remote) + listener(cloned) + }) + }, + onMessage(handler) { + local.add(handler) + return () => { + local.delete(handler) + } + }, + } + } + return { + panel: endpoint(leftListeners, rightListeners), + page: endpoint(rightListeners, leftListeners), + listeners: [leftListeners, rightListeners], + } +} + +const cleanup: (() => void)[] = [] +afterEach(() => { + for (const dispose of cleanup.splice(0).reverse()) + dispose() +}) + +function session(route: string) { + const viewer = fakeWindow() + const panelWindow = fakeWindow() + const page = fakeWindow() + panelWindow.win.parent = viewer.window + panelWindow.win.sender = viewer.window + viewer.win.sender = panelWindow.window + const transport = transportPair() + const stopPage = createInPageChannelRelay({ role: 'page', window: page.window, transport: transport.page }) + const stopPanel = createInPageChannelRelay({ role: 'panel', window: viewer.window, transport: transport.panel }) + cleanup.push(stopPage, stopPanel) + const highlight = vi.fn((selector: string) => `${route}:${selector}`) + const pageScript = createPageScriptChannel({ + name: 'devframes:relay-test', + window: page.window, + heartbeat: false, + functions: { highlight: { handler: highlight } }, + }) + const panel = connectPanelChannel({ + name: 'devframes:relay-test', + window: panelWindow.window, + heartbeat: false, + helloIntervalMs: 5, + functions: {}, + }) + cleanup.push(() => pageScript.close(), () => panel.close()) + return { viewer, panelWindow, page, transport, stopPanel, stopPage, pageScript, panel, highlight } +} + +describe('in-page channel relay', () => { + it('connects existing panels to the inspected document for shared reports and highlighting', async () => { + const s = session('/') + const report = await s.pageScript.sharedState.get('report', { initialValue: { route: '/', count: 2 } }) + await vi.waitFor(() => expect(s.panel.status).toBe('connected')) + const remoteReport = await s.panel.sharedState.get('report') + expect(remoteReport.value()).toEqual({ route: '/', count: 2 }) + await expect(s.panel.call('highlight', '#submit')).resolves.toBe('/:#submit') + expect(s.highlight).toHaveBeenCalledExactlyOnceWith('#submit') + report.mutate((draft) => { + draft.count = 3 + }) + await vi.waitFor(() => expect(remoteReport.value().count).toBe(3)) + }) + + it('keeps two inspected sessions on the same origin isolated', async () => { + const first = session('/first') + const second = session('/second') + await vi.waitFor(() => { + expect(first.panel.status).toBe('connected') + expect(second.panel.status).toBe('connected') + }) + await expect(first.panel.call('highlight', '#one')).resolves.toBe('/first:#one') + await expect(second.panel.call('highlight', '#two')).resolves.toBe('/second:#two') + expect(first.highlight).toHaveBeenCalledExactlyOnceWith('#one') + expect(second.highlight).toHaveBeenCalledExactlyOnceWith('#two') + }) + + it('reconnects an existing panel after its page relay is replaced', async () => { + const s = session('/') + await vi.waitFor(() => expect(s.panel.status).toBe('connected')) + s.stopPage() + await vi.waitFor(() => expect(s.panel.status).not.toBe('connected')) + cleanup.push(createInPageChannelRelay({ role: 'page', window: s.page.window, transport: s.transport.page })) + await vi.waitFor(() => expect(s.panel.status).toBe('connected')) + await expect(s.panel.call('highlight', '#after-reconnect')).resolves.toBe('/:#after-reconnect') + expect(s.pageScript.panels).toHaveLength(1) + }) + + it('can dispose after the external transport has disconnected', async () => { + const s = session('/') + await vi.waitFor(() => expect(s.panel.status).toBe('connected')) + s.transport.panel.postMessage = () => { + throw new Error('transport disconnected') + } + expect(() => s.stopPanel()).not.toThrow() + expect(s.viewer.win.listeners.size).toBe(0) + await vi.waitFor(() => expect(s.panel.status).not.toBe('connected')) + }) + + it('disconnects real endpoints and removes subscriptions when the relays are disposed', async () => { + const s = session('/') + await vi.waitFor(() => expect(s.panel.status).toBe('connected')) + s.stopPanel() + s.stopPanel() + s.stopPage() + await vi.waitFor(() => { + expect(s.pageScript.panels).toHaveLength(0) + expect(s.panel.status).not.toBe('connected') + }) + expect(s.viewer.win.listeners.size).toBe(0) + for (const listeners of s.transport.listeners) + expect(listeners.size).toBe(0) + }) + + it('ignores unrelated, cross-origin, non-descendant and wrong-version window messages', async () => { + const viewer = fakeWindow() + const child = fakeWindow() + const unrelated = fakeWindow() + child.win.parent = viewer.window + const send = vi.fn() + const stop = createInPageChannelRelay({ + role: 'panel', + window: viewer.window, + transport: { postMessage: send, onMessage: () => () => {} }, + }) + cleanup.push(stop) + const hello = { + channel: IN_PAGE_CHANNEL_TAG, + v: IN_PAGE_CHANNEL_VERSION, + kind: 'hello', + name: 'devframes:relay-test', + panelId: 'panel', + } + viewer.win.dispatch({ data: { arbitrary: true }, origin: 'https://app.test', source: child.window }) + viewer.win.dispatch({ data: hello, origin: 'https://other.test', source: child.window }) + viewer.win.dispatch({ data: hello, origin: 'https://app.test', source: unrelated.window }) + viewer.win.dispatch({ data: { ...hello, v: 99 }, origin: 'https://app.test', source: child.window }) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(send).not.toHaveBeenCalled() + viewer.win.dispatch({ data: hello, origin: 'https://app.test', source: child.window }) + await vi.waitFor(() => expect(send).toHaveBeenCalledOnce()) + }) +}) diff --git a/packages/devframe/src/in-page-channel/relay.ts b/packages/devframe/src/in-page-channel/relay.ts new file mode 100644 index 000000000..c90263a3d --- /dev/null +++ b/packages/devframe/src/in-page-channel/relay.ts @@ -0,0 +1,228 @@ +import type { InPageChannelHandshakeMessage } from './protocol' +import { nanoid } from 'devframe/utils/nanoid' +import { IN_PAGE_CHANNEL_TAG, IN_PAGE_CHANNEL_VERSION, isHandshakeMessage } from './protocol' + +/** A message transport bound to one inspected document and one hub UI provider document. */ +export interface InPageChannelRelayTransport { + postMessage: (data: unknown) => void + onMessage: (handler: (data: unknown) => void) => () => void +} + +export interface InPageChannelRelayOptions { + /** `panel` runs in the hub UI provider document containing panels; `page` in the inspected document. */ + role: 'panel' | 'page' + window?: Window + transport: InPageChannelRelayTransport +} + +interface RelayMessage { + channel: typeof IN_PAGE_CHANNEL_TAG + relay: 1 + id: string + kind: 'open' | 'grant' | 'data' | 'close' + handshake?: InPageChannelHandshakeMessage + data?: unknown +} + +interface RelayConnection { + hello: InPageChannelHandshakeMessage + source?: Window + port?: MessagePort + detach?: () => void +} + +function validHandshake(data: unknown, kind: 'hello' | 'grant'): data is InPageChannelHandshakeMessage { + return isHandshakeMessage(data) + && data.v === IN_PAGE_CHANNEL_VERSION + && data.kind === kind + && (data.instanceId === undefined || typeof data.instanceId === 'string') +} + +function isRelayMessage(data: unknown): data is RelayMessage { + if (!data || typeof data !== 'object') + return false + const message = data as Partial + return message.channel === IN_PAGE_CHANNEL_TAG && message.relay === 1 + && typeof message.id === 'string' + && (message.kind === 'data' || message.kind === 'close' + || (message.kind === 'open' && validHandshake(message.handshake, 'hello')) + || (message.kind === 'grant' && validHandshake(message.handshake, 'grant'))) +} + +function isDescendant(source: Window, ancestor: Window): boolean { + try { + let current = source + while (current.parent && current.parent !== current) { + current = current.parent + if (current === ancestor) + return true + } + } + catch { + // An inaccessible ancestor cannot establish the panel's ownership. + } + return false +} + +/** + * Relay existing in-page handshakes and their dedicated ports across a transport + * supplied by a hub UI provider. The provider authenticates and binds that + * transport to one inspected document, and disposes both relays on navigation. + * Panels and page scripts keep their existing channel APIs unchanged. + */ +export function createInPageChannelRelay(options: InPageChannelRelayOptions): () => void { + const win = options.window ?? window + const origin = win.location.origin + if (!origin || origin === 'null') + throw new Error('An in-page channel relay requires a document with an origin') + const connections = new Map() + let disposed = false + + function send(message: Omit): void { + try { + options.transport.postMessage({ channel: IN_PAGE_CHANNEL_TAG, relay: 1, ...message }) + } + catch { + close(message.id, false) + } + } + + function close(id: string, notify = true): void { + const connection = connections.get(id) + if (!connection) + return + connections.delete(id) + connection.detach?.() + // Endpoints recognize bye immediately, including browsers where closing + // the other port does not dispatch a close event. + try { + connection.port?.postMessage({ __dfIpc: 'bye' }) + } + catch { + // A detached port is already disconnected. + } + connection.port?.close() + if (notify) + send({ id, kind: 'close' }) + } + + function attach(id: string, connection: RelayConnection, port: MessagePort): void { + connection.port = port + const onMessage = (event: MessageEvent): void => { + send({ id, kind: 'data', data: event.data }) + if (event.data?.__dfIpc === 'bye') + close(id) + } + const onClose = (): void => close(id) + port.addEventListener('message', onMessage) + port.addEventListener('close', onClose) + connection.detach = () => { + port.removeEventListener('message', onMessage) + port.removeEventListener('close', onClose) + } + port.start() + } + + function matches(grant: InPageChannelHandshakeMessage, hello: InPageChannelHandshakeMessage): boolean { + return grant.name === hello.name && grant.panelId === hello.panelId + && typeof grant.instanceId === 'string' + && (!hello.instanceId || grant.instanceId === hello.instanceId) + } + + function sameHello(left: InPageChannelHandshakeMessage, right: InPageChannelHandshakeMessage): boolean { + return left.name === right.name && left.panelId === right.panelId && left.instanceId === right.instanceId + } + + function onPanelHello(event: MessageEvent): void { + if (!validHandshake(event.data, 'hello') || !event.source + || !isDescendant(event.source as Window, win)) { + return + } + const hello = event.data + let id: string | undefined + for (const [key, connection] of connections) { + if (connection.source === event.source && connection.hello.panelId === hello.panelId + && connection.hello.name === hello.name && connection.hello.instanceId === hello.instanceId) { + if (connection.port) + return + id = key + break + } + } + id ??= nanoid() + connections.set(id, { hello, source: event.source as Window }) + send({ id, kind: 'open', handshake: hello }) + } + + function onPageGrant(event: MessageEvent): void { + if (event.source !== win || !validHandshake(event.data, 'grant') || !event.ports[0]) + return + for (const [id, connection] of connections) { + if (!connection.port && matches(event.data, connection.hello)) { + attach(id, connection, event.ports[0]) + send({ id, kind: 'grant', handshake: event.data }) + return + } + } + } + + const onWindowMessage = (event: MessageEvent): void => { + if (disposed || event.origin !== origin) + return + if (options.role === 'panel') + onPanelHello(event) + else + onPageGrant(event) + } + + function forwardHello(id: string, hello: InPageChannelHandshakeMessage): void { + const connection = connections.get(id) + if (connection?.port || (connection && !sameHello(connection.hello, hello))) + return + connections.set(id, { hello }) + win.postMessage(hello, origin) + } + + const unsubscribe = options.transport.onMessage((data) => { + if (disposed || !isRelayMessage(data)) + return + const connection = connections.get(data.id) + if (data.kind === 'open' && options.role === 'page') { + forwardHello(data.id, data.handshake!) + } + else if (data.kind === 'grant' && options.role === 'panel' && connection + && !connection.port && matches(data.handshake!, connection.hello)) { + const channel = new MessageChannel() + attach(data.id, connection, channel.port1) + try { + connection.source!.postMessage(data.handshake, origin, [channel.port2]) + } + catch { + channel.port2.close() + close(data.id) + } + } + else if (data.kind === 'data') { + try { + connection?.port?.postMessage(data.data) + } + catch { + close(data.id) + } + } + else if (data.kind === 'close') { + close(data.id, false) + } + }) + win.addEventListener('message', onWindowMessage) + + return () => { + if (disposed) + return + disposed = true + win.removeEventListener('message', onWindowMessage) + unsubscribe() + for (const id of connections.keys()) + close(id) + } +} diff --git a/packages/hub-ui/src/client/standalone/main.ts b/packages/hub-ui/src/client/standalone/main.ts index 5383a4b68..b818f7ecf 100644 --- a/packages/hub-ui/src/client/standalone/main.ts +++ b/packages/hub-ui/src/client/standalone/main.ts @@ -5,6 +5,7 @@ import { watchEffect } from 'vue' import { applyDocumentHead, applyPrimaryColor, setBranding, useBrandingBackground } from '../state/branding' import { isDark } from '../state/color-mode' import { DEFAULT_DOCK_SESSION_STORE } from '../state/docks' +import { connectInspectedPage } from '../state/inspected-page' import { applyViewerBackground } from './viewer-background' // The standalone viewer: a vanilla shell served at the hub base itself @@ -26,6 +27,12 @@ watchEffect(() => { }) async function main(): Promise { + const inspectedPage = await connectInspectedPage() + let disconnected = false + inspectedPage?.onDisconnect(() => { + disconnected = true + showConnectionError('The inspected page disconnected. Reopen the browser DevTools panel.') + }) // Served at the hub base with relative assets: `./__connection.json` // resolves against the page URL first, the module URL second. Read // import.meta.url through a variable so Vite's build doesn't rewrite the @@ -35,6 +42,8 @@ async function main(): Promise { baseURL: ['./', new URL('./', moduleUrl).href], simpleAuth: false, }) + if (disconnected) + return // Resolve branding before mount; the standalone page owns its own head, so // apply title/favicon/description here too. Read from @@ -52,13 +61,24 @@ async function main(): Promise { ) const { createDocksContext } = await import('../state/context') - const context = await createDocksContext('standalone', rpc, undefined, session) + const context = await createDocksContext('standalone', rpc, undefined, session, undefined, inspectedPage) + if (disconnected) + return setDevframeClientContext(context) const { DockStandalone } = await import('../components/DockStandalone') + if (disconnected) + return const el = new DockStandalone({ context }) applyPrimaryColor(el, branding.primaryColor) document.getElementById('app')!.appendChild(el) } -void main() +function showConnectionError(message: string): void { + const alert = document.createElement('p') + alert.setAttribute('role', 'alert') + alert.textContent = message + document.getElementById('app')!.replaceChildren(alert) +} + +void main().catch(error => showConnectionError(error instanceof Error ? error.message : String(error))) diff --git a/packages/hub-ui/src/client/state/action-activation.test.ts b/packages/hub-ui/src/client/state/action-activation.test.ts new file mode 100644 index 000000000..109da35f4 --- /dev/null +++ b/packages/hub-ui/src/client/state/action-activation.test.ts @@ -0,0 +1,102 @@ +import type { DevframeDockEntry } from '@devframes/hub' +import type { DevframeRpcClient } from '@devframes/hub/client' +import { HUB_EVENTS } from '@devframes/hub/constants' +import { createEventEmitter } from 'devframe/utils/events' +import { createSharedState } from 'devframe/utils/shared-state' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import { createDocksContext } from './context' +import { executeSetupScript } from './setup-script' + +vi.mock('./setup-script', () => ({ + executeSetupScript: vi.fn(async () => {}), +})) + +const tracerEntry = { + id: 'vue-tracer', + type: 'action', + title: 'Vue Tracer', + icon: 'ph:cursor-duotone', + action: { importFrom: '/tracer-client.js' }, +} satisfies DevframeDockEntry + +function createStubRpc(): DevframeRpcClient { + // eslint-disable-next-line slop/no-chained-type-assertions -- this test double supplies the RPC members exercised by createDocksContext. + return { + isTrusted: true, + status: 'connected', + connectionError: null, + connectionMeta: { backend: 'live', configs: {} }, + connection: {}, + events: createEventEmitter(), + sharedState: { + async get(key: string, options?: { initialValue?: object }) { + return createSharedState({ + initialValue: key === HUB_EVENTS.sharedState.docks + ? [tracerEntry] + : key === HUB_EVENTS.sharedState.dockRenderers + ? {} + : options?.initialValue ?? {}, + }) + }, + }, + client: { register: vi.fn() }, + call: vi.fn(), + } as unknown as DevframeRpcClient +} + +function delayActionSetup() { + let finishImport!: () => void + const imported = new Promise((resolve) => { + finishImport = resolve + }) + const activated = vi.fn() + vi.mocked(executeSetupScript).mockImplementationOnce(async (_entry, context) => { + // Vue Tracer attaches its listener only once its client module has loaded. + await imported + context.current.events.on('entry:activated', activated) + }) + return { finishImport, activated } +} + +describe('action setup and activation ordering', () => { + beforeEach(() => { + vi.mocked(executeSetupScript).mockReset() + }) + + it('delivers the first activation to a listener installed after an async import', async () => { + const context = await createDocksContext('embedded', createStubRpc()) + const { finishImport, activated } = delayActionSetup() + + const switching = context.docks.switchEntry(tracerEntry.id) + await nextTick() + await nextTick() + expect(executeSetupScript).toHaveBeenCalledOnce() + expect(activated).not.toHaveBeenCalled() + + finishImport() + await switching + await nextTick() + + expect(activated).toHaveBeenCalledOnce() + expect(context.docks.selected?.id).toBe(tracerEntry.id) + }) + + it.each([null, '~settings'])('preserves navigation to %s while action setup is pending', async (destination) => { + const context = await createDocksContext('embedded', createStubRpc()) + const { finishImport, activated } = delayActionSetup() + + const switching = context.docks.switchEntry(tracerEntry.id) + await nextTick() + await nextTick() + expect(executeSetupScript).toHaveBeenCalledOnce() + await context.docks.switchEntry(destination) + + finishImport() + await switching + await nextTick() + + expect(activated).not.toHaveBeenCalled() + expect(context.docks.selected?.id ?? null).toBe(destination) + }) +}) diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index 4ded43869..0e1e18c79 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -4,6 +4,7 @@ import type { SharedState } from 'devframe/utils/shared-state' import type { WhenContext } from 'devframe/utils/when' import type { Ref } from 'vue' import type { DevframeDocksUserSettings } from './dock-settings' +import type { InspectedPageTarget } from './inspected-page' import { attachFrameNavClient, createDockRenderersContext } from '@devframes/hub/client' import { DEFAULT_STATE_USER_SETTINGS, DOCK_RENDERERS_STATE_KEY, HUB_EVENTS } from '@devframes/hub/constants' import { DEVFRAME_EVENTS } from 'devframe/constants' @@ -14,6 +15,7 @@ import { useBranding } from './branding' import { createCommandsContext } from './commands' import { docksGroupByCategories, getGroupMembers, getRegisteredGroupIds, resolveCommandIcon, resolveGroupPreferredChild } from './dock-settings' import { createDockEntryState, DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE, sharedStateToRef, useDocksEntries, waitForInitialSharedStateSync } from './docks' +import { installInspectedPageHost } from './inspected-page' import { createClientMessagesClient } from './messages-client' import { dockCommandId } from './palette' import { registerMainFrameDockActionHandler, triggerMainFrameDockAction, useIsDockPopupOpen } from './popup' @@ -26,6 +28,7 @@ export async function createDocksContext( panelStore?: Ref, sessionStore?: Ref, panelVisible: Ref = ref(true), + inspectedPage?: InspectedPageTarget, ): Promise { if (docksContextByRpc.has(rpc)) { return docksContextByRpc.get(rpc)! @@ -233,6 +236,11 @@ export async function createDocksContext( const hasScript = entry.type === 'action' || entry.type === 'custom-render' || (entry.type === 'iframe' && entry.clientScript) if (!hasScript) return + if (inspectedPage && entry.type !== 'custom-render') { + if (entry.type === 'iframe' && !await inspectedPage.prepare(entry.id)) + throw new Error(`The inspected page could not prepare dock "${entry.id}".`) + return + } const messagesClient = createClientMessagesClient(rpc) const scriptContext: DockClientScriptContext = reactive({ ...toRefs(docksContext) as any, @@ -255,7 +263,42 @@ export async function createDocksContext( sessionStore.value.selectedDockRoute = null } + let selectionGeneration = 0 + let inspectedActionQueue = Promise.resolve() + const prepareAction = async (entry: DevframeDockEntry, generation: number): Promise => { + if (inspectedPage) { + // Complete stale activation cleanup before starting a newer action, + // including another activation of the same dock entry. + const activation = inspectedActionQueue.then(async () => { + if (generation !== selectionGeneration || !await inspectedPage.activate(entry.id)) + return false + if (generation !== selectionGeneration) { + await inspectedPage.deactivate(entry.id) + return false + } + return true + }) + inspectedActionQueue = activation.then(() => {}, () => {}) + return activation + } + else { + const delegated = await triggerMainFrameDockAction(clientType, entry.id) + if (delegated != null) + return false + // Import/setup must finish before activation is emitted. Page scripts + // such as Vue Tracer install their activation listener during setup. + await runDockSetupScript(entry) + } + return generation === selectionGeneration + } + const switchEntry = async (id: string | null = null): Promise => { + const generation = ++selectionGeneration + const previous = selected.value + if (inspectedPage && previous?.type === 'action' && previous.id !== id) + await inspectedPage.deactivate(previous.id) + if (generation !== selectionGeneration) + return false if (id == null) { initialRestorePending.value = false selectedDockId.value = null @@ -279,18 +322,15 @@ export async function createDocksContext( if (redirect !== null) return switchEntry(redirect) - // If the action is in a popup, delegate to the main frame - if (entry.type === 'action') { - const delegated = await triggerMainFrameDockAction(clientType, entry.id) - if (delegated != null) - return false - } + if (entry.type === 'action' && !await prepareAction(entry, generation)) + return false initialRestorePending.value = false selectedDockId.value = entry.id sessionStore.value.open = true - await runDockSetupScript(entry) + if (entry.type !== 'action') + await runDockSetupScript(entry) rememberEntrySelection(entry) return true } @@ -411,9 +451,8 @@ export async function createDocksContext( icon: 'ph:x-circle-duotone', when: 'dockOpen && !paletteOpen', keybindings: [{ key: 'Escape' }], - action: () => { - sessionStore.value.open = false - selectedDockId.value = null + action: async () => { + await switchEntry(null) }, }, { @@ -648,6 +687,42 @@ export async function createDocksContext( return switchEntry(entry.id) }) + inspectedPage?.onSelection((id) => { + if (selected.value?.type === 'action' && id !== selectedDockId.value) { + selectedDockId.value = null + sessionStore.value.open = false + } + }) + + if (clientType === 'embedded' && typeof window !== 'undefined') { + installInspectedPageHost({ + async prepare(id) { + if (!rpc.isTrusted) + return false + const entry = entries.value.find(entry => entry.id === id) + if (!entry || entry.type !== 'iframe' || !entry.clientScript) + return false + await runDockSetupScript(entry) + return true + }, + async activate(id) { + if (!rpc.isTrusted || entries.value.find(entry => entry.id === id)?.type !== 'action') + return false + return switchEntry(id) + }, + async deactivate(id) { + if (selected.value?.type !== 'action' || selectedDockId.value !== id) + return false + return switchEntry(null) + }, + onSelection(listener) { + return panelEvents.on(HUB_EVENTS.client.docksPanelStateChanged, () => { + listener(selected.value?.type === 'action' ? selected.value.id : null) + }) + }, + }) + } + const waitUntilTrusted = async (): Promise => { if (rpc.isTrusted) return diff --git a/packages/hub-ui/src/client/state/inspected-page-context.test.ts b/packages/hub-ui/src/client/state/inspected-page-context.test.ts new file mode 100644 index 000000000..91062f3e9 --- /dev/null +++ b/packages/hub-ui/src/client/state/inspected-page-context.test.ts @@ -0,0 +1,295 @@ +import type { DevframeDockEntry } from '@devframes/hub' +import type { DevframeRpcClient } from '@devframes/hub/client' +import type { InspectedPageTarget } from './inspected-page' +import { HUB_EVENTS } from '@devframes/hub/constants' +import { createEventEmitter } from 'devframe/utils/events' +import { createSharedState } from 'devframe/utils/shared-state' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import { createDocksContext } from './context' +import { connectInspectedPage, installInspectedPageHost } from './inspected-page' +import { fakeWindow } from './inspected-page.test-utils' +import { executeSetupScript } from './setup-script' + +vi.mock('./setup-script', () => ({ + executeSetupScript: vi.fn(async () => {}), +})) + +const tracerEntry = { + id: 'vue-tracer', + type: 'action', + title: 'Vue Tracer', + icon: 'ph:cursor-duotone', + action: { importFrom: '/tracer-client.js' }, +} satisfies DevframeDockEntry + +const a11yEntry = { + id: 'a11y', + type: 'iframe', + title: 'Accessibility', + icon: 'ph:person-duotone', + url: '/a11y/', + clientScript: { importFrom: '/a11y-client.js' }, +} satisfies DevframeDockEntry + +const customEntry = { + id: 'custom', + type: 'custom-render', + title: 'Custom renderer', + icon: 'ph:squares-four-duotone', + renderer: { importFrom: '/custom-client.js' }, +} satisfies DevframeDockEntry + +function createStubRpc(): DevframeRpcClient { + // eslint-disable-next-line slop/no-chained-type-assertions -- this test double supplies the RPC members exercised by createDocksContext. + return { + isTrusted: true, + status: 'connected', + connectionError: null, + connectionMeta: { backend: 'live', configs: {} }, + connection: {}, + events: createEventEmitter(), + sharedState: { + async get(key: string, options?: { initialValue?: object }) { + return createSharedState({ + initialValue: key === HUB_EVENTS.sharedState.docks + ? [tracerEntry, a11yEntry, customEntry] + : key === HUB_EVENTS.sharedState.dockRenderers + ? {} + : options?.initialValue ?? {}, + }) + }, + }, + client: { register: vi.fn() }, + call: vi.fn(), + } as unknown as DevframeRpcClient +} + +function createInspectedPage() { + const selectionListeners = new Set<(entryId: string | null) => void>() + const target = { + prepare: vi.fn(async (_entryId: string) => true), + activate: vi.fn(async (_entryId: string) => true), + deactivate: vi.fn(async (_entryId: string) => true), + onSelection(listener: (entryId: string | null) => void) { + selectionListeners.add(listener) + return () => { + selectionListeners.delete(listener) + } + }, + onDisconnect: vi.fn(() => () => {}), + close: vi.fn(), + } satisfies InspectedPageTarget + return { + target, + select(entryId: string | null) { + for (const listener of selectionListeners) + listener(entryId) + }, + } +} + +function createRemoteContext(target: InspectedPageTarget) { + return createDocksContext('standalone', createStubRpc(), undefined, undefined, undefined, target) +} + +describe('dock scripts in an inspected page', () => { + beforeEach(() => { + vi.mocked(executeSetupScript).mockReset() + }) + + it('activates Vue Tracer in the inspected page and selects its dock in the panel', async () => { + const { target } = createInspectedPage() + const context = await createRemoteContext(target) + + expect(await context.docks.switchEntry(tracerEntry.id)).toBe(true) + + expect(target.activate).toHaveBeenCalledExactlyOnceWith(tracerEntry.id) + expect(target.prepare).not.toHaveBeenCalled() + expect(executeSetupScript).not.toHaveBeenCalled() + expect(context.docks.selected?.id).toBe(tracerEntry.id) + }) + + it('prepares the A11y page script remotely while selecting its iframe in the panel', async () => { + const { target } = createInspectedPage() + const context = await createRemoteContext(target) + + expect(await context.docks.switchEntry(a11yEntry.id)).toBe(true) + + expect(target.prepare).toHaveBeenCalledExactlyOnceWith(a11yEntry.id) + expect(target.activate).not.toHaveBeenCalled() + expect(executeSetupScript).not.toHaveBeenCalled() + expect(context.docks.selected?.id).toBe(a11yEntry.id) + }) + + it('runs a custom renderer in the panel document', async () => { + const { target } = createInspectedPage() + const context = await createRemoteContext(target) + + await context.docks.switchEntry(customEntry.id) + + expect(executeSetupScript).toHaveBeenCalledExactlyOnceWith(customEntry, expect.objectContaining({ + current: context.docks.getStateById(customEntry.id), + })) + expect(target.activate).not.toHaveBeenCalled() + expect(target.prepare).not.toHaveBeenCalled() + }) + + it.each(['unavailable', 'disconnected'] as const)('keeps a remote action %s instead of executing it in the panel', async (failure) => { + const { target } = createInspectedPage() + const context = await createRemoteContext(target) + await context.docks.switchEntry('~settings') + + if (failure === 'unavailable') { + target.activate.mockResolvedValueOnce(false) + expect(await context.docks.switchEntry(tracerEntry.id)).toBe(false) + } + else { + target.activate.mockRejectedValueOnce(new Error('The inspected page disconnected.')) + await expect(context.docks.switchEntry(tracerEntry.id)).rejects.toThrow('disconnected') + } + + expect(context.docks.selected?.id).toBe('~settings') + expect(executeSetupScript).not.toHaveBeenCalled() + }) + + it.each(['unavailable', 'disconnected'] as const)('reports an A11y page script that is %s without scanning the panel', async (failure) => { + const { target } = createInspectedPage() + const context = await createRemoteContext(target) + if (failure === 'unavailable') + target.prepare.mockResolvedValueOnce(false) + else + target.prepare.mockRejectedValueOnce(new Error('The inspected page disconnected.')) + + await expect(context.docks.switchEntry(a11yEntry.id)).rejects.toThrow() + + expect(executeSetupScript).not.toHaveBeenCalled() + expect(target.activate).not.toHaveBeenCalled() + }) + + it.each([null, 'a11y'])('deactivates the inspected-page action when navigating to %s', async (destination) => { + const { target } = createInspectedPage() + const context = await createRemoteContext(target) + await context.docks.switchEntry(tracerEntry.id) + + await context.docks.switchEntry(destination) + + expect(target.deactivate).toHaveBeenCalledExactlyOnceWith(tracerEntry.id) + expect(context.docks.selected?.id ?? null).toBe(destination) + expect(executeSetupScript).not.toHaveBeenCalled() + }) + + it('clears the panel action when inspection ends in the inspected page', async () => { + const { target, select } = createInspectedPage() + const context = await createRemoteContext(target) + await context.docks.switchEntry(tracerEntry.id) + + select(null) + await nextTick() + + expect(context.docks.selected).toBeNull() + expect(context.panel.state.state).toBe('closed') + expect(target.deactivate).not.toHaveBeenCalled() + }) + + it('deactivates the inspected-page action when the Escape close command runs', async () => { + const { target } = createInspectedPage() + const context = await createRemoteContext(target) + await context.docks.switchEntry(tracerEntry.id) + + expect(context.commands.getKeybindings('devframes:close-panel')).toContainEqual({ key: 'Escape' }) + await context.commands.execute('devframes:close-panel') + + expect(target.deactivate).toHaveBeenCalledExactlyOnceWith(tracerEntry.id) + expect(context.docks.selected).toBeNull() + expect(context.panel.state.state).toBe('closed') + }) + + it('keeps the A11y iframe open when the inspected page has no active action', async () => { + const { target, select } = createInspectedPage() + const context = await createRemoteContext(target) + await context.docks.switchEntry(a11yEntry.id) + + select(null) + await nextTick() + + expect(context.docks.selected?.id).toBe(a11yEntry.id) + expect(context.panel.state.state).toBe('open') + }) + + it('allows another activation after a page script fails to load', async () => { + const { target } = createInspectedPage() + const context = await createRemoteContext(target) + target.activate.mockRejectedValueOnce(new Error('The page script failed to load.')) + + await expect(context.docks.switchEntry(tracerEntry.id)).rejects.toThrow('failed to load') + await expect(context.docks.switchEntry(tracerEntry.id)).resolves.toBe(true) + + expect(target.activate).toHaveBeenCalledTimes(2) + expect(context.docks.selected?.id).toBe(tracerEntry.id) + }) + + it.each([true, false])('cleans up a pending activation before returning to Tracer: %s', async (returnToTracer) => { + const extension = fakeWindow('chrome-extension://test') + const panel = fakeWindow('https://app.test', '?devframe-inspected-page=session-a&devframe-parent-origin=chrome-extension%3A%2F%2Ftest') + const page = fakeWindow() + panel.win.parent = extension.window + extension.win.sender = panel.window + extension.win.addEventListener('message', event => page.win.dispatch('message', { + data: event.data, + origin: page.win.location.origin, + source: page.window, + ports: event.ports, + })) + + let finishImport!: () => void + const imported = new Promise((resolve) => { + finishImport = resolve + }) + const selectionListeners = new Set<(id: string | null) => void>() + let activeEntry: string | null = null + const select = (id: string | null) => { + activeEntry = id + for (const listener of selectionListeners) + listener(id) + } + const host = { + prepare: async () => true, + activate: vi.fn(async (id: string) => { + await imported + select(id) + return true + }), + deactivate: async () => { + select(null) + return true + }, + onSelection(listener: (id: string | null) => void) { + selectionListeners.add(listener) + return () => selectionListeners.delete(listener) + }, + } + const stopHost = installInspectedPageHost(host, page.window) + let target: InspectedPageTarget | undefined + try { + target = await connectInspectedPage(panel.window) + const context = await createRemoteContext(target!) + const first = context.docks.switchEntry(tracerEntry.id) + await vi.waitFor(() => expect(host.activate).toHaveBeenCalledOnce()) + await context.docks.switchEntry('~settings') + const latest = returnToTracer ? context.docks.switchEntry(tracerEntry.id) : undefined + + finishImport() + await Promise.all([first, latest]) + await nextTick() + + expect(activeEntry).toBe(returnToTracer ? tracerEntry.id : null) + expect(context.docks.selected?.id).toBe(returnToTracer ? tracerEntry.id : '~settings') + } + finally { + finishImport() + target?.close() + stopHost() + } + }) +}) diff --git a/packages/hub-ui/src/client/state/inspected-page.test-utils.ts b/packages/hub-ui/src/client/state/inspected-page.test-utils.ts new file mode 100644 index 000000000..c44a68164 --- /dev/null +++ b/packages/hub-ui/src/client/state/inspected-page.test-utils.ts @@ -0,0 +1,39 @@ +export function fakeWindow(origin = 'https://app.test', search = '') { + const listeners = new Map void>>() + const storage = new Map() + const win = { + location: { origin, search }, + sessionStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + }, + parent: undefined as Window | undefined, + opener: null, + sender: undefined as Window | undefined, + addEventListener(type: string, fn: (event: MessageEvent) => void) { + if (!listeners.has(type)) + listeners.set(type, new Set()) + listeners.get(type)!.add(fn) + }, + removeEventListener(type: string, fn: (event: MessageEvent) => void) { + listeners.get(type)?.delete(fn) + }, + postMessage(data: unknown, targetOrigin: string, ports: MessagePort[] = []) { + if (targetOrigin !== origin && targetOrigin !== '*') + return + win.dispatch('message', { data, origin: win.sender!.location.origin, source: win.sender!, ports }) + }, + dispatch(type: string, event: Partial) { + queueMicrotask(() => { + for (const listener of listeners.get(type) ?? []) + listener(event as MessageEvent) + }) + }, + listeners, + } + // eslint-disable-next-line slop/no-chained-type-assertions -- the fake supplies the browser Window boundary used by the public bridge APIs + const window = win as unknown as Window + win.parent = window + win.sender = window + return { win, window } +} diff --git a/packages/hub-ui/src/client/state/inspected-page.test.ts b/packages/hub-ui/src/client/state/inspected-page.test.ts new file mode 100644 index 000000000..aaa61db5d --- /dev/null +++ b/packages/hub-ui/src/client/state/inspected-page.test.ts @@ -0,0 +1,245 @@ +import type { InPageChannelProtocol } from 'devframe/in-page-channel' +import { connectPanelChannel, createPageScriptChannel } from 'devframe/in-page-channel' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { connectInspectedPage, installInspectedPageHost } from './inspected-page' +import { fakeWindow } from './inspected-page.test-utils' + +const cleanup: (() => void)[] = [] +afterEach(() => { + for (const dispose of cleanup.splice(0).reverse()) + dispose() + vi.useRealTimers() +}) + +function setup(options: { adapter?: boolean, activate?: (id: string) => Promise } = {}) { + const extension = fakeWindow('chrome-extension://test') + const viewer = fakeWindow('https://app.test', '?devframe-inspected-page=session-a&devframe-parent-origin=chrome-extension%3A%2F%2Ftest') + const page = fakeWindow() + viewer.win.parent = extension.window + extension.win.sender = viewer.window + const selectionListeners = new Set<(id: string | null) => void>() + const host = { + prepare: vi.fn(async (_id: string) => true), + activate: vi.fn(options.activate ?? (async (_id: string) => true)), + deactivate: vi.fn(async (_id: string) => true), + onSelection: (listener: (id: string | null) => void) => { + selectionListeners.add(listener) + return () => selectionListeners.delete(listener) + }, + } + const stopHost = installInspectedPageHost(host, page.window) + cleanup.push(stopHost) + if (options.adapter !== false) { + extension.win.addEventListener('message', (event) => { + // The browser adapter binds this port to its known viewer and inspected + // document. Production adapter identity checks live in packages/webext. + if (event.source !== viewer.window || event.origin !== viewer.win.location.origin + || event.data?.session !== 'session-a') { + return + } + page.win.dispatch('message', { + data: event.data, + origin: page.win.location.origin, + source: page.window, + ports: event.ports, + }) + }) + } + return { viewer, extension, page, host, stopHost, selectionListeners } +} + +async function connect(s: ReturnType) { + const target = await connectInspectedPage(s.viewer.window) + expect(target).toBeDefined() + cleanup.push(() => target!.close()) + return target! +} + +describe('inspected page bridge', () => { + it('prepares and activates registered entries on the inspected page and forwards selection', async () => { + const s = setup() + const target = await connect(s) + await expect(target.prepare('a11y')).resolves.toBe(true) + await expect(target.activate('tracer')).resolves.toBe(true) + expect(s.host.prepare).toHaveBeenCalledExactlyOnceWith('a11y') + expect(s.host.activate).toHaveBeenCalledExactlyOnceWith('tracer') + const changed = vi.fn() + const unsubscribe = target.onSelection(changed) + for (const listener of s.selectionListeners) + listener(null) + await vi.waitFor(() => expect(changed).toHaveBeenCalledExactlyOnceWith(null)) + unsubscribe() + await expect(target.deactivate('tracer')).resolves.toBe(true) + expect(s.host.deactivate).toHaveBeenCalledExactlyOnceWith('tracer') + }) + + it('carries an existing panel channel through the dedicated bridge port', async () => { + interface Protocol extends InPageChannelProtocol { + pageScript: { route: () => string } + panel: Record + } + const s = setup() + const pageScript = createPageScriptChannel({ + name: 'devframes:bridge-test', + window: s.page.window, + heartbeat: false, + functions: { route: { handler: () => '/' } }, + }) + cleanup.push(() => pageScript.close()) + await connect(s) + const frame = fakeWindow() + frame.win.parent = s.viewer.window + frame.win.sender = s.viewer.window + s.viewer.win.sender = frame.window + const channel = connectPanelChannel({ + name: 'devframes:bridge-test', + window: frame.window, + heartbeat: false, + helloIntervalMs: 5, + functions: {}, + }) + cleanup.push(() => channel.close()) + await vi.waitFor(() => expect(channel.status).toBe('connected')) + await expect(channel.call('route')).resolves.toBe('/') + }) + + it('deactivates an in-flight action after disconnecting and rejects its pending response', async () => { + let finish!: (value: boolean) => void + const activation = new Promise((resolve) => { + finish = resolve + }) + const s = setup({ activate: () => activation }) + const target = await connect(s) + const result = target.activate('tracer') + const rejected = expect(result).rejects.toThrow('disconnected') + await vi.waitFor(() => expect(s.host.activate).toHaveBeenCalledOnce()) + target.close() + await rejected + finish(true) + await vi.waitFor(() => expect(s.host.deactivate).toHaveBeenCalledExactlyOnceWith('tracer')) + expect(s.selectionListeners.size).toBe(0) + }) + + it('finishes old-session teardown before enabling the same action in its replacement', async () => { + let finish!: (value: boolean) => void + const firstActivation = new Promise((resolve) => { + finish = resolve + }) + let enabled = false + let activations = 0 + const order: string[] = [] + const s = setup({ + activate: async () => { + activations++ + if (activations === 1) { + order.push('first:start') + await firstActivation + order.push('first:finish') + } + else { + order.push('replacement:start') + } + enabled = true + return true + }, + }) + s.host.deactivate.mockImplementation(async () => { + order.push('deactivate') + enabled = false + return true + }) + const first = await connect(s) + const firstResult = expect(first.activate('tracer')).rejects.toThrow('disconnected') + await vi.waitFor(() => expect(s.host.activate).toHaveBeenCalledOnce()) + const replacement = await connect(s) + await firstResult + const replacementResult = replacement.activate('tracer') + try { + // Give the replacement request time to reach the host while the original + // action is still loading. Its execution must wait for old teardown. + await new Promise(resolve => setTimeout(resolve, 20)) + expect(s.host.activate).toHaveBeenCalledOnce() + } + finally { + finish(true) + } + await expect(replacementResult).resolves.toBe(true) + expect(order).toEqual(['first:start', 'first:finish', 'deactivate', 'replacement:start']) + expect(enabled).toBe(true) + expect(s.host.deactivate).toHaveBeenCalledExactlyOnceWith('tracer') + }) + + it('closes a timed-out activation and tears down when the pending action finishes', async () => { + let finish!: (value: boolean) => void + let started!: () => void + const activation = new Promise((resolve) => { + finish = resolve + }) + const activationStarted = new Promise((resolve) => { + started = resolve + }) + const s = setup({ + activate: () => { + started() + return activation + }, + }) + const target = await connect(s) + const disconnected = vi.fn() + target.onDisconnect(disconnected) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const result = expect(target.activate('tracer')).rejects.toThrow('did not answer the activate request') + await activationStarted + await vi.advanceTimersByTimeAsync(12_000) + await result + expect(disconnected).toHaveBeenCalledOnce() + await expect(target.prepare('a11y')).rejects.toThrow('disconnected') + finish(true) + vi.useRealTimers() + await vi.waitFor(() => expect(s.host.deactivate).toHaveBeenCalledExactlyOnceWith('tracer')) + }) + + it('rejects unsupported inspected-page targets instead of selecting a local target', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const s = setup({ adapter: false }) + const result = expect(connectInspectedPage(s.viewer.window)).rejects.toThrow('does not support') + await vi.advanceTimersByTimeAsync(12_000) + await result + expect(s.host.prepare).not.toHaveBeenCalled() + expect(s.viewer.win.listeners.get('pagehide')?.size).toBe(0) + }) + + it('ignores host connection requests with an untrusted source, origin or invalid session', async () => { + const s = setup({ adapter: false }) + const ports = Array.from({ length: 4 }, () => new MessageChannel()) + const received = vi.fn() + for (const pair of ports) { + pair.port1.addEventListener('message', received) + pair.port1.start() + cleanup.push(() => { + pair.port1.close() + pair.port2.close() + }) + } + const valid = { type: 'devframe:inspected-page:connect', session: 'session-a' } + s.page.win.dispatch('message', { data: valid, origin: 'https://app.test', source: s.viewer.window, ports: [ports[0]!.port2] }) + s.page.win.dispatch('message', { data: valid, origin: 'https://evil.test', source: s.page.window, ports: [ports[1]!.port2] }) + s.page.win.dispatch('message', { data: { ...valid, session: '' }, origin: 'https://app.test', source: s.page.window, ports: [ports[2]!.port2] }) + s.page.win.dispatch('message', { data: { ...valid, session: 1 }, origin: 'https://app.test', source: s.page.window, ports: [ports[3]!.port2] }) + await new Promise(resolve => setTimeout(resolve, 20)) + expect(received).not.toHaveBeenCalled() + expect(s.selectionListeners.size).toBe(0) + }) + + it('returns no inspected target when the viewer has no session query', async () => { + const viewer = fakeWindow() + await expect(connectInspectedPage(viewer.window)).resolves.toBeUndefined() + expect(viewer.win.listeners.size).toBe(0) + }) + + it.each(['*', 'null', ''])('rejects an invalid parent origin %j before connecting', async (parentOrigin) => { + const viewer = fakeWindow('https://app.test', `?devframe-inspected-page=session&devframe-parent-origin=${encodeURIComponent(parentOrigin)}`) + viewer.win.parent = fakeWindow('chrome-extension://test').window + await expect(connectInspectedPage(viewer.window)).rejects.toThrow('adapter is unavailable') + }) +}) diff --git a/packages/hub-ui/src/client/state/inspected-page.ts b/packages/hub-ui/src/client/state/inspected-page.ts new file mode 100644 index 000000000..329fae92e --- /dev/null +++ b/packages/hub-ui/src/client/state/inspected-page.ts @@ -0,0 +1,242 @@ +import { HUB_EVENTS } from '@devframes/hub/constants' +import { createInPageChannelRelay } from 'devframe/in-page-channel' + +const CONNECT = HUB_EVENTS.postMessage.inspectedPage +const TIMEOUT = 12_000 +type Method = 'prepare' | 'activate' | 'deactivate' + +export interface InspectedPageTarget { + prepare: (entryId: string) => Promise + activate: (entryId: string) => Promise + deactivate: (entryId: string) => Promise + onSelection: (listener: (entryId: string | null) => void) => () => void + onDisconnect: (listener: () => void) => () => void + close: () => void +} + +interface InspectedPageHost { + prepare: (entryId: string) => Promise + activate: (entryId: string) => Promise + deactivate: (entryId: string) => Promise + onSelection: (listener: (entryId: string | null) => void) => () => void +} + +function channelTransport(port: MessagePort) { + return { + postMessage(data: unknown) { + port.postMessage({ type: 'channel', data }) + }, + onMessage(handler: (data: unknown) => void) { + const listener = (event: MessageEvent) => { + if (event.data?.type === 'channel') + handler(event.data.data) + } + port.addEventListener('message', listener) + return () => port.removeEventListener('message', listener) + }, + } +} + +/** + * Accept a dedicated transport supplied by a hub UI provider's browser adapter. + * The adapter owns tab/document routing; this endpoint only accepts a port + * delivered by this host page and operates on registered dock entries. + */ +export function installInspectedPageHost(host: InspectedPageHost, win: Window = window): () => void { + const sessions = new Map void>() + // Replacement connections share the action queue, including teardown. An + // older pending activation must finish and deactivate before its successor. + let operations = Promise.resolve() + const connect = (event: MessageEvent) => { + if (event.source !== win || event.origin !== win.location.origin) + return + const data = event.data + if (data?.type !== CONNECT || typeof data.session !== 'string' || !data.session || !event.ports[0]) + return + sessions.get(data.session)?.() + const port = event.ports[0] + let activeEntry: string | undefined + let closed = false + const stopRelay = createInPageChannelRelay({ role: 'page', window: win, transport: channelTransport(port) }) + const stopSelection = host.onSelection(entryId => port.postMessage({ type: 'selection', entryId })) + function close() { + if (closed) + return + closed = true + stopSelection() + stopRelay() + port.removeEventListener('message', receive) + port.postMessage({ type: 'disconnect' }) + port.close() + sessions.delete(data.session) + // Queue teardown after in-flight activation so closing the extension + // cannot leave an inspector enabled after its asynchronous script loads. + operations = operations.then(async () => { + if (activeEntry) + await host.deactivate(activeEntry) + }).catch(() => {}) + } + function receive(message: MessageEvent) { + const request = message.data + if (request?.type === 'disconnect') { + close() + return + } + if (request?.type !== 'request' || typeof request.id !== 'string' || typeof request.entryId !== 'string') + return + if (!['prepare', 'activate', 'deactivate'].includes(request.method)) + return + const method = request.method as Method + operations = operations.then(async () => { + if (closed) + return + try { + const result = await host[method](request.entryId) + if (method === 'activate' && result) + activeEntry = request.entryId + if (method === 'deactivate' && activeEntry === request.entryId) + activeEntry = undefined + if (!closed) + port.postMessage({ type: 'response', id: request.id, result }) + } + catch (error) { + if (!closed) + port.postMessage({ type: 'response', id: request.id, error: error instanceof Error ? error.message : String(error) }) + } + }) + } + sessions.set(data.session, close) + port.addEventListener('message', receive) + port.start() + port.postMessage({ type: 'ready' }) + } + win.addEventListener('message', connect) + const dispose = () => { + win.removeEventListener('message', connect) + win.removeEventListener('pagehide', dispose) + for (const close of [...sessions.values()]) + close() + } + win.addEventListener('pagehide', dispose) + return dispose +} + +/** A remote target is explicit and fail-closed: never execute its scripts locally. */ +export async function connectInspectedPage(win: Window = window): Promise { + const query = new URLSearchParams(win.location.search) + const session = query.get('devframe-inspected-page') + if (!session) + return undefined + const parentOrigin = query.get('devframe-parent-origin') + if (!parentOrigin || parentOrigin === '*' || parentOrigin === 'null' || win.parent === win) + throw new Error('The inspected-page adapter is unavailable. Reopen the browser DevTools panel.') + const { port1: port, port2 } = new MessageChannel() + const selectionListeners = new Set<(id: string | null) => void>() + const disconnectListeners = new Set<() => void>() + const pending = new Map void, reject: (reason: Error) => void, timer: ReturnType }>() + let sequence = 0 + let closed = false + let stopRelay = () => {} + let readyResolve!: () => void + let readyReject!: (error: Error) => void + const ready = new Promise((resolve, reject) => { + readyResolve = resolve + readyReject = reject + }) + const timeout = setTimeout(() => { + readyReject(new Error('The inspected page does not support the DevTools bridge. Update the hub UI package and reload the page.')) + close() + }, TIMEOUT) + function close() { + if (closed) + return + closed = true + clearTimeout(timeout) + stopRelay() + readyReject(new Error('The inspected page disconnected. Reopen the browser DevTools panel.')) + for (const call of pending.values()) { + clearTimeout(call.timer) + call.reject(new Error('The inspected page disconnected.')) + } + pending.clear() + port.postMessage({ type: 'disconnect' }) + port.close() + win.removeEventListener('pagehide', close) + for (const listener of disconnectListeners) + listener() + } + function receiveResponse(data: { id: string, result?: unknown, error?: unknown }) { + const call = pending.get(data.id) + if (!call) + return + pending.delete(data.id) + clearTimeout(call.timer) + if (typeof data.error === 'string') + call.reject(new Error(data.error)) + else + call.resolve(data.result === true) + } + port.addEventListener('message', (event) => { + const data = event.data + if (closed) + return + if (data?.type === 'ready') { + clearTimeout(timeout) + readyResolve() + } + else if (data?.type === 'response' && typeof data.id === 'string') { + receiveResponse(data) + } + else if (data?.type === 'selection' && (data.entryId === null || typeof data.entryId === 'string')) { + for (const listener of selectionListeners) + listener(data.entryId) + } + else if (data?.type === 'disconnect' || data?.type === 'error') { + if (typeof data.message === 'string') + readyReject(new Error(data.message)) + close() + } + }) + port.start() + win.addEventListener('pagehide', close) + try { + win.parent.postMessage({ type: CONNECT, session }, parentOrigin, [port2]) + } + catch (error) { + readyReject(error instanceof Error ? error : new Error(String(error))) + port2.close() + close() + } + await ready + stopRelay = createInPageChannelRelay({ role: 'panel', window: win, transport: channelTransport(port) }) + const request = (method: Method, entryId: string): Promise => { + if (closed) + return Promise.reject(new Error('The inspected page disconnected.')) + return new Promise((resolve, reject) => { + const id = String(++sequence) + const timer = setTimeout(() => { + pending.delete(id) + reject(new Error(`The inspected page did not answer the ${method} request.`)) + // The operation may still complete in the inspected document. Closing + // queues its teardown there, so a late activation cannot stay enabled. + close() + }, TIMEOUT) + pending.set(id, { resolve, reject, timer }) + port.postMessage({ type: 'request', id, method, entryId }) + }) + } + return { + prepare: id => request('prepare', id), + activate: id => request('activate', id), + deactivate: id => request('deactivate', id), + onSelection(listener) { + selectionListeners.add(listener) + return () => selectionListeners.delete(listener) + }, + onDisconnect(listener) { + disconnectListeners.add(listener) + return () => disconnectListeners.delete(listener) + }, + close, + } +} diff --git a/packages/hub/src/events.ts b/packages/hub/src/events.ts index c93893335..cc0a60e44 100644 --- a/packages/hub/src/events.ts +++ b/packages/hub/src/events.ts @@ -69,6 +69,7 @@ export const HUB_EVENTS = { /** `postMessage` channels for host ↔ iframe protocols, `devframe:` prefix. */ postMessage: { frameNav: 'devframe:frame-nav', + inspectedPage: 'devframe:inspected-page:connect', }, /** * Same-origin `BroadcastChannel` names, `devframe:` prefix. Used on a diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts index 659d3ac10..b47d15cc6 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts @@ -55,6 +55,7 @@ export declare const HUB_EVENTS: { }; readonly postMessage: { readonly frameNav: "devframe:frame-nav"; + readonly inspectedPage: "devframe:inspected-page:connect"; }; readonly broadcastChannel: { readonly docksActivate: "devframe:docks:activate"; 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 217e64918..9d0e565a8 100644 --- a/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts @@ -20,6 +20,15 @@ export interface InPageChannelProtocol { panel?: Record any>; sharedStates?: Record; } +export interface InPageChannelRelayOptions { + role: 'panel' | 'page'; + window?: Window; + transport: InPageChannelRelayTransport; +} +export interface InPageChannelRelayTransport { + postMessage: (_: unknown) => void; + onMessage: (_: (_: unknown) => void) => () => void; +} export interface PageScriptChannel

{ readonly name: string; readonly instanceId: string; @@ -87,6 +96,7 @@ export declare class InPageChannelError extends Error { // #region Functions export declare function connectPanelChannel

(_: ConnectPanelChannelOptions

): PanelChannel

; +export declare function createInPageChannelRelay(_: InPageChannelRelayOptions): () => void; export declare function createPageScriptChannel

(_: CreatePageScriptChannelOptions

): PageScriptChannel

; export declare function defineChannelFunction(_: InPageFunctionDefinition): InPageFunctionDefinition; // #endregion diff --git a/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.js b/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.js index 7a8f6f89c..c4984b8ae 100644 --- a/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.js @@ -11,6 +11,7 @@ export class InPageChannelError extends Error { // #region Functions export function connectPanelChannel(_) {} +export function createInPageChannelRelay(_) {} export function createPageScriptChannel(_) {} export function defineChannelFunction(_) {} // #endregion \ No newline at end of file