UNPKG

@amadeus-it-group/microfrontends

Version:
862 lines (852 loc) 32.1 kB
'use strict'; /** * List of all service message types */ const SERVICE_MESSAGE_TYPES = { handshake: true, error: true, disconnect: true, declare_messages: true, connect: true, }; /** * Checks if a particular message is a {@link ServiceMessage}, like `connect`, `disconnect`, `handshake`, etc. * * ```ts * // Example of usage * if (isServiceMessage(message)) { * switch (message.type) { * case 'connect': * // handle connect message with narrowed type * break; * } * } * ``` * @param message - Message to check */ function isServiceMessage(message) { return SERVICE_MESSAGE_TYPES[message?.type] !== undefined; } /** * Error class for errors related to message processing * @param message - The error message * @param messageObject - The message that caused the error */ class MessageError extends Error { messageObject; constructor(messageObject, message) { super(message); this.messageObject = messageObject; this.name = 'MessageError'; } } let LOGGING_ENABLED = false; /** * If true, tracing information to help debugging will be logged in the console * @param enabled */ function enableLogging(enabled) { LOGGING_ENABLED = enabled; } /** * Logs things * @param args - whatever */ function logger(...args) { if (LOGGING_ENABLED) { console.log(...args); } } /** * Checks that message has correct 'from', 'to', 'payload', 'payload.type' and 'payload.version' properties * @param message Message to check * @throws MessageError */ function checkMessageHasCorrectStructure(message) { // check 'from' and 'to' if (!(message && message.from && message.to && typeof message.from === 'string' && Array.isArray(message.to))) { throw new MessageError(message, `Message should have 'from'(string) and 'to'(string|string[]) properties`); } // check 'payload', 'payload.type' and 'payload.version' const { payload } = message; if (!(payload && payload.type && payload.version && typeof payload.type === 'string' && typeof payload.version === 'string')) { throw new MessageError(message, `Message should have 'payload' property that has 'type'(string) and 'version'(string) defined`); } } function checkOriginIsValid(origin) { const parsedURL = URL.parse(origin); if (!parsedURL) { throw new Error(`'${origin}' is not a valid URL`); } if (parsedURL.origin !== origin) { throw new Error(`'${origin}' is not a valid origin, did you mean '${parsedURL.origin}'?`); } } /* eslint-disable @typescript-eslint/no-explicit-any */ /** * Simple MessagePort implementation */ class LocalMessagePort extends EventTarget { otherPort = null; onmessage = null; onmessageerror = null; postMessage(message) { const event = new MessageEvent('message', { data: structuredClone(message) }); this.otherPort?.dispatchEvent(event); this.otherPort?.onmessage?.call(this.otherPort, event); } start() { // no need to implement } close() { // no need to implement } } /** * Simple MessageChannel implementation */ class LocalMessageChannel { port1; port2; constructor() { this.port1 = new LocalMessagePort(); this.port2 = new LocalMessagePort(); this.port1.otherPort = this.port2; this.port2.otherPort = this.port1; } } class Endpoint { id; // message channel #channel = null; #port = null; #handshakeListener = null; #remoteId = null; // connection #connection = null; #connected = false; #messageQueue = []; // message processing #onMessage = null; #onError = null; constructor(id) { this.id = id; } listen(endpointId, options) { const { hostOrigin, hostWindow } = this.#processStartOptions(options); this.#remoteId = endpointId; logger(`EP(${this.id}): waiting for connections from '${endpointId}' at ${hostOrigin}`); if (!this.#connection) { this.#connection = new Promise((resolve) => { // Listening for handshake messages on our channel that match the target origin this.#handshakeListener = (handshakeEvent) => { let event; if (handshakeEvent instanceof CustomEvent) { event = handshakeEvent.detail; logger(`EP(${this.id}): received 'CustomEvent'`, handshakeEvent); } else { event = handshakeEvent; logger(`EP(${this.id}): received 'postMessage'`, handshakeEvent); } const { origin, ports, source } = event; const message = event.data; // only accept messages of: // - correct structure // - type 'handshake' with matching 'id' and 'remoteId' // - expected origin // - 'null' origin with expected source try { checkMessageHasCorrectStructure(message); const { payload } = message; if (payload.type === `handshake` && payload.endpointId === this.id && payload.remoteId === endpointId && (origin === hostOrigin || (origin === 'null' && source === hostWindow))) { // if the other party has died and reconnecting // we need to disconnect first this.#port?.close(); this.#port = ports[0]; this.#remoteId = payload.remoteId; this.#port.onmessage = (event) => { const message = event.data; logger(`EP(${this.id}): '${payload.type}' message received from '${this.#remoteId ?? '?'}':`, message); this.#processMessage(message); }; const handshake = this.#createHandshakeMessage(endpointId, options.knownPeers); logger(`EP(${this.id}): handshake received from '${endpointId}', sending handshake back`, handshake); this.#onMessage?.(message); this.#port.postMessage(handshake); this.#connected = true; this.#sendQueuedMessages(); resolve(() => this.disconnect()); } } catch { // ignore invalid handshake message attempts } }; window.addEventListener('message', this.#handshakeListener); window.addEventListener('handshake', this.#handshakeListener); }); } return this.#connection; } connect(endpointId, options) { const { hostOrigin, hostWindow } = this.#processStartOptions(options); this.#remoteId = endpointId; logger(`EP(${this.id}): connecting to '${endpointId}' at ${hostOrigin}`); // client tries to establish connection with the server if (!this.#connection) { this.#connection = new Promise((resolve, reject) => { // create a new message channel // same window -> simple LocalMessageChannel based on EventTarget // different windows -> real MessageChannel this.#channel = window === hostWindow ? new LocalMessageChannel() : new MessageChannel(); this.#port = this.#channel.port1; // incoming message handling this.#port.onmessage = (event) => { const message = event.data; const payload = message.payload; logger(`EP(${this.id}): '${payload.type}' message received from '${this.#remoteId ?? '?'}':`, message); // Connected if (this.#connected) { this.#processMessage(message); } // Not connected yet, expecting handshake else if (payload.type === 'handshake') { if (payload.endpointId === this.id && payload.remoteId === endpointId) { this.#remoteId = payload.remoteId; logger(`EP(${this.id}): handshake received from ${this.remoteId}:`, hostOrigin); this.#onMessage?.(message); this.#connected = true; this.#sendQueuedMessages(); resolve(() => this.disconnect()); } } else { logger(`EP(${this.id}): handshake was expected, got:`, message); reject(`Handshake was expected, got: ${JSON.stringify(message)}`); } }; // Send handshake message to the host window const handshake = this.#createHandshakeMessage(endpointId, options.knownPeers); // Same window -> CustomEvent if (window === hostWindow) { const message = { data: handshake, origin: hostOrigin, ports: [this.#channel.port2] }; logger(`EP(${this.id}): sending 'CustomEvent' handshake to '${endpointId}':`, handshake); window.dispatchEvent(new CustomEvent('handshake', { detail: message })); } // Different window -> postMessage else { logger(`EP(${this.id}): sending 'postMessage' handshake to '${endpointId}':`, handshake); hostWindow.postMessage(handshake, { targetOrigin: hostOrigin, transfer: [this.#channel.port2], }); } }); } return this.#connection; } get connected() { return this.#connected; } get remoteId() { return this.#remoteId; } disconnect() { this.#remoteId = null; this.#onMessage = null; this.#onError = null; this.#connection = null; this.#connected = false; this.#port?.close(); this.#port = null; this.#channel = null; window.removeEventListener('message', this.#handshakeListener); window.removeEventListener('handshake', this.#handshakeListener); this.#handshakeListener = null; } send(message) { if (this.#connected && this.#port) { logger(`EP(${this.id}): sending message '${message.payload.type}' to '${this.#remoteId}':`, message); this.#port.postMessage(message); } else { logger(`EP(${this.id}): queueing message:`, message); // making sure we have a cloned message in case message contains references // and pushing connect message before in the queue if (message.payload.type === 'connect') { this.#messageQueue.unshift(structuredClone(message)); } else { this.#messageQueue.push(structuredClone(message)); } } } #processStartOptions(options) { // window const hostWindow = options?.window || window; const hostOrigin = options?.origin || window.origin; // throw error if origin is not valid checkOriginIsValid(hostOrigin); // messageHandling this.#onMessage = options?.onMessage; this.#onError = options?.onError || ((error) => console.warn(error)); return { hostOrigin, hostWindow }; } #createHandshakeMessage(endpointId, knownPeers) { return { from: this.id, to: [endpointId], payload: { type: 'handshake', version: '1.0', endpointId, remoteId: this.id, knownPeers: new Map(knownPeers), }, }; } #processMessage(message) { // TODO: maybe just do all this at the peer level? try { // validating incoming message structure checkMessageHasCorrectStructure(message); if (message.payload.type === 'handshake') { // TODO: what if we receive handshake, throw error ? console.warn(`EP(${this.id}): Unexpected handshake message received:`, message); } else { this.#onMessage?.(message); } } catch (error) { logger(`EP(${this.id}):`, error); this.#onError?.(error); } } #sendQueuedMessages() { for (const message of this.#messageQueue) { this.send(message); } this.#messageQueue.length = 0; } } /** * Checks that message type is known for the peer * @param message Message to check * @param peer Endpoint that processes the message */ function checkMessageIsKnown(message, peer) { const knownMessages = peer.knownPeers.get(peer.id); const { payload } = message; if (knownMessages && !knownMessages.find(({ type }) => type === payload.type)) { const knownTypes = [...new Set(knownMessages.map(({ type }) => type))]; throw new MessageError(message, `Unknown message type "${payload.type}". Known types: ${JSON.stringify(knownTypes)}`); } } /** * Checks that message version is known for the peer * @param message Message to check * @param peer Endpoint that processes the message */ function checkMessageVersionIsKnown(message, peer) { const knownMessages = peer.knownPeers.get(peer.id); const { payload } = message; if (knownMessages && !knownMessages?.find(({ type, version }) => type === payload.type && version === payload.version)) { const knownVersions = knownMessages .filter(({ type }) => type === payload.type) .map(({ version }) => version); throw new MessageError(message, `Unknown message version "${payload.version}". Known versions: ${JSON.stringify(knownVersions)}`); } } /** * Default message checks that peer performs on incoming messages */ function defaultMessageChecks() { return [ { description: 'Check that message is known', check: checkMessageIsKnown, }, { description: 'Check that message version is known', check: checkMessageVersionIsKnown, }, ]; } const EMPTY_SUBSCRIPTION = { unsubscribe: () => { // do nothing }, }; class Emitter { #subscribers = new Set(); get subscribers() { return this.#subscribers; } subscribe(subscriber) { if (subscriber) { this.#subscribers.add(subscriber); return { unsubscribe: () => { this.#subscribers.delete(subscriber); }, }; } else { return EMPTY_SUBSCRIPTION; } } emit(value) { for (const subscriber of this.#subscribers) { if (typeof subscriber === 'function') { subscriber(value); } else { subscriber.next?.(value); } } } [Symbol.observable]() { return this; } ['@@observable']() { return this; } } /* eslint-disable @typescript-eslint/no-explicit-any */ const DEFAULT_START_OPTIONS = { window, origin: window.origin, }; /** * Default message peer that can send and receive messages to/from other peers in the same document * or across different windows or iframes. * * Messages will be sent in a synchronous way if both peers are in the same window. * Otherwise, a `MessageChannel` will be established to send messages between different windows. * * ```ts * // Simple example of creating two peers and sending messages between them * const one = new MessagePeer({ id: 'one', onMessage: (message) => {} }); * const two = new MessagePeer({ id: 'two', onMessage: (message) => {} }); * * // connecting two peers * one.listen('two'); * two.connect('one'); * * // sending messages * one.send({ type: 'ping', version: '1.0' }); // broadcast * two.send({ type: 'pong', version: '1.0' }, { to: 'one' }); // send to a specific peer * * // learning about the network * one.knownPeers; // lists all known peers and messages they can receive * * // disconnecting * one.disconnect(); // disconnects from all peers * one.disconnect('two'); // disconnects from a specific peer * ``` */ class MessagePeer { #id; #endpoints = new Map(); #endpointPeers = new Map(); #messageEmitter = new Emitter(); #serviceMessageEmitter = new Emitter(); #errorEmitter = new Emitter(); #messageChecks = [...defaultMessageChecks()]; #knownPeers = new Map(); #messageQueue = []; constructor(options) { this.#id = options.id; this.#knownPeers.set(this.id, []); if (options.knownMessages) { for (const message of options.knownMessages) { this.registerMessage(message); } } logger(`PEER(${this.id}): created`, this.#knownPeers); } /** * @inheritDoc */ get id() { return this.#id; } /** * @inheritDoc */ get knownPeers() { return this.#knownPeers; } /** * @inheritDoc */ get messages() { return this.#messageEmitter; } /** * @inheritDoc */ get serviceMessages() { return this.#serviceMessageEmitter; } /** * @inheritDoc */ get errors() { return this.#errorEmitter; } /** * @inheritDoc */ connect(peerId, options) { logger(`PEER(${this.id}): connecting to '${peerId}'`); const endpoint = new Endpoint(this.id); this.#endpoints.set(peerId, endpoint); return endpoint.connect(peerId, { ...DEFAULT_START_OPTIONS, ...options, knownPeers: this.#knownPeers, onMessage: (message) => this.#handleEndpointMessage(endpoint, message), onError: (error) => this.#handleEndpointError(endpoint, error), }); } /** * @inheritDoc */ send(message, options) { this.#send(message, options); } /** * @inheritDoc */ listen(peerId, options) { logger(`PEER(${this.id}): listening for '${peerId}'`); const endpoint = new Endpoint(this.id); this.#endpoints.set(peerId, endpoint); return endpoint.listen(peerId, { ...DEFAULT_START_OPTIONS, ...options, knownPeers: this.#knownPeers, onMessage: (message) => this.#handleEndpointMessage(endpoint, message), onError: (error) => this.#handleEndpointError(endpoint, error), }); } /** * @inheritDoc */ registerMessage(message) { const knownMessages = this.#knownPeers.get(this.id); if (!knownMessages.find((m) => m.type === message.type && m.version === message.version)) { knownMessages.push(message); } } /** * Logs the current state of the peer to the console */ log() { const endpoints = [...this.#endpoints.values()].map((e) => `${e.id}:${e.connected ? e.remoteId : e.remoteId + '*'}`); const endpointPeers = [...this.#endpointPeers].map(([id, peers]) => `${id}: ${[...peers].join(', ')}`); console.log(`PEER(${this.id}):`, endpoints, endpointPeers, this.#knownPeers); } /** * @inheritDoc */ disconnect(peerId) { if (peerId) { const endpoint = this.#endpoints.get(peerId); if (endpoint) { this.#disconnectEndpoint(endpoint); } } else { for (const endpoint of this.#endpoints.values()) { this.#disconnectEndpoint(endpoint); } } } /** * Disconnect from a particular endpoint * @param endpoint - endpoint to disconnect from */ #disconnectEndpoint(endpoint) { const remoteId = endpoint.remoteId; // 0. collecting all peers that will be disconnected const disconnectedPeers = [...(this.#endpointPeers.get(remoteId) || [])]; const unreachable = [this.id]; for (const [peerId, peers] of this.#endpointPeers) { if (peerId !== remoteId) { unreachable.push(...peers); } } // 1. notify the other side that WE will disconnect if (endpoint.connected) { endpoint.send({ from: this.id, to: [], payload: { type: 'disconnect', version: '1.0', disconnected: this.id, unreachable, }, }); } // 2. physically disconnecting this.#endpointPeers.delete(remoteId); for (const id of disconnectedPeers) { this.#knownPeers.delete(id); } this.#endpoints.delete(remoteId); endpoint.disconnect(); // 3. notify all other endpoints about the disconnection this.#send({ type: 'disconnect', version: '1.0', disconnected: remoteId, unreachable: disconnectedPeers, }); logger(`PEER(${this.id}): disconnected from '${remoteId}'`, this.#endpoints, this.#knownPeers); } #handleEndpointError(endpoint, error) { this.#errorEmitter.emit(error); if (this.#errorEmitter.subscribers.size === 0) { console.error(endpoint); } // sending back to the endpoint we got it from endpoint.send({ from: this.id, to: [error.messageObject.from], payload: { type: 'error', version: '1.0', error: error.message, message: error.messageObject, }, }); } /** * Processes the message received form a particular endpoint. * In the end it can either notify the user about the message or forward it to other endpoints. * @param endpoint - endpoint that sent the message * @param message - message to process */ #handleEndpointMessage(endpoint, message) { logger(`PEER(${this.id}): received message`, message, this.#knownPeers); const { payload } = message; // handle service messages if (isServiceMessage(payload)) { switch (payload.type) { case 'handshake': { logger(`PEER(${this.id}): handshake message from '${payload.remoteId}'`, payload); const connected = [...this.knownPeers.keys()]; // 1. registering the new endpoint and its messages for (const [id, messages] of payload.knownPeers) { this.#registerRemoteMessages(id, messages); } this.#endpointPeers.set(payload.remoteId, new Set(payload.knownPeers.keys())); // 2. notifying all other endpoints that new endpoint is connected for (const e of this.#endpoints.values()) { if (e !== endpoint && e.connected) { e.send({ from: this.id, to: [], payload: { type: 'connect', version: '1.0', knownPeers: this.#knownPeers, connected: [...payload.knownPeers.keys()], }, }); } } // 3. notifying the new endpoint about all other previously connected endpoints endpoint.send({ from: this.id, to: [payload.remoteId], payload: { type: 'connect', version: '1.0', knownPeers: new Map(), // TODO: not sure this is OK connected, }, }); break; } case 'connect': { // only process the message if it's addressed to us, forward otherwise if (message.to.includes(this.id) || message.to.length === 0) { logger(`PEER(${this.id}): connect message from '${endpoint.remoteId}'`, payload); // 1. registering new messages for (const [id, messages] of payload.knownPeers) { this.#registerRemoteMessages(id, messages); } // 2. updating the list of known peers const knownPeers = this.#endpointPeers.get(endpoint.remoteId); for (const id of payload.knownPeers.keys()) { if (id === this.id || [...this.#endpoints.keys()].includes(id)) { continue; } knownPeers.add(id); } // as soon as we're connected properly, we dump the message queue this.#sendQueuedMessages(); // 3. passing the message to the user this.#serviceMessageEmitter.emit({ ...message, payload: { ...payload, knownPeers: [] }, }); } this.#forwardMessage(endpoint, message); break; } case 'disconnect': { logger(`PEER(${this.id}): disconnect message from '${endpoint.remoteId}'`, payload); // 1. disconnecting the endpoint const endpointToDisconnect = this.#endpoints.get(payload.disconnected); if (endpointToDisconnect) { endpoint.disconnect(); } // 2. removing all unreachable peers and endpoints for (const id of payload.unreachable) { this.#knownPeers.delete(id); this.#endpoints.delete(id); for (const [peerId, peers] of this.#endpointPeers) { peers.delete(id); if (peers.size === 0) { this.#endpointPeers.delete(peerId); } } } // 3. passing the message to the user and further on the network this.#serviceMessageEmitter.emit(message); this.#forwardMessage(endpoint, message); break; } case 'declare_messages': { logger(`PEER(${this.id}): declare_messages from '${message.from}'`, payload); this.#registerRemoteMessages(message.from, payload.messages); this.#serviceMessageEmitter.emit(message); this.#forwardMessage(endpoint, message); break; } case 'error': { logger(`PEER(${this.id}): 'error' message from '${message.from}'`, payload); if (message.to.includes(this.id)) { this.#serviceMessageEmitter.emit(message); } else { this.#forwardMessage(endpoint, message); } break; } default: { logger(`PEER(${this.id}):`, `unknown message type: ${payload['type']}`); this.#handleEndpointError(endpoint, new MessageError(message, `unknown message type: ${payload['type']}`)); } } } // handling user messages, processing errors and forwarding the message further if necessary else { try { if (message.to.includes(this.id) || message.to.length === 0) { for (const { check } of this.#messageChecks) { check(message, this); } this.#messageEmitter.emit(message); } } catch (error) { logger(`PEER(${this.id}):`, error); this.#handleEndpointError(endpoint, error); } finally { this.#forwardMessage(endpoint, message); } } } /** * Sends a message `M` or {@link ServiceMessage} to the network. * @param payload - message to send * @param options - additional {@link PeerSendOptions} for the message delivery */ #send(payload, options) { const message = { from: this.id, to: options?.to ? (Array.isArray(options.to) ? options.to : [options.to]) : [], payload, }; // we have established connection to at least one peer if (this.#knownPeers.size > 1) { for (const endpoint of this.#endpoints.values()) { endpoint.send(message); } } else { this.#messageQueue.push(message); } } /** * Forwards the message to all other endpoints except the receivedFrom. * @param receivedFrom - endpoint that should not receive the message, we just got the message from it * @param message - message to forward */ #forwardMessage(receivedFrom, message) { // we're the only recipient for the message -> no need to forward if (message.to.length === 1 && message.to[0] === this.id) { return; } // forwarding the message to all other endpoints for (const e of this.#endpoints.values()) { if (e !== receivedFrom && e.connected) { e.send(message); } } } /** * Registers messages that the remote peer can receive. * @param peerId - id of the peer that can receive the messages * @param messages - list of messages the peer can receive */ #registerRemoteMessages(peerId, messages) { if (this.id !== peerId) { const knownMessages = this.#knownPeers.get(peerId); if (!knownMessages) { this.#knownPeers.set(peerId, messages); } else { for (const message of messages) { if (!knownMessages.find((m) => m.type === message.type && m.version === message.version)) { knownMessages.push(message); } } } } } #sendQueuedMessages() { for (const message of this.#messageQueue) { for (const e of this.#endpoints.values()) { e.send(message); } } this.#messageQueue.length = 0; } } exports.MessageError = MessageError; exports.MessagePeer = MessagePeer; exports.enableLogging = enableLogging; exports.isServiceMessage = isServiceMessage; //# sourceMappingURL=index.cjs.map