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.

402 lines (397 loc) • 11.7 kB
import { c as resolveAllowedHostOrigins, n as SOURCE, o as candidateParentOrigin, r as parseHostToModuleMessage, s as personaDashboardHostOrigins, t as PROTOCOL_VERSION } from "./protocol-CdFmFd-i.mjs"; import { Component, useEffect, useRef, useState } from "react"; import { jsx } from "react/jsx-runtime"; //#region src/bridge/rpc.ts var BridgeDestroyedError = class extends Error { constructor(message = "Bridge has been destroyed") { super(message); this.name = "BridgeDestroyedError"; } }; var BridgeTimeoutError = class extends Error { constructor(message = "Bridge request timed out") { super(message); this.name = "BridgeTimeoutError"; } }; function createRequestId() { const cryptoApi = globalThis.crypto; if (cryptoApi?.randomUUID) return cryptoApi.randomUUID(); return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; } function createPendingRequests() { const pending = /* @__PURE__ */ new Map(); return { add(timeoutMs) { const requestId = createRequestId(); return { requestId, promise: new Promise((resolve, reject) => { const timer = setTimeout(() => { pending.delete(requestId); reject(new BridgeTimeoutError()); }, timeoutMs); pending.set(requestId, { resolve, reject, timer }); }) }; }, settle(requestId, settlement) { const request = pending.get(requestId); if (!request) return false; pending.delete(requestId); clearTimeout(request.timer); if ("error" in settlement) request.reject(settlement.error); else request.resolve(settlement.result); return true; }, rejectAll(error) { for (const [requestId, request] of pending) { pending.delete(requestId); clearTimeout(request.timer); request.reject(error); } } }; } //#endregion //#region src/bridge/iframe/createModuleBridge.ts const measureDocumentHeight = (element) => { const rect = element.getBoundingClientRect(); let trailing = 0; for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) { const style = window.getComputedStyle(ancestor); trailing += parseFloat(style.paddingBottom) + parseFloat(style.borderBottomWidth) + parseFloat(style.marginBottom); } return Math.ceil(rect.top + window.scrollY + rect.height + trailing); }; function createModuleBridge({ allowedHostOrigins = personaDashboardHostOrigins, resolveTimeoutMs = 3e4, signalsLoaded = false } = {}) { const isEmbedded = typeof window !== "undefined" && window.parent !== window; const allowedOrigin = resolveAllowedHostOrigins(allowedHostOrigins); if (!isEmbedded) { const notEmbeddedError = new BridgeDestroyedError("Bridge is not embedded"); return { ready: async () => ({ data: null, context: null }), getData: () => null, getContext: () => null, subscribe: () => () => {}, reportRenderError: () => {}, resolveDataPath: async () => { throw notEmbeddedError; }, observeHeight: () => () => {}, signalLoaded: () => {}, destroy: () => {} }; } let destroyed = false; let hostOrigin; let data = null; let context = null; let readyResolved = false; let initialReadyPosted = false; let loadedRequested = false; let loadedPosted = false; const listeners = /* @__PURE__ */ new Set(); const observers = /* @__PURE__ */ new Set(); const pendingHeightObservers = /* @__PURE__ */ new Set(); const pendingRequests = createPendingRequests(); const queuedRequests = /* @__PURE__ */ new Map(); let resolveReady; let rejectReady; const readyPromise = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; }); const post = (message) => { if (!destroyed && hostOrigin) { window.parent.postMessage(message, hostOrigin); return true; } return false; }; const flushLoaded = () => { if (!destroyed && loadedRequested && !loadedPosted && hostOrigin && pendingHeightObservers.size === 0) loadedPosted = post({ source: SOURCE, protocolVersion: PROTOCOL_VERSION, type: "loaded" }); }; const flushQueuedRequests = () => { if (!hostOrigin) return; for (const [requestId, request] of queuedRequests) { post({ source: SOURCE, protocolVersion: PROTOCOL_VERSION, type: "resolve-data-path", requestId, dataPath: request.dataPath, ...request.variableMap === void 0 ? {} : { variableMap: request.variableMap } }); queuedRequests.delete(requestId); } }; const pinHostOrigin = (origin) => { if (!allowedOrigin(origin)) return false; if (hostOrigin !== void 0 && hostOrigin !== origin) return false; hostOrigin = origin; return true; }; const notify = () => { for (const listener of listeners) listener(); }; const onMessage = (event) => { if (destroyed || event.source !== window.parent) return; const message = parseHostToModuleMessage(event.data); if (!message || !pinHostOrigin(event.origin)) return; if (message.type === "init" && signalsLoaded && !initialReadyPosted) { initialReadyPosted = true; post({ source: SOURCE, protocolVersion: PROTOCOL_VERSION, type: "ready", capabilities: { loaded: true } }); } flushQueuedRequests(); flushLoaded(); switch (message.type) { case "init": data = message.data; context = message.context; notify(); if (!readyResolved) { readyResolved = true; resolveReady({ data, context }); } break; case "data": data = message.data; notify(); break; case "context": context = message.context; notify(); break; case "data-path-result": if (message.error !== void 0) pendingRequests.settle(message.requestId, { error: new Error(message.error) }); else pendingRequests.settle(message.requestId, { result: message.result ?? null }); break; } }; window.addEventListener("message", onMessage); const parentOrigin = candidateParentOrigin(); if (parentOrigin && allowedOrigin(parentOrigin)) { window.parent.postMessage({ source: SOURCE, protocolVersion: PROTOCOL_VERSION, type: "ready", ...signalsLoaded ? { capabilities: { loaded: true } } : {} }, parentOrigin); initialReadyPosted = true; } return { ready: () => readyPromise, getData: () => data, getContext: () => context, subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); }, reportRenderError(message) { post({ source: SOURCE, protocolVersion: PROTOCOL_VERSION, type: "render-error", message }); }, resolveDataPath(dataPath, variableMap) { if (destroyed) return Promise.reject(new BridgeDestroyedError()); const { requestId, promise } = pendingRequests.add(resolveTimeoutMs); queuedRequests.set(requestId, { dataPath, variableMap }); flushQueuedRequests(); return promise; }, observeHeight(element) { if (destroyed || typeof ResizeObserver === "undefined") return () => {}; let lastHeight; let frame; let disconnected = false; let observer; const postHeight = (height) => { if (disconnected || height === lastHeight) return; if (!post({ source: SOURCE, protocolVersion: PROTOCOL_VERSION, type: "set-height", height })) return; lastHeight = height; if (pendingHeightObservers.delete(observer)) flushLoaded(); }; observer = new ResizeObserver(() => { if (frame !== void 0) return; const callback = () => { frame = void 0; postHeight(measureDocumentHeight(element)); }; frame = typeof window.requestAnimationFrame === "function" ? window.requestAnimationFrame(callback) : window.setTimeout(callback, 0); }); observers.add(observer); pendingHeightObservers.add(observer); observer.observe(element); const body = element.ownerDocument?.body; if (body && body !== element) observer.observe(body); return () => { disconnected = true; if (frame !== void 0) { window.cancelAnimationFrame?.(frame); window.clearTimeout(frame); } observer.disconnect(); observers.delete(observer); if (pendingHeightObservers.delete(observer)) flushLoaded(); }; }, signalLoaded() { if (destroyed || loadedRequested) return; loadedRequested = true; flushLoaded(); }, destroy() { if (destroyed) return; destroyed = true; window.removeEventListener("message", onMessage); for (const observer of observers) observer.disconnect(); observers.clear(); pendingHeightObservers.clear(); queuedRequests.clear(); const error = new BridgeDestroyedError(); pendingRequests.rejectAll(error); if (!readyResolved) rejectReady(error); } }; } //#endregion //#region src/bridge/iframe/useRemoteModuleBridge.ts const rejectNotReady = async () => { throw new BridgeDestroyedError("Bridge is not ready"); }; function useRemoteModuleBridge(options = {}) { const optionsRef = useRef(options); const [bridge, setBridge] = useState(null); const [ready, setReady] = useState(false); const [data, setData] = useState(null); const [context, setContext] = useState(null); useEffect(() => { let active = true; const currentBridge = createModuleBridge(optionsRef.current); setBridge(currentBridge); setData(currentBridge.getData()); setContext(currentBridge.getContext()); const unsubscribe = currentBridge.subscribe(() => { if (active) { setData(currentBridge.getData()); setContext(currentBridge.getContext()); } }); currentBridge.ready().then(({ data: initialData, context: initialContext }) => { if (active) { setData(initialData); setContext(initialContext); setReady(true); } }, () => { if (active) setReady(false); }); return () => { active = false; unsubscribe(); currentBridge.destroy(); setBridge(null); setData(null); setContext(null); setReady(false); }; }, []); return { bridge, context, data, ready, resolveDataPath: bridge?.resolveDataPath ?? rejectNotReady }; } //#endregion //#region src/bridge/iframe/LayoutModuleBridge.tsx let mountedBridgeCount = 0; function BridgeErrorContent({ render }) { return render(); } var BridgeErrorBoundary = class extends Component { state = { error: null }; static getDerivedStateFromError(error) { return { error }; } componentDidCatch(error, _errorInfo) { this.props.bridge.reportRenderError(error.message); } render() { return this.state.error ? this.props.fallback : /* @__PURE__ */ jsx(BridgeErrorContent, { render: this.props.render }); } }; function LayoutModuleBridge({ allowedHostOrigins, resolveTimeoutMs, observeHeight = true, fallback = null, children }) { const { bridge, context, data, ready, resolveDataPath } = useRemoteModuleBridge({ allowedHostOrigins, resolveTimeoutMs, signalsLoaded: true }); const rootRef = useRef(null); useEffect(() => { mountedBridgeCount += 1; if (typeof process !== "undefined" && mountedBridgeCount > 1) console.warn("Multiple LayoutModuleBridge instances are mounted in the same document; this is unsupported."); return () => { mountedBridgeCount -= 1; }; }, []); useEffect(() => { if (!ready || !observeHeight || !rootRef.current || !bridge) return; return bridge.observeHeight(rootRef.current); }, [ bridge, observeHeight, ready ]); useEffect(() => { if (ready && bridge) bridge.signalLoaded(); }, [bridge, ready]); if (!ready || !bridge) return fallback; return /* @__PURE__ */ jsx(BridgeErrorBoundary, { bridge, fallback, render: () => /* @__PURE__ */ jsx("div", { ref: rootRef, style: { display: "flow-root" }, children: children({ context, data, resolveDataPath }) }) }); } //#endregion export { LayoutModuleBridge, personaDashboardHostOrigins };