UNPKG

@discoveryjs/discovery

Version:

Frontend framework for rapid data (JSON) analysis, shareable serverless reports and dashboards

402 lines (401 loc) 13.3 kB
import { Emitter } from "../core/emitter.js"; import { Observer } from "../core/observer.js"; import { randomId } from "../core/utils/id.js"; import { extractResourceMetadata, getReadableStreamFromSource } from "../core/utils/load-data.js"; import { loadStages, decodeStageProgress } from "../core/utils/progressbar.js"; import { createLocationSync } from "../core/utils/location-sync.js"; ; ; const logPrefix = "[Discovery/embed-host]"; const noop = () => { }; const isStreamTransferable = (() => { try { const stream = new ReadableStream(); new MessageChannel().port1.postMessage(stream, [stream]); return true; } catch { return false; } })(); class BaseApp extends Emitter { window; id; actions; dataLoadToken; constructor(window, id, actions) { super(); this.window = window; this.id = id; this.actions = actions; this.dataLoadToken = null; } sendMessage(type, payload, transfer) { const message = { id: this.id, from: "discoveryjs-app", type, payload: payload || null }; this.window.postMessage(message, "*", transfer); } destroy() { this.destroy = noop; this.emit("destroy"); this.dataLoadToken = null; this.window = null; this.sendMessage = noop; } } class EmbedPreinitApp extends BaseApp { publicApi; static createPublicApi(app) { return Object.freeze({ // FIXME: TS should infer types for on/once/off, however it doesn't // and produces `any` instead. Used `as EmbedPreinitApp[method]` as a workaround. on: app.on.bind(app), once: app.once.bind(app), off: app.off.bind(app), defineAction(name, fn) { app.actions.set(name, fn); app.sendMessage("defineAction", name); }, setPageHash(hash, replace = false) { app.sendMessage("setPageHash", { hash, replace }); }, setRouterPreventLocationUpdate(allow = true) { app.sendMessage("setRouterPreventLocationUpdate", allow); } }); } constructor(window, id, actions) { super(window, id, actions); this.publicApi = EmbedPreinitApp.createPublicApi(this); } processMessage(message) { switch (message.type) { case "loadingState": { this.emit("loadingStateChanged", message.payload); break; } } } } class EmbedApp extends BaseApp { commandMap; dataLoadToken; pageHash; pageId; pageRef; pageParams; pageAnchor; locationSync; colorScheme; publicApi; static createPublicApi(app) { const nav = { primary: createNavSection("primary", app.sendMessage.bind(app), app.commandMap), secondary: createNavSection("secondary", app.sendMessage.bind(app), app.commandMap), menu: createNavSection("menu", app.sendMessage.bind(app), app.commandMap) }; return Object.freeze({ pageHash: app.pageHash.readonly, pageId: app.pageId.readonly, pageRef: app.pageRef.readonly, pageAnchor: app.pageAnchor.readonly, pageParams: app.pageParams.readonly, colorScheme: app.colorScheme.readonly, // FIXME: TS should infer types for on/once/off, however it doesn't // and produces `any` instead. Used `as EmbedApp[method]` as a workaround. on: app.on.bind(app), once: app.once.bind(app), off: app.off.bind(app), nav: Object.assign(nav.secondary, nav), notify(name, details) { app.sendMessage("notification", { name, details }); }, defineAction(name, fn) { app.actions.set(name, fn); app.sendMessage("defineAction", name); }, setPageHash(hash, replace = false) { app.sendMessage("setPageHash", { hash, replace }); }, setPageHashState(pageState, replace = false) { app.sendMessage("setPageHashState", { ...pageState, replace }); }, setPageHashStateWithAnchor(pageStateWithAnchor, replace = false) { app.sendMessage("setPageHashStateWithAnchor", { ...pageStateWithAnchor, replace }); }, setPage(id, ref, params, replace = false) { app.sendMessage("setPage", { id, ref, params, replace }); }, setPageRef(ref, replace = false) { app.sendMessage("setPageRef", { ref, replace }); }, setPageParams(params, replace = false) { app.sendMessage("setPageParams", { params, replace }); }, setPageAnchor(anchor, replace = false) { app.sendMessage("setPageAnchor", { anchor, replace }); }, setColorSchemeState(value) { app.sendMessage("setColorSchemeState", value); }, setRouterPreventLocationUpdate(allow = true) { app.sendMessage("setRouterPreventLocationUpdate", allow); }, setLocationSync(enabled = true) { if (enabled && !app.locationSync) { app.locationSync = createLocationSync((hash) => app.publicApi.setPageHash(hash)); app.on("pageHashChanged", app.locationSync.set); } else if (!enabled && app.locationSync) { app.off("pageHashChanged", app.locationSync.set); app.locationSync.dispose(); app.locationSync = null; } }, unloadData() { app.sendMessage("unloadData", null); }, async uploadData(source, getResourceMetadataFromSource) { const dataLoadToken = randomId(); app.dataLoadToken = dataLoadToken; try { return await uploadData(app, source, getResourceMetadataFromSource); } finally { if (app.dataLoadToken === dataLoadToken) { app.dataLoadToken = null; } } } }); } constructor(window, id, actions) { super(window, id, actions); this.commandMap = /* @__PURE__ */ new Map(); this.dataLoadToken = null; this.pageHash = new Observer("#"); this.pageId = new Observer(""); this.pageRef = new Observer(null); this.pageParams = new Observer({}); this.pageAnchor = new Observer(null); this.locationSync = null; this.colorScheme = new Observer( { state: "unknown", value: "unknown" }, (prev, next) => prev.state !== next.state || prev.value !== next.value ); this.publicApi = EmbedApp.createPublicApi(this); } async processMessage(message) { switch (message.type) { case "destroy": { this.destroy(); break; } case "action": { const { callId, name, args } = message.payload; const fn = this.actions.get(name); if (typeof fn === "function") { try { this.sendMessage("actionResult", { callId, value: await fn(...args) }); } catch (error) { this.sendMessage("actionResult", { callId, error }); } } else { console.warn(`${logPrefix} Action "${name}" was not found`); } break; } case "navMethod": { const fn = this.commandMap.get(message.payload); if (typeof fn === "function") { fn(); } else { console.warn(`${logPrefix} Nav command "${message.payload}" was not found`); } break; } case "pageHashChanged": { const { replace, hash, id, ref, params, anchor } = message.payload || {}; const hash_ = String(hash).startsWith("#") ? hash : "#" + hash; this.pageHash.set(hash_); this.pageId.set(id); this.pageRef.set(ref); this.pageParams.set(params); this.pageAnchor.set(anchor); this.emit("pageHashChanged", hash_, replace); break; } case "colorSchemeChanged": { const value = message.payload; this.colorScheme.set(value); this.emit("colorSchemeChanged", value); break; } case "unloadData": { this.emit("unloadData"); break; } case "data": { this.emit("data"); break; } case "loadingState": { this.emit("loadingStateChanged", message.payload); break; } default: console.error(`${logPrefix} Unknown embed message type "${message.type}"`); } } destroy() { if (this.locationSync) { this.locationSync.dispose(); this.locationSync = null; } super.destroy(); } } export { loadStages, decodeStageProgress }; export function connectToEmbedApp(iframe, onPreinit, onConnect) { const actions = Object.assign(/* @__PURE__ */ new Map(), { id: "" }); let embedApp = null; let onDisconnect = void 0; const callbacks = typeof onPreinit === "function" && typeof onConnect !== "function" ? { onPreinit: void 0, onConnect: onPreinit } : { onPreinit, onConnect }; addEventListener("message", handleIncomingMessages); return () => { removeEventListener("message", handleIncomingMessages); resetIfNeeded(); }; function resetIfNeeded() { if (embedApp !== null) { embedApp.destroy(); if (typeof onDisconnect === "function") { onDisconnect(); } embedApp = null; onDisconnect = void 0; } } async function handleIncomingMessages(e) { const data = e.data || {}; if (e.isTrusted && (e.source === iframe.contentWindow || e.source === null) && data.from === "discoveryjs-app") { if (data.type === "ready") { resetIfNeeded(); if (actions.id !== data.id) { actions.clear(); actions.id = data.id; } const { colorScheme, page: pageHashState } = data.payload; embedApp = new EmbedApp(iframe.contentWindow, data.id, actions); embedApp.pageHash.set(pageHashState.hash); embedApp.pageId.set(pageHashState.id); embedApp.pageRef.set(pageHashState.ref); embedApp.pageParams.set(pageHashState.params); embedApp.pageAnchor.set(pageHashState.anchor); embedApp.colorScheme.set(colorScheme); embedApp.once("destroy", resetIfNeeded); onDisconnect = callbacks.onConnect(embedApp.publicApi); return; } if (data.type === "preinit") { resetIfNeeded(); if (typeof callbacks.onPreinit === "function") { if (actions.id !== data.id) { actions.clear(); actions.id = data.id; } embedApp = new EmbedPreinitApp(iframe.contentWindow, data.id, actions); embedApp.once("destroy", resetIfNeeded); onDisconnect = callbacks.onPreinit(embedApp.publicApi); } return; } if (embedApp?.id === data.id) { embedApp.processMessage(data); return; } } } } async function uploadData(app, data, getResourceMetadataFromSource = extractResourceMetadata) { const acceptToken = app.dataLoadToken; const maybeAbort = () => { if (app?.dataLoadToken !== acceptToken) { throw new Error("Data upload aborted"); } }; if (!acceptToken) { throw new Error("No acceptToken specified"); } const source = typeof data === "function" ? await data() : await data; maybeAbort(); const resource = typeof getResourceMetadataFromSource === "function" ? getResourceMetadataFromSource(source) || {} : {}; const stream = getReadableStreamFromSource(source); if (isStreamTransferable) { app.sendMessage("dataStream", { stream, resource }, [stream]); } else { const reader = stream.getReader(); app.sendMessage("startChunkedDataUpload", { acceptToken, resource }); try { while (true) { const { value, done } = await reader.read(); maybeAbort(); app.sendMessage( "dataChunk", { acceptToken, value, done }, typeof value !== "string" && value?.buffer ? [value.buffer] : void 0 ); if (done) { break; } } } catch (error) { app.sendMessage("cancelChunkedDataUpload", { acceptToken, error }); throw error; } finally { reader.releaseLock(); } } } function createNavSection(section, sendMessage, commandMap) { function prepareConfig(config) { const commands = []; return { commands, config: JSON.parse(JSON.stringify(config, (key, value) => { if (typeof value === "function") { const id = "nav-command-" + randomId(); commands.push(id); commandMap.set(id, value); return id; } return value; })) }; } return { insert(config, position, name) { sendMessage("changeNavButtons", { section, action: "insert", name, position, ...prepareConfig(config) }); }, prepend(config) { sendMessage("changeNavButtons", { section, action: "prepend", ...prepareConfig(config) }); }, append(config) { sendMessage("changeNavButtons", { section, action: "append", ...prepareConfig(config) }); }, before(name, config) { sendMessage("changeNavButtons", { section, action: "before", name, ...prepareConfig(config) }); }, after(name, config) { sendMessage("changeNavButtons", { section, action: "after", name, ...prepareConfig(config) }); }, replace(name, config) { sendMessage("changeNavButtons", { section, action: "replace", name, ...prepareConfig(config) }); }, remove(name) { sendMessage("changeNavButtons", { section, action: "remove", name }); } }; }