UNPKG

forma-embedded-view-sdk

Version:

The Forma Embedded View SDK is a JavaScript library for creating custom extensions in Autodesk Forma (previously Spacemaker).

550 lines (549 loc) 19.7 kB
/** * Structured error that can be used for errors on requests, * such as data validation errors. * * A handler can throw this error and it will properly be serialized * as part of the reply and deserialized on the other side. This allows * to return errors with additional context as a low-level primitive. */ export class RequestError extends Error { type; data; constructor(message, type, data, options) { super(message, { cause: options?.cause, }); this.type = type; this.data = data; this.name = "RequestError"; } } function parseErrorPayload(payload) { if (payload instanceof Error) { return payload; } if (payload != null && typeof payload === "object" && "message" in payload && typeof payload.message === "string" && "type" in payload && typeof payload.type === "string" && "data" in payload) { return new RequestError(payload.message, payload.type, payload.data); } // We don't expect this to happen, as we should either // be returning actual Error objects or our custom structure. console.error("Unknown error", payload); throw new Error("Unknown error occurred. See console"); } function toErrorPayload(error) { if (error instanceof RequestError) { return { message: error.message, type: error.type, data: error.data, }; } return error; } const messageTypeRequest = "IFRAME-MESSAGE-REQUEST"; const messageTypeResponse = "IFRAME-MESSAGE-RESPONSE"; const messageTypeEvent = "IFRAME-MESSAGE-EVENT"; const connectEventAction = "_connect"; const disconnectEventAction = "_disconnect"; const createSubscriptionRequestAction = "_createSubscription"; const removeSubscriptionRequestAction = "_removeSubscription"; const subscriptionEventAction = "_subscriptionEvent"; /** Check if the message data received is a request. */ function isRequest(data) { return (typeof data === "object" && data != null && "id" in data && "action" in data && "type" in data && data.type === messageTypeRequest); } /** Check if the message data received is a response. */ function isResponse(data) { return (typeof data === "object" && data != null && "id" in data && "type" in data && data.type == messageTypeResponse); } /** Check if the message data received is an event. */ function isEvent(data) { return (typeof data === "object" && data != null && "action" in data && "type" in data && data.type === messageTypeEvent); } var State; (function (State) { /** While initialized it will also respond to connect events. */ State["INITIALIZED"] = "initialized"; /** The connecting state means we have sent a connect message. */ State["CONNECTING"] = "connecting"; State["ESTABLISHED"] = "established"; State["DISCONNECTED"] = "disconnected"; })(State || (State = {})); class PubSub { #delegate = new EventTarget(); subscribe(type, handler) { const internalHandler = (_event) => { const event = _event; handler(event.detail); }; this.#delegate.addEventListener(type, internalHandler); return () => { this.#delegate.removeEventListener(type, internalHandler); }; } publish(type, data) { this.#delegate.dispatchEvent(new CustomEvent(type, { detail: data, })); } } /** * IframeMessenger handles communication in/out of an iframe. * * It creates an abstraction on top of the Window.postMessage() browser * API to make it more convenient to handle states, message passing * and routing messages to handlers. * * Functionality provided: * * - Sending requests and waiting for the response for it. Think of it * as a way of doing async functions across browser windows. Since the * cross-window communcation is async itself, there is no support for * synchronous requests. * * - Sending events to the other window. Events are one-way messages that * do not expect a reply. Use cases for this is rare, and in most * cases solved by subscriptions instead. * * - Subscriptions. This is a way to subscribe to specific events from the * other window. The window that exposes a subscription source will contain * a handler that can emit events for it when the subscription is created. * This allows for lazy event streams that are only created when needed. * * Internally subscriptions are built on top of both requests (creating and * deleting subscriptions) and events mentioned above. * * Subscriptions are unsubscribed automatically on disconnect. * * - Connected callbacks. This allows to add logic that should happen * as soon as the connection between the windows are established. * * By default messages are queued until the connection is established, * so the user does not have to be concerned about connection details. * * When an iframe is removed from DOM, the connection should be explicitly * disconnected by calling disconnect() to trigger proper cleanup. * * @hidden * @internal */ export class IframeMessenger { #state = State.INITIALIZED; // eslint-disable-next-line @typescript-eslint/no-explicit-any #subscriptionEventHandlers = new Map(); #unsubscribeHandlers = new Map(); debug; #internalEvents = new PubSub(); source; sourceOrigin; incomingMessageInterceptorResolver; outgoingMessageInterceptorResolver; requestResolver; eventResolver; subscribeResolver; constructor(options) { this.source = options.source; this.sourceOrigin = options.sourceOrigin; this.requestResolver = options.requestResolver; this.eventResolver = options.eventResolver; this.subscribeResolver = options.subscribeResolver; this.debug = options.debug ?? false; this.incomingMessageInterceptorResolver = options.incomingMessageInterceptorResolver; this.outgoingMessageInterceptorResolver = options.outgoingMessageInterceptorResolver; this.#internalEvents.subscribe("state", ({ state }) => { if (state === State.DISCONNECTED) { this.#unsubscribeAllHandlers(); } }); window.addEventListener("message", this.#messageHandler); } connect() { if (this.#state === State.INITIALIZED || this.#state === State.DISCONNECTED) { this.#sendEventInternal(connectEventAction, null, false).catch((e) => { console.error("Sending connect action failed", e); }); this.#setState(State.CONNECTING); } } disconnect() { this.#sendEventInternal(disconnectEventAction, null, false).catch((e) => { console.error("Sending disconnect action failed", e); }); this.#setState(State.DISCONNECTED); } /** * Subscribe to state transitions for being connected and * from being connected to disconnected. * * This essentially acts as a boolean flag if it is connected * or not. */ onStateChange(handler) { return this.#internalEvents.subscribe("state", ({ state, prevState }) => { if (state === State.ESTABLISHED && prevState !== State.ESTABLISHED) { handler("connected"); } else if (state !== State.ESTABLISHED && prevState === State.ESTABLISHED) { handler("disconnected"); } }); } #setState(state) { const prevState = this.#state; this.#state = state; this.#internalEvents.publish("state", { state, prevState, }); } get isConnected() { return this.#state === State.ESTABLISHED; } /** * Wait for the state to be connected. * * If a disconnect (e.g. due to unmount) is requested before being * connected, the promise will be rejected to ensure the promise * is fulfilled. */ connectedPromise() { if (this.isConnected) { return Promise.resolve(); } if (this.debug) { console.log("connectedPromise waiting for connection..."); } return new Promise((resolve, reject) => { const unsubscribe = this.#internalEvents.subscribe("state", ({ state }) => { switch (state) { case State.DISCONNECTED: unsubscribe(); reject(new Error("Disconnected")); break; case State.ESTABLISHED: unsubscribe(); resolve(); break; } }); }); } #processIncomingMessage = (message) => { const interceptor = this.incomingMessageInterceptorResolver?.(); if (!interceptor) { return message; } const result = interceptor(message); if (this.debug) { console.log("Resulting data after message interceptor", result); } return result; }; #processOutgoingMessage = (message, transfer, request) => { const interceptor = this.outgoingMessageInterceptorResolver?.(); if (!interceptor) { return message; } // Make sure we can safely mutate the message in the interceptor. // For incoming messages we don't need to do this as the message // has already been structured cloned by the browser. const messageCopy = structuredClone(message, { transfer: transfer ?? [], }); const result = interceptor(messageCopy, { request, }); if (this.debug) { console.log("Resulting data after message interceptor", result); } return result; }; #messageHandler = (message) => { // This method will be called on all "message" events. // We need to filter only those we want to handle. const source = message.source; if (source == null || source !== this.source || (this.sourceOrigin != null && message.origin !== this.sourceOrigin)) { return; } if (this.debug) { console.log(`Message from origin ${message.origin}:`, message.data); } const data = this.#processIncomingMessage(message.data); if (isEvent(data)) { this.#handleEvent(data); } if (isRequest(data)) { this.#handleRequest(data, message, source); } }; #handleConnectAction(payload) { if (this.#state !== State.INITIALIZED && this.#state !== State.CONNECTING) { return; } if (!payload?.ack) { const response = { ack: true, }; this.#sendEventInternal(connectEventAction, response, false).catch((e) => { console.error("Sending event failed", e); }); } this.#setState(State.ESTABLISHED); } #handleDisconnectAction(payload) { if (this.#state === State.DISCONNECTED) { return; } if (!payload?.ack) { const response = { ack: true, }; this.#sendEventInternal(disconnectEventAction, response, false).catch((e) => { console.error("Sending event failed", e); }); } this.#setState(State.DISCONNECTED); } #handleEvent(message) { if (message.action === connectEventAction) { this.#handleConnectAction(message.payload); return; } if (message.action === disconnectEventAction) { this.#handleDisconnectAction(message.payload); return; } if (message.action === subscriptionEventAction) { this.#handleSubscriptionEvent(message.payload); return; } if (this.eventResolver) { const handler = this.eventResolver(message.action); if (handler == null) { console.warn("Unknown action for message", message); return; } handler(message.payload); } } #getRequestHandler(action) { switch (action) { case createSubscriptionRequestAction: return this.#handleCreateSubscriptionRequest.bind(this); case removeSubscriptionRequestAction: return this.#handleRemoveSubscriptionRequest.bind(this); default: return this.requestResolver?.(action); } } #handleRequest(request, event, source) { const reply = (payload, error) => { const response = { id: request.id, type: messageTypeResponse, payload, error, }; const targetOrigin = this.#getTargetOrigin(event.origin); if (this.debug) { console.log(`Sending message to ${targetOrigin}:`, response); } source.postMessage(this.#processOutgoingMessage(response, undefined, request), targetOrigin); }; const handler = this.#getRequestHandler(request.action); if (handler == null) { console.warn("Unknown action for request", request); reply(new Error(`Unknown action: ${request.action}`), true); return; } Promise.resolve() .then(() => handler(request.payload)) .then((response) => { reply(response); }) .catch((err) => { console.error(`Failed during request action ${request.action}`, err); reply(toErrorPayload(err), true); }); } #receive(id) { let callback; return new Promise((resolve, reject) => { callback = (event) => { if (event.source === this.source && (this.sourceOrigin == null || event.origin === this.sourceOrigin) && isResponse(event.data) && event.data.id === id) { if (event.data.error) { reject(parseErrorPayload(event.data.payload)); } else { resolve(event.data.payload); } } }; window.addEventListener("message", callback); }).finally(() => { window.removeEventListener("message", callback); }); } async sendRequest(action, payload, transfer) { return this.#sendRequestInternal(action, payload, true, transfer); } async #sendRequestInternal(action, payload, waitForConnected, transfer) { const id = crypto.randomUUID(); const responsePromise = this.#receive(id); const message = { id, type: messageTypeRequest, action, payload, }; const [response] = await Promise.all([ responsePromise, this.#postMessage(message, transfer, waitForConnected), ]); return response; } async sendEvent(action, payload, transfer) { await this.#sendEventInternal(action, payload, true, transfer); } async #sendEventInternal(action, payload, waitForConnected, transfer) { const message = { type: messageTypeEvent, action, payload, }; await this.#postMessage(message, transfer, waitForConnected); } async #postMessage(message, transfer, waitForConnected) { if (this.source == null) { throw new Error("Missing source"); } const targetOrigin = this.#getTargetOrigin(this.sourceOrigin); if (this.debug) { console.log(`Sending message to ${targetOrigin}:`, message); } if (waitForConnected) { await this.connectedPromise(); } this.source.postMessage(this.#processOutgoingMessage(message, transfer), targetOrigin, transfer); } #getTargetOrigin(value) { return value === "null" || value == null ? "*" : value; } async createSubscription(name, handler, options) { const subscriptionId = crypto.randomUUID(); this.#subscriptionEventHandlers.set(subscriptionId, handler); if (this.debug) { console.log(`Creating subscription with ID ${subscriptionId} for ${name}`); } try { const payload = { subscriptionId, name, }; if (options) { payload.options = options; } await this.#sendRequestInternal(createSubscriptionRequestAction, payload, true); } catch (e) { throw new Error("Failed to create subscription", { cause: e }); } return { unsubscribe: () => { if (this.debug) { console.log(`Unsubscribing from subscription ${subscriptionId} for ${name}`); } this.#subscriptionEventHandlers.delete(subscriptionId); this.#removeSubscription(subscriptionId); }, }; } #removeSubscription(subscriptionId) { const payload = { subscriptionId, }; this.#sendRequestInternal(removeSubscriptionRequestAction, payload, true).catch((e) => { console.warn("Failed to remove subscription", e); }); } #unsubscribeAllHandlers() { for (const unsubscribe of this.#unsubscribeHandlers.values()) { try { unsubscribe(); } catch (e) { console.warn("Failed to unsubscribe", e); } } this.#unsubscribeHandlers.clear(); this.#subscriptionEventHandlers.clear(); } async #handleCreateSubscriptionRequest(payload) { const subscribe = this.subscribeResolver?.(payload.name); if (!subscribe) { throw new Error(`Unknown subscription name: ${payload.name}`); } const sendEvent = (data) => { const event = { subscriptionId: payload.subscriptionId, data, }; this.#sendEventInternal(subscriptionEventAction, event, true).catch((err) => { console.error("Sending event failed", err); }); }; const { unsubscribe } = await subscribe(sendEvent, payload.options); this.#unsubscribeHandlers.set(payload.subscriptionId, unsubscribe); return { ack: true, }; } #handleRemoveSubscriptionRequest(payload) { const unsubscribe = this.#unsubscribeHandlers.get(payload.subscriptionId); // Ignore if the subscription does not exist. Assume it has already been removed. if (unsubscribe) { unsubscribe(); this.#unsubscribeHandlers.delete(payload.subscriptionId); } return { ack: true, }; } #handleSubscriptionEvent(event) { const subscriptionId = event.subscriptionId; const handler = this.#subscriptionEventHandlers.get(subscriptionId); if (handler == null) { console.debug(`Unknown subscription ID: ${event.subscriptionId}`, event); return; } handler(event.data); } }