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:
@@ -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"
|
||||
}
|
||||
}
|
||||
158
packages/iframe-bridge/src/index.ts
Normal file
158
packages/iframe-bridge/src/index.ts
Normal 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;
|
||||
15
packages/iframe-bridge/tsconfig.json
Normal file
15
packages/iframe-bridge/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user