From 2983d266e2595909ec3911bc86882b3d2b4966cd Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 5 Feb 2026 17:42:59 +0100 Subject: [PATCH 1/3] feat(kernel-browser-runtime): add UI vat infrastructure with slot-based orchestration Add UIOrchestrator for managing visible UI vat iframes in named slots, enabling multiple caplet UIs to coexist in a structured layout. - Add UIOrchestrator class with slot-based iframe management - Add makeUIVatWorker factory for VatWorker interface - Add
mounting point to vat/iframe.html - Export SlotName type (currently 'main', extensible) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 1 + .../kernel-browser-runtime/src/index.test.ts | 2 + packages/kernel-browser-runtime/src/index.ts | 1 + .../src/ui/UIOrchestrator.test.ts | 617 ++++++++++++++++++ .../src/ui/UIOrchestrator.ts | 433 ++++++++++++ .../kernel-browser-runtime/src/ui/index.ts | 12 + .../src/ui/makeUIVatWorker.ts | 96 +++ .../src/vat/iframe.html | 4 +- 8 files changed, 1165 insertions(+), 1 deletion(-) create mode 100644 packages/kernel-browser-runtime/src/ui/UIOrchestrator.test.ts create mode 100644 packages/kernel-browser-runtime/src/ui/UIOrchestrator.ts create mode 100644 packages/kernel-browser-runtime/src/ui/index.ts create mode 100644 packages/kernel-browser-runtime/src/ui/makeUIVatWorker.ts diff --git a/.gitignore b/.gitignore index 1279a097f..6171d4a15 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,4 @@ test-results # Claude .claude/settings.local.json +.playwright-mcp/ \ No newline at end of file diff --git a/packages/kernel-browser-runtime/src/index.test.ts b/packages/kernel-browser-runtime/src/index.test.ts index 0a4415bdc..1b437e543 100644 --- a/packages/kernel-browser-runtime/src/index.test.ts +++ b/packages/kernel-browser-runtime/src/index.test.ts @@ -7,6 +7,7 @@ describe('index', () => { expect(Object.keys(indexModule).sort()).toStrictEqual([ 'PlatformServicesClient', 'PlatformServicesServer', + 'UIOrchestrator', 'connectToKernel', 'createRelayQueryString', 'getCapTPMessage', @@ -17,6 +18,7 @@ describe('index', () => { 'makeBackgroundCapTP', 'makeCapTPNotification', 'makeIframeVatWorker', + 'makeUIVatWorker', 'parseRelayQueryString', 'receiveInternalConnections', 'rpcHandlers', diff --git a/packages/kernel-browser-runtime/src/index.ts b/packages/kernel-browser-runtime/src/index.ts index 4c10590e3..3ee76b6e9 100644 --- a/packages/kernel-browser-runtime/src/index.ts +++ b/packages/kernel-browser-runtime/src/index.ts @@ -21,3 +21,4 @@ export { type BackgroundCapTPOptions, type CapTPMessage, } from './background-captp.ts'; +export * from './ui/index.ts'; diff --git a/packages/kernel-browser-runtime/src/ui/UIOrchestrator.test.ts b/packages/kernel-browser-runtime/src/ui/UIOrchestrator.test.ts new file mode 100644 index 000000000..3a66f606b --- /dev/null +++ b/packages/kernel-browser-runtime/src/ui/UIOrchestrator.test.ts @@ -0,0 +1,617 @@ +// @vitest-environment jsdom + +import { Logger } from '@metamask/logger'; +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; + +import { UIOrchestrator } from './UIOrchestrator.ts'; +import type { UiVatConfig } from './UIOrchestrator.ts'; + +// Mock initializeMessageChannel +const mockPort = { + close: vi.fn(), + postMessage: vi.fn(), + onmessage: null, + onmessageerror: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + start: vi.fn(), + dispatchEvent: vi.fn(), +} as unknown as MessagePort; + +vi.mock('@metamask/streams/browser', () => ({ + initializeMessageChannel: vi.fn(async () => mockPort), +})); + +/** + * Creates a mock container element with tracking for appended children. + * + * @returns A mock container element. + */ +const makeContainer = (): HTMLElement & { + children: HTMLElement[]; + appendedChildren: HTMLElement[]; +} => { + const appendedChildren: HTMLElement[] = []; + const children: HTMLElement[] = []; + + return { + appendChild: vi.fn((child: HTMLElement) => { + appendedChildren.push(child); + children.push(child); + return child; + }), + removeChild: vi.fn((child: HTMLElement) => { + const index = children.indexOf(child); + if (index !== -1) { + children.splice(index, 1); + } + return child; + }), + appendedChildren, + children, + } as unknown as HTMLElement & { + children: HTMLElement[]; + appendedChildren: HTMLElement[]; + }; +}; + +/** + * Creates a mock iframe element that simulates loading. + * + * @returns A mock iframe element. + */ +const makeIframe = (): HTMLIFrameElement & { + loadListeners: (() => void)[]; + errorListeners: ((event: Event) => void)[]; + simulateLoad: () => void; + simulateError: (message: string) => void; + removed: boolean; +} => { + const loadListeners: (() => void)[] = []; + const errorListeners: ((event: Event) => void)[] = []; + let removed = false; + const sandbox = { + value: '', + }; + const dataset: Record = {}; + const style: Partial = {}; + + const iframe = { + id: '', + className: '', + src: '', + title: '', + sandbox, + dataset, + style, + contentWindow: { + postMessage: vi.fn(), + } as unknown as Window, + contentDocument: { + readyState: 'complete', + }, + loadListeners, + errorListeners, + removed, + addEventListener: vi.fn((event: string, listener: unknown) => { + if (event === 'load') { + loadListeners.push(listener as () => void); + } else if (event === 'error') { + errorListeners.push(listener as (event: Event) => void); + } + }), + removeEventListener: vi.fn((event: string, listener: unknown) => { + if (event === 'load') { + const index = loadListeners.indexOf(listener as () => void); + if (index !== -1) { + loadListeners.splice(index, 1); + } + } else if (event === 'error') { + const index = errorListeners.indexOf( + listener as (event: Event) => void, + ); + if (index !== -1) { + errorListeners.splice(index, 1); + } + } + }), + remove: vi.fn(() => { + removed = true; + }), + simulateLoad: () => { + for (const listener of [...loadListeners]) { + listener(); + } + }, + simulateError: (message: string) => { + const event = new ErrorEvent('error', { message }); + for (const listener of [...errorListeners]) { + listener(event); + } + }, + }; + + // Make removed accessible via getter + Object.defineProperty(iframe, 'removed', { + get: () => removed, + }); + + return iframe as unknown as HTMLIFrameElement & { + loadListeners: (() => void)[]; + errorListeners: ((event: Event) => void)[]; + simulateLoad: () => void; + simulateError: (message: string) => void; + removed: boolean; + }; +}; + +describe('UIOrchestrator', () => { + let mainSlot: ReturnType; + let orchestrator: UIOrchestrator; + let createdIframes: ReturnType[]; + + beforeEach(() => { + vi.clearAllMocks(); + mainSlot = makeContainer(); + createdIframes = []; + + // Mock document.createElement to return our mock iframes + vi.spyOn(document, 'createElement').mockImplementation( + (tagName: string) => { + if (tagName === 'iframe') { + const iframe = makeIframe(); + createdIframes.push(iframe); + return iframe as unknown as HTMLElement; + } + return document.createElement(tagName); + }, + ); + + orchestrator = UIOrchestrator.make({ + slots: { main: mainSlot }, + logger: new Logger('UIOrchestrator-test'), + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('make', () => { + it('creates an orchestrator instance', () => { + expect(orchestrator).toBeInstanceOf(UIOrchestrator); + }); + + it('creates an orchestrator without explicit logger', () => { + const orch = UIOrchestrator.make({ slots: { main: mainSlot } }); + expect(orch).toBeInstanceOf(UIOrchestrator); + }); + }); + + describe('launch', () => { + it('creates an iframe with correct configuration', async () => { + const config: UiVatConfig = { + id: 'test-ui-vat', + uri: 'https://example.com/ui.html', + slot: 'main', + title: 'Test UI', + }; + + const launchPromise = orchestrator.launch(config); + + // Simulate the iframe loading + await Promise.resolve(); + createdIframes[0]?.simulateLoad(); + + await launchPromise; + + const iframe = createdIframes[0]; + expect(iframe).toBeDefined(); + expect(iframe?.id).toBe('ui-vat-test-ui-vat'); + expect(iframe?.className).toBe('ui-vat-iframe'); + expect(iframe?.dataset.uiVatId).toBe('test-ui-vat'); + expect(iframe?.dataset.testid).toBe('ui-vat-iframe-test-ui-vat'); + expect(iframe?.title).toBe('Test UI'); + expect(iframe?.sandbox.value).toBe('allow-scripts allow-same-origin'); + expect(iframe?.src).toContain('uiVatId=test-ui-vat'); + }); + + it('appends iframe to slot', async () => { + const config: UiVatConfig = { + id: 'test-ui-vat', + uri: 'https://example.com/ui.html', + slot: 'main', + }; + + const launchPromise = orchestrator.launch(config); + await Promise.resolve(); + createdIframes[0]?.simulateLoad(); + await launchPromise; + + expect(mainSlot.appendChild).toHaveBeenCalledWith(createdIframes[0]); + }); + + it('returns MessagePort for communication', async () => { + const config: UiVatConfig = { + id: 'test-ui-vat', + uri: 'https://example.com/ui.html', + slot: 'main', + }; + + const launchPromise = orchestrator.launch(config); + await Promise.resolve(); + createdIframes[0]?.simulateLoad(); + + const port = await launchPromise; + expect(port).toBe(mockPort); + }); + + it('throws if UI vat with same ID already exists', async () => { + const config: UiVatConfig = { + id: 'test-ui-vat', + uri: 'https://example.com/ui.html', + slot: 'main', + }; + + const launchPromise = orchestrator.launch(config); + await Promise.resolve(); + createdIframes[0]?.simulateLoad(); + await launchPromise; + + await expect(orchestrator.launch(config)).rejects.toThrow( + 'UI vat "test-ui-vat" already exists', + ); + }); + + it('sets default title when not provided', async () => { + const config: UiVatConfig = { + id: 'test-ui-vat', + uri: 'https://example.com/ui.html', + slot: 'main', + }; + + const launchPromise = orchestrator.launch(config); + await Promise.resolve(); + createdIframes[0]?.simulateLoad(); + await launchPromise; + + expect(createdIframes[0]?.title).toBe('UI Vat: test-ui-vat'); + }); + + it('creates hidden iframe when visible is false', async () => { + const config: UiVatConfig = { + id: 'test-ui-vat', + uri: 'https://example.com/ui.html', + slot: 'main', + visible: false, + }; + + const launchPromise = orchestrator.launch(config); + await Promise.resolve(); + createdIframes[0]?.simulateLoad(); + await launchPromise; + + expect(createdIframes[0]?.style.display).toBe('none'); + }); + }); + + describe('terminate', () => { + it('removes iframe from DOM', async () => { + const config: UiVatConfig = { + id: 'test-ui-vat', + uri: 'https://example.com/ui.html', + slot: 'main', + }; + + const launchPromise = orchestrator.launch(config); + await Promise.resolve(); + createdIframes[0]?.simulateLoad(); + await launchPromise; + + orchestrator.terminate('test-ui-vat'); + + expect(createdIframes[0]?.remove).toHaveBeenCalled(); + }); + + it('closes MessagePort', async () => { + const config: UiVatConfig = { + id: 'test-ui-vat', + uri: 'https://example.com/ui.html', + slot: 'main', + }; + + const launchPromise = orchestrator.launch(config); + await Promise.resolve(); + createdIframes[0]?.simulateLoad(); + await launchPromise; + + orchestrator.terminate('test-ui-vat'); + + expect(mockPort.close).toHaveBeenCalled(); + }); + + it('removes UI vat from tracking', async () => { + const config: UiVatConfig = { + id: 'test-ui-vat', + uri: 'https://example.com/ui.html', + slot: 'main', + }; + + const launchPromise = orchestrator.launch(config); + await Promise.resolve(); + createdIframes[0]?.simulateLoad(); + await launchPromise; + + expect(orchestrator.has('test-ui-vat')).toBe(true); + + orchestrator.terminate('test-ui-vat'); + + expect(orchestrator.has('test-ui-vat')).toBe(false); + }); + + it('throws if UI vat does not exist', () => { + expect(() => orchestrator.terminate('nonexistent')).toThrow( + 'UI vat "nonexistent" not found', + ); + }); + }); + + describe('terminateAll', () => { + it('terminates all UI vats', async () => { + const config1: UiVatConfig = { + id: 'ui-vat-1', + uri: 'https://example.com/ui1.html', + slot: 'main', + }; + const config2: UiVatConfig = { + id: 'ui-vat-2', + uri: 'https://example.com/ui2.html', + slot: 'main', + }; + + const promise1 = orchestrator.launch(config1); + await Promise.resolve(); + createdIframes[0]?.simulateLoad(); + await promise1; + + const promise2 = orchestrator.launch(config2); + await Promise.resolve(); + createdIframes[1]?.simulateLoad(); + await promise2; + + expect(orchestrator.getIds()).toHaveLength(2); + + orchestrator.terminateAll(); + + expect(orchestrator.getIds()).toHaveLength(0); + expect(createdIframes[0]?.remove).toHaveBeenCalled(); + expect(createdIframes[1]?.remove).toHaveBeenCalled(); + }); + }); + + describe('show', () => { + it('makes iframe visible', async () => { + const config: UiVatConfig = { + id: 'test-ui-vat', + uri: 'https://example.com/ui.html', + slot: 'main', + visible: false, + }; + + const launchPromise = orchestrator.launch(config); + await Promise.resolve(); + createdIframes[0]?.simulateLoad(); + await launchPromise; + + expect(createdIframes[0]?.style.display).toBe('none'); + + orchestrator.show('test-ui-vat'); + + expect(createdIframes[0]?.style.display).toBe(''); + }); + + it('throws if UI vat does not exist', () => { + expect(() => orchestrator.show('nonexistent')).toThrow( + 'UI vat "nonexistent" not found', + ); + }); + }); + + describe('hide', () => { + it('hides iframe', async () => { + const config: UiVatConfig = { + id: 'test-ui-vat', + uri: 'https://example.com/ui.html', + slot: 'main', + }; + + const launchPromise = orchestrator.launch(config); + await Promise.resolve(); + createdIframes[0]?.simulateLoad(); + await launchPromise; + + orchestrator.hide('test-ui-vat'); + + expect(createdIframes[0]?.style.display).toBe('none'); + }); + + it('throws if UI vat does not exist', () => { + expect(() => orchestrator.hide('nonexistent')).toThrow( + 'UI vat "nonexistent" not found', + ); + }); + }); + + describe('has', () => { + it('returns true if UI vat exists', async () => { + const config: UiVatConfig = { + id: 'test-ui-vat', + uri: 'https://example.com/ui.html', + slot: 'main', + }; + + const launchPromise = orchestrator.launch(config); + await Promise.resolve(); + createdIframes[0]?.simulateLoad(); + await launchPromise; + + expect(orchestrator.has('test-ui-vat')).toBe(true); + }); + + it('returns false if UI vat does not exist', () => { + expect(orchestrator.has('nonexistent')).toBe(false); + }); + }); + + describe('getIds', () => { + it('returns all UI vat IDs', async () => { + const config1: UiVatConfig = { + id: 'ui-vat-1', + uri: 'https://example.com/ui1.html', + slot: 'main', + }; + const config2: UiVatConfig = { + id: 'ui-vat-2', + uri: 'https://example.com/ui2.html', + slot: 'main', + }; + + const promise1 = orchestrator.launch(config1); + await Promise.resolve(); + createdIframes[0]?.simulateLoad(); + await promise1; + + const promise2 = orchestrator.launch(config2); + await Promise.resolve(); + createdIframes[1]?.simulateLoad(); + await promise2; + + expect(orchestrator.getIds()).toStrictEqual(['ui-vat-1', 'ui-vat-2']); + }); + + it('returns empty array when no UI vats', () => { + expect(orchestrator.getIds()).toStrictEqual([]); + }); + }); + + describe('getPort', () => { + it('returns MessagePort for UI vat', async () => { + const config: UiVatConfig = { + id: 'test-ui-vat', + uri: 'https://example.com/ui.html', + slot: 'main', + }; + + const launchPromise = orchestrator.launch(config); + await Promise.resolve(); + createdIframes[0]?.simulateLoad(); + await launchPromise; + + expect(orchestrator.getPort('test-ui-vat')).toBe(mockPort); + }); + + it('throws if UI vat does not exist', () => { + expect(() => orchestrator.getPort('nonexistent')).toThrow( + 'UI vat "nonexistent" not found', + ); + }); + }); + + describe('getIframe', () => { + it('returns iframe element for UI vat', async () => { + const config: UiVatConfig = { + id: 'test-ui-vat', + uri: 'https://example.com/ui.html', + slot: 'main', + }; + + const launchPromise = orchestrator.launch(config); + await Promise.resolve(); + createdIframes[0]?.simulateLoad(); + await launchPromise; + + expect(orchestrator.getIframe('test-ui-vat')).toBe(createdIframes[0]); + }); + + it('throws if UI vat does not exist', () => { + expect(() => orchestrator.getIframe('nonexistent')).toThrow( + 'UI vat "nonexistent" not found', + ); + }); + }); + + describe('getSlotNames', () => { + it('returns all slot names', () => { + expect(orchestrator.getSlotNames()).toStrictEqual(['main']); + }); + + it('returns multiple slot names', () => { + const sidebarSlot = makeContainer(); + const multiSlotOrchestrator = UIOrchestrator.make({ + slots: { main: mainSlot, sidebar: sidebarSlot }, + }); + expect(multiSlotOrchestrator.getSlotNames().sort()).toStrictEqual([ + 'main', + 'sidebar', + ]); + }); + }); + + describe('getVatsInSlot', () => { + it('returns UI vat IDs in a specific slot', async () => { + const config: UiVatConfig = { + id: 'test-ui-vat', + uri: 'https://example.com/ui.html', + slot: 'main', + }; + + const launchPromise = orchestrator.launch(config); + await Promise.resolve(); + createdIframes[0]?.simulateLoad(); + await launchPromise; + + expect(orchestrator.getVatsInSlot('main')).toStrictEqual(['test-ui-vat']); + }); + + it('returns empty array for empty slot', () => { + expect(orchestrator.getVatsInSlot('main')).toStrictEqual([]); + }); + }); + + describe('getSlot', () => { + it('returns slot name for UI vat', async () => { + const config: UiVatConfig = { + id: 'test-ui-vat', + uri: 'https://example.com/ui.html', + slot: 'main', + }; + + const launchPromise = orchestrator.launch(config); + await Promise.resolve(); + createdIframes[0]?.simulateLoad(); + await launchPromise; + + expect(orchestrator.getSlot('test-ui-vat')).toBe('main'); + }); + + it('throws if UI vat does not exist', () => { + expect(() => orchestrator.getSlot('nonexistent')).toThrow( + 'UI vat "nonexistent" not found', + ); + }); + }); + + describe('slot validation', () => { + it('throws if slot does not exist', async () => { + const config: UiVatConfig = { + id: 'test-ui-vat', + uri: 'https://example.com/ui.html', + slot: 'nonexistent', + }; + + await expect(orchestrator.launch(config)).rejects.toThrow( + 'Slot "nonexistent" not found', + ); + }); + }); +}); diff --git a/packages/kernel-browser-runtime/src/ui/UIOrchestrator.ts b/packages/kernel-browser-runtime/src/ui/UIOrchestrator.ts new file mode 100644 index 000000000..3638f4033 --- /dev/null +++ b/packages/kernel-browser-runtime/src/ui/UIOrchestrator.ts @@ -0,0 +1,433 @@ +import { Logger } from '@metamask/logger'; +import { initializeMessageChannel } from '@metamask/streams/browser'; + +/** + * Unique identifier for a UI vat. + */ +export type UiVatId = string; + +/** + * Name of a slot where UI vats can be rendered. + * Currently only 'main' is supported. + */ +export type SlotName = 'main'; + +/** + * Configuration for a UI vat. + */ +export type UiVatConfig = { + /** Unique identifier for the UI vat */ + id: UiVatId; + /** URI of the HTML document to load in the iframe */ + uri: string; + /** Name of the slot to render into */ + slot: SlotName; + /** Optional title for the iframe (used for accessibility) */ + title?: string; + /** Whether the iframe should be visible immediately */ + visible?: boolean; +}; + +/** + * State of a UI vat managed by the orchestrator. + */ +export type UiVatState = { + /** The UI vat configuration */ + config: UiVatConfig; + /** The iframe element */ + iframe: HTMLIFrameElement; + /** The MessagePort for communication with the UI vat */ + port: MessagePort; + /** The slot this UI vat is rendered in */ + slot: SlotName; + /** Whether the UI vat is currently visible */ + visible: boolean; +}; + +/** + * Options for creating a UIOrchestrator. + */ +export type UIOrchestratorOptions = { + /** Named slots where UI vats can be rendered */ + slots: Record; + /** Logger instance */ + logger?: Logger; +}; + +/** + * The sandbox attribute value for UI vat iframes. + * + * UI vats run under lockdown() but need DOM access for rendering. + * We allow: + * - allow-scripts: Required for JavaScript execution + * - allow-same-origin: Required for CapTP communication via postMessage + * + * We intentionally do NOT allow: + * - allow-forms: No form submission + * - allow-popups: No popup windows + * - allow-top-navigation: Cannot navigate the parent + * - allow-modals: No alert/confirm/prompt + */ +const UI_VAT_SANDBOX = 'allow-scripts allow-same-origin'; + +/** + * CSS class applied to all UI vat iframes. + */ +const UI_VAT_IFRAME_CLASS = 'ui-vat-iframe'; + +/** + * Orchestrates the creation, lifecycle, and communication of UI vat iframes. + * + * UI vats are visible iframes that run hardened JavaScript (under lockdown()) + * and can render UI using DOM APIs. They communicate with bootstrap vats + * via CapTP over MessageChannel. + * + * Unlike headless vat iframes (which run VatSupervisor), UI vats are intended + * for user-facing interfaces within caplets. + */ +export class UIOrchestrator { + readonly #slots: Record; + + readonly #logger: Logger; + + readonly #uiVats: Map = new Map(); + + /** + * Creates a new UIOrchestrator. + * + * @param options - The orchestrator options. + * @param options.slots - Named slots where UI vats can be rendered. + * @param options.logger - Logger instance. + */ + constructor({ slots, logger }: UIOrchestratorOptions) { + this.#slots = slots; + this.#logger = logger ?? new Logger('UIOrchestrator'); + harden(this); + } + + /** + * Factory method to create a UIOrchestrator. + * + * @param options - The orchestrator options. + * @returns A new UIOrchestrator instance. + */ + static make(options: UIOrchestratorOptions): UIOrchestrator { + return new UIOrchestrator(options); + } + + /** + * Launch a new UI vat. + * + * Creates a sandboxed iframe, sets up a MessageChannel for communication, + * and waits for the iframe to signal readiness. + * + * @param config - The UI vat configuration. + * @returns A promise that resolves to the MessagePort for communicating with the UI vat. + * @throws If a UI vat with the same ID already exists. + */ + async launch(config: UiVatConfig): Promise { + const { id, uri, slot, title, visible = true } = config; + + if (this.#uiVats.has(id)) { + throw new Error(`UI vat "${id}" already exists`); + } + + const slotElement = this.#slots[slot]; + if (!slotElement) { + throw new Error(`Slot "${slot}" not found`); + } + + this.#logger.info(`Launching UI vat: ${id} in slot: ${slot}`); + + const iframe = this.#createIframe(id, uri, title, visible); + slotElement.appendChild(iframe); + + // Wait for iframe to load and establish MessageChannel + const port = await this.#establishConnection(iframe); + + const state: UiVatState = { + config, + iframe, + port, + slot, + visible, + }; + this.#uiVats.set(id, state); + + this.#logger.info(`UI vat "${id}" launched successfully in slot: ${slot}`); + return port; + } + + /** + * Terminate a UI vat. + * + * Closes the MessagePort and removes the iframe from the DOM. + * + * @param id - The ID of the UI vat to terminate. + * @throws If the UI vat does not exist. + */ + terminate(id: UiVatId): void { + const state = this.#uiVats.get(id); + if (!state) { + throw new Error(`UI vat "${id}" not found`); + } + + this.#logger.info(`Terminating UI vat: ${id}`); + + // Close the port + state.port.close(); + + // Remove iframe from DOM + state.iframe.remove(); + + // Remove from our tracking + this.#uiVats.delete(id); + + this.#logger.info(`UI vat "${id}" terminated`); + } + + /** + * Terminate all UI vats. + */ + terminateAll(): void { + this.#logger.info('Terminating all UI vats'); + for (const id of this.#uiVats.keys()) { + this.terminate(id); + } + } + + /** + * Show a UI vat's iframe. + * + * @param id - The ID of the UI vat. + * @throws If the UI vat does not exist. + */ + show(id: UiVatId): void { + const state = this.#getState(id); + state.iframe.style.display = ''; + state.visible = true; + this.#logger.info(`UI vat "${id}" shown`); + } + + /** + * Hide a UI vat's iframe. + * + * @param id - The ID of the UI vat. + * @throws If the UI vat does not exist. + */ + hide(id: UiVatId): void { + const state = this.#getState(id); + state.iframe.style.display = 'none'; + state.visible = false; + this.#logger.info(`UI vat "${id}" hidden`); + } + + /** + * Check if a UI vat exists. + * + * @param id - The ID of the UI vat. + * @returns True if the UI vat exists. + */ + has(id: UiVatId): boolean { + return this.#uiVats.has(id); + } + + /** + * Get the IDs of all active UI vats. + * + * @returns Array of UI vat IDs. + */ + getIds(): UiVatId[] { + return Array.from(this.#uiVats.keys()); + } + + /** + * Get the names of all available slots. + * + * @returns Array of slot names. + */ + getSlotNames(): SlotName[] { + return Object.keys(this.#slots) as SlotName[]; + } + + /** + * Get the IDs of UI vats rendered in a specific slot. + * + * @param slot - The slot name. + * @returns Array of UI vat IDs in the slot. + */ + getVatsInSlot(slot: SlotName): UiVatId[] { + return Array.from(this.#uiVats.entries()) + .filter(([_, state]) => state.slot === slot) + .map(([id]) => id); + } + + /** + * Get the slot a UI vat is rendered in. + * + * @param id - The ID of the UI vat. + * @returns The slot name. + * @throws If the UI vat does not exist. + */ + getSlot(id: UiVatId): SlotName { + return this.#getState(id).slot; + } + + /** + * Get the MessagePort for a UI vat. + * + * @param id - The ID of the UI vat. + * @returns The MessagePort for the UI vat. + * @throws If the UI vat does not exist. + */ + getPort(id: UiVatId): MessagePort { + return this.#getState(id).port; + } + + /** + * Get the iframe element for a UI vat. + * + * This is primarily for testing and debugging. + * + * @param id - The ID of the UI vat. + * @returns The iframe element. + * @throws If the UI vat does not exist. + */ + getIframe(id: UiVatId): HTMLIFrameElement { + return this.#getState(id).iframe; + } + + /** + * Get the state of a UI vat. + * + * @param id - The ID of the UI vat. + * @returns The UI vat state. + * @throws If the UI vat does not exist. + */ + #getState(id: UiVatId): UiVatState { + const state = this.#uiVats.get(id); + if (!state) { + throw new Error(`UI vat "${id}" not found`); + } + return state; + } + + /** + * Create an iframe element for a UI vat. + * + * @param id - The UI vat ID. + * @param uri - The URI to load. + * @param title - Optional accessibility title. + * @param visible - Whether the iframe should be initially visible. + * @returns The configured iframe element. + */ + #createIframe( + id: UiVatId, + uri: string, + title?: string, + visible = true, + ): HTMLIFrameElement { + const iframe = document.createElement('iframe'); + + // Identity + iframe.id = `ui-vat-${id}`; + iframe.className = UI_VAT_IFRAME_CLASS; + iframe.dataset.uiVatId = id; + iframe.dataset.testid = `ui-vat-iframe-${id}`; + + // Security: sandbox with minimal permissions + iframe.sandbox.value = UI_VAT_SANDBOX; + + // Accessibility + iframe.title = title ?? `UI Vat: ${id}`; + + // Visibility + if (!visible) { + iframe.style.display = 'none'; + } + + // Source - add uiVatId as query parameter + const url = new URL(uri, window.location.href); + url.searchParams.set('uiVatId', id); + iframe.src = url.toString(); + + return iframe; + } + + /** + * Wait for an iframe to finish loading. + * + * @param iframe - The iframe to wait for. + * @returns A promise that resolves when the iframe is loaded. + */ + async #waitForIframeLoad(iframe: HTMLIFrameElement): Promise { + // Check if already loaded + if (iframe.contentWindow) { + try { + // Try to access document to check if loaded + // This may throw if cross-origin + const _doc = iframe.contentDocument; + if (_doc?.readyState === 'complete') { + return Promise.resolve(); + } + } catch { + // Cross-origin, wait for load event + } + } + + return new Promise((resolve, reject) => { + // Use AbortController for cleanup to avoid circular reference issues + const controller = new AbortController(); + const { signal } = controller; + + iframe.addEventListener( + 'load', + () => { + controller.abort(); + resolve(); + }, + { signal }, + ); + + iframe.addEventListener( + 'error', + (event: Event) => { + controller.abort(); + reject( + new Error( + `Failed to load iframe: ${(event as ErrorEvent).message ?? 'Unknown error'}`, + ), + ); + }, + { signal }, + ); + }); + } + + /** + * Establish a MessageChannel connection with an iframe. + * + * Waits for the iframe to load, then uses initializeMessageChannel + * to establish a port pair for communication. + * + * @param iframe - The iframe to connect to. + * @returns A promise that resolves to the local MessagePort. + */ + async #establishConnection(iframe: HTMLIFrameElement): Promise { + // Wait for iframe to load + await this.#waitForIframeLoad(iframe); + + const { contentWindow } = iframe; + if (!contentWindow) { + throw new Error('Iframe contentWindow is null after load'); + } + + // Establish MessageChannel using the standard initialization protocol + const port = await initializeMessageChannel((message, transfer) => + contentWindow.postMessage(message, '*', transfer), + ); + + return port; + } +} +harden(UIOrchestrator); diff --git a/packages/kernel-browser-runtime/src/ui/index.ts b/packages/kernel-browser-runtime/src/ui/index.ts new file mode 100644 index 000000000..888edf90a --- /dev/null +++ b/packages/kernel-browser-runtime/src/ui/index.ts @@ -0,0 +1,12 @@ +export { + UIOrchestrator, + type UiVatId, + type UiVatConfig, + type UiVatState, + type UIOrchestratorOptions, + type SlotName, +} from './UIOrchestrator.ts'; +export { + makeUIVatWorker, + type MakeUIVatWorkerOptions, +} from './makeUIVatWorker.ts'; diff --git a/packages/kernel-browser-runtime/src/ui/makeUIVatWorker.ts b/packages/kernel-browser-runtime/src/ui/makeUIVatWorker.ts new file mode 100644 index 000000000..89ececba2 --- /dev/null +++ b/packages/kernel-browser-runtime/src/ui/makeUIVatWorker.ts @@ -0,0 +1,96 @@ +import { Logger } from '@metamask/logger'; +import type { VatConfig } from '@metamask/ocap-kernel'; + +import type { VatWorker } from '../PlatformServicesServer.ts'; +import type { UiVatId, SlotName } from './UIOrchestrator.ts'; +import { UIOrchestrator } from './UIOrchestrator.ts'; + +/** + * Options for creating a UI vat worker factory. + */ +export type MakeUIVatWorkerOptions = { + /** Unique identifier for this UI vat */ + id: UiVatId; + /** URI of the UI vat iframe HTML (e.g., './vat/iframe.html') */ + iframeUri: string; + /** Shared UIOrchestrator instance */ + orchestrator: UIOrchestrator; + /** Name of the slot to render into */ + slot: SlotName; + /** Optional title for the iframe (used for accessibility) */ + title?: string; + /** Whether the iframe should be visible immediately (default: true) */ + visible?: boolean; + /** Optional logger instance */ + logger?: Logger; +}; + +/** + * Create a VatWorker that launches a UI vat in a visible iframe. + * + * Uses a shared UIOrchestrator to manage the iframe in a specific slot. + * + * @param options - Configuration for the UI vat worker. + * @param options.id - Unique identifier for this UI vat. + * @param options.iframeUri - URI of the UI vat iframe HTML. + * @param options.orchestrator - Shared UIOrchestrator instance. + * @param options.slot - Name of the slot to render into. + * @param options.title - Optional title for the iframe (used for accessibility). + * @param options.visible - Whether the iframe should be visible immediately. + * @param options.logger - Optional logger instance. + * @returns A VatWorker interface for kernel integration. + * @example + * ```typescript + * const orchestrator = UIOrchestrator.make({ + * slots: { main: document.getElementById('main-slot')! }, + * }); + * + * const uiWorker = makeUIVatWorker({ + * id: 'my-ui-vat', + * iframeUri: './vat/iframe.html', + * orchestrator, + * slot: 'main', + * }); + * + * const [port, _window] = await uiWorker.launch(vatConfig); + * // Use port for CapTP communication + * ``` + */ +export const makeUIVatWorker = ({ + id, + iframeUri, + orchestrator, + slot, + title, + visible = true, + logger, +}: MakeUIVatWorkerOptions): VatWorker => { + return { + launch: async (_vatConfig: VatConfig): Promise<[MessagePort, unknown]> => { + const port = await orchestrator.launch({ + id, + uri: iframeUri, + slot, + ...(title !== undefined && { title }), + visible, + }); + + // Return the port and iframe window (for consistency with makeIframeVatWorker) + const iframe = orchestrator.getIframe(id); + return [port, iframe.contentWindow]; + }, + + terminate: async (): Promise => { + if (orchestrator.has(id)) { + orchestrator.terminate(id); + } else if (logger) { + logger.warn(`UI vat "${id}" not found for termination`); + } else { + new Logger('makeUIVatWorker').warn( + `UI vat "${id}" not found for termination`, + ); + } + return null; + }, + }; +}; diff --git a/packages/kernel-browser-runtime/src/vat/iframe.html b/packages/kernel-browser-runtime/src/vat/iframe.html index a86a9e243..2bac1dc7e 100644 --- a/packages/kernel-browser-runtime/src/vat/iframe.html +++ b/packages/kernel-browser-runtime/src/vat/iframe.html @@ -5,5 +5,7 @@ Ocap Kernel Vat - + +
+ From 44243514ae93f0bfb356c781f8edb9bddaea863f Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 5 Feb 2026 18:42:46 +0100 Subject: [PATCH 2/3] fix(kernel-browser-runtime): address UI vat infrastructure issues - Create logger once at construction time in makeUIVatWorker instead of per method call - Track in-progress launches to prevent concurrent launches with same ID from orphaning iframes - Clean up iframe from DOM when connection establishment fails Co-Authored-By: Claude Opus 4.5 --- .../src/ui/UIOrchestrator.ts | 19 ++++++++++++++++--- .../src/ui/makeUIVatWorker.ts | 8 +++----- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/kernel-browser-runtime/src/ui/UIOrchestrator.ts b/packages/kernel-browser-runtime/src/ui/UIOrchestrator.ts index 3638f4033..c674b2e57 100644 --- a/packages/kernel-browser-runtime/src/ui/UIOrchestrator.ts +++ b/packages/kernel-browser-runtime/src/ui/UIOrchestrator.ts @@ -92,6 +92,8 @@ export class UIOrchestrator { readonly #uiVats: Map = new Map(); + readonly #launchesInProgress: Set = new Set(); + /** * Creates a new UIOrchestrator. * @@ -128,7 +130,7 @@ export class UIOrchestrator { async launch(config: UiVatConfig): Promise { const { id, uri, slot, title, visible = true } = config; - if (this.#uiVats.has(id)) { + if (this.#uiVats.has(id) || this.#launchesInProgress.has(id)) { throw new Error(`UI vat "${id}" already exists`); } @@ -137,13 +139,23 @@ export class UIOrchestrator { throw new Error(`Slot "${slot}" not found`); } + this.#launchesInProgress.add(id); + this.#logger.info(`Launching UI vat: ${id} in slot: ${slot}`); const iframe = this.#createIframe(id, uri, title, visible); slotElement.appendChild(iframe); - // Wait for iframe to load and establish MessageChannel - const port = await this.#establishConnection(iframe); + let port: MessagePort; + try { + // Wait for iframe to load and establish MessageChannel + port = await this.#establishConnection(iframe); + } catch (error) { + // Clean up iframe if connection establishment fails + iframe.remove(); + this.#launchesInProgress.delete(id); + throw error; + } const state: UiVatState = { config, @@ -153,6 +165,7 @@ export class UIOrchestrator { visible, }; this.#uiVats.set(id, state); + this.#launchesInProgress.delete(id); this.#logger.info(`UI vat "${id}" launched successfully in slot: ${slot}`); return port; diff --git a/packages/kernel-browser-runtime/src/ui/makeUIVatWorker.ts b/packages/kernel-browser-runtime/src/ui/makeUIVatWorker.ts index 89ececba2..debaa45f4 100644 --- a/packages/kernel-browser-runtime/src/ui/makeUIVatWorker.ts +++ b/packages/kernel-browser-runtime/src/ui/makeUIVatWorker.ts @@ -65,6 +65,8 @@ export const makeUIVatWorker = ({ visible = true, logger, }: MakeUIVatWorkerOptions): VatWorker => { + const workerLogger = logger ?? new Logger('makeUIVatWorker'); + return { launch: async (_vatConfig: VatConfig): Promise<[MessagePort, unknown]> => { const port = await orchestrator.launch({ @@ -83,12 +85,8 @@ export const makeUIVatWorker = ({ terminate: async (): Promise => { if (orchestrator.has(id)) { orchestrator.terminate(id); - } else if (logger) { - logger.warn(`UI vat "${id}" not found for termination`); } else { - new Logger('makeUIVatWorker').warn( - `UI vat "${id}" not found for termination`, - ); + workerLogger.warn(`UI vat "${id}" not found for termination`); } return null; }, From eec21e3987d726a4507c2be387e0f68231cfa695 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 5 Feb 2026 20:58:15 +0100 Subject: [PATCH 3/3] fix(kernel-browser-runtime): fix test mock recursion and iframe creation error handling - Save original document.createElement before mocking to prevent infinite recursion when creating non-iframe elements in tests - Move iframe creation inside try block so ID is removed from launchesInProgress if createIframe throws Co-Authored-By: Claude Opus 4.5 --- .../src/ui/UIOrchestrator.test.ts | 5 ++++- .../kernel-browser-runtime/src/ui/UIOrchestrator.ts | 11 ++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/kernel-browser-runtime/src/ui/UIOrchestrator.test.ts b/packages/kernel-browser-runtime/src/ui/UIOrchestrator.test.ts index 3a66f606b..831daec52 100644 --- a/packages/kernel-browser-runtime/src/ui/UIOrchestrator.test.ts +++ b/packages/kernel-browser-runtime/src/ui/UIOrchestrator.test.ts @@ -145,6 +145,9 @@ const makeIframe = (): HTMLIFrameElement & { }; }; +// Save original createElement before any mocking +const originalCreateElement = document.createElement.bind(document); + describe('UIOrchestrator', () => { let mainSlot: ReturnType; let orchestrator: UIOrchestrator; @@ -163,7 +166,7 @@ describe('UIOrchestrator', () => { createdIframes.push(iframe); return iframe as unknown as HTMLElement; } - return document.createElement(tagName); + return originalCreateElement(tagName); }, ); diff --git a/packages/kernel-browser-runtime/src/ui/UIOrchestrator.ts b/packages/kernel-browser-runtime/src/ui/UIOrchestrator.ts index c674b2e57..d1c4a5c6f 100644 --- a/packages/kernel-browser-runtime/src/ui/UIOrchestrator.ts +++ b/packages/kernel-browser-runtime/src/ui/UIOrchestrator.ts @@ -143,16 +143,17 @@ export class UIOrchestrator { this.#logger.info(`Launching UI vat: ${id} in slot: ${slot}`); - const iframe = this.#createIframe(id, uri, title, visible); - slotElement.appendChild(iframe); - + let iframe: HTMLIFrameElement | undefined; let port: MessagePort; try { + iframe = this.#createIframe(id, uri, title, visible); + slotElement.appendChild(iframe); + // Wait for iframe to load and establish MessageChannel port = await this.#establishConnection(iframe); } catch (error) { - // Clean up iframe if connection establishment fails - iframe.remove(); + // Clean up iframe if it was created and appended + iframe?.remove(); this.#launchesInProgress.delete(id); throw error; }