@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
JavaScript
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 };