@discoveryjs/discovery
Version:
Frontend framework for rapid data (JSON) analysis, shareable serverless reports and dashboards
402 lines (401 loc) • 13.3 kB
JavaScript
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 });
}
};
}