From ad9e8d4d6c4346602efbe77e65deb4878946a9bb Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 5 Feb 2026 17:42:59 +0100 Subject: [PATCH 1/5] 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 --- .../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 +- 7 files changed, 1164 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/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 95e07ecf50e111bb7b864a6802a20ca45c982a5f Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 5 Feb 2026 18:42:46 +0100 Subject: [PATCH 2/5] 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 8c8c2fa0658d0185cd84fce1bf8c30a987f03e36 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 5 Feb 2026 20:58:15 +0100 Subject: [PATCH 3/5] 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; } From 4daeb5760908b50a953ddd29d5b822ee6a05f2d1 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Mon, 9 Feb 2026 16:17:36 +0100 Subject: [PATCH 4/5] fix(kernel-browser-runtime): snapshot map keys before iteration in terminateAll Use Array.from() to snapshot uiVats keys before iterating, consistent with the pattern in PlatformServicesServer.ts. Prevents potential issues from map modification during iteration. Co-Authored-By: Claude Opus 4.6 --- packages/kernel-browser-runtime/src/ui/UIOrchestrator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kernel-browser-runtime/src/ui/UIOrchestrator.ts b/packages/kernel-browser-runtime/src/ui/UIOrchestrator.ts index d1c4a5c6f..0c4b44abb 100644 --- a/packages/kernel-browser-runtime/src/ui/UIOrchestrator.ts +++ b/packages/kernel-browser-runtime/src/ui/UIOrchestrator.ts @@ -205,7 +205,7 @@ export class UIOrchestrator { */ terminateAll(): void { this.#logger.info('Terminating all UI vats'); - for (const id of this.#uiVats.keys()) { + for (const id of Array.from(this.#uiVats.keys())) { this.terminate(id); } } From fa096ce6855597fa38bd0b0fbfb01a66720dec91 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Mon, 9 Feb 2026 16:28:31 +0100 Subject: [PATCH 5/5] test(kernel-browser-runtime): add tests for error cleanup and concurrent launch prevention Cover the iframe load error cleanup path (remove iframe, clear launchesInProgress, allow retry) and the concurrent launch guard that rejects duplicate IDs while a launch is in progress. Co-Authored-By: Claude Opus 4.6 --- .../src/ui/UIOrchestrator.test.ts | 78 ++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/packages/kernel-browser-runtime/src/ui/UIOrchestrator.test.ts b/packages/kernel-browser-runtime/src/ui/UIOrchestrator.test.ts index 831daec52..61ccf13c4 100644 --- a/packages/kernel-browser-runtime/src/ui/UIOrchestrator.test.ts +++ b/packages/kernel-browser-runtime/src/ui/UIOrchestrator.test.ts @@ -58,15 +58,20 @@ const makeContainer = (): HTMLElement & { /** * Creates a mock iframe element that simulates loading. * + * @param options - Options for the mock iframe. + * @param options.readyState - The initial readyState of the iframe document. * @returns A mock iframe element. */ -const makeIframe = (): HTMLIFrameElement & { +const makeIframe = ( + options: { readyState?: string } = {}, +): HTMLIFrameElement & { loadListeners: (() => void)[]; errorListeners: ((event: Event) => void)[]; simulateLoad: () => void; simulateError: (message: string) => void; removed: boolean; } => { + const { readyState = 'complete' } = options; const loadListeners: (() => void)[] = []; const errorListeners: ((event: Event) => void)[] = []; let removed = false; @@ -88,7 +93,7 @@ const makeIframe = (): HTMLIFrameElement & { postMessage: vi.fn(), } as unknown as Window, contentDocument: { - readyState: 'complete', + readyState, }, loadListeners, errorListeners, @@ -281,6 +286,75 @@ describe('UIOrchestrator', () => { expect(createdIframes[0]?.title).toBe('UI Vat: test-ui-vat'); }); + it('cleans up and allows retry after iframe load error', async () => { + // Use a loading iframe that won't short-circuit #waitForIframeLoad + vi.spyOn(document, 'createElement').mockImplementation( + (tagName: string) => { + if (tagName === 'iframe') { + const iframe = makeIframe({ readyState: 'loading' }); + createdIframes.push(iframe); + return iframe as unknown as HTMLElement; + } + return originalCreateElement(tagName); + }, + ); + + 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]?.simulateError('Network error'); + + await expect(launchPromise).rejects.toThrow( + 'Failed to load iframe: Network error', + ); + expect(createdIframes[0]?.removed).toBe(true); + expect(orchestrator.has('test-ui-vat')).toBe(false); + + // Should be able to retry after failure + const retryPromise = orchestrator.launch(config); + await Promise.resolve(); + createdIframes[1]?.simulateLoad(); + const retryPort = await retryPromise; + expect(retryPort).toBeDefined(); + }); + + it('prevents concurrent launch attempts for same ID', async () => { + // Use a loading iframe so launch stays pending + vi.spyOn(document, 'createElement').mockImplementation( + (tagName: string) => { + if (tagName === 'iframe') { + const iframe = makeIframe({ readyState: 'loading' }); + createdIframes.push(iframe); + return iframe as unknown as HTMLElement; + } + return originalCreateElement(tagName); + }, + ); + + const config: UiVatConfig = { + id: 'test-ui-vat', + uri: 'https://example.com/ui.html', + slot: 'main', + }; + + const firstLaunch = orchestrator.launch(config); + + // Second launch with same ID while first is in progress + await expect(orchestrator.launch(config)).rejects.toThrow( + 'UI vat "test-ui-vat" already exists', + ); + + // Complete first launch + await Promise.resolve(); + createdIframes[0]?.simulateLoad(); + await firstLaunch; + }); + it('creates hidden iframe when visible is false', async () => { const config: UiVatConfig = { id: 'test-ui-vat',