feat(iframe): add iframe bridge for ragflow integration

- Implement Penpal-based iframe communication bridge between host and child apps
- Add route handling for ragflow integration with '/route-ragflow' prefix
- Update navigation hooks to support embedded mode via iframe bridge
- Configure build and dependencies for new iframe-bridge package
- Adjust nginx config for proper SPA routing in subpath deployments
This commit is contained in:
2025-11-10 16:11:21 +08:00
parent 81fa34669a
commit a3ff72e575
17 changed files with 377 additions and 37137 deletions

View File

@@ -7,6 +7,9 @@
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -b"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
@@ -29,5 +32,8 @@
"penpal": {
"optional": true
}
},
"devDependencies": {
"typescript": "~5.9.3"
}
}

View File

@@ -0,0 +1,158 @@
/**
* @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;

View File

@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.node.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"tsBuildInfoFile": "./dist/.tsbuildinfo",
"composite": true,
"noEmit": false,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowImportingTsExtensions": false,
"declaration": true,
"declarationMap": true
},
"include": ["src"]
}