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
JavaScript
/**
* 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);
}
}