@amadeus-it-group/microfrontends
Version:
Amadeus Micro Frontend Toolkit
862 lines (852 loc) • 32.1 kB
JavaScript
'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