Files
lotteryFront/src/components/iframe-bridge.tsx
2026-07-21 23:41:18 +08:00

257 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useCallback, type ReactNode } from "react";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { setPlayerBearerToken } from "@/lib/lottery-auth";
import {
loadIframeAllowedOrigins,
messageToken,
resolvePostMessageTargetOrigin,
resolveTrustedParentMessage,
} from "@/lib/iframe-origins";
function sanitizeUrlForParent(href: string): string {
try {
const url = new URL(href);
url.searchParams.delete("token");
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return href;
}
}
function resolveSafeInAppPath(path: string): string | null {
if (!path.startsWith("/") || path.startsWith("//")) {
return null;
}
try {
const url = new URL(path, window.location.origin);
if (url.origin !== window.location.origin) {
return null;
}
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return null;
}
}
/**
* iframe 通信桥接组件
*
* 功能:
* 1. 监听父窗口(主站)通过 postMessage 发送的 Token
* 2. 向父窗口发送心跳和状态通知
* 3. 支持在主站 iframe 内嵌入时的双向通信
*/
export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
const setBearerToken = usePlayerSessionStore((state) => state.setBearerToken);
/**
* 向父窗口发送消息
*/
const sendToParent = useCallback(
(type: string, payload?: Record<string, unknown>): void => {
if (typeof window === "undefined" || window.parent === window) return;
const targetOrigin = resolvePostMessageTargetOrigin();
if (!targetOrigin) return;
window.parent.postMessage(
{
type: `LOTTERY_${type}`,
payload,
timestamp: Date.now(),
source: "lottery-iframe",
},
targetOrigin,
);
},
[],
);
/**
* 通知父窗口:已准备就绪
*/
const notifyReady = useCallback((): void => {
sendToParent("READY", {
url: sanitizeUrlForParent(window.location.href),
userAgent: navigator.userAgent,
});
}, [sendToParent]);
/**
* 通知父窗口:需要新 Token
*/
const notifyTokenNeeded = useCallback((): void => {
sendToParent("TOKEN_NEEDED", {
reason: "token_expired",
});
}, [sendToParent]);
/**
* 通知父窗口Token 刷新成功
*/
const notifyTokenRefreshed = useCallback((): void => {
sendToParent("TOKEN_REFRESHED");
}, [sendToParent]);
/**
* 通知父窗口:发生错误
*/
const notifyError = useCallback(
(error: string): void => {
sendToParent("ERROR", { error });
},
[sendToParent],
);
/**
* 监听父窗口消息
*/
useEffect(() => {
if (typeof window === "undefined") return;
// 检查是否在 iframe 内
const isInIframe = window.self !== window.top;
if (!isInIframe) {
console.log("[IframeBridge] Not in iframe, skipping bridge setup");
return;
}
console.log("[IframeBridge] Setting up iframe communication");
const handleMessage = async (event: MessageEvent): Promise<void> => {
const data = await resolveTrustedParentMessage(event);
if (data === null) {
console.warn("[IframeBridge] Rejected untrusted parent message from:", event.origin);
return;
}
console.log("[IframeBridge] Received message:", data.type);
switch (data.type) {
// 主站发送初始化 Token
case "MAIN_INIT_TOKEN": {
const token = messageToken(data);
if (token !== null) {
console.log("[IframeBridge] Received initial token");
setBearerToken(token);
setPlayerBearerToken(token);
// 勿再 notifyReady(),否则主站会重复 MAIN_INIT_TOKEN 导致消息刷屏
}
break;
}
// 主站刷新 Token
case "MAIN_REFRESH_TOKEN": {
const token = messageToken(data);
if (token !== null) {
console.log("[IframeBridge] Received refreshed token");
setBearerToken(token);
setPlayerBearerToken(token);
notifyTokenRefreshed();
}
break;
}
// 主站通知 Token 即将过期
case "MAIN_TOKEN_EXPIRING":
console.log("[IframeBridge] Token expiring soon");
// 可以显示提示或自动刷新
break;
// 主站请求当前状态
case "MAIN_REQUEST_STATUS":
sendToParent("STATUS_RESPONSE", {
isReady: true,
currentPath: window.location.pathname,
});
break;
// 主站导航请求
case "MAIN_NAVIGATE":
if (typeof data.path === "string") {
const nextPath = resolveSafeInAppPath(data.path);
if (nextPath) {
window.history.pushState({}, "", nextPath);
}
}
break;
default:
break;
}
};
window.addEventListener("message", handleMessage);
// 先加载后台白名单,再发送 READY避免父站立即回 Token 时被本端误拒。
void loadIframeAllowedOrigins().finally(() => {
notifyReady();
});
const originsRefresh = setInterval(() => {
void loadIframeAllowedOrigins(true);
}, 60_000);
// 定期发送心跳
const heartbeat = setInterval(() => {
sendToParent("HEARTBEAT", {
timestamp: Date.now(),
});
}, 30000); // 每 30 秒
return () => {
window.removeEventListener("message", handleMessage);
clearInterval(originsRefresh);
clearInterval(heartbeat);
};
}, [notifyReady, notifyTokenRefreshed, sendToParent, setBearerToken]);
// 暴露全局方法供调试
useEffect(() => {
if (typeof window === "undefined") return;
(window as unknown as Record<string, unknown>).lotteryIframeBridge = {
notifyReady,
notifyTokenNeeded,
notifyError,
sendToParent,
};
}, [notifyError, notifyReady, notifyTokenNeeded, sendToParent]);
return children;
}
/**
* 检测当前是否在 iframe 内
*/
export function isInIframe(): boolean {
if (typeof window === "undefined") return false;
try {
return window.self !== window.top;
} catch {
return true; // 跨域时无法访问 window.top说明在 iframe 内
}
}
/**
* 获取父窗口信息
*/
export function getParentInfo(): {
isInIframe: boolean;
referrer: string;
} {
if (typeof window === "undefined") {
return { isInIframe: false, referrer: "" };
}
return {
isInIframe: isInIframe(),
referrer: document.referrer || "",
};
}