/** * @teres/iframe-bridge * Penpal-based utilities for host ↔ iframe communication, * plus minimal helpers for embed detection and path conversion. */ import { connectToChild, connectToParent } from 'penpal'; // Penpal RPC API definitions export interface HostApi { navigate: (to: string) => void; close: () => void; agentReady?: (payload?: any) => void; } export interface ChildApi { navigate?: (to: string) => void; close?: () => void; ready?: (payload?: any) => void; } // Unified logger for debugging host/child interactions const LOG_PREFIX = '[@teres/iframe-bridge]'; function log(...args: any[]) { try { // eslint-disable-next-line no-console console.log(LOG_PREFIX, ...args); } catch {} } function safePathname(): string { try { const p = window.location?.pathname || ''; log('safePathname()', p); return p; } catch { log('safePathname() error, return empty pathname'); return ''; } } function isInIframe(): boolean { try { const inIframe = window.self !== window.top; log('isInIframe()', inIframe); return inIframe; } catch { // Cross-origin access throws, treat as embedded log('isInIframe() cross-origin error, treat as embedded'); return true; } } export function isEmbedded(): boolean { const path = safePathname(); const inIframe = isInIframe(); const embedded = path.includes('/route-ragflow') || inIframe; log('isEmbedded()', { path, inIframe, embedded }); return embedded; } /** Add "/ragflow" prefix when targeting host routes. */ export function toHostPath(to: string): string { const normalized = to.startsWith('/') ? to : `/${to}`; const target = normalized.startsWith('/route-ragflow') ? normalized : `/ragflow${normalized}`; log('toHostPath()', { input: to, normalized, target }); return target; } /** Remove leading "/route-ragflow" prefix when targeting child routes. */ export function toChildPath(to: string): string { const target = to.replace(/^\/route-ragflow/, '') || '/'; log('toChildPath()', { input: to, target }); return target; } // Cached Penpal connections let clientHostApiPromise: Promise | null = null; /** Create a Penpal connection from child to parent and cache the Host API. */ export function getClientHostApi(options?: { parentOrigin?: string }): Promise { if (!isEmbedded()) { log('getClientHostApi() aborted: not embedded'); return Promise.reject(new Error('Not embedded')); } if (!clientHostApiPromise) { const parentOrigin = options?.parentOrigin ?? window.location.origin; log('getClientHostApi() connecting to parent', { parentOrigin }); clientHostApiPromise = connectToParent({ parentOrigin }).promise.then((host) => { log('getClientHostApi() connected', { hasNavigate: typeof host.navigate === 'function', hasClose: typeof host.close === 'function', }); return host; }); } return clientHostApiPromise; } export async function clientNavigate(to: string): Promise { log('clientNavigate() begin', { to }); const host = await getClientHostApi(); await host.navigate(to); log('clientNavigate() done', { to }); } export async function clientClose(): Promise { log('clientClose() begin'); const host = await getClientHostApi(); log('clientClose() host api', { hasClose: typeof host.close === 'function', }); await host.close(); log('clientClose() done'); } export async function clientReady(payload?: any): Promise { log('clientReady() begin', { payload }); const host = await getClientHostApi(); const agentReady = host.agentReady; const hasAgentReady = typeof agentReady === 'function'; log('clientReady() host agentReady', { hasAgentReady }); if (hasAgentReady) await agentReady!(payload); log('clientReady() done'); } /** Establish Penpal host bridge bound to an iframe and expose Host API to child. */ export function createPenpalHostBridge(params: { iframe: HTMLIFrameElement; origin?: string; methods: HostApi; }) { const childOrigin = params.origin ?? window.location.origin; log('createPenpalHostBridge() start', { childOrigin }); const connection = connectToChild({ iframe: params.iframe, childOrigin, methods: params.methods as any }); connection.promise.then(() => log('createPenpalHostBridge() child connected')).catch((err) => log('createPenpalHostBridge() connect error', err)); return { child: connection.promise, destroy: () => { log('createPenpalHostBridge() destroy'); return connection.destroy?.(); }, }; } // Provide a default export with all APIs to maximize compatibility with bundlers // that prefer default-only federation or mis-handle named ESM exports in dev. const bridge = { isEmbedded, toHostPath, toChildPath, getClientHostApi, clientNavigate, clientClose, clientReady, createPenpalHostBridge, }; export default bridge;