159 lines
4.8 KiB
TypeScript
159 lines
4.8 KiB
TypeScript
|
|
/**
|
||
|
|
* @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<HostApi> | null = null;
|
||
|
|
|
||
|
|
/** Create a Penpal connection from child to parent and cache the Host API. */
|
||
|
|
export function getClientHostApi(options?: { parentOrigin?: string }): Promise<HostApi> {
|
||
|
|
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<HostApi>({ 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<void> {
|
||
|
|
log('clientNavigate() begin', { to });
|
||
|
|
const host = await getClientHostApi();
|
||
|
|
await host.navigate(to);
|
||
|
|
log('clientNavigate() done', { to });
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function clientClose(): Promise<void> {
|
||
|
|
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<void> {
|
||
|
|
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<ChildApi>({ 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;
|