diff --git a/packages/caplet-sandbox-prototype/package.json b/packages/caplet-sandbox-prototype/package.json new file mode 100644 index 000000000..dbbcdf75f --- /dev/null +++ b/packages/caplet-sandbox-prototype/package.json @@ -0,0 +1,66 @@ +{ + "name": "@ocap/caplet-sandbox-prototype", + "version": "0.0.0", + "private": true, + "description": "Prototype for sandboxed iframe + Preact UI architecture for caplets", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/ocap-kernel.git" + }, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", + "lint:dependencies": "depcheck --quiet", + "lint:eslint": "eslint . --cache", + "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write && yarn constraints --fix && yarn lint:dependencies", + "lint:misc": "prettier --no-error-on-unmatched-pattern '**/*.json' '**/*.md' '**/*.html' '!**/CHANGELOG.old.md' '**/*.yml' '!.yarnrc.yml' '!merged-packages/**' --ignore-path ../../.gitignore --log-level error", + "test": "vitest run --config vitest.config.ts", + "test:dev": "yarn test --mode development", + "test:dev:quiet": "yarn test:dev --reporter @ocap/repo-tools/vitest-reporters/silent", + "build:docs": "typedoc", + "test:clean": "yarn test --no-cache --coverage.clean", + "test:verbose": "yarn test --reporter verbose", + "test:watch": "vitest --config vitest.config.ts" + }, + "dependencies": { + "preact": "^10.26.4" + }, + "devDependencies": { + "@metamask/eslint-config": "^15.0.0", + "@metamask/eslint-config-nodejs": "^15.0.0", + "@metamask/eslint-config-typescript": "^15.0.0", + "@ocap/repo-tools": "workspace:^", + "@preact/preset-vite": "^2.10.2", + "@typescript-eslint/eslint-plugin": "^8.29.0", + "@typescript-eslint/parser": "^8.29.0", + "@typescript-eslint/utils": "^8.29.0", + "@vitest/eslint-plugin": "^1.6.5", + "depcheck": "^1.4.7", + "eslint": "^9.23.0", + "eslint-config-prettier": "^10.1.1", + "eslint-import-resolver-typescript": "^4.3.1", + "eslint-plugin-import-x": "^4.10.0", + "eslint-plugin-jsdoc": "^50.6.9", + "eslint-plugin-n": "^17.17.0", + "eslint-plugin-prettier": "^5.2.6", + "eslint-plugin-promise": "^7.2.1", + "jsdom": "^27.4.0", + "prettier": "^3.5.3", + "rimraf": "^6.0.1", + "turbo": "^2.5.6", + "typescript": "~5.8.2", + "typescript-eslint": "^8.29.0", + "vite": "^7.3.0", + "vitest": "^4.0.16" + }, + "engines": { + "node": "^20.11 || >=22" + }, + "exports": { + "./package.json": "./package.json" + } +} diff --git a/packages/caplet-sandbox-prototype/src/caplet/Slot.tsx b/packages/caplet-sandbox-prototype/src/caplet/Slot.tsx new file mode 100644 index 000000000..ca8fbf3a2 --- /dev/null +++ b/packages/caplet-sandbox-prototype/src/caplet/Slot.tsx @@ -0,0 +1,56 @@ +import { useEffect, useRef } from 'preact/hooks'; + +type SlotProps = { + widgetId: string; + widgetUrl: string; + style?: preact.JSX.CSSProperties; +}; + +/** + * Slot component for embedding caplet-backed widgets. + * Creates a nested sandboxed iframe that communicates with window.top. + * + * @param props - Slot properties. + * @param props.widgetId - Unique identifier for the widget caplet. + * @param props.widgetUrl - URL of the widget iframe content. + * @param props.style - Optional CSS styles for the container. + * @returns Slot container element. + */ +export function Slot({ + widgetId, + widgetUrl, + style, +}: SlotProps): preact.JSX.Element { + const containerRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) { + return undefined; + } + + const iframe = document.createElement('iframe'); + iframe.sandbox.add('allow-scripts'); + iframe.sandbox.add('allow-same-origin'); // Required for dev server + iframe.src = `${widgetUrl}?capletId=${encodeURIComponent(widgetId)}`; + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.border = 'none'; + + containerRef.current.appendChild(iframe); + + return () => { + iframe.remove(); + }; + }, [widgetId, widgetUrl]); + + return ( +
+ ); +} diff --git a/packages/caplet-sandbox-prototype/src/caplet/bootstrap.tsx b/packages/caplet-sandbox-prototype/src/caplet/bootstrap.tsx new file mode 100644 index 000000000..a3043791f --- /dev/null +++ b/packages/caplet-sandbox-prototype/src/caplet/bootstrap.tsx @@ -0,0 +1,38 @@ +import { render } from 'preact'; + +import { initBridge } from './caplet-bridge.ts'; + +/** + * Gets the capletId from URL query parameters. + * + * @returns The capletId or throws if not found. + */ +function getCapletIdFromUrl(): string { + const params = new URLSearchParams(window.location.search); + const capletId = params.get('capletId'); + if (!capletId) { + throw new Error('Missing capletId in URL query parameters'); + } + return capletId; +} + +/** + * Bootstraps a caplet by initializing the bridge and rendering the app. + * + * @param AppComponent - The Preact component to render. + */ +export async function bootstrapCaplet( + AppComponent: () => preact.JSX.Element, +): Promise { + const capletId = getCapletIdFromUrl(); + const bridge = initBridge(capletId); + + await bridge.waitForInit(); + + const appElement = document.getElementById('app'); + if (!appElement) { + throw new Error('App element not found'); + } + + render(, appElement); +} diff --git a/packages/caplet-sandbox-prototype/src/caplet/caplet-bridge.ts b/packages/caplet-sandbox-prototype/src/caplet/caplet-bridge.ts new file mode 100644 index 000000000..e6fa79709 --- /dev/null +++ b/packages/caplet-sandbox-prototype/src/caplet/caplet-bridge.ts @@ -0,0 +1,255 @@ +import type { HostMessage, IframeMessage } from '../types.ts'; + +type PendingCall = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; +}; + +type StateSubscriber = (state: State) => void; + +/** + * Bridge for communication from caplet/widget iframe to host. + * Handles postMessage protocol and provides state subscription. + * Communicates with window.top to support nested iframes. + */ +export class CapletBridge { + readonly #capletId: string; + + readonly #pendingCalls = new Map(); + + readonly #subscribers = new Set>(); + + #callId = 0; + + #state: State | null = null; + + readonly #readyPromise: Promise; + + #resolveReady!: (state: State) => void; + + /** + * Creates a CapletBridge for a specific caplet. + * + * @param capletId - Unique identifier for this caplet. + */ + constructor(capletId: string) { + this.#capletId = capletId; + + this.#readyPromise = new Promise((resolve) => { + this.#resolveReady = resolve; + }); + + window.addEventListener('message', this.#handleMessage); + + // Send ready signal to host (window.top) + this.#sendToHost({ type: 'ready' }); + } + + /** + * Gets the caplet ID. + * + * @returns The caplet ID. + */ + getCapletId(): string { + return this.#capletId; + } + + /** + * Waits for initial state from host. + * + * @returns Promise that resolves with initial state. + */ + async waitForInit(): Promise { + return this.#readyPromise; + } + + /** + * Gets the current state. + * + * @returns Current state or null if not initialized. + */ + getState(): State | null { + return this.#state; + } + + /** + * Subscribes to state changes. + * + * @param subscriber - Callback function called with new state. + * @returns Unsubscribe function. + */ + subscribe(subscriber: StateSubscriber): () => void { + this.#subscribers.add(subscriber); + return () => { + this.#subscribers.delete(subscriber); + }; + } + + /** + * Calls a method on the host backend. + * + * @param method - Method name. + * @param args - Method arguments. + * @returns Promise that resolves with method result. + */ + async callMethod(method: string, args: unknown[]): Promise { + const id = this.#generateCallId(); + + const promise = new Promise((resolve, reject) => { + this.#pendingCalls.set(id, { + resolve: resolve as (value: unknown) => void, + reject, + }); + }); + + this.#sendToHost({ type: 'method-call', id, method, args }); + + return promise; + } + + /** + * Sends a message to the host (window.top). + * + * @param message - The message to send (capletId will be added). + */ + #sendToHost(message: Omit): void { + const fullMessage: IframeMessage = { + ...message, + capletId: this.#capletId, + } as IframeMessage; + + // Always send to window.top to support nested iframes + window.top?.postMessage(fullMessage, '*'); + } + + /** + * Handles incoming messages from the host. + * + * @param event - The message event. + */ + readonly #handleMessage = (event: MessageEvent>): void => { + const message = event.data; + + // Validate message structure and filter by capletId + if ( + !message || + typeof message !== 'object' || + !('capletId' in message) || + message.capletId !== this.#capletId + ) { + return; + } + + switch (message.type) { + case 'init': + this.#handleInit(message.state); + break; + case 'state-update': + this.#handleStateUpdate(message.state); + break; + case 'method-response': + this.#handleMethodResponse(message.id, message.result, message.error); + break; + default: + // Ignore unknown message types + break; + } + }; + + /** + * Handles the init message from the host. + * + * @param state - The initial state. + */ + #handleInit(state: State): void { + this.#state = state; + this.#resolveReady(state); + this.#notifySubscribers(); + } + + /** + * Handles a state update message from the host. + * + * @param state - The updated state. + */ + #handleStateUpdate(state: State): void { + this.#state = state; + this.#notifySubscribers(); + } + + /** + * Handles a method response from the host. + * + * @param id - The call ID. + * @param result - The result value. + * @param error - The error message if any. + */ + #handleMethodResponse( + id: string, + result: unknown, + error: string | undefined, + ): void { + const pending = this.#pendingCalls.get(id); + if (!pending) { + return; + } + + this.#pendingCalls.delete(id); + + if (error) { + pending.reject(new Error(error)); + } else { + pending.resolve(result); + } + } + + /** + * Notifies all subscribers of a state change. + */ + #notifySubscribers(): void { + if (this.#state) { + for (const subscriber of this.#subscribers) { + subscriber(this.#state); + } + } + } + + /** + * Generates a unique call ID. + * + * @returns A unique call ID string. + */ + #generateCallId(): string { + this.#callId += 1; + return `call-${this.#callId}`; + } +} + +// Global bridge instance, initialized by bootstrap +let globalBridge: CapletBridge | null = null; + +/** + * Gets the global bridge instance. + * + * @returns The global CapletBridge instance. + */ +export function getBridge(): CapletBridge { + if (!globalBridge) { + throw new Error('Bridge not initialized. Call initBridge() first.'); + } + return globalBridge as CapletBridge; +} + +/** + * Initializes the global bridge with a capletId. + * + * @param capletId - The caplet ID. + * @returns The initialized bridge. + */ +export function initBridge(capletId: string): CapletBridge { + if (globalBridge) { + throw new Error('Bridge already initialized.'); + } + globalBridge = new CapletBridge(capletId); + return globalBridge as CapletBridge; +} diff --git a/packages/caplet-sandbox-prototype/src/caplet/index.html b/packages/caplet-sandbox-prototype/src/caplet/index.html new file mode 100644 index 000000000..2eacf8273 --- /dev/null +++ b/packages/caplet-sandbox-prototype/src/caplet/index.html @@ -0,0 +1,30 @@ + + + + + + Caplet + + + +
+ + + diff --git a/packages/caplet-sandbox-prototype/src/caplet/sdk.tsx b/packages/caplet-sandbox-prototype/src/caplet/sdk.tsx new file mode 100644 index 000000000..0282b3df5 --- /dev/null +++ b/packages/caplet-sandbox-prototype/src/caplet/sdk.tsx @@ -0,0 +1,90 @@ +import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'; + +import { getBridge } from './caplet-bridge.ts'; + +/** + * Creates a typed backend proxy for calling methods. + * + * @param methods - Object mapping method names to their implementations. + * @returns Backend proxy object. + */ +export function createBackend< + Backend extends Record, +>(methods: { + [K in keyof Backend]: ( + ...args: Parameters unknown)> + ) => Promise; +}): Backend { + return methods as Backend; +} + +/** + * Hook that provides a proxy for calling backend methods. + * The methods object should be created with createBackend(). + * + * @param methodNames - Array of method names to expose. + * @returns Backend proxy object with methods that call the host. + */ +export function useBackendMethods( + methodNames: Methods, +): Record Promise> { + const bridge = getBridge(); + + const backend = useMemo(() => { + const methods: Record Promise> = {}; + for (const method of methodNames) { + methods[method] = async (...args: unknown[]) => { + await bridge.callMethod(method, args); + }; + } + return methods; + }, [bridge, ...methodNames]); + + return backend as Record< + Methods[number], + (...args: unknown[]) => Promise + >; +} + +/** + * Hook that subscribes to backend state and provides reactive updates. + * + * @param selector - Function to select a portion of the state. + * @returns Selected state value. + */ +export function useBackendState( + selector: (state: State) => Selected, +): Selected | undefined { + const bridge = getBridge(); + + const [selected, setSelected] = useState(() => { + const state = bridge.getState(); + return state ? selector(state) : undefined; + }); + + const memoizedSelector = useCallback(selector, []); + + useEffect(() => { + const unsubscribe = bridge.subscribe((state) => { + setSelected(memoizedSelector(state)); + }); + + const currentState = bridge.getState(); + if (currentState) { + setSelected(memoizedSelector(currentState)); + } + + return unsubscribe; + }, [bridge, memoizedSelector]); + + return selected; +} + +/** + * Hook that provides the full backend state. + * + * @returns Full application state or undefined if not initialized. + */ +export function useFullState(): State | undefined { + return useBackendState((state) => state); +} diff --git a/packages/caplet-sandbox-prototype/src/example-caplet/App.tsx b/packages/caplet-sandbox-prototype/src/example-caplet/App.tsx new file mode 100644 index 000000000..184f8d6eb --- /dev/null +++ b/packages/caplet-sandbox-prototype/src/example-caplet/App.tsx @@ -0,0 +1,205 @@ +import { useState } from 'preact/hooks'; + +import { useBackendMethods, useBackendState } from '../caplet/sdk.tsx'; +import { Slot } from '../caplet/Slot.tsx'; +import type { MainCapletState } from '../types.ts'; + +const styles = { + container: { + fontFamily: + "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", + }, + section: { + marginBottom: '24px', + padding: '16px', + background: 'white', + borderRadius: '8px', + boxShadow: '0 1px 3px rgba(0,0,0,0.1)', + }, + sectionTitle: { + fontSize: '14px', + fontWeight: '600' as const, + color: '#666', + marginBottom: '12px', + textTransform: 'uppercase' as const, + }, + counterDisplay: { + fontSize: '48px', + fontWeight: 'bold' as const, + textAlign: 'center' as const, + marginBottom: '16px', + }, + buttonGroup: { + display: 'flex', + gap: '8px', + justifyContent: 'center', + }, + button: { + padding: '8px 16px', + fontSize: '16px', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + background: '#0066cc', + color: 'white', + }, + dangerButton: { + padding: '4px 8px', + fontSize: '12px', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + background: '#cc3333', + color: 'white', + }, + inputGroup: { + display: 'flex', + gap: '8px', + marginBottom: '12px', + }, + input: { + flex: 1, + padding: '8px 12px', + fontSize: '14px', + border: '1px solid #ccc', + borderRadius: '4px', + }, + list: { + listStyle: 'none', + padding: 0, + }, + listItem: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '8px 12px', + background: '#f8f8f8', + marginBottom: '4px', + borderRadius: '4px', + }, + slotContainer: { + border: '2px dashed #ccc', + borderRadius: '8px', + padding: '8px', + minHeight: '150px', + }, +}; + +/** + * Fires and forgets an async function, suppressing any errors. + * + * @param fn - The async function to call. + */ +function fireAndForget(fn: () => Promise): void { + fn().catch(() => undefined); +} + +/** + * Creates an onClick handler that fires and forgets an async operation. + * + * @param fn - The async function to call. + * @returns A void-returning click handler. + */ +function onClickAsync(fn: () => Promise): () => void { + return () => fireAndForget(fn); +} + +/** + * Main caplet UI demonstrating the SDK hooks and Slot component. + * + * @returns Preact component. + */ +export function App(): preact.JSX.Element { + const backend = useBackendMethods([ + 'addItem', + 'removeItem', + 'increment', + 'decrement', + ]); + const counter = useBackendState( + (state) => state.counter, + ); + const items = useBackendState( + (state) => state.items, + ); + const [newItem, setNewItem] = useState(''); + + const handleAddItem = (): void => { + if (newItem.trim()) { + fireAndForget(async () => backend.addItem(newItem.trim())); + setNewItem(''); + } + }; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Enter') { + handleAddItem(); + } + }; + + return ( +
+
+
Counter
+
{counter ?? '...'}
+
+ + +
+
+ +
+
Items List
+
+ + setNewItem((event.target as HTMLInputElement).value) + } + onKeyDown={handleKeyDown} + placeholder="Enter new item..." + style={styles.input} + /> + +
+
    + {items?.map((item, index) => ( +
  • + {item} + +
  • + ))} +
+
+ +
+
Embedded Widget (Slot)
+
+ +
+
+
+ ); +} diff --git a/packages/caplet-sandbox-prototype/src/example-caplet/main.tsx b/packages/caplet-sandbox-prototype/src/example-caplet/main.tsx new file mode 100644 index 000000000..e4e51cbd6 --- /dev/null +++ b/packages/caplet-sandbox-prototype/src/example-caplet/main.tsx @@ -0,0 +1,5 @@ +import { App } from './App.tsx'; +import { bootstrapCaplet } from '../caplet/bootstrap.tsx'; + +// eslint-disable-next-line no-console +bootstrapCaplet(App).catch(console.error); diff --git a/packages/caplet-sandbox-prototype/src/example-widget/App.tsx b/packages/caplet-sandbox-prototype/src/example-widget/App.tsx new file mode 100644 index 000000000..4675ba4f8 --- /dev/null +++ b/packages/caplet-sandbox-prototype/src/example-widget/App.tsx @@ -0,0 +1,77 @@ +import { useBackendMethods, useBackendState } from '../caplet/sdk.tsx'; +import type { ColorWidgetState } from '../types.ts'; + +const styles = { + container: { + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + gap: '12px', + padding: '16px', + }, + title: { + fontSize: '14px', + fontWeight: '600' as const, + color: '#666', + textTransform: 'uppercase' as const, + }, + colorPreview: { + width: '60px', + height: '60px', + borderRadius: '8px', + border: '2px solid #ccc', + }, + colorInput: { + width: '100px', + height: '36px', + padding: '4px', + border: '1px solid #ccc', + borderRadius: '4px', + cursor: 'pointer', + }, + colorValue: { + fontSize: '12px', + color: '#999', + fontFamily: 'monospace', + }, +}; + +/** + * Fires and forgets an async function, suppressing any errors. + * + * @param fn - The async function to call. + */ +function fireAndForget(fn: () => Promise): void { + fn().catch(() => undefined); +} + +/** + * Color picker widget demonstrating nested iframe communication. + * + * @returns Preact component. + */ +export function App(): preact.JSX.Element { + const backend = useBackendMethods(['setColor']); + const color = useBackendState( + (state) => state.color, + ); + + const handleColorChange = (event: Event): void => { + const newColor = (event.target as HTMLInputElement).value; + fireAndForget(async () => backend.setColor(newColor)); + }; + + return ( +
+
Color Widget
+
+ +
{color ?? '...'}
+
+ ); +} diff --git a/packages/caplet-sandbox-prototype/src/example-widget/index.html b/packages/caplet-sandbox-prototype/src/example-widget/index.html new file mode 100644 index 000000000..16325f5a5 --- /dev/null +++ b/packages/caplet-sandbox-prototype/src/example-widget/index.html @@ -0,0 +1,30 @@ + + + + + + Color Widget + + + +
+ + + diff --git a/packages/caplet-sandbox-prototype/src/example-widget/main.tsx b/packages/caplet-sandbox-prototype/src/example-widget/main.tsx new file mode 100644 index 000000000..e4e51cbd6 --- /dev/null +++ b/packages/caplet-sandbox-prototype/src/example-widget/main.tsx @@ -0,0 +1,5 @@ +import { App } from './App.tsx'; +import { bootstrapCaplet } from '../caplet/bootstrap.tsx'; + +// eslint-disable-next-line no-console +bootstrapCaplet(App).catch(console.error); diff --git a/packages/caplet-sandbox-prototype/src/host/caplet-manager.ts b/packages/caplet-sandbox-prototype/src/host/caplet-manager.ts new file mode 100644 index 000000000..0bac7fc92 --- /dev/null +++ b/packages/caplet-sandbox-prototype/src/host/caplet-manager.ts @@ -0,0 +1,280 @@ +import type { + CapletConfig, + HostMessage, + IframeMessage, + MethodHandler, +} from '../types.ts'; + +type RegisteredCaplet = { + state: State; + methods: Record>; + iframe: HTMLIFrameElement | null; + /** Window reference for sending messages (from event.source or iframe). */ + targetWindow: WindowProxy | null; + isReady: boolean; + readyPromise: Promise; + resolveReady: () => void; +}; + +/** + * Manages multiple caplets and widgets, routing messages by capletId. + * All caplets/widgets communicate directly with the host via window.top. + */ +export class CapletManager { + readonly #caplets = new Map(); + + /** + * Creates a CapletManager and sets up the global message listener. + */ + constructor() { + window.addEventListener('message', this.#handleMessage); + } + + /** + * Registers a caplet or widget backend. + * + * @param capletId - Unique identifier for the caplet. + * @param config - Caplet configuration with initial state and methods. + */ + registerCaplet(capletId: string, config: CapletConfig): void { + let resolveReady: (() => void) | undefined; + const readyPromise = new Promise((resolve) => { + resolveReady = resolve; + }); + + // The resolver is guaranteed to be assigned by the Promise constructor + const resolver = resolveReady as () => void; + + this.#caplets.set(capletId, { + state: config.state, + methods: config.methods as Record>, + iframe: null, + targetWindow: null, + isReady: false, + readyPromise, + resolveReady: resolver, + }); + } + + /** + * Creates a sandboxed iframe for a registered caplet. + * + * @param container - DOM element to mount the iframe into. + * @param capletId - ID of the registered caplet. + * @param url - URL of the caplet iframe content. + * @returns The created iframe element. + */ + createIframe( + container: HTMLElement, + capletId: string, + url: string, + ): HTMLIFrameElement { + const caplet = this.#caplets.get(capletId); + if (!caplet) { + throw new Error(`Caplet not registered: ${capletId}`); + } + + const iframe = document.createElement('iframe'); + iframe.sandbox.add('allow-scripts'); + iframe.sandbox.add('allow-same-origin'); // Required for dev server + iframe.src = `${url}?capletId=${encodeURIComponent(capletId)}`; + + caplet.iframe = iframe; + // targetWindow will be set when iframe sends 'ready' message + container.appendChild(iframe); + + return iframe; + } + + /** + * Waits for a caplet to signal it's ready. + * + * @param capletId - ID of the caplet. + * @returns Promise that resolves when caplet is ready. + */ + async waitForReady(capletId: string): Promise { + const caplet = this.#caplets.get(capletId); + if (!caplet) { + throw new Error(`Caplet not registered: ${capletId}`); + } + return caplet.readyPromise; + } + + /** + * Gets the current state of a caplet. + * + * @param capletId - ID of the caplet. + * @returns Current state. + */ + getState(capletId: string): State { + const caplet = this.#caplets.get(capletId); + if (!caplet) { + throw new Error(`Caplet not registered: ${capletId}`); + } + return caplet.state as State; + } + + /** + * Unregisters a caplet and removes its iframe. + * + * @param capletId - ID of the caplet to unregister. + */ + unregisterCaplet(capletId: string): void { + const caplet = this.#caplets.get(capletId); + if (caplet?.iframe) { + caplet.iframe.remove(); + } + this.#caplets.delete(capletId); + } + + /** + * Cleans up all caplets and event listeners. + */ + destroy(): void { + window.removeEventListener('message', this.#handleMessage); + for (const [capletId] of this.#caplets) { + this.unregisterCaplet(capletId); + } + } + + /** + * Sends a message to a specific caplet's iframe. + * + * @param capletId - ID of the target caplet. + * @param message - Message to send (capletId will be added). + */ + #send( + capletId: string, + message: Omit, 'capletId'>, + ): void { + const caplet = this.#caplets.get(capletId); + if (!caplet?.targetWindow) { + return; + } + + const fullMessage: HostMessage = { + ...message, + capletId, + } as HostMessage; + + caplet.targetWindow.postMessage(fullMessage, '*'); + } + + /** + * Broadcasts state update to a caplet. + * + * @param capletId - ID of the caplet. + */ + #broadcastState(capletId: string): void { + const caplet = this.#caplets.get(capletId); + if (!caplet?.isReady) { + return; + } + + this.#send(capletId, { type: 'state-update', state: caplet.state }); + } + + /** + * Handles incoming messages from any caplet/widget iframe. + * + * @param event - The message event. + */ + readonly #handleMessage = (event: MessageEvent): void => { + const message = event.data; + + // Validate message structure + if (!message || typeof message !== 'object' || !('capletId' in message)) { + return; + } + + const caplet = this.#caplets.get(message.capletId); + if (!caplet) { + return; + } + + switch (message.type) { + case 'ready': + this.#handleReady( + message.capletId, + caplet, + event.source as WindowProxy, + ); + break; + case 'method-call': + this.#handleMethodCall( + message.capletId, + caplet, + message.id, + message.method, + message.args, + ).catch(() => undefined); + break; + default: + // Ignore unknown message types + break; + } + }; + + /** + * Handles the ready signal from a caplet. + * + * @param capletId - ID of the caplet. + * @param caplet - The registered caplet. + * @param source - The window that sent the message. + */ + #handleReady( + capletId: string, + caplet: RegisteredCaplet, + source: WindowProxy, + ): void { + caplet.isReady = true; + caplet.targetWindow = source; + this.#send(capletId, { type: 'init', state: caplet.state }); + caplet.resolveReady(); + } + + /** + * Handles a method call from a caplet. + * + * @param capletId - ID of the caplet. + * @param caplet - The registered caplet. + * @param id - The call ID. + * @param method - The method name. + * @param args - The method arguments. + */ + async #handleMethodCall( + capletId: string, + caplet: RegisteredCaplet, + id: string, + method: string, + args: unknown[], + ): Promise { + const handler = caplet.methods[method]; + + if (!handler) { + this.#send(capletId, { + type: 'method-response', + id, + error: `Unknown method: ${method}`, + }); + return; + } + + try { + const newState = await handler(caplet.state, ...args); + // Re-fetch caplet after await to avoid race condition + const currentCaplet = this.#caplets.get(capletId); + if (currentCaplet) { + currentCaplet.state = newState; + } + this.#send(capletId, { type: 'method-response', id, result: undefined }); + this.#broadcastState(capletId); + } catch (error) { + this.#send(capletId, { + type: 'method-response', + id, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} diff --git a/packages/caplet-sandbox-prototype/src/host/host.ts b/packages/caplet-sandbox-prototype/src/host/host.ts new file mode 100644 index 000000000..1269cb019 --- /dev/null +++ b/packages/caplet-sandbox-prototype/src/host/host.ts @@ -0,0 +1,83 @@ +import type { ColorWidgetState, MainCapletState } from '../types.ts'; +import { CapletManager } from './caplet-manager.ts'; + +/** + * Host application that manages multiple caplets and widgets. + */ + +const stateDisplay = document.getElementById('state-display'); +const iframeMount = document.getElementById('iframe-mount'); + +if (!stateDisplay || !iframeMount) { + throw new Error('Required DOM elements not found'); +} + +const manager = new CapletManager(); + +// Register main caplet backend +manager.registerCaplet('main-caplet', { + state: { + items: ['First item', 'Second item'], + counter: 0, + }, + methods: { + addItem: (state, item) => ({ + ...state, + items: [...state.items, item as string], + }), + removeItem: (state, index) => { + const newItems = [...state.items]; + newItems.splice(index as number, 1); + return { ...state, items: newItems }; + }, + increment: (state) => ({ + ...state, + counter: state.counter + 1, + }), + decrement: (state) => ({ + ...state, + counter: state.counter - 1, + }), + }, +}); + +// Register color widget backend +manager.registerCaplet('color-widget', { + state: { + color: '#3366cc', + }, + methods: { + setColor: (state, color) => ({ + ...state, + color: color as string, + }), + }, +}); + +/** + * Updates the state display in the host UI. + */ +function updateStateDisplay(): void { + const mainState = manager.getState('main-caplet'); + const widgetState = manager.getState('color-widget'); + + if (stateDisplay) { + stateDisplay.textContent = JSON.stringify( + { + 'main-caplet': mainState, + 'color-widget': widgetState, + }, + null, + 2, + ); + } +} + +// Create main caplet iframe +manager.createIframe(iframeMount, 'main-caplet', '/caplet/index.html'); + +// Update state display initially and periodically +updateStateDisplay(); +setInterval(updateStateDisplay, 500); + +manager.waitForReady('main-caplet').catch(() => undefined); diff --git a/packages/caplet-sandbox-prototype/src/host/index.html b/packages/caplet-sandbox-prototype/src/host/index.html new file mode 100644 index 000000000..4d7764a7e --- /dev/null +++ b/packages/caplet-sandbox-prototype/src/host/index.html @@ -0,0 +1,107 @@ + + + + + + Caplet Sandbox Prototype - Host + + + +
+

Caplet Sandbox Prototype - Nested Widgets

+ +
+

Host State

+
Loading...
+
+ +
+

Sandboxed Caplet Iframe

+
+
+ Sandbox restrictions: allow-scripts and + allow-same-origin (required for dev server). No forms, popups, or top + navigation. In production, caplets would be served from a different + origin without allow-same-origin for full isolation. +
+
+
+ + + + diff --git a/packages/caplet-sandbox-prototype/src/types.test.ts b/packages/caplet-sandbox-prototype/src/types.test.ts new file mode 100644 index 000000000..3d17566ac --- /dev/null +++ b/packages/caplet-sandbox-prototype/src/types.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; + +import type { + ColorWidgetState, + HostMessage, + IframeMessage, + MainCapletState, +} from './types.ts'; + +describe('types', () => { + describe('MainCapletState', () => { + it('has expected shape', () => { + const state: MainCapletState = { + items: ['item1', 'item2'], + counter: 5, + }; + + expect(state.items).toStrictEqual(['item1', 'item2']); + expect(state.counter).toBe(5); + }); + }); + + describe('ColorWidgetState', () => { + it('has expected shape', () => { + const state: ColorWidgetState = { + color: '#ff0000', + }; + + expect(state.color).toBe('#ff0000'); + }); + }); + + describe('HostMessage', () => { + it('supports init message with capletId', () => { + const message: HostMessage = { + capletId: 'main-caplet', + type: 'init', + state: { items: [], counter: 0 }, + }; + + expect(message.capletId).toBe('main-caplet'); + expect(message.type).toBe('init'); + }); + + it('supports state-update message with capletId', () => { + const message: HostMessage = { + capletId: 'main-caplet', + type: 'state-update', + state: { items: ['test'], counter: 1 }, + }; + + expect(message.capletId).toBe('main-caplet'); + expect(message.type).toBe('state-update'); + }); + + it('supports method-response message with capletId', () => { + const success: HostMessage = { + capletId: 'main-caplet', + type: 'method-response', + id: 'call-1', + result: 'ok', + }; + + const error: HostMessage = { + capletId: 'main-caplet', + type: 'method-response', + id: 'call-2', + error: 'Something went wrong', + }; + + expect(success.capletId).toBe('main-caplet'); + expect(error.capletId).toBe('main-caplet'); + }); + }); + + describe('IframeMessage', () => { + it('supports ready message with capletId', () => { + const message: IframeMessage = { + capletId: 'main-caplet', + type: 'ready', + }; + + expect(message.capletId).toBe('main-caplet'); + expect(message.type).toBe('ready'); + }); + + it('supports method-call message with capletId', () => { + const message: IframeMessage = { + capletId: 'color-widget', + type: 'method-call', + id: 'call-1', + method: 'setColor', + args: ['#ff0000'], + }; + + expect(message.capletId).toBe('color-widget'); + expect(message.type).toBe('method-call'); + }); + }); +}); diff --git a/packages/caplet-sandbox-prototype/src/types.ts b/packages/caplet-sandbox-prototype/src/types.ts new file mode 100644 index 000000000..791caca0a --- /dev/null +++ b/packages/caplet-sandbox-prototype/src/types.ts @@ -0,0 +1,73 @@ +/** + * Base message type with caplet routing. + */ +type BaseMessage = { + capletId: string; +}; + +/** + * Messages sent from host to iframe. + */ +export type HostMessage = BaseMessage & + ( + | { type: 'init'; state: State } + | { type: 'state-update'; state: State } + | { type: 'method-response'; id: string; result?: unknown; error?: string } + ); + +/** + * Messages sent from iframe to host. + */ +export type IframeMessage = BaseMessage & + ( + | { type: 'ready' } + | { type: 'method-call'; id: string; method: string; args: unknown[] } + ); + +/** + * Main caplet application state. + */ +export type MainCapletState = { + items: string[]; + counter: number; +}; + +/** + * Color widget state. + */ +export type ColorWidgetState = { + color: string; +}; + +/** + * Backend interface exposed to main caplet. + */ +export type MainCapletBackend = { + addItem: (item: string) => Promise; + removeItem: (index: number) => Promise; + increment: () => Promise; + decrement: () => Promise; +}; + +/** + * Backend interface exposed to color widget. + */ +export type ColorWidgetBackend = { + setColor: (color: string) => Promise; +}; + +/** + * Method handler function type. + */ +export type MethodHandler = ( + state: State, + ...args: unknown[] +) => State | Promise; + +/** + * Caplet registration configuration. + */ +export type CapletConfig = { + state: State; + methods: Record>; +}; diff --git a/packages/caplet-sandbox-prototype/tsconfig.json b/packages/caplet-sandbox-prototype/tsconfig.json new file mode 100644 index 000000000..f0dae18f1 --- /dev/null +++ b/packages/caplet-sandbox-prototype/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact", + "allowJs": true, + "baseUrl": "./", + "isolatedModules": true, + "lib": ["DOM", "ES2022"], + "types": ["vitest"] + }, + "references": [{ "path": "../repo-tools" }], + "include": [ + "../../vitest.config.ts", + "./src/**/*.ts", + "./src/**/*.tsx", + "./vite.config.ts", + "./vitest.config.ts" + ] +} diff --git a/packages/caplet-sandbox-prototype/vite.config.ts b/packages/caplet-sandbox-prototype/vite.config.ts new file mode 100644 index 000000000..9790ffdca --- /dev/null +++ b/packages/caplet-sandbox-prototype/vite.config.ts @@ -0,0 +1,25 @@ +import { preact } from '@preact/preset-vite'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vite'; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + root: path.resolve(dirname, 'src'), + plugins: [preact()], + build: { + outDir: path.resolve(dirname, 'dist'), + emptyOutDir: true, + rollupOptions: { + input: { + host: path.resolve(dirname, 'src/host/index.html'), + caplet: path.resolve(dirname, 'src/caplet/index.html'), + widget: path.resolve(dirname, 'src/example-widget/index.html'), + }, + }, + }, + server: { + open: '/host/index.html', + }, +}); diff --git a/packages/caplet-sandbox-prototype/vitest.config.ts b/packages/caplet-sandbox-prototype/vitest.config.ts new file mode 100644 index 000000000..fcb76e7fd --- /dev/null +++ b/packages/caplet-sandbox-prototype/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + }, +}); diff --git a/tsconfig.json b/tsconfig.json index c7c624374..36c38ffdf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "files": [], "include": [], "references": [ + { "path": "./packages/caplet-sandbox-prototype" }, { "path": "./packages/cli" }, { "path": "./packages/create-package" }, { "path": "./packages/extension" }, diff --git a/yarn.lock b/yarn.lock index c5cbd044b..80107a829 100644 --- a/yarn.lock +++ b/yarn.lock @@ -171,70 +171,79 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.25.9, @babel/code-frame@npm:^7.26.2, @babel/code-frame@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/code-frame@npm:7.27.1" +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.25.9, @babel/code-frame@npm:^7.26.2, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/code-frame@npm:7.29.0" dependencies: - "@babel/helper-validator-identifier": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.28.5" js-tokens: "npm:^4.0.0" picocolors: "npm:^1.1.1" - checksum: 10/721b8a6e360a1fa0f1c9fe7351ae6c874828e119183688b533c477aa378f1010f37cc9afbfc4722c686d1f5cdd00da02eab4ba7278a0c504fa0d7a321dcd4fdf + checksum: 10/199e15ff89007dd30675655eec52481cb245c9fdf4f81e4dc1f866603b0217b57aff25f5ffa0a95bbc8e31eb861695330cd7869ad52cc211aa63016320ef72c5 languageName: node linkType: hard -"@babel/compat-data@npm:^7.27.2": - version: 7.28.5 - resolution: "@babel/compat-data@npm:7.28.5" - checksum: 10/5a5ff00b187049e847f04bd02e21fbd8094544e5016195c2b45e56fa2e311eeb925b158f52a85624c9e6bacc1ce0323e26c303513723d918a8034e347e22610d +"@babel/compat-data@npm:^7.28.6": + version: 7.29.0 + resolution: "@babel/compat-data@npm:7.29.0" + checksum: 10/7f21beedb930ed8fbf7eabafc60e6e6521c1d905646bf1317a61b2163339157fe797efeb85962bf55136e166b01fd1a6b526a15974b92a8b877d564dcb6c9580 languageName: node linkType: hard -"@babel/core@npm:^7.23.2, @babel/core@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/core@npm:7.28.5" +"@babel/core@npm:^7.22.1, @babel/core@npm:^7.23.2, @babel/core@npm:^7.28.5": + version: 7.29.0 + resolution: "@babel/core@npm:7.29.0" dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@babel/generator": "npm:^7.28.5" - "@babel/helper-compilation-targets": "npm:^7.27.2" - "@babel/helper-module-transforms": "npm:^7.28.3" - "@babel/helpers": "npm:^7.28.4" - "@babel/parser": "npm:^7.28.5" - "@babel/template": "npm:^7.27.2" - "@babel/traverse": "npm:^7.28.5" - "@babel/types": "npm:^7.28.5" + "@babel/code-frame": "npm:^7.29.0" + "@babel/generator": "npm:^7.29.0" + "@babel/helper-compilation-targets": "npm:^7.28.6" + "@babel/helper-module-transforms": "npm:^7.28.6" + "@babel/helpers": "npm:^7.28.6" + "@babel/parser": "npm:^7.29.0" + "@babel/template": "npm:^7.28.6" + "@babel/traverse": "npm:^7.29.0" + "@babel/types": "npm:^7.29.0" "@jridgewell/remapping": "npm:^2.3.5" convert-source-map: "npm:^2.0.0" debug: "npm:^4.1.0" gensync: "npm:^1.0.0-beta.2" json5: "npm:^2.2.3" semver: "npm:^6.3.1" - checksum: 10/2f1e224125179f423f4300d605a0c5a3ef315003281a63b1744405b2605ee2a2ffc5b1a8349aa4f262c72eca31c7e1802377ee04ad2b852a2c88f8ace6cac324 + checksum: 10/25f4e91688cdfbaf1365831f4f245b436cdaabe63d59389b75752013b8d61819ee4257101b52fc328b0546159fd7d0e74457ed7cf12c365fea54be4fb0a40229 languageName: node linkType: hard -"@babel/generator@npm:^7.25.9, @babel/generator@npm:^7.26.3, @babel/generator@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/generator@npm:7.28.5" +"@babel/generator@npm:^7.25.9, @babel/generator@npm:^7.26.3, @babel/generator@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/generator@npm:7.29.0" dependencies: - "@babel/parser": "npm:^7.28.5" - "@babel/types": "npm:^7.28.5" + "@babel/parser": "npm:^7.29.0" + "@babel/types": "npm:^7.29.0" "@jridgewell/gen-mapping": "npm:^0.3.12" "@jridgewell/trace-mapping": "npm:^0.3.28" jsesc: "npm:^3.0.2" - checksum: 10/ae618f0a17a6d76c3983e1fd5d9c2f5fdc07703a119efdb813a7d9b8ad4be0a07d4c6f0d718440d2de01a68e321f64e2d63c77fc5d43ae47ae143746ef28ac1f + checksum: 10/e144a5d3db43207e0909702c60a01928be8751c3df12cb99e94249a618358acd773c99d33c2209a9049142034e13591ba0a7ce938da49d9f7709dc3814020d1e languageName: node linkType: hard -"@babel/helper-compilation-targets@npm:^7.27.2": - version: 7.27.2 - resolution: "@babel/helper-compilation-targets@npm:7.27.2" +"@babel/helper-annotate-as-pure@npm:^7.27.3": + version: 7.27.3 + resolution: "@babel/helper-annotate-as-pure@npm:7.27.3" dependencies: - "@babel/compat-data": "npm:^7.27.2" + "@babel/types": "npm:^7.27.3" + checksum: 10/63863a5c936ef82b546ca289c9d1b18fabfc24da5c4ee382830b124e2e79b68d626207febc8d4bffc720f50b2ee65691d7d12cc0308679dee2cd6bdc926b7190 + languageName: node + linkType: hard + +"@babel/helper-compilation-targets@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-compilation-targets@npm:7.28.6" + dependencies: + "@babel/compat-data": "npm:^7.28.6" "@babel/helper-validator-option": "npm:^7.27.1" browserslist: "npm:^4.24.0" lru-cache: "npm:^5.1.1" semver: "npm:^6.3.1" - checksum: 10/bd53c30a7477049db04b655d11f4c3500aea3bcbc2497cf02161de2ecf994fec7c098aabbcebe210ffabc2ecbdb1e3ffad23fb4d3f18723b814f423ea1749fe8 + checksum: 10/f512a5aeee4dfc6ea8807f521d085fdca8d66a7d068a6dd5e5b37da10a6081d648c0bbf66791a081e4e8e6556758da44831b331540965dfbf4f5275f3d0a8788 languageName: node linkType: hard @@ -245,33 +254,33 @@ __metadata: languageName: node linkType: hard -"@babel/helper-module-imports@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-module-imports@npm:7.27.1" +"@babel/helper-module-imports@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-module-imports@npm:7.28.6" dependencies: - "@babel/traverse": "npm:^7.27.1" - "@babel/types": "npm:^7.27.1" - checksum: 10/58e792ea5d4ae71676e0d03d9fef33e886a09602addc3bd01388a98d87df9fcfd192968feb40ac4aedb7e287ec3d0c17b33e3ecefe002592041a91d8a1998a8d + "@babel/traverse": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + checksum: 10/64b1380d74425566a3c288074d7ce4dea56d775d2d3325a3d4a6df1dca702916c1d268133b6f385de9ba5b822b3c6e2af5d3b11ac88e5453d5698d77264f0ec0 languageName: node linkType: hard -"@babel/helper-module-transforms@npm:^7.28.3": - version: 7.28.3 - resolution: "@babel/helper-module-transforms@npm:7.28.3" +"@babel/helper-module-transforms@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-module-transforms@npm:7.28.6" dependencies: - "@babel/helper-module-imports": "npm:^7.27.1" - "@babel/helper-validator-identifier": "npm:^7.27.1" - "@babel/traverse": "npm:^7.28.3" + "@babel/helper-module-imports": "npm:^7.28.6" + "@babel/helper-validator-identifier": "npm:^7.28.5" + "@babel/traverse": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10/598fdd8aa5b91f08542d0ba62a737847d0e752c8b95ae2566bc9d11d371856d6867d93e50db870fb836a6c44cfe481c189d8a2b35ca025a224f070624be9fa87 + checksum: 10/2e421c7db743249819ee51e83054952709dc2e197c7d5d415b4bdddc718580195704bfcdf38544b3f674efc2eccd4d29a65d38678fc827ed3934a7690984cd8b languageName: node linkType: hard -"@babel/helper-plugin-utils@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-plugin-utils@npm:7.27.1" - checksum: 10/96136c2428888e620e2ec493c25888f9ceb4a21099dcf3dd4508ea64b58cdedbd5a9fb6c7b352546de84d6c24edafe482318646932a22c449ebd16d16c22d864 +"@babel/helper-plugin-utils@npm:^7.27.1, @babel/helper-plugin-utils@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-plugin-utils@npm:7.28.6" + checksum: 10/21c853bbc13dbdddf03309c9a0477270124ad48989e1ad6524b83e83a77524b333f92edd2caae645c5a7ecf264ec6d04a9ebe15aeb54c7f33c037b71ec521e4a languageName: node linkType: hard @@ -282,7 +291,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-validator-identifier@npm:^7.25.9, @babel/helper-validator-identifier@npm:^7.27.1, @babel/helper-validator-identifier@npm:^7.28.5": +"@babel/helper-validator-identifier@npm:^7.25.9, @babel/helper-validator-identifier@npm:^7.28.5": version: 7.28.5 resolution: "@babel/helper-validator-identifier@npm:7.28.5" checksum: 10/8e5d9b0133702cfacc7f368bf792f0f8ac0483794877c6dca5fcb73810ee138e27527701826fb58a40a004f3a5ec0a2f3c3dd5e326d262530b119918f3132ba7 @@ -296,24 +305,24 @@ __metadata: languageName: node linkType: hard -"@babel/helpers@npm:^7.28.4": - version: 7.28.4 - resolution: "@babel/helpers@npm:7.28.4" +"@babel/helpers@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helpers@npm:7.28.6" dependencies: - "@babel/template": "npm:^7.27.2" - "@babel/types": "npm:^7.28.4" - checksum: 10/5a70a82e196cf8808f8a449cc4780c34d02edda2bb136d39ce9d26e63b615f18e89a95472230c3ce7695db0d33e7026efeee56f6454ed43480f223007ed205eb + "@babel/template": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + checksum: 10/213485cdfffc4deb81fc1bf2cefed61bc825049322590ef69690e223faa300a2a4d1e7d806c723bb1f1f538226b9b1b6c356ca94eb47fa7c6d9e9f251ee425e6 languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.0, @babel/parser@npm:^7.25.3, @babel/parser@npm:^7.25.9, @babel/parser@npm:^7.27.2, @babel/parser@npm:^7.27.5, @babel/parser@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/parser@npm:7.28.5" +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.0, @babel/parser@npm:^7.25.3, @babel/parser@npm:^7.25.9, @babel/parser@npm:^7.27.5, @babel/parser@npm:^7.28.5, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/parser@npm:7.29.0" dependencies: - "@babel/types": "npm:^7.28.5" + "@babel/types": "npm:^7.29.0" bin: parser: ./bin/babel-parser.js - checksum: 10/8d9bfb437af6c97a7f6351840b9ac06b4529ba79d6d3def24d6c2996ab38ff7f1f9d301e868ca84a93a3050fadb3d09dbc5105b24634cd281671ac11eebe8df7 + checksum: 10/b1576dca41074997a33ee740d87b330ae2e647f4b7da9e8d2abd3772b18385d303b0cee962b9b88425e0f30d58358dbb8d63792c1a2d005c823d335f6a029747 languageName: node linkType: hard @@ -328,6 +337,28 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-syntax-jsx@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-syntax-jsx@npm:7.28.6" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.28.6" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/572e38f5c1bb4b8124300e7e3dd13e82ae84a21f90d3f0786c98cd05e63c78ca1f32d1cfe462dfbaf5e7d5102fa7cd8fd741dfe4f3afc2e01a3b2877dcc8c866 + languageName: node + linkType: hard + +"@babel/plugin-transform-react-jsx-development@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-react-jsx-development@npm:7.27.1" + dependencies: + "@babel/plugin-transform-react-jsx": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/b88865d5b8c018992f2332da939faa15c4d4a864c9435a5937beaff3fe43781432cc42e0a5d5631098e0bd4066fc33f5fa72203b388b074c3545fe7aaa21e474 + languageName: node + linkType: hard + "@babel/plugin-transform-react-jsx-self@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-transform-react-jsx-self@npm:7.27.1" @@ -350,6 +381,21 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-react-jsx@npm:^7.27.1": + version: 7.28.6 + resolution: "@babel/plugin-transform-react-jsx@npm:7.28.6" + dependencies: + "@babel/helper-annotate-as-pure": "npm:^7.27.3" + "@babel/helper-module-imports": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/plugin-syntax-jsx": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/c6eade7309f0710b6aac9e747f8c3305633801c035a35efc5e2436742cc466e457ed5848d3dd5dade36e34332cfc50ac92d69a33f7803d66ae2d72f13a76c3bc + languageName: node + linkType: hard + "@babel/runtime@npm:^7.12.5": version: 7.27.0 resolution: "@babel/runtime@npm:7.27.0" @@ -359,29 +405,29 @@ __metadata: languageName: node linkType: hard -"@babel/template@npm:^7.25.9, @babel/template@npm:^7.27.2": - version: 7.27.2 - resolution: "@babel/template@npm:7.27.2" +"@babel/template@npm:^7.25.9, @babel/template@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/template@npm:7.28.6" dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@babel/parser": "npm:^7.27.2" - "@babel/types": "npm:^7.27.1" - checksum: 10/fed15a84beb0b9340e5f81566600dbee5eccd92e4b9cc42a944359b1aa1082373391d9d5fc3656981dff27233ec935d0bc96453cf507f60a4b079463999244d8 + "@babel/code-frame": "npm:^7.28.6" + "@babel/parser": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + checksum: 10/0ad6e32bf1e7e31bf6b52c20d15391f541ddd645cbd488a77fe537a15b280ee91acd3a777062c52e03eedbc2e1f41548791f6a3697c02476ec5daf49faa38533 languageName: node linkType: hard -"@babel/traverse@npm:^7.23.2, @babel/traverse@npm:^7.27.1, @babel/traverse@npm:^7.28.3, @babel/traverse@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/traverse@npm:7.28.5" +"@babel/traverse@npm:^7.23.2, @babel/traverse@npm:^7.28.6, @babel/traverse@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/traverse@npm:7.29.0" dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@babel/generator": "npm:^7.28.5" + "@babel/code-frame": "npm:^7.29.0" + "@babel/generator": "npm:^7.29.0" "@babel/helper-globals": "npm:^7.28.0" - "@babel/parser": "npm:^7.28.5" - "@babel/template": "npm:^7.27.2" - "@babel/types": "npm:^7.28.5" + "@babel/parser": "npm:^7.29.0" + "@babel/template": "npm:^7.28.6" + "@babel/types": "npm:^7.29.0" debug: "npm:^4.3.1" - checksum: 10/1fce426f5ea494913c40f33298ce219708e703f71cac7ac045ebde64b5a7b17b9275dfa4e05fb92c3f123136913dff62c8113172f4a5de66dab566123dbe7437 + checksum: 10/3a0d0438f1ba9fed4fbe1706ea598a865f9af655a16ca9517ab57bda526e224569ca1b980b473fb68feea5e08deafbbf2cf9febb941f92f2d2533310c3fc4abc languageName: node linkType: hard @@ -400,13 +446,13 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.23.0, @babel/types@npm:^7.25.9, @babel/types@npm:^7.26.10, @babel/types@npm:^7.27.1, @babel/types@npm:^7.28.4, @babel/types@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/types@npm:7.28.5" +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.23.0, @babel/types@npm:^7.25.9, @babel/types@npm:^7.26.10, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.5, @babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/types@npm:7.29.0" dependencies: "@babel/helper-string-parser": "npm:^7.27.1" "@babel/helper-validator-identifier": "npm:^7.28.5" - checksum: 10/4256bb9fb2298c4f9b320bde56e625b7091ea8d2433d98dcf524d4086150da0b6555aabd7d0725162670614a9ac5bf036d1134ca13dedc9707f988670f1362d7 + checksum: 10/bfc2b211210f3894dcd7e6a33b2d1c32c93495dc1e36b547376aa33441abe551ab4bc1640d4154ee2acd8e46d3bbc925c7224caae02fcaf0e6a771e97fccc661 languageName: node linkType: hard @@ -3352,6 +3398,40 @@ __metadata: languageName: unknown linkType: soft +"@ocap/caplet-sandbox-prototype@workspace:packages/caplet-sandbox-prototype": + version: 0.0.0-use.local + resolution: "@ocap/caplet-sandbox-prototype@workspace:packages/caplet-sandbox-prototype" + dependencies: + "@metamask/eslint-config": "npm:^15.0.0" + "@metamask/eslint-config-nodejs": "npm:^15.0.0" + "@metamask/eslint-config-typescript": "npm:^15.0.0" + "@ocap/repo-tools": "workspace:^" + "@preact/preset-vite": "npm:^2.10.2" + "@typescript-eslint/eslint-plugin": "npm:^8.29.0" + "@typescript-eslint/parser": "npm:^8.29.0" + "@typescript-eslint/utils": "npm:^8.29.0" + "@vitest/eslint-plugin": "npm:^1.6.5" + depcheck: "npm:^1.4.7" + eslint: "npm:^9.23.0" + eslint-config-prettier: "npm:^10.1.1" + eslint-import-resolver-typescript: "npm:^4.3.1" + eslint-plugin-import-x: "npm:^4.10.0" + eslint-plugin-jsdoc: "npm:^50.6.9" + eslint-plugin-n: "npm:^17.17.0" + eslint-plugin-prettier: "npm:^5.2.6" + eslint-plugin-promise: "npm:^7.2.1" + jsdom: "npm:^27.4.0" + preact: "npm:^10.26.4" + prettier: "npm:^3.5.3" + rimraf: "npm:^6.0.1" + turbo: "npm:^2.5.6" + typescript: "npm:~5.8.2" + typescript-eslint: "npm:^8.29.0" + vite: "npm:^7.3.0" + vitest: "npm:^4.0.16" + languageName: unknown + linkType: soft + "@ocap/cli@workspace:^, @ocap/cli@workspace:packages/cli": version: 0.0.0-use.local resolution: "@ocap/cli@workspace:packages/cli" @@ -4574,6 +4654,64 @@ __metadata: languageName: node linkType: hard +"@preact/preset-vite@npm:^2.10.2": + version: 2.10.3 + resolution: "@preact/preset-vite@npm:2.10.3" + dependencies: + "@babel/plugin-transform-react-jsx": "npm:^7.27.1" + "@babel/plugin-transform-react-jsx-development": "npm:^7.27.1" + "@prefresh/vite": "npm:^2.4.11" + "@rollup/pluginutils": "npm:^5.0.0" + babel-plugin-transform-hook-names: "npm:^1.0.2" + debug: "npm:^4.4.3" + picocolors: "npm:^1.1.1" + vite-prerender-plugin: "npm:^0.5.8" + peerDependencies: + "@babel/core": 7.x + vite: 2.x || 3.x || 4.x || 5.x || 6.x || 7.x + checksum: 10/0d2aa3c33d29a142f20935ab54fbc96a33bbfb66ae9121c812d1cca354a691e51f7c4e617d80c8ca307e4b067cc2f5897d0e83f8d79e2ca6b5ad76b583ac8196 + languageName: node + linkType: hard + +"@prefresh/babel-plugin@npm:0.5.2": + version: 0.5.2 + resolution: "@prefresh/babel-plugin@npm:0.5.2" + checksum: 10/d0ea6b1feb399c034f2de75ce943018dbbdbb68e16201da2139f5e0e7980f62507292dcee714bc8891dafa770cf5a3d996cbdb398cdab097ae2937e5e96a6172 + languageName: node + linkType: hard + +"@prefresh/core@npm:^1.5.0": + version: 1.5.9 + resolution: "@prefresh/core@npm:1.5.9" + peerDependencies: + preact: ^10.0.0 || ^11.0.0-0 + checksum: 10/ebc97917a13d4d995e9f9c60b4ca746ccccdc75abe3d39837413d6a854e71b61c169a9e4d22cf299098c4abae0e08ef0ae35e1a9117223d7a4f582cf3bbcb8d8 + languageName: node + linkType: hard + +"@prefresh/utils@npm:^1.2.0": + version: 1.2.1 + resolution: "@prefresh/utils@npm:1.2.1" + checksum: 10/d184a11a44ccea4a1aace9f1129f36eb1607632aad9395ce8c0b6b7cf539efd017fd00514222339d1ed2db5cecc53d5ff7218627fc67c6b3d512eedfaa3fa0a7 + languageName: node + linkType: hard + +"@prefresh/vite@npm:^2.4.11": + version: 2.4.11 + resolution: "@prefresh/vite@npm:2.4.11" + dependencies: + "@babel/core": "npm:^7.22.1" + "@prefresh/babel-plugin": "npm:0.5.2" + "@prefresh/core": "npm:^1.5.0" + "@prefresh/utils": "npm:^1.2.0" + "@rollup/pluginutils": "npm:^4.2.1" + peerDependencies: + preact: ^10.4.0 || ^11.0.0-0 + vite: ">=2.0.0" + checksum: 10/7a14d10538eecde9c3ffc439d2dca6041a575ec8ac9d1a57c2f05c692c4149029e0fc5320bd5427c19a2494fe5d24ecf1779f4f0e593618907255d784d9ae5d3 + languageName: node + linkType: hard + "@radix-ui/react-compose-refs@npm:1.1.2": version: 1.1.2 resolution: "@radix-ui/react-compose-refs@npm:1.1.2" @@ -4609,9 +4747,19 @@ __metadata: languageName: node linkType: hard -"@rollup/pluginutils@npm:^5.1.4": - version: 5.2.0 - resolution: "@rollup/pluginutils@npm:5.2.0" +"@rollup/pluginutils@npm:^4.2.1": + version: 4.2.1 + resolution: "@rollup/pluginutils@npm:4.2.1" + dependencies: + estree-walker: "npm:^2.0.1" + picomatch: "npm:^2.2.2" + checksum: 10/503a6f0a449e11a2873ac66cfdfb9a3a0b77ffa84c5cad631f5e4bc1063c850710e8d5cd5dab52477c0d66cda2ec719865726dbe753318cd640bab3fff7ca476 + languageName: node + linkType: hard + +"@rollup/pluginutils@npm:^5.0.0, @rollup/pluginutils@npm:^5.1.4": + version: 5.3.0 + resolution: "@rollup/pluginutils@npm:5.3.0" dependencies: "@types/estree": "npm:^1.0.0" estree-walker: "npm:^2.0.2" @@ -4621,7 +4769,7 @@ __metadata: peerDependenciesMeta: rollup: optional: true - checksum: 10/15e98a9e7ebeb9fdbbf072ad40e72947654abf98bcd389d6e54dcffe28f7eb93d9653037d5e18b703b0160e04210a1995cf08fc2bf45601ce77b17e4461f55c0 + checksum: 10/6c7dbab90e0ca5918a36875f745a0f30b47d5e0f45b42ed381ad8f7fed76b23e935766b66e3ae75375a42a80369569913abc8fd2529f4338471a1b2b4dfebaff languageName: node linkType: hard @@ -6683,6 +6831,15 @@ __metadata: languageName: node linkType: hard +"babel-plugin-transform-hook-names@npm:^1.0.2": + version: 1.0.2 + resolution: "babel-plugin-transform-hook-names@npm:1.0.2" + peerDependencies: + "@babel/core": ^7.12.10 + checksum: 10/ccb41ed9e052880e3669deaf1f8251bcd84e18d3d4d6933a82ac621f7fe40022c24423ea6ccc5584bd82b1e432b6c6a79c0d1000ba12e8acc3652636a34f68e0 + languageName: node + linkType: hard + "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -7575,15 +7732,15 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.6, debug@npm:^4.4.0": - version: 4.4.1 - resolution: "debug@npm:4.4.1" +"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.6, debug@npm:^4.4.0, debug@npm:^4.4.3": + version: 4.4.3 + resolution: "debug@npm:4.4.3" dependencies: ms: "npm:^2.1.3" peerDependenciesMeta: supports-color: optional: true - checksum: 10/8e2709b2144f03c7950f8804d01ccb3786373df01e406a0f66928e47001cf2d336cbed9ee137261d4f90d68d8679468c755e3548ed83ddacdc82b194d2468afe + checksum: 10/9ada3434ea2993800bd9a1e320bd4aa7af69659fb51cca685d390949434bc0a8873c21ed7c9b852af6f2455a55c6d050aa3937d52b3c69f796dab666f762acad languageName: node linkType: hard @@ -8728,7 +8885,7 @@ __metadata: languageName: node linkType: hard -"estree-walker@npm:^2.0.2": +"estree-walker@npm:^2.0.1, estree-walker@npm:^2.0.2": version: 2.0.2 resolution: "estree-walker@npm:2.0.2" checksum: 10/b02109c5d46bc2ed47de4990eef770f7457b1159a229f0999a09224d2b85ffeed2d7679cffcff90aeb4448e94b0168feb5265b209cdec29aad50a3d6e93d21e2 @@ -9633,7 +9790,7 @@ __metadata: languageName: node linkType: hard -"he@npm:^1.2.0": +"he@npm:1.2.0, he@npm:^1.2.0": version: 1.2.0 resolution: "he@npm:1.2.0" bin: @@ -10855,7 +11012,7 @@ __metadata: languageName: node linkType: hard -"kolorist@npm:^1.8.0": +"kolorist@npm:^1.6.0, kolorist@npm:^1.8.0": version: 1.8.0 resolution: "kolorist@npm:1.8.0" checksum: 10/71d5d122951cc65f2f14c3e1d7f8fd91694b374647d4f6deec3816d018cd04a44edd9578d93e00c82c2053b925e5d30a0565746c4171f4ca9fce1a13bd5f3315 @@ -11078,7 +11235,7 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.11, magic-string@npm:^0.30.17, magic-string@npm:^0.30.21": +"magic-string@npm:0.x >= 0.26.0, magic-string@npm:^0.30.11, magic-string@npm:^0.30.17, magic-string@npm:^0.30.21": version: 0.30.21 resolution: "magic-string@npm:0.30.21" dependencies: @@ -11791,6 +11948,16 @@ __metadata: languageName: node linkType: hard +"node-html-parser@npm:^6.1.12": + version: 6.1.13 + resolution: "node-html-parser@npm:6.1.13" + dependencies: + css-select: "npm:^5.1.0" + he: "npm:1.2.0" + checksum: 10/f5ebc5cea22e819e7b726b483e12241039ebdeac6eba198ebdb55a0921536798c1acd9c6f543e82f77542dc44fb2230f55891b1baae218c17969dd08f7295d14 + languageName: node + linkType: hard + "node-releases@npm:^2.0.19": version: 2.0.19 resolution: "node-releases@npm:2.0.19" @@ -12414,7 +12581,7 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.3.1": +"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.2, picomatch@npm:^2.3.1": version: 2.3.1 resolution: "picomatch@npm:2.3.1" checksum: 10/60c2595003b05e4535394d1da94850f5372c9427ca4413b71210f437f7b2ca091dbd611c45e8b37d10036fa8eade25c1b8951654f9d3973bfa66a2ff4d3b08bc @@ -12619,6 +12786,13 @@ __metadata: languageName: node linkType: hard +"preact@npm:^10.26.4": + version: 10.28.3 + resolution: "preact@npm:10.28.3" + checksum: 10/b89c328f7ea13976dc2849962141fb4268afbbb364fa2f0a235788c2dba2a32786db43531fec5b64960917a078e4b78bd88967f13120df350573a619c8286f86 + languageName: node + linkType: hard + "prebuild-install@npm:^7.1.1, prebuild-install@npm:^7.1.3": version: 7.1.3 resolution: "prebuild-install@npm:7.1.3" @@ -13657,6 +13831,15 @@ __metadata: languageName: node linkType: hard +"simple-code-frame@npm:^1.3.0": + version: 1.3.0 + resolution: "simple-code-frame@npm:1.3.0" + dependencies: + kolorist: "npm:^1.6.0" + checksum: 10/103c78b3a5794b263125b93df66012974e315bbff773d0239f9c51c3a02d90da1fcf7e5affdd309db5f991123f53efed46a400ad9870a571dcc59170305bd245 + languageName: node + linkType: hard + "simple-concat@npm:^1.0.0": version: 1.0.1 resolution: "simple-concat@npm:1.0.1" @@ -13784,6 +13967,13 @@ __metadata: languageName: node linkType: hard +"source-map@npm:^0.7.4": + version: 0.7.6 + resolution: "source-map@npm:0.7.6" + checksum: 10/c8d2da7c57c14f3fd7568f764b39ad49bbf9dd7632b86df3542b31fed117d4af2fb74a4f886fc06baf7a510fee68e37998efc3080aacdac951c36211dc29a7a3 + languageName: node + linkType: hard + "source-map@npm:~0.6.1": version: 0.6.1 resolution: "source-map@npm:0.6.1" @@ -13874,6 +14064,13 @@ __metadata: languageName: node linkType: hard +"stack-trace@npm:^1.0.0-pre2": + version: 1.0.0-pre2 + resolution: "stack-trace@npm:1.0.0-pre2" + checksum: 10/a64099f86acc01980b0a7fbc662f3233bf8626daf95c53e31c835b2252ae11fc3dbfe8f3e77a7f8310132dd488af2795057cd7db599de0c41a6fa99b16068273 + languageName: node + linkType: hard + "stackback@npm:0.0.2": version: 0.0.2 resolution: "stackback@npm:0.0.2" @@ -15160,6 +15357,22 @@ __metadata: languageName: node linkType: hard +"vite-prerender-plugin@npm:^0.5.8": + version: 0.5.12 + resolution: "vite-prerender-plugin@npm:0.5.12" + dependencies: + kolorist: "npm:^1.8.0" + magic-string: "npm:0.x >= 0.26.0" + node-html-parser: "npm:^6.1.12" + simple-code-frame: "npm:^1.3.0" + source-map: "npm:^0.7.4" + stack-trace: "npm:^1.0.0-pre2" + peerDependencies: + vite: 5.x || 6.x || 7.x + checksum: 10/41039498105e9a59d145258469926fcfd6b9fac2add71c92465920a9f2ec176b8607aeee255d48dfafa6c56c24b3fe9f1192373643895e9ced3a548238fe3dc1 + languageName: node + linkType: hard + "vite-tsconfig-paths@npm:^6.0.3": version: 6.0.3 resolution: "vite-tsconfig-paths@npm:6.0.3"