feat(iframe-bridge): implement iframe communication bridge and language sync

This commit is contained in:
2025-11-14 20:07:08 +08:00
parent 4356813820
commit 034c190373
15 changed files with 469 additions and 160 deletions

View File

@@ -0,0 +1,98 @@
/**
* 子端iframe 内)到宿主的客户端方法集合。
* - 负责与父窗口建立并缓存 Penpal 连接
* - 提供便捷的客户端调用navigate/close/ready
*/
import { connectToParent } from 'penpal';
import type { HostApi, ChildApi } from './types';
import { isEmbedded } from './path';
import { log } from './logger';
// Cached Penpal connections
// 缓存与宿主的连接,以避免重复握手
let clientHostApiPromise: Promise<HostApi> | null = null;
/** Create a Penpal connection from child to parent and cache the Host API. */
/**
* 建立从子端到父端的 Penpal 连接,并返回宿主 HostApi带缓存
* @param options.parentOrigin 可选,宿主的 origin默认使用当前 window.location.origin
*/
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;
}
/**
* 请求宿主进行导航。
* @param to 目标路径(会由宿主自行处理前缀与实际路由规则)
*/
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');
}
/**
* 通知宿主:子端已就绪(可携带 agentId 等信息)。
*/
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');
}
/**
* 子应用初始化:暴露 ChildApi 给宿主,并获取宿主 HostApi用于后续 clientNavigate、clientClose 等)。
* 如果已调用过 getClientHostApi会复用同一连接。
*/
/**
* 子应用初始化:
* - 暴露 ChildApi 给宿主(如 changeLanguage
* - 并获取宿主 HostApi复用同一连接
*/
export function initChildBridge(params: {
methods?: ChildApi;
parentOrigin?: string;
}) {
const parentOrigin = params.parentOrigin ?? window.location.origin;
log('initChildBridge() start', { parentOrigin, hasMethods: !!params.methods });
const connection = connectToParent<HostApi>({ parentOrigin, methods: params.methods as any });
// 设置并复用 clientHostApiPromise
clientHostApiPromise = connection.promise;
connection.promise
.then(() => log('initChildBridge() parent connected'))
.catch((err) => log('initChildBridge() connect error', err));
return connection.promise;
}

View File

@@ -0,0 +1,38 @@
/**
* 宿主父窗口与子端iframe之间的桥接创建。
* - 绑定 iframe 元素,暴露宿主方法给子端调用
* - 返回 child Promise解析为子端暴露的方法与 destroy 销毁函数
*/
import { connectToChild } from 'penpal';
import type { HostApi, ChildApi } from './types';
import { log } from './logger';
/** Establish Penpal host bridge bound to an iframe and expose Host API to child. */
/**
* 建立宿主侧 Penpal 桥接并暴露 HostApi。
* @param params.iframe 目标 iframe 元素
* @param params.origin 可选,子端 origin默认当前 window.location.origin
* @param params.methods 宿主暴露给子端的方法集合
* @returns `{ child, destroy }`
* - `child`: Promise<ChildApiResolved>,解析后可直接调用子端暴露的方法(如 changeLanguage
* - `destroy`: 断开连接与清理监听器
*/
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?.();
},
};
}

View File

@@ -1,149 +1,31 @@
// 入口文件:仅聚合与导出 API不承载具体逻辑
export type { HostApi, ChildApi } from './types';
export { isEmbedded, toHostPath, toChildPath } from './path';
export {
getClientHostApi,
clientNavigate,
clientClose,
clientReady,
initChildBridge,
} from './client';
export { createPenpalHostBridge } from './host';
// 默认导出一个聚合对象:
// - 兼容某些只识别 default 的打包方案或联邦场景
// - 同时保留命名导出,建议优先使用命名导出
import { isEmbedded, toHostPath, toChildPath } from './path';
import {
getClientHostApi,
clientNavigate,
clientClose,
clientReady,
initChildBridge,
} from './client';
import { createPenpalHostBridge } from './host';
/**
* @teres/iframe-bridge
* Penpal-based utilities for host ↔ iframe communication,
* plus minimal helpers for embed detection and path conversion.
* 默认聚合导出对象:包含全部对外 API。
*/
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,
@@ -153,6 +35,7 @@ const bridge = {
clientClose,
clientReady,
createPenpalHostBridge,
initChildBridge,
};
export default bridge;

View File

@@ -0,0 +1,15 @@
/**
* 统一日志前缀,便于在控制台筛选相关输出。
*/
export const LOG_PREFIX = '[@teres/iframe-bridge]';
/**
* 简单的日志输出封装:
* - 在某些环境下 console 可能被拦截,故使用 try/catch。
*/
export function log(...args: any[]) {
try {
// eslint-disable-next-line no-console
console.log(LOG_PREFIX, ...args);
} catch {}
}

View File

@@ -0,0 +1,69 @@
/**
* 路径与嵌入状态相关的工具函数:
* - 判断是否处于 iframe 场景(需进行跨窗口通信)
* - 提供宿主/子端路径的前缀转换
*/
import { log } from './logger';
/**
* 安全获取当前窗口的 pathname避免跨域等异常导致报错。
*/
function safePathname(): string {
try {
const p = window.location?.pathname || '';
log('safePathname()', p);
return p;
} catch {
log('safePathname() error, return empty pathname');
return '';
}
}
/**
* 判断是否运行在 iframe 中。
* 如因跨域访问 window.top 抛错,则视为在 iframe 中embedded
*/
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;
}
}
/**
* 是否处于“嵌入模式”:
* - 存在 '/route-ragflow' 路径标识 或
* - 运行在 iframe 中
*/
export function isEmbedded(): boolean {
const path = safePathname();
const inIframe = isInIframe();
const embedded = path.includes('/route-ragflow') || inIframe;
log('isEmbedded()', { path, inIframe, embedded });
return embedded;
}
/**
* 将子端路由转换为宿主路由(添加宿主前缀)。
* 默认会为路径添加 '/ragflow' 前缀。
*/
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;
}
/**
* 将宿主路由转换回子端路由(去除 '/route-ragflow' 前缀)。
*/
export function toChildPath(to: string): string {
const target = to.replace(/^\/route-ragflow/, '') || '/';
log('toChildPath()', { input: to, target });
return target;
}

View File

@@ -0,0 +1,22 @@
/**
* Penpal RPC API 定义(宿主与子端共享的类型)。
*/
export interface HostApi {
/** 宿主导航到指定路径 */
navigate: (to: string) => void;
/** 宿主执行关闭或返回动作 */
close: () => void;
/** 接收子端就绪通知(可携带 agentId 等信息) */
agentReady?: (payload?: any) => void;
}
export interface ChildApi {
/** 子端主动请求宿主导航(若需要) */
navigate?: (to: string) => void;
/** 子端主动请求宿主关闭(若需要) */
close?: () => void;
/** 子端向宿主发送就绪事件(若需要) */
ready?: (payload?: any) => void;
/** 子应用语言切换(例如 'zh' | 'en' | 'zh-TRADITIONAL' */
changeLanguage?: (lng: string) => Promise<void> | void;
}