Files

234 lines
7.5 KiB
TypeScript

"use client";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTheme } from "@/components/theme/app-theme";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { resolveWebAppAppearance } from "./web-app-appearance";
import {
createWebAppStartupNonce,
createWebAppStartupProbe,
parseWebAppStartupResult,
WEB_APP_STARTUP_TIMEOUT_MS,
type WebAppStartupFailureReason,
} from "./web-app-startup";
type WebAppFrameProps = {
/** A short-lived capability URL returned by the backend. */
runtimeUrl?: string | null;
/** The host-owned accessible name. Runtime content is untrusted. */
title: string;
className?: string;
onLoad?: () => void;
onError?: (reason: WebAppStartupFailureReason) => void;
};
type FrameState = "loading" | "ready" | "unavailable";
type IframeReference = { current: HTMLIFrameElement | null };
type WebAppFrameLifecycleProps = {
runtimeUrl?: string | null;
iframeRef: IframeReference;
sendAppearance: () => void;
onLoad?: () => void;
onError?: (reason: WebAppStartupFailureReason) => void;
};
type WebAppStartupOutcome =
| { result: "ready" }
| { result: "failed"; reason: WebAppStartupFailureReason };
type StartupAttempt = {
nonce: string;
frameWindow: Window | null;
settled: boolean;
timer: ReturnType<typeof setTimeout> | null;
};
function useWebAppStartupMessageListener({
runtimeUrl,
iframeRef,
attemptRef,
finishAttempt,
}: {
runtimeUrl?: string | null;
iframeRef: IframeReference;
attemptRef: { current: StartupAttempt | null };
finishAttempt: (outcome: WebAppStartupOutcome) => void;
}) {
useLayoutEffect(() => {
if (!runtimeUrl) return;
const attempt: StartupAttempt = {
nonce: createWebAppStartupNonce(),
frameWindow: null,
settled: false,
timer: null,
};
attemptRef.current = attempt;
attempt.timer = setTimeout(
() => finishAttempt({ result: "failed", reason: "timeout" }),
WEB_APP_STARTUP_TIMEOUT_MS,
);
const handleStartupMessage = (event: MessageEvent<unknown>) => {
if (attemptRef.current !== attempt || attempt.settled) return;
const currentFrameWindow = iframeRef.current?.contentWindow;
if (
!currentFrameWindow ||
attempt.frameWindow !== currentFrameWindow ||
event.source !== currentFrameWindow
) {
return;
}
const result = parseWebAppStartupResult(event.data, attempt.nonce);
if (!result) return;
finishAttempt(
result.result === "ready" ? { result: "ready" } : { result: "failed", reason: result.code },
);
};
window.addEventListener("message", handleStartupMessage);
return () => {
window.removeEventListener("message", handleStartupMessage);
if (attempt.timer !== null) clearTimeout(attempt.timer);
attempt.timer = null;
if (attemptRef.current === attempt) attemptRef.current = null;
};
}, [finishAttempt, iframeRef, runtimeUrl]);
}
function useWebAppFrameLifecycle({
runtimeUrl,
iframeRef,
sendAppearance,
onLoad,
onError,
}: WebAppFrameLifecycleProps) {
const [frameState, setFrameState] = useState<FrameState>(runtimeUrl ? "loading" : "unavailable");
const frameReadyRef = useRef(false);
const onLoadRef = useRef(onLoad);
const onErrorRef = useRef(onError);
const sendAppearanceRef = useRef(sendAppearance);
const attemptRef = useRef<StartupAttempt | null>(null);
useEffect(() => {
onLoadRef.current = onLoad;
onErrorRef.current = onError;
sendAppearanceRef.current = sendAppearance;
}, [onError, onLoad, sendAppearance]);
useLayoutEffect(() => {
frameReadyRef.current = false;
setFrameState(runtimeUrl ? "loading" : "unavailable");
attemptRef.current = null;
}, [runtimeUrl]);
useEffect(() => {
if (frameReadyRef.current) sendAppearance();
}, [sendAppearance]);
const finishAttempt = useCallback((outcome: WebAppStartupOutcome) => {
const attempt = attemptRef.current;
if (!attempt || attempt.settled) return;
attempt.settled = true;
if (attempt.timer !== null) {
clearTimeout(attempt.timer);
attempt.timer = null;
}
if (outcome.result === "failed") {
frameReadyRef.current = false;
setFrameState("unavailable");
onErrorRef.current?.(outcome.reason);
return;
}
frameReadyRef.current = true;
sendAppearanceRef.current();
window.requestAnimationFrame(() => {
if (attemptRef.current !== attempt) return;
setFrameState("ready");
onLoadRef.current?.();
});
}, []);
useWebAppStartupMessageListener({ runtimeUrl, iframeRef, attemptRef, finishAttempt });
const handleLoad = useCallback(() => {
const attempt = attemptRef.current;
const frameWindow = iframeRef.current?.contentWindow;
if (!attempt || attempt.settled || !frameWindow) return;
attempt.frameWindow = frameWindow;
sendAppearanceRef.current();
frameWindow.postMessage(createWebAppStartupProbe(attempt.nonce), "*");
}, [iframeRef]);
return { frameState, handleLoad };
}
/**
* Hosts a static plugin web application without bringing it into the SPA
* process. The sandbox preserves same-origin requests while omitting top
* navigation, popups, downloads, and a privileged host bridge.
*/
export function WebAppFrame({ runtimeUrl, title, className, onLoad, onError }: WebAppFrameProps) {
const { isMobile } = useResponsiveBreakpoint();
const { t } = useTranslation();
const { resolvedTheme } = useTheme();
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const sendAppearance = useCallback(() => {
const target = iframeRef.current?.contentWindow;
if (!target) return;
target.postMessage(resolveWebAppAppearance(document, resolvedTheme), "*");
}, [resolvedTheme]);
const { frameState, handleLoad } = useWebAppFrameLifecycle({
runtimeUrl,
iframeRef,
sendAppearance,
onLoad,
onError,
});
return (
<div
data-testid="web-app-frame"
data-frame-state={frameState}
data-mobile={isMobile ? "true" : "false"}
className={cn(
"relative flex min-h-0 min-w-0 flex-1 overflow-hidden",
isMobile && "pb-[env(safe-area-inset-bottom)]",
className,
)}
aria-busy={frameState === "loading"}
>
{runtimeUrl && frameState !== "unavailable" ? (
// No onError here: React never attaches a DOM "error" listener for iframe/object/embed
// (it only listens for "load"), so a document-load failure is unobservable from the
// frame element itself. It surfaces only through the guest's own postMessage handshake
// (the "document_error" reason) or the startup timeout.
<iframe
key={runtimeUrl}
title={title}
src={runtimeUrl}
ref={iframeRef}
sandbox="allow-scripts allow-forms allow-same-origin"
referrerPolicy="no-referrer"
loading="eager"
className="block h-full min-h-0 w-full min-w-0 flex-1 border-0"
onLoad={handleLoad}
/>
) : null}
{frameState !== "ready" && (
<div
role={frameState === "unavailable" ? "alert" : "status"}
aria-live="polite"
className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background/80 p-4 text-center text-sm text-muted-foreground"
>
{t(frameState === "unavailable" ? "plugins:webAppUnavailable" : "plugins:webAppLoading")}
</div>
)}
</div>
);
}