257 lines
6.5 KiB
TypeScript
257 lines
6.5 KiB
TypeScript
"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 || "",
|
||
};
|
||
}
|