From 634b1e7125b144410efcad192fcd68b3542620f8 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 2 Jul 2026 09:03:43 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(hub):=20client-context=20parity=20?= =?UTF-8?q?=E2=80=94=20iframe=20skip=20guard,=20entry-scoped=20messages,?= =?UTF-8?q?=20level=20shortcuts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hub/src/client/__tests__/host.test.ts | 79 +++++++++++++++++-- .../hub/src/client/__tests__/messages.test.ts | 45 +++++++++++ packages/hub/src/client/client-script.ts | 3 +- packages/hub/src/client/context.ts | 8 +- packages/hub/src/client/host.ts | 51 +++++++++++- packages/hub/src/client/messages.ts | 33 ++++++-- .../src/node/__tests__/host-messages.test.ts | 16 ++++ packages/hub/src/node/host-messages.ts | 21 +++++ packages/hub/src/types/messages.ts | 28 ++++++- .../@devframes/hub/client.snapshot.d.ts | 17 +++- .../tsnapi/@devframes/hub/client.snapshot.js | 2 +- .../tsnapi/@devframes/hub/index.snapshot.d.ts | 2 + .../tsnapi/@devframes/hub/node.snapshot.d.ts | 5 ++ .../tsnapi/@devframes/hub/node.snapshot.js | 5 ++ .../tsnapi/@devframes/hub/types.snapshot.d.ts | 2 + 15 files changed, 291 insertions(+), 26 deletions(-) create mode 100644 packages/hub/src/client/__tests__/messages.test.ts diff --git a/packages/hub/src/client/__tests__/host.test.ts b/packages/hub/src/client/__tests__/host.test.ts index ed92708..8ee790d 100644 --- a/packages/hub/src/client/__tests__/host.test.ts +++ b/packages/hub/src/client/__tests__/host.test.ts @@ -1,11 +1,20 @@ import type { DevframeRpcClient } from 'devframe/client' import type { SharedState } from 'devframe/utils/shared-state' import type { DevframeDockEntry } from '../../types/docks' +import type { DevframeClientHost, DevframeClientHostOptions } from '../host' import { createEventEmitter } from 'devframe/utils/events' import { describe, expect, it, vi } from 'vitest' import { getDevframeClientContext } from '../context' import { createDevframeClientHost } from '../host' +/** Boot a host that is expected to actually boot (narrow away the skipped arm). */ +async function boot(options: DevframeClientHostOptions): Promise { + const host = await createDevframeClientHost(options) + if (host.skipped) + throw new Error('expected the client host to boot, but it skipped') + return host +} + interface StubSharedState extends SharedState { /** Replace the state wholesale and emit `updated` (simulates a server patch). */ push: (next: T) => void @@ -43,6 +52,8 @@ function createStubRpc() { }, call: async (...args: any[]) => { calls.push(args) + if (args[0] === 'hub:messages:add') + return { id: 'msg-1', timestamp: 1, from: 'browser', ...args[1] } return `rpc:${args[0]}` }, } as unknown as DevframeRpcClient @@ -56,7 +67,7 @@ function iframeEntry(id: string, extra?: Record): DevframeDockE describe('createDevframeClientHost', () => { it('publishes the global client context with the full surface', async () => { const { rpc } = createStubRpc() - const host = await createDevframeClientHost({ rpc }) + const host = await boot({ rpc }) expect(getDevframeClientContext()).toBe(host.context) expect(host.context.clientType).toBe('standalone') @@ -73,7 +84,7 @@ describe('createDevframeClientHost', () => { it('reconciles dock entries from shared state and tracks per-entry state', async () => { const { rpc, states } = createStubRpc() - const host = await createDevframeClientHost({ rpc }) + const host = await boot({ rpc }) const docksState = states.get('devframe:docks')! docksState.push([iframeEntry('one'), iframeEntry('two')]) @@ -88,7 +99,7 @@ describe('createDevframeClientHost', () => { it('switches entries with activation/deactivation events and when-context updates', async () => { const { rpc, states } = createStubRpc() - const host = await createDevframeClientHost({ rpc }) + const host = await boot({ rpc }) states.get('devframe:docks')!.push([iframeEntry('one'), iframeEntry('two')]) const activated: string[] = [] @@ -115,7 +126,7 @@ describe('createDevframeClientHost', () => { it('executes client commands locally and server commands over hub:commands:execute', async () => { const { rpc, calls } = createStubRpc() - const host = await createDevframeClientHost({ rpc }) + const host = await boot({ rpc }) const ran: any[] = [] const off = host.context.commands.register({ @@ -139,8 +150,8 @@ describe('createDevframeClientHost', () => { }) it('imports a dock entry client script and hands it the script context', async () => { - const { rpc, states } = createStubRpc() - const host = await createDevframeClientHost({ rpc }) + const { rpc, states, calls } = createStubRpc() + const host = await boot({ rpc }) const received: any[] = [] ;(globalThis as any).__DF_TEST_SCRIPT__ = (ctx: any) => received.push(ctx) @@ -153,8 +164,62 @@ describe('createDevframeClientHost', () => { const scriptCtx = received[0] expect(scriptCtx.current.entryMeta.id).toBe('scripted') expect(scriptCtx.rpc).toBe(rpc) - expect(typeof scriptCtx.messages.add).toBe('function') + + // The messages client is scoped to the entry: `category` defaults to the + // entry id, and the doc'd per-level shortcuts delegate to add(). + await scriptCtx.messages.add({ message: 'hello', level: 'info' }) + expect(calls.at(-1)).toEqual(['hub:messages:add', { message: 'hello', level: 'info', category: 'scripted' }]) + await scriptCtx.messages.warn('careful', { category: 'a11y' }) + expect(calls.at(-1)).toEqual(['hub:messages:add', { message: 'careful', level: 'warn', category: 'a11y' }]) + delete (globalThis as any).__DF_TEST_SCRIPT__ host.dispose() }) + + it('skips boot inside an iframe for embedded hosts, overridable via skipInIframe', async () => { + vi.stubGlobal('window', { self: {}, top: {} }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const { rpc } = createStubRpc() + const skipped = await createDevframeClientHost({ rpc, clientType: 'embedded' }) + expect(skipped.skipped).toBe(true) + expect(skipped.context).toBeUndefined() + expect(getDevframeClientContext()).toBeUndefined() + skipped.dispose() + + // Standalone hosts (and an explicit opt-out) still boot inside iframes. + const standalone = await createDevframeClientHost({ rpc }) + expect(standalone.skipped).toBe(false) + standalone.dispose() + const optOut = await createDevframeClientHost({ rpc, clientType: 'embedded', skipInIframe: false }) + expect(optOut.skipped).toBe(false) + optOut.dispose() + } + finally { + vi.unstubAllGlobals() + warn.mockRestore() + } + }) + + it('warns when a second host replaces a published context; dispose unpublishes it', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const { rpc } = createStubRpc() + const first = await boot({ rpc }) + expect(warn).not.toHaveBeenCalled() + + const second = await boot({ rpc }) + expect(warn).toHaveBeenCalledOnce() + expect(getDevframeClientContext()).toBe(second.context) + + // The first host no longer owns the published context — leave it alone. + first.dispose() + expect(getDevframeClientContext()).toBe(second.context) + second.dispose() + expect(getDevframeClientContext()).toBeUndefined() + } + finally { + warn.mockRestore() + } + }) }) diff --git a/packages/hub/src/client/__tests__/messages.test.ts b/packages/hub/src/client/__tests__/messages.test.ts new file mode 100644 index 0000000..3a3a2d9 --- /dev/null +++ b/packages/hub/src/client/__tests__/messages.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { createMessagesClient } from '../messages' + +function createStubRpc() { + const calls: any[][] = [] + const rpc = { + call: async (...args: any[]) => { + calls.push(args) + if (args[0] === 'hub:messages:add') + return { id: 'msg-1', timestamp: 1, from: 'browser', ...args[1] } + if (args[0] === 'hub:messages:update') + return { id: args[1], timestamp: 1, from: 'browser', ...args[2] } + return undefined + }, + } as any + return { rpc, calls } +} + +describe('createMessagesClient', () => { + it('merges defaults beneath the input; explicit input fields win', async () => { + const { rpc, calls } = createStubRpc() + const messages = createMessagesClient(rpc, { defaults: { category: 'my-entry' } }) + + await messages.add({ message: 'hello', level: 'info' }) + expect(calls.at(-1)).toEqual(['hub:messages:add', { message: 'hello', level: 'info', category: 'my-entry' }]) + + await messages.add({ message: 'custom', level: 'info', category: 'a11y' }) + expect(calls.at(-1)?.[1].category).toBe('a11y') + }) + + it('provides per-level shortcuts that delegate to add()', async () => { + const { rpc, calls } = createStubRpc() + const messages = createMessagesClient(rpc) + + const handle = await messages.info('hi') + expect(calls.at(-1)).toEqual(['hub:messages:add', { message: 'hi', level: 'info' }]) + expect(handle.id).toBe('msg-1') + + await messages.error('boom', { notify: true }) + expect(calls.at(-1)).toEqual(['hub:messages:add', { message: 'boom', level: 'error', notify: true }]) + + await handle.dismiss() + expect(calls.at(-1)).toEqual(['hub:messages:remove', 'msg-1']) + }) +}) diff --git a/packages/hub/src/client/client-script.ts b/packages/hub/src/client/client-script.ts index 86d2f8f..fee5958 100644 --- a/packages/hub/src/client/client-script.ts +++ b/packages/hub/src/client/client-script.ts @@ -10,7 +10,8 @@ export interface DockClientScriptContext extends DocksContext { */ current: DockEntryState /** - * Messages client scoped to this dock entry's source + * Messages client scoped to this dock entry — messages it adds default + * their `category` to the entry id, so the feed can attribute them. */ messages: DevframeMessagesClient } diff --git a/packages/hub/src/client/context.ts b/packages/hub/src/client/context.ts index d97cd94..e7ba0a3 100644 --- a/packages/hub/src/client/context.ts +++ b/packages/hub/src/client/context.ts @@ -10,11 +10,11 @@ export function getDevframeClientContext(): DevframeClientContext | undefined { } /** - * Publish the global Devframe client context. Called by - * {@link import('./host').createDevframeClientHost}; a dock client script or a - * viewer reads it back with {@link getDevframeClientContext}. + * Publish the global Devframe client context (or clear it with `undefined`). + * Called by {@link import('./host').createDevframeClientHost}; a dock client + * script or a viewer reads it back with {@link getDevframeClientContext}. */ -export function setDevframeClientContext(ctx: DevframeClientContext): void { +export function setDevframeClientContext(ctx: DevframeClientContext | undefined): void { (globalThis as any)[CLIENT_CONTEXT_KEY] = ctx } diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index a37c8e8..e785567 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -25,7 +25,7 @@ import type { import { connectDevframe } from 'devframe/client' import { createEventEmitter } from 'devframe/utils/events' import { DEFAULT_CATEGORIES_ORDER, DEFAULT_STATE_USER_SETTINGS } from '../constants' -import { setDevframeClientContext } from './context' +import { getDevframeClientContext, setDevframeClientContext } from './context' import { createMessagesClient } from './messages' const DOCKS_STATE_KEY = 'devframe:docks' @@ -52,15 +52,34 @@ export interface DevframeClientHostOptions { * `custom-render`, and iframe `clientScript`). Default `true`. */ loadClientScripts?: boolean + /** + * Skip booting when the page runs inside an iframe, so a nested frame never + * publishes a second context or re-runs every dock client script. Defaults + * to `true` for `'embedded'` hosts (dock iframe panels and nested copies of + * the app skip themselves) and `false` for `'standalone'` hosts (a hub UI + * page stays bootable when intentionally iframed). + */ + skipInIframe?: boolean } export interface DevframeClientHost { + /** The host booted and published its context. */ + skipped: false /** The assembled, globally-registered client host context. */ context: DevframeClientContext /** Tear down listeners and stop tracking newly-registered client scripts. */ dispose: () => void } +export interface DevframeClientHostSkipped { + /** Boot was skipped — the page runs inside an iframe (see `skipInIframe`). */ + skipped: true + context: undefined + dispose: () => void +} + +export type DevframeClientHostResult = DevframeClientHost | DevframeClientHostSkipped + /** * Boot the framework-level client host: connect RPC, assemble the full * {@link DevframeClientContext} (panel, docks, commands, when) from the hub's @@ -73,8 +92,13 @@ export interface DevframeClientHost { */ export async function createDevframeClientHost( options: DevframeClientHostOptions = {}, -): Promise { +): Promise { const clientType: DockClientType = options.clientType ?? 'standalone' + if ((options.skipInIframe ?? clientType === 'embedded') && isInsideIframe()) { + console.warn('[@devframes/hub] Skipping client host in iframe') + return { skipped: true, context: undefined, dispose: () => {} } + } + const rpc = options.rpc ?? await connectDevframe(options.connect) const [docksState, commandsState, settings] = await Promise.all([ @@ -115,9 +139,14 @@ export async function createDevframeClientHost( reconcileEntries() disposers.push(docksState.on('updated', reconcileEntries)) + if (getDevframeClientContext()) { + console.warn( + '[@devframes/hub] A client host context is already published on this page — replacing it. ' + + 'Boot createDevframeClientHost() once per page (e.g. HTML injection combined with a manual import boots it twice).', + ) + } setDevframeClientContext(context) - const messages = createMessagesClient(rpc) const loadedScripts = new Set() if (options.loadClientScripts ?? true) { loadClientScripts() @@ -125,9 +154,12 @@ export async function createDevframeClientHost( } return { + skipped: false, context, dispose() { for (const off of disposers.splice(0)) off() + if (getDevframeClientContext() === context) + setDevframeClientContext(undefined) }, } @@ -277,6 +309,9 @@ export async function createDevframeClientHost( const current = entryToStateMap.get(entryId) if (!current) return + // Scope the messages client to this entry: its messages default their + // `category` to the entry id, so the feed can attribute and group them. + const messages = createMessagesClient(rpc, { defaults: { category: entryId } }) const scriptContext: DockClientScriptContext = { ...context, current, messages } await fn(scriptContext) } @@ -289,6 +324,16 @@ export async function createDevframeClientHost( // ── shared helpers ───────────────────────────────────────────────────────── +function isInsideIframe(): boolean { + try { + return typeof window !== 'undefined' && window.self !== window.top + } + catch { + // Cross-origin access threw — we are definitely inside a foreign frame. + return true + } +} + function createPanelContext(clientType: DockClientType): DocksPanelContext { const store: DocksPanelContext['store'] = { mode: 'edge', diff --git a/packages/hub/src/client/messages.ts b/packages/hub/src/client/messages.ts index e84220e..da20601 100644 --- a/packages/hub/src/client/messages.ts +++ b/packages/hub/src/client/messages.ts @@ -3,16 +3,27 @@ import type { DevframeMessageEntry, DevframeMessageEntryInput, DevframeMessageHandle, + DevframeMessageLevel, DevframeMessagesClient, + DevframeMessageShortcutInput, } from '../types/messages' +export interface MessagesClientOptions { + /** + * Default fields merged beneath every `add()` input — the client host passes + * `{ category: entry.id }` to scope a dock client script's messages to its + * entry. Fields set on the input itself win. + */ + defaults?: Partial +} + /** * Build a browser-side {@link DevframeMessagesClient} that writes into the * hub's messages subsystem over the `hub:messages:*` built-in RPCs. The handle * returned by `add` proxies `update`/`dismiss` back through the same RPCs, so a * dock client script reports into the very feed the server writes to. */ -export function createMessagesClient(rpc: DevframeRpcClient): DevframeMessagesClient { +export function createMessagesClient(rpc: DevframeRpcClient, options: MessagesClientOptions = {}): DevframeMessagesClient { // The `hub:messages:*` ids aren't in the statically-typed server map. const call = rpc.call as (name: string, ...args: any[]) => Promise @@ -35,12 +46,24 @@ export function createMessagesClient(rpc: DevframeRpcClient): DevframeMessagesCl } } + async function add(input: DevframeMessageEntryInput): Promise { + const entry = await call('hub:messages:add', { ...options.defaults, ...input }) as DevframeMessageEntry + return makeHandle(entry) + } + + function levelShortcut(level: DevframeMessageLevel) { + return (message: string, extra?: DevframeMessageShortcutInput) => + add({ ...extra, message, level }) + } + return { - async add(input: DevframeMessageEntryInput) { - const entry = await call('hub:messages:add', input) as DevframeMessageEntry - return makeHandle(entry) - }, + add, remove: id => call('hub:messages:remove', id) as Promise, clear: () => call('hub:messages:clear') as Promise, + info: levelShortcut('info'), + warn: levelShortcut('warn'), + error: levelShortcut('error'), + success: levelShortcut('success'), + debug: levelShortcut('debug'), } } diff --git a/packages/hub/src/node/__tests__/host-messages.test.ts b/packages/hub/src/node/__tests__/host-messages.test.ts index 903de4b..a40ff45 100644 --- a/packages/hub/src/node/__tests__/host-messages.test.ts +++ b/packages/hub/src/node/__tests__/host-messages.test.ts @@ -20,4 +20,20 @@ describe('devframeMessagesHost', () => { expect(host.removals[0].id).toBe('message:5') expect(host.removals.at(-1)?.id).toBe('message:1004') }) + + it('provides per-level shortcuts that delegate to add()', async () => { + const host = new DevframeMessagesHost({} as DevframeHubContext) + + const handle = await host.info('booted', { category: 'lifecycle' }) + expect(handle.entry).toMatchObject({ + message: 'booted', + level: 'info', + category: 'lifecycle', + from: 'server', + }) + + await host.error('boom') + const levels = [...host.entries.values()].map(e => e.level) + expect(levels).toEqual(['info', 'error']) + }) }) diff --git a/packages/hub/src/node/host-messages.ts b/packages/hub/src/node/host-messages.ts index 592db29..c8334a7 100644 --- a/packages/hub/src/node/host-messages.ts +++ b/packages/hub/src/node/host-messages.ts @@ -2,6 +2,7 @@ import type { DevframeMessageEntry, DevframeMessageEntryInput, DevframeMessageHandle, + DevframeMessageShortcutInput, DevframeMessagesHost as DevframeMessagesHostType, } from '../types/messages' import type { DevframeHubContext } from './context' @@ -120,6 +121,26 @@ export class DevframeMessagesHost implements DevframeMessagesHostType { this.events.emit('message:removed', id) } + info(message: string, extra?: DevframeMessageShortcutInput): Promise { + return this.add({ ...extra, message, level: 'info' }) + } + + warn(message: string, extra?: DevframeMessageShortcutInput): Promise { + return this.add({ ...extra, message, level: 'warn' }) + } + + error(message: string, extra?: DevframeMessageShortcutInput): Promise { + return this.add({ ...extra, message, level: 'error' }) + } + + success(message: string, extra?: DevframeMessageShortcutInput): Promise { + return this.add({ ...extra, message, level: 'success' }) + } + + debug(message: string, extra?: DevframeMessageShortcutInput): Promise { + return this.add({ ...extra, message, level: 'debug' }) + } + async clear(): Promise { for (const timer of this._autoDeleteTimers.values()) clearTimeout(timer) diff --git a/packages/hub/src/types/messages.ts b/packages/hub/src/types/messages.ts index 43ba3c9..a0c1cf9 100644 --- a/packages/hub/src/types/messages.ts +++ b/packages/hub/src/types/messages.ts @@ -105,7 +105,31 @@ export interface DevframeMessageHandle { dismiss: () => Promise } -export interface DevframeMessagesClient { +/** + * Extra fields accepted by the per-level message shortcuts — + * everything on {@link DevframeMessageEntryInput} except the + * `message` and `level` the shortcut itself provides. + */ +export type DevframeMessageShortcutInput = Omit + +/** + * Per-level shortcuts shared by the client and the node host — + * `messages.info('...')` is `messages.add({ message: '...', level: 'info' })`. + */ +export interface DevframeMessagesLevelShortcuts { + /** Shortcut for `add({ message, level: 'info', ...extra })` */ + info: (message: string, extra?: DevframeMessageShortcutInput) => Promise + /** Shortcut for `add({ message, level: 'warn', ...extra })` */ + warn: (message: string, extra?: DevframeMessageShortcutInput) => Promise + /** Shortcut for `add({ message, level: 'error', ...extra })` */ + error: (message: string, extra?: DevframeMessageShortcutInput) => Promise + /** Shortcut for `add({ message, level: 'success', ...extra })` */ + success: (message: string, extra?: DevframeMessageShortcutInput) => Promise + /** Shortcut for `add({ message, level: 'debug', ...extra })` */ + debug: (message: string, extra?: DevframeMessageShortcutInput) => Promise +} + +export interface DevframeMessagesClient extends DevframeMessagesLevelShortcuts { /** * Add a message entry. Returns a Promise resolving to a handle for subsequent updates/dismissal. * Can be used without `await` for fire-and-forget usage. @@ -117,7 +141,7 @@ export interface DevframeMessagesClient { clear: () => Promise } -export interface DevframeMessagesHost { +export interface DevframeMessagesHost extends DevframeMessagesLevelShortcuts { readonly entries: Map readonly events: EventEmitter<{ 'message:added': (entry: DevframeMessageEntry) => void diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts index 9af8e57..98a3fb8 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts @@ -12,6 +12,7 @@ export interface CommandsContext { paletteOpen: boolean; } export interface DevframeClientHost { + skipped: false; context: DevframeClientContext; dispose: () => void; } @@ -20,6 +21,12 @@ export interface DevframeClientHostOptions { connect?: DevframeRpcClientOptions; clientType?: DockClientType; loadClientScripts?: boolean; + skipInIframe?: boolean; +} +export interface DevframeClientHostSkipped { + skipped: true; + context: undefined; + dispose: () => void; } export interface DockClientScriptContext extends DocksContext { current: DockEntryState; @@ -75,6 +82,9 @@ export interface DocksPanelContext { isResizing: boolean; readonly isVertical: boolean; } +export interface MessagesClientOptions { + defaults?: Partial; +} export interface WhenClauseContext { readonly context: WhenContext; } @@ -83,16 +93,17 @@ export interface WhenClauseContext { // #region Types export type ConnectRemoteDevframeOptions = Omit; export type DevframeClientContext = DocksContext; +export type DevframeClientHostResult = DevframeClientHost | DevframeClientHostSkipped; export type DockClientType = 'embedded' | 'standalone'; // #endregion // #region Functions export declare function connectRemoteDevframe(_?: ConnectRemoteDevframeOptions): Promise; -export declare function createDevframeClientHost(_?: DevframeClientHostOptions): Promise; -export declare function createMessagesClient(_: DevframeRpcClient): DevframeMessagesClient; +export declare function createDevframeClientHost(_?: DevframeClientHostOptions): Promise; +export declare function createMessagesClient(_: DevframeRpcClient, _?: MessagesClientOptions): DevframeMessagesClient; export declare function getDevframeClientContext(): DevframeClientContext | undefined; export declare function parseRemoteConnection(_?: string): RemoteConnectionInfo | null; -export declare function setDevframeClientContext(_: DevframeClientContext): void; +export declare function setDevframeClientContext(_: DevframeClientContext | undefined): void; // #endregion // #region Variables diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js index f1f4c6a..79c9f6e 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js @@ -4,7 +4,7 @@ // #region Functions export async function connectRemoteDevframe(_) {} export async function createDevframeClientHost(_) {} -export function createMessagesClient(_) {} +export function createMessagesClient(_, _) {} export function getDevframeClientContext() {} export function parseRemoteConnection(_) {} export function setDevframeClientContext(_) {} diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index 5fd55dd..eeee1a1 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -51,7 +51,9 @@ export { DevframeMessageFilePosition } export { DevframeMessageHandle } export { DevframeMessageLevel } export { DevframeMessagesClient } +export { DevframeMessageShortcutInput } export { DevframeMessagesHost } +export { DevframeMessagesLevelShortcuts } export { DevframeNodeRpcSession } export { DevframeRpcClientFunctions } export { DevframeRpcServerFunctions } diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts index 452a6e1..136a109 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts @@ -59,6 +59,11 @@ export declare class DevframeMessagesHost implements DevframeMessagesHost$1 { add(_: DevframeMessageEntryInput): Promise; update(_: string, _: Partial): Promise; remove(_: string): Promise; + info(_: string, _?: DevframeMessageShortcutInput): Promise; + warn(_: string, _?: DevframeMessageShortcutInput): Promise; + error(_: string, _?: DevframeMessageShortcutInput): Promise; + success(_: string, _?: DevframeMessageShortcutInput): Promise; + debug(_: string, _?: DevframeMessageShortcutInput): Promise; clear(): Promise; private _createHandle; } diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js index 8933bd2..cac7f89 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js @@ -43,6 +43,11 @@ export class DevframeMessagesHost { async add(_) {} async update(_, _) {} async remove(_) {} + info(_, _) {} + warn(_, _) {} + error(_, _) {} + success(_, _) {} + debug(_, _) {} async clear() {} _createHandle(_) {} } diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts index 946c6d5..ce01ea4 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts @@ -37,7 +37,9 @@ export { DevframeMessageFilePosition } export { DevframeMessageHandle } export { DevframeMessageLevel } export { DevframeMessagesClient } +export { DevframeMessageShortcutInput } export { DevframeMessagesHost } +export { DevframeMessagesLevelShortcuts } export { DevframeNodeRpcSession } export { DevframeRpcClientFunctions } export { DevframeRpcServerFunctions } From 4ffa6dd72f83f4b65587a54e2e019a3954d7df0a Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 2 Jul 2026 09:34:39 +0000 Subject: [PATCH 2/5] =?UTF-8?q?feat(plugin-a11y):=20consume=20the=20hub=20?= =?UTF-8?q?client-script=20context=20=E2=80=94=20mirror=20scans=20into=20t?= =?UTF-8?q?he=20messages=20feed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/a11y/README.md | 14 ++- plugins/a11y/src/inject/index.ts | 36 ++++-- plugins/a11y/src/inject/messages.ts | 131 +++++++++++++++++++ plugins/a11y/src/inject/scanner.ts | 1 + plugins/a11y/src/shared/protocol.ts | 2 + plugins/a11y/tests/inject-messages.test.ts | 139 +++++++++++++++++++++ 6 files changed, 309 insertions(+), 14 deletions(-) create mode 100644 plugins/a11y/src/inject/messages.ts create mode 100644 plugins/a11y/tests/inject-messages.test.ts diff --git a/plugins/a11y/README.md b/plugins/a11y/README.md index bdca90c..f502375 100644 --- a/plugins/a11y/README.md +++ b/plugins/a11y/README.md @@ -35,10 +35,14 @@ agent is the author-provided bridge into the page being checked. In a hub, the agent is the a11y dock's **client script**: attach `a11yAgentBundlePath` as the dock's `clientScript` (resolved to an importable URL — `/@fs/…` under Vite, or a statically-served path) and the hub's client runtime (`createDevframeClientHost` -from `@devframes/hub/client`) imports it into the host page, where it scans, -reports, and highlights on demand. Both minimal hub examples do exactly this. -Outside a hub, one ``. + * `` — or let a + * hub load it as the a11y dock's client script, in which case the default + * export receives the hub's client-script context and additionally mirrors + * each scan into the hub's messages feed. */ import type { A11yMessage, ScanReport } from '../shared/protocol.ts' +import type { A11yAgentContext } from './messages.ts' import { A11Y_CHANNEL } from '../shared/protocol.ts' +import { createMessagesReporter } from './messages.ts' import { createOverlay } from './overlay.ts' import { resolveElement, scan } from './scanner.ts' const GLOBAL_FLAG = '__DF_A11Y_AGENT__' -function start() { +function start(context?: A11yAgentContext) { const w = window as unknown as Record if (w[GLOBAL_FLAG]) return @@ -26,6 +31,10 @@ function start() { const overlay = createOverlay() document.documentElement.appendChild(overlay.root) + // Booted as a hub dock client script — mirror every scan into the hub's + // messages feed. Standalone boots have no context and skip the mirror. + const reporter = context?.messages ? createMessagesReporter(context.messages) : undefined + let lastReport: ScanReport | null = null let scanning = false let rescanQueued = false @@ -62,15 +71,18 @@ function start() { } scanning = true post({ type: 'a11y:scanning' }) + reporter?.scanning() // Suspend observation so attribute-stamping during the scan doesn't // retrigger us. observer.disconnect() try { lastReport = await scan() post({ type: 'a11y:report', report: lastReport }) + reporter?.report(lastReport) } catch (error) { console.error('[a11y-inspector] scan failed', error) + reporter?.failed(error) } finally { observe() @@ -130,14 +142,20 @@ function findRule(report: ScanReport | null, nodeId: string) { } /** - * Client-script entry the hub runtime calls after importing this module. The - * agent needs no context — it talks to the panel over the same-origin - * BroadcastChannel — so this just boots it. `start()` is idempotent. + * Client-script entry the hub runtime calls after importing this module, + * passing its `DockClientScriptContext`. The live scan/highlight loop rides + * the same-origin BroadcastChannel either way; when the context carries a + * `messages` client (duck-typed — see {@link A11yAgentContext}), the agent + * additionally mirrors each scan into the hub's messages feed. `start()` is + * idempotent. */ -export default function runA11yAgent(): void { - start() +export default function runA11yAgent(context?: A11yAgentContext): void { + start(context) } // Also self-boot so a plain `