UNPKG

@persona/layout-editor-remote-module

Version:

Shared manifest format, Vite plugin, and host/iframe bridge for remotely-defined dashboard layout modules in Persona dashboard apps.

294 lines (290 loc) • 8.87 kB
import { a as assertNotSelfOrigin, i as parseModuleToHostMessage, n as SOURCE, t as PROTOCOL_VERSION } from "./protocol-CdFmFd-i.mjs"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { jsx, jsxs } from "react/jsx-runtime"; //#region src/bridge/host/buildModuleSrc.ts const reservedParams = new Set([ "access_token", "environment_id", "locale" ]); const credentialParamPattern = /token|secret|password|authorization|api[_-]?key/i; function buildModuleSrc(documentUrl, options = {}) { const url = new URL(documentUrl, "http://remote-module.invalid"); for (const [key, value] of Object.entries(options.extraParams ?? {})) { if (reservedParams.has(key) || credentialParamPattern.test(key)) throw new Error(`extraParams contains a reserved or credential-like key: ${key}`); url.searchParams.set(key, value); } if (options.accessToken !== void 0) url.searchParams.set("access_token", options.accessToken); if (options.environmentId !== void 0) url.searchParams.set("environment_id", options.environmentId); if (options.locale !== void 0) url.searchParams.set("locale", options.locale); if (/^[a-z][a-z\d+.-]*:\/\//i.test(documentUrl)) return url.toString(); return `${url.pathname}${url.search}${url.hash}`; } //#endregion //#region src/bridge/host/createHostBridge.ts function createHostBridge({ iframe, moduleOrigin, getData, getContext, resolveDataPath, onHeight, onRenderError, onLoaded, initRetryMs = 250, initRetryTimeoutMs = 1e4 }) { assertNotSelfOrigin(moduleOrigin); if (moduleOrigin === "*") throw new Error("Remote module origin must be explicit"); let destroyed = false; let initialized = false; let capable; let loaded = false; let fallbackTimer; let fallbackTimerStarted = false; let initRetryTimer; const clearInitRetry = () => { if (initRetryTimer !== void 0) { clearInterval(initRetryTimer); initRetryTimer = void 0; } }; const clearFallbackTimer = () => { if (fallbackTimer !== void 0) { clearTimeout(fallbackTimer); fallbackTimer = void 0; } }; const markLoaded = () => { if (loaded) return; loaded = true; clearFallbackTimer(); onLoaded?.(); }; const post = (message) => { if (!destroyed) iframe.contentWindow?.postMessage(message, moduleOrigin); }; const sendInit = () => { initialized = true; post({ source: SOURCE, protocolVersion: PROTOCOL_VERSION, type: "init", data: getData(), context: getContext() }); }; const startInitRetry = () => { clearInitRetry(); sendInit(); if (initRetryMs <= 0 || initRetryTimeoutMs <= 0) return; const deadline = Date.now() + initRetryTimeoutMs; initRetryTimer = setInterval(() => { if (Date.now() >= deadline) { clearInitRetry(); return; } sendInit(); }, initRetryMs); }; const sendData = (data, requestId) => { post({ source: SOURCE, protocolVersion: PROTOCOL_VERSION, type: "data", ...requestId === void 0 ? {} : { requestId }, data }); }; const onLoad = () => { if (!destroyed) { if (!fallbackTimerStarted) { fallbackTimerStarted = true; fallbackTimer = setTimeout(() => { fallbackTimer = void 0; if (capable === void 0) markLoaded(); }, Math.max(initRetryTimeoutMs, 0) + 500); } startInitRetry(); } }; const onMessage = (event) => { if (destroyed || event.origin !== moduleOrigin || event.source !== iframe.contentWindow) return; const message = parseModuleToHostMessage(event.data); if (!message) return; clearInitRetry(); if (message.type === "ready") { if (capable === void 0) { capable = message.capabilities?.loaded === true; if (!capable) markLoaded(); } } else if (message.type !== "loaded" && message.type !== "render-error" && capable === void 0) { capable = false; markLoaded(); } switch (message.type) { case "ready": sendInit(); break; case "loaded": markLoaded(); break; case "request-data": sendData(getData(), message.requestId); break; case "set-height": onHeight?.(message.height); break; case "render-error": markLoaded(); onRenderError?.(message.message); break; case "resolve-data-path": Promise.resolve().then(() => resolveDataPath(message.dataPath, message.variableMap)).then((result) => { post({ source: SOURCE, protocolVersion: PROTOCOL_VERSION, type: "data-path-result", requestId: message.requestId, result: result ?? null }); }, (error) => { post({ source: SOURCE, protocolVersion: PROTOCOL_VERSION, type: "data-path-result", requestId: message.requestId, error: error instanceof Error ? error.message : String(error) }); }); break; } }; window.addEventListener("message", onMessage); iframe.addEventListener("load", onLoad); return { pushData(data) { if (!destroyed && initialized) sendData(data); }, pushContext(context) { if (!destroyed && initialized) post({ source: SOURCE, protocolVersion: PROTOCOL_VERSION, type: "context", context }); }, destroy() { if (destroyed) return; destroyed = true; clearInitRetry(); clearFallbackTimer(); window.removeEventListener("message", onMessage); iframe.removeEventListener("load", onLoad); } }; } //#endregion //#region src/bridge/host/RemoteModuleFrame.tsx function withDynamicHeight(context, dynamicHeight) { return { ...context, dynamicHeight }; } function RemoteModuleFrame({ documentUrl, moduleOrigin, accessToken, environmentId, locale, extraParams, title, sandbox, allow, referrerPolicy = "no-referrer", initRetryMs, initRetryTimeoutMs, dynamicHeight = false, heightOffset = 0, minHeight, className, style, iframeRef: externalIframeRef, data, context, resolveDataPath, onRenderError, loadingFallback }) { const iframeRef = useRef(null); const dataRef = useRef(data); const contextRef = useRef(withDynamicHeight(context, dynamicHeight)); const onRenderErrorRef = useRef(onRenderError); const resolveDataPathRef = useRef(resolveDataPath); const bridgeRef = useRef(void 0); const externalCleanupRef = useRef(void 0); const [height, setHeight] = useState(); const [loadedSrc, setLoadedSrc] = useState(); const setRefs = useCallback((node) => { iframeRef.current = node; if (typeof externalIframeRef === "function") if (node) { const cleanup = externalIframeRef(node); externalCleanupRef.current = typeof cleanup === "function" ? cleanup : void 0; } else if (externalCleanupRef.current) { const cleanup = externalCleanupRef.current; externalCleanupRef.current = void 0; cleanup(); } else externalIframeRef(null); else if (externalIframeRef) externalIframeRef.current = node; }, [externalIframeRef]); const hostContext = useMemo(() => withDynamicHeight(context, dynamicHeight), [context, dynamicHeight]); dataRef.current = data; contextRef.current = hostContext; onRenderErrorRef.current = onRenderError; resolveDataPathRef.current = resolveDataPath; const src = useMemo(() => buildModuleSrc(documentUrl, { accessToken, environmentId, locale, extraParams }), [ accessToken, documentUrl, environmentId, extraParams, locale ]); useEffect(() => { const iframe$1 = iframeRef.current; if (!iframe$1) return; const bridge = createHostBridge({ iframe: iframe$1, moduleOrigin, getData: () => dataRef.current, getContext: () => contextRef.current, resolveDataPath: (...args) => resolveDataPathRef.current(...args), initRetryMs, initRetryTimeoutMs, onHeight: (nextHeight) => { setHeight(nextHeight); }, onRenderError: (message) => onRenderErrorRef.current?.(message), onLoaded: () => setLoadedSrc(src) }); bridgeRef.current = bridge; return () => { bridge.destroy(); bridgeRef.current = void 0; }; }, [ initRetryMs, initRetryTimeoutMs, moduleOrigin, src ]); useEffect(() => { bridgeRef.current?.pushData(data); }, [data]); useEffect(() => { bridgeRef.current?.pushContext(hostContext); }, [hostContext]); const computedHeight = dynamicHeight && height !== void 0 ? `${Math.max(height + heightOffset, minHeight ?? 0)}px` : minHeight === void 0 ? "100%" : `${minHeight}px`; const iframe = /* @__PURE__ */ jsx("iframe", { ref: setRefs, src, title, sandbox, allow, referrerPolicy, className, style: { ...style, height: computedHeight, display: style?.display ?? "block", width: "100%", border: "none" } }, src); return /* @__PURE__ */ jsxs("div", { style: { position: "relative", width: "100%", height: computedHeight === "100%" ? "100%" : void 0 }, children: [iframe, loadingFallback !== void 0 && !(loadedSrc === src) && /* @__PURE__ */ jsx("div", { style: { position: "absolute", inset: 0 }, children: loadingFallback })] }); } //#endregion export { RemoteModuleFrame };