import { css, html, LitElement, PropertyValues } from "lit"; import { property } from "lit/decorators.js"; import { createRef, Ref, ref } from "lit/directives/ref.js"; import * as IPC from "./ipc.ts"; import { getIframeContextHandler, Receipt } from "./context.ts"; import OuterFrame from "./outer-frame.ts"; import { HealthCheck, HealthCheckAbort, HealthCheckTimeout, } from "./health-check.ts"; import { sleep } from "@commontools/utils/sleep"; let FRAME_IDS = 0; // Currently, recipes are expected to handle heavy processing, // and backgrounding the tab affects the timers. In the future, // we could handle this more dynamically. // As this will need to be influenced by heuristics, and currently, // usually wanting to wait for a recipe to finish processing, // we will not "crash tabs" yet until things settle. const HEALTH_CHECKING_ENABLED = false; // Delay, in ms, after starting page load before // health checks result in freezing content. const HEALTH_CHECK_LOAD_DELAY = 5000; // The time frame, in ms, that content must respond to within // in order to pass the health check. // Should be rather low, but currently it's too likely for a // charm to spend a lot of time processing. const HEALTH_CHECK_TIMEOUT = 3000; type CommonIframeLoadState = "" | "loading" | "loaded"; // @summary A sandboxed iframe to execute arbitrary scripts. // @tag common-iframe-sandbox // @prop {string} src - String representation of HTML content to load within an iframe. // @prop context - Cell context. // @event {CustomEvent} error - An error from the iframe. // @event {CustomEvent} load - The iframe was successfully loaded. export class CommonIframeSandboxElement extends LitElement { @property() src = ""; @property() context?: object; @property() crashed = false; @property({ attribute: "load-state", reflect: true }) loadState: CommonIframeLoadState = ""; static override styles = css` :host { display: block; width: 100%; height: 100%; overflow: hidden; background-color: #ddd; } #crash-message { width: 50%; margin: 20px auto; display: flex; flex-direction: column; text-align: center; } #crash-message > * { flex: 1; } #crash-message button { font-size: 20px; background-color: white; border: 1px solid black; } `; // Static id for this component for its lifetime. private frameId: number = ++FRAME_IDS; // An incrementing id for each new page load to disambiguate // requests between inner page loads. private instanceId: number = 0; private iframeRef: Ref = createRef(); private initialized: boolean = false; private subscriptions: Map = new Map(); // Timestamp of when the inner frame was loaded. private pageLoadTimestamp: number = 0; // Called when the outer frame emits // `IPCGuestMessageType.Ready`, only once, upon // the initial render. private onOuterReady() { if (this.initialized) { throw new Error(`common-iframe-sandbox: Already initialized.`); } this.initialized = true; this.toGuest({ id: this.frameId, type: IPC.IPCHostMessageType.Init, }); if (this.src) { this.loadInnerDoc(); } } // Message from the outer frame. private onMessage = (event: MessageEvent) => { if (event.source !== this.iframeRef.value?.contentWindow) { return; } if (!IPC.isIPCGuestMessage(event.data)) { console.error( "common-iframe-sandbox: Malformed message from guest.", event.data, ); return; } const outerMessage: IPC.IPCGuestMessage = event.data; switch (outerMessage.type) { case IPC.IPCGuestMessageType.Load: { this.pageLoadTimestamp = performance.now(); if (this.contentSupportsHealthCheck()) { this.requestHealthCheck(); } this.loadState = "loaded"; this.dispatchEvent(new CustomEvent("load")); return; } case IPC.IPCGuestMessageType.Error: { console.error( `common-iframe-sandbox: Error from outer frame: ${outerMessage.data}`, ); return; } case IPC.IPCGuestMessageType.Ready: { this.onOuterReady(); return; } case IPC.IPCGuestMessageType.Passthrough: { this.onGuestMessage(outerMessage.data); return; } } }; // Message from the inner frame. private async onGuestMessage(message: IPC.GuestMessage) { const IframeHandler = getIframeContextHandler(); if (IframeHandler == null) { console.error("common-iframe-sandbox: No iframe handler defined."); return; } if (!this.context) { console.warn("common-iframe-sandbox: missing `context`."); return; } switch (message.type) { case IPC.GuestMessageType.Error: { const { description, source, lineno, colno, stacktrace } = message.data; const error = { description, message: description, source, lineno, colno, stacktrace, stack: stacktrace, }; this.dispatchEvent( new CustomEvent("common-iframe-error", { detail: error, bubbles: true, composed: true, }), ); return; } case IPC.GuestMessageType.Read: { const key = message.data; const value = IframeHandler.read(this, this.context, key); this.toGuest({ id: this.frameId, type: IPC.IPCHostMessageType.Passthrough, data: { type: IPC.HostMessageType.Update, data: [key, value], }, }); return; } case IPC.GuestMessageType.Write: { const [key, value] = message.data; IframeHandler.write(this, this.context, key, value); return; } case IPC.GuestMessageType.Subscribe: { const keys = typeof message.data === "string" ? [message.data] : message.data; // TODO(seefeld): Remove this and make this default true on 3/31/2025 or // whenever we delete all charms anyway. This is just a stopgap to not // break existing charms. const doNotSendMyDataBack = Array.isArray(message.data); for (const key of keys) { if (this.subscriptions.has(key)) { console.warn( "common-iframe-sandbox: Already subscribed to `${key}`", ); continue; } const receipt = IframeHandler.subscribe( this, this.context, key, (key, value) => this.notifySubscribers(key, value), doNotSendMyDataBack, ); this.subscriptions.set(key, receipt); } return; } case IPC.GuestMessageType.Unsubscribe: { const keys = typeof message.data === "string" ? [message.data] : message.data; for (const key of keys) { const receipt = this.subscriptions.get(key); if (!receipt) { continue; } IframeHandler.unsubscribe(this, this.context, receipt); this.subscriptions.delete(key); } return; } case IPC.GuestMessageType.LLMRequest: { const payload = message.data; const promise = IframeHandler.onLLMRequest(this, this.context, payload); const instanceId = this.instanceId; promise.then((result: object) => { if (!this.ensureSameDocument(instanceId)) { return; } this.toGuest({ id: this.frameId, type: IPC.IPCHostMessageType.Passthrough, data: { type: IPC.HostMessageType.LLMResponse, request: payload, data: result, error: undefined, }, }); }, (error: unknown) => { if (!this.ensureSameDocument(instanceId)) { return; } this.toGuest({ id: this.frameId, type: IPC.IPCHostMessageType.Passthrough, data: { type: IPC.HostMessageType.LLMResponse, request: payload, data: null, error, }, }); }); return; } case IPC.GuestMessageType.WebpageRequest: { const payload = message.data; const instanceId = this.instanceId; let result, error; try { result = await IframeHandler.onReadWebpageRequest( this, this.context, payload, ); } catch (e) { error = e; } if (!this.ensureSameDocument(instanceId)) { return; } this.toGuest({ id: this.frameId, type: IPC.IPCHostMessageType.Passthrough, data: { type: IPC.HostMessageType.ReadWebpageResponse, request: payload, data: result || null, error, }, }); return; } case IPC.GuestMessageType.Perform: { const instanceId = this.instanceId; IframeHandler.onPerform(this, this.context, message.data).then( (result) => { if (!this.ensureSameDocument(instanceId)) { return; } this.toGuest({ id: this.frameId, type: IPC.IPCHostMessageType.Passthrough, data: { type: IPC.HostMessageType.Effect, id: message.data.id, result, }, }); }, ); return; } case IPC.GuestMessageType.Pong: { if (!this.healthCheck) { return; } this.healthCheck.tryFulfill(message.data); } } } private loadInnerDoc() { this.loadState = "loading"; // Remove all active subscriptions when navigating // to a new document. const IframeHandler = getIframeContextHandler(); if (IframeHandler != null) { for (const [_, receipt] of this.subscriptions) { IframeHandler.unsubscribe(this, this.context, receipt); } this.subscriptions.clear(); } ++this.instanceId; this.toGuest({ id: this.frameId, type: IPC.IPCHostMessageType.LoadDocument, data: this.src, }); } private healthCheck?: HealthCheck; private async requestHealthCheck() { if (!HEALTH_CHECKING_ENABLED) { return; } if (this.healthCheck) { this.healthCheck.abort(); this.healthCheck = undefined; } const instanceId = this.instanceId; const startJitter = performance.now(); // Wait between 100-500ms to schedule // the next health check to avoid janky cycles. const jitter = 100 + (Math.random() * 400); await sleep(jitter); // If the jitter took longer than expected by HEALTH_CHECK_TIMEOUT, // the inner frame could have blocked the main thread while waiting // on jitter. We see this in Firefox. // Using iframes with different domains should run this in a separate thread, // but as we're using srcdoc iframe, it's likely to always block main thread here. if ((performance.now() - startJitter) > jitter + HEALTH_CHECK_TIMEOUT) { this.onHealthCheckFailure(new HealthCheckTimeout()); } if (!this.ensureSameDocument(instanceId)) { return; } this.healthCheck = new HealthCheck(HEALTH_CHECK_TIMEOUT); this.healthCheck.result().then( () => this.requestHealthCheck(), (e) => this.onHealthCheckFailure(e), ); this.toGuest({ id: this.frameId, type: IPC.IPCHostMessageType.Passthrough, data: { type: IPC.HostMessageType.Ping, data: this.healthCheck.nonce, }, }); } private onHealthCheckFailure(e: Error) { // Ignore aborted health checks. if (e.name === HealthCheckAbort.prototype.name) { return; } // Ignore if health checks fail near page load -- let // things settle a bit, and queue up another check. if ( (performance.now() - this.pageLoadTimestamp) < HEALTH_CHECK_LOAD_DELAY ) { this.requestHealthCheck(); return; } this.crashed = true; this.initialized = false; } // This is to be called with the `instanceId` of a request // after an async boundary to ensure the inner frame // was not reloaded. private ensureSameDocument(instanceId: number): boolean { return this.instanceId === instanceId; } private notifySubscribers(key: string, value: unknown) { const response: IPC.IPCHostMessage = { id: this.frameId, type: IPC.IPCHostMessageType.Passthrough, data: { type: IPC.HostMessageType.Update, data: [key, value], }, }; this.toGuest(response); } private onCrashReload() { this.crashed = false; } private toGuest(event: IPC.IPCHostMessage) { this.iframeRef.value?.contentWindow?.postMessage(event, "*"); } // In lieu of versioning, check the content to see // if there is a ping handler; otherwise, older charms // will always fail health check, as it cannot respond // to the ping. private contentSupportsHealthCheck() { return /\/.test(this.src); } private boundOnMessage = this.onMessage.bind(this); override connectedCallback() { super.connectedCallback(); globalThis.addEventListener("message", this.boundOnMessage); } override disconnectedCallback() { super.disconnectedCallback(); globalThis.removeEventListener("message", this.boundOnMessage); } override willUpdate(changedProperties: PropertyValues) { if (changedProperties.has("src") && this.initialized) { this.loadInnerDoc(); } } override render() { if (this.crashed) { return html`
🤨 Charm crashed! 🤨
`; } return html` `; } } customElements.define("common-iframe-sandbox", CommonIframeSandboxElement);