import { agentNativePath } from "./api-path.js"; /** * Frame Communication (browser) * * Utilities for communicating with the parent frame via postMessage. * Provides typed request/response patterns and message sending. */ // --------------------------------------------------------------------------- // Low-level parent messaging // --------------------------------------------------------------------------- /** * Send a typed message to the parent frame. * No-op if running at top level (no parent frame). */ export function sendToFrame(type: string, data?: any): void { if (typeof window === "undefined") return; const target = window.parent !== window ? window.parent : window; const targetOrigin = getFramePostMessageTargetOrigin() || window.location.origin; target.postMessage({ type, data }, targetOrigin); } /** * Listen for a specific message type from the parent frame. * Returns a cleanup function. */ export function onFrameMessage( type: string, handler: (data: any) => void, ): () => void { if (typeof window === "undefined") return () => {}; const listener = (event: MessageEvent) => { if (!isTrustedFrameMessage(event)) return; if (event.data?.type === type) { handler(event.data.data ?? event.data.detail ?? event.data); } }; window.addEventListener("message", listener); return () => window.removeEventListener("message", listener); } // --------------------------------------------------------------------------- // Frame Origin // --------------------------------------------------------------------------- let _frameOrigin: string | null = null; function normalizeOrigin(value: unknown): string | null { if (typeof value !== "string") return null; if (value === "null") return "null"; try { return new URL(value).origin; } catch { return null; } } export function getFramePostMessageTargetOrigin(): string | null { const origin = getFrameOrigin(); return origin === "null" ? "*" : origin; } export function isTrustedFrameMessage(event: MessageEvent): boolean { if (typeof window === "undefined") return false; const ownOrigin = window.location.origin; if (event.origin === ownOrigin) return true; const frameOrigin = getFrameOrigin(); if (!frameOrigin) return false; if (frameOrigin === "null") { return ( event.origin === "null" && (event.source === window.parent || event.source === window) ); } if (event.origin !== frameOrigin) return false; return event.source === window.parent || event.source === window; } // --------------------------------------------------------------------------- // Content-height reporting (for MCP App embeds) // --------------------------------------------------------------------------- // // The embed shell renders this app in a cross-origin iframe and can't measure // its content, so we post the real content height for the shell to size to. // Only shrinks when the document flows to its natural height — embed layouts // must not force `100vh`/`h-screen` on the root, or scrollHeight just equals // the iframe height. let _contentHeightReportingStarted = false; let _lastReportedContentHeight = 0; function measureContentHeight(): number { if (typeof document === "undefined") return 0; const doc = document.documentElement; const body = document.body; return Math.ceil( Math.max(doc ? doc.scrollHeight : 0, body ? body.scrollHeight : 0), ); } function reportContentHeight(): void { if (typeof window === "undefined" || window.parent === window) return; const height = measureContentHeight(); if (!height || Math.abs(height - _lastReportedContentHeight) < 2) return; _lastReportedContentHeight = height; sendToFrame("agentNative.contentHeight", { height }); } function startContentHeightReporting(): void { if (_contentHeightReportingStarted || typeof window === "undefined") return; if (window.parent === window) return; _contentHeightReportingStarted = true; let scheduled = false; const schedule = () => { if (scheduled) return; scheduled = true; window.requestAnimationFrame(() => { scheduled = false; reportContentHeight(); }); }; schedule(); for (const delay of [100, 300, 800, 1500]) window.setTimeout(schedule, delay); try { const observer = new ResizeObserver(schedule); if (document.documentElement) observer.observe(document.documentElement); if (document.body) observer.observe(document.body); } catch { window.addEventListener("resize", schedule, { passive: true }); } window.addEventListener("load", schedule, { passive: true }); } // Listen for frame origin message and cache it. // Only accept from the direct parent frame, and only set once. if (typeof window !== "undefined") { window.addEventListener("message", (event: MessageEvent) => { const eventOrigin = normalizeOrigin(event.origin); const payloadOrigin = normalizeOrigin(event.data?.origin); const origin = eventOrigin ?? payloadOrigin; if ( event.data?.type === "agentNative.frameOrigin" && origin && (!payloadOrigin || payloadOrigin === origin || origin === "null") && !_frameOrigin && event.source === window.parent ) { _frameOrigin = origin; window.parent.postMessage( { type: "agentNative.embeddedAppReady" }, getFramePostMessageTargetOrigin() ?? window.location.origin, ); startContentHeightReporting(); } }); } /** * Get the frame origin (e.g. "http://localhost:3334"). * Returns null if not running inside a frame iframe. */ export function getFrameOrigin(): string | null { return _frameOrigin; } /** * Returns true if the app is running inside a frame iframe * (local dev frame, Builder.io, or any compatible frame). */ export function isInFrame(): boolean { return _frameOrigin !== null; } /** * Get the origin for OAuth callbacks. * Always uses the app's own origin (window.location.origin), NOT the frame * origin. The redirect URI registered in Google Cloud Console (or any OAuth * provider) must match the template app's direct URL, not the dev frame's * proxy URL, so this must be consistent regardless of how the app is accessed. */ export function getCallbackOrigin(): string { return typeof window !== "undefined" ? window.location.origin : ""; } function envFlag(name: string): boolean { const value = runtimeEnvValue(name); return value === "1" || value === "true" || value === true; } function runtimeEnvValue(name: string): string | boolean | undefined { const importMetaEnv = ( import.meta as unknown as { env?: Record; } ).env; if (importMetaEnv?.[name] !== undefined) return importMetaEnv[name]; return typeof process !== "undefined" ? (process.env as Record)?.[name] : undefined; } function workspaceOAuthOrigin(): string | null { const raw = runtimeEnvValue("VITE_WORKSPACE_OAUTH_ORIGIN") || runtimeEnvValue("WORKSPACE_OAUTH_ORIGIN") || runtimeEnvValue("VITE_APP_URL") || runtimeEnvValue("APP_URL") || runtimeEnvValue("VITE_BETTER_AUTH_URL") || runtimeEnvValue("BETTER_AUTH_URL") || runtimeEnvValue("VITE_WORKSPACE_GATEWAY_URL") || runtimeEnvValue("WORKSPACE_GATEWAY_URL"); if (typeof raw !== "string" || !raw) return null; try { return new URL(raw).origin; } catch { return null; } } function shouldUseWorkspaceCallbackRelay(path: string): boolean { return ( envFlag("VITE_AGENT_NATIVE_WORKSPACE") && path.startsWith("/_agent-native/") && (path.endsWith("/callback") || path.includes("/callback/")) ); } /** * Build an OAuth redirect URI for a framework callback route. * * Workspace deploys use one provider-registered root callback URL and then * relay to the app-specific callback based on OAuth state. Standalone apps * keep using their mounted app callback path. */ export function oauthRedirectUri(callbackPath: string): string { const normalized = callbackPath.startsWith("/") ? callbackPath : `/${callbackPath}`; const path = shouldUseWorkspaceCallbackRelay(normalized) ? normalized : agentNativePath(normalized); const oauthOrigin = shouldUseWorkspaceCallbackRelay(normalized) ? workspaceOAuthOrigin() : null; const origin = oauthOrigin ?? getCallbackOrigin(); return `${origin}${path}`; } // --------------------------------------------------------------------------- // User Info // --------------------------------------------------------------------------- export interface UserInfo { name?: string; email?: string; } /** * Request user info (name + email) from the parent frame. * Falls back to empty object if frame doesn't respond within timeout. */ export function requestUserInfo(timeoutMs = 1500): Promise { return new Promise((resolve) => { if (typeof window === "undefined" || window.parent === window) { resolve({}); return; } let settled = false; const timer = setTimeout(() => { if (!settled) { settled = true; window.removeEventListener("message", handler); resolve({}); } }, timeoutMs); function handler(event: MessageEvent) { if (!event.data || event.data.type !== "agentNative.userInfo") return; if (event.source !== window.parent) return; const frameOrigin = getFrameOrigin(); if (frameOrigin && event.origin !== frameOrigin) return; if (settled) return; settled = true; clearTimeout(timer); window.removeEventListener("message", handler); const { name, email } = event.data.data ?? {}; resolve({ name: name || undefined, email: email || undefined }); } window.addEventListener("message", handler); window.parent.postMessage( { type: "agentNative.getUserInfo" }, getFramePostMessageTargetOrigin() ?? window.location.origin, ); }); } // --------------------------------------------------------------------------- // Selection Mode (visual editing) // --------------------------------------------------------------------------- /** * Enter visual editing selection mode for a specific element. */ export function enterStyleEditing(selector: string): void { sendToFrame("agentNative.enterStyleEditing", { selector }); } /** * Enter text editing mode for a specific element. */ export function enterTextEditing(selector: string): void { sendToFrame("agentNative.enterTextEditing", { selector }); } /** * Exit selection mode. */ export function exitSelectionMode(): void { sendToFrame("agentNative.exitSelectionMode"); }