matssocket
Version:
MatsSocket client library
847 lines (799 loc) • 180 kB
JavaScript
import {AuthorizationRequiredEvent, AuthorizationRequiredEventType} from './AuthorizationRequiredEvent.js';
import {ConnectionState} from './ConnectionState.js';
import {ConnectionEvent, ConnectionEventType} from './ConnectionEvent.js';
import {MessageType} from './MessageType.js';
import {ReceivedEvent, ReceivedEventType} from './ReceivedEvent.js';
import {MessageEvent, MessageEventType} from './MessageEvent.js';
import {SubscriptionEvent, SubscriptionEventType} from "./SubscriptionEvent.js";
import {InitiationProcessedEvent, InitiationProcessedEventType} from './InitiationProcessedEvent.js';
import {PingPong} from './PingPong.js';
import {MatsSocketCloseCodes, MatsSocketCloseCodesUtil} from './MatsSocketCloseCodes.js';
import {ErrorEvent} from './ErrorEvent.js';
import {DebugInformation, DebugOption} from "./DebugInformation.js";
export {
MatsSocket,
AuthorizationRequiredEvent, AuthorizationRequiredEventType,
ConnectionState,
ConnectionEvent, ConnectionEventType,
MessageType,
ReceivedEvent, ReceivedEventType,
MessageEvent, MessageEventType,
SubscriptionEvent, SubscriptionEventType,
InitiationProcessedEvent, InitiationProcessedEventType,
PingPong,
MatsSocketCloseCodes,
ErrorEvent,
DebugOption, DebugInformation
}
/**
* Creates a MatsSocket, requiring the using Application's name and version, and which URLs to connect to.
* <p/>
* Note: Public, Private and Privileged modelled after
* <a href="http://crockford.com/javascript/private.html">http://crockford.com/javascript/private.html</a>
*
* @param {string} appName the name of the application using this MatsSocket.js client library
* @param {string} appVersion the version of the application using this MatsSocket.js client library
* @param {array} urls an array of WebSocket URLs speaking 'matssocket' protocol, or a single string URL.
* @param {object} config an optional object carrying extra configuration. Current sole key: 'webSocketFactory': how to
* make WebSockets, not required in a browser setting as it will use window.WebSocket if not set.
* @class
*/
function MatsSocket(appName, appVersion, urls, config = null) {
let CLIENT_LIB_VERSION = "1.0.0-2025-10-27";
let CLIENT_LIB_NAME_AND_VERSION = "MatsSocket.js," + CLIENT_LIB_VERSION;
// :: Validate primary arguments
if (typeof appName !== "string") {
throw new Error("appName must be a string, was: [" + appName + "]");
}
if (typeof appVersion !== "string") {
throw new Error("appVersion must be a string, was: [" + appVersion + "]");
}
// 'urls' must either be a string, String, or an Array that is not 0 elements.
let urlsOk = ((typeof urls === 'string') || (urls instanceof String)) || (Array.isArray(urls) && urls.length > 0);
if (!urlsOk) {
throw new Error("urls must have at least 1 url set, got: [" + urls + "]");
}
// If we haven't gotten 'webSocketFactory', and we're in Node.js env, this field is set to true, and an attempt to
// import('ws') is performed. When the import has gone either OK or not OK, this field is set to false, and the the
// next field's value will be invoked.
let _nodeJsTryingToImportWebSocketModule = false;
// If !undefined, will be invoked once import of WebSocket module is either OK or Not OK.
// If the 'webSocketFactory' is !undefined, it went OK. If the above field is false, it went to hell.
let _nodeJsCallbackOnceWebSocketModuleResolved = undefined;
// Note: Alternative would be main vs. browser in package.json, e.g. here: https://stackoverflow.com/a/67393553/39334
// :: Provide default for socket factory if not defined.
let webSocketFactory = undefined;
if (config) {
if (config.webSocketFactory) {
if (typeof config.webSocketFactory !== "function") {
throw new Error("config.webSocketFactory should be a function, instead got [" + (typeof config.webSocketFactory) + "].");
}
webSocketFactory = config.webSocketFactory;
} else if (config.webSocket) {
if (typeof config.webSocket !== "function") {
throw new Error("config.webSocket should be a function (constructor), instead got [" + (typeof config.webSocket) + "].");
}
webSocketFactory = function (url, protocol) {
return new config.webSocket(url, protocol);
};
}
}
// ?: Did we get webSocketFactory from 'config'?
if (!webSocketFactory) {
// -> No, so try for global WebSocket
if (typeof WebSocket === "function") {
webSocketFactory = (url, protocol) => new WebSocket(url, protocol);
} else if (_isNodeJs()) {
_nodeJsTryingToImportWebSocketModule = true;
// -> Seemingly Node.js environment, try to dynamically import the 'ws' library.
// NOTE: Such import is specified as an asynchronous operation, albeit it seems synchronous when running in Node.
// However, we'll have to treat it as async, so a bit of handling both here and in the WebSocket creation
// code below, by means of "stop process and restart once import resolved" logic.
_importWsLazy()
.then((ws) => {
log("Constructor: NodeJs import('ws') went OK: Got WebSocket module");
const {default: WebSocket} = ws;
setTimeout(() => {
webSocketFactory = (url, protocol) => new WebSocket(url, protocol);
log("Constructor: 'webSocketFactory' is now set.");
_nodeJsTryingToImportWebSocketModule = false;
if (typeof _nodeJsCallbackOnceWebSocketModuleResolved === 'function') {
log("Constructor: Invoking callback-method to restart WebSocket creation attempt.");
_nodeJsCallbackOnceWebSocketModuleResolved();
}
}, 0)
})
.catch(reason => {
_nodeJsTryingToImportWebSocketModule = false;
error("Import of WebSocket module failed", "In Node.js environment, the import('ws') failed", reason);
});
}
else {
throw new Error("Missing config.webSocket, config.webSocketFactory, global WebSocket (window.WebSocket)," +
" and seemingly not Node.js so cannot dynamically import 'ws' module: Cannot create MatsSocket.");
}
}
// :: Polyfill performance.now() for Node.js: if window.performance is present, use this.
let performance = ((typeof (window) === "object" && window.performance) || {
now: function now() {
return Date.now();
}
});
const that = this;
const userAgent = (typeof (self) === 'object' && typeof (self.navigator) === 'object') ? self.navigator.userAgent : "Unknown";
// Ensure that 'urls' is an array or 1 or several URLs.
urls = [].concat(urls);
// ==============================================================================================
// PUBLIC:
// ==============================================================================================
/**
* 'sessionId' is set when we get the SessionId from WELCOME, set back to undefined on SessionClose
* (along with _matsSocketOpen = false)
*
* @type {string}
*/
this.sessionId = undefined;
/**
* Whether to log via console.log. The logging is quite extensive. <b>Default <code>false</code></b>.
*
* @type {boolean}
*/
this.logging = false;
/**
* Whether to log errors via console.error. <b>Default <code>true</code></b>.
*
* @type {boolean}
*/
this.errorLogging = true;
/**
* "Out-of-band Close" refers to a small hack to notify the server about a MatsSocketSession being Closed even
* if the WebSocket is not live anymore: When {@link MatsSocket#close} is invoked, an attempt is done to close
* the WebSocket with CloseCode {@link MatsSocketCloseCodes.CLOSE_SESSION} - but whether the WebSocket is open
* or not, this "Out-of-band Close" will also be invoked if enabled and MatsSocket SessionId is present.
* <p/>
* Values:
* <ul>
* <li>"Falsy", e.g. <code>false</code>: Disables this functionality</li>
* <li>A <code>function</code>: The function is invoked when close(..) is invoked, the
* single parameter being an object with two keys: <code>'webSocketUrl'</code> is the current WebSocket
* url, i.e. the URL that the WebSocket was connected to, e.g. "wss://example.com/matssocket".
* <code>'sessionId'</code> is the current MatsSocket SessionId - the one we're trying to close.</li>
* <li>"Truthy", e.g. <code>true</code> <b>(default)</b>: When this MatsSocket library is used in
* a web browser context, the following code is executed:
* <code>navigator.sendBeacon(webSocketUrl.replace('ws', 'http')+"/close_session?sessionId={sessionId}")</code>.
* Note that replace is replace-first, and that an extra 's' in 'wss' thus results in 'https'.</li>
* </ul>
* The default is <code>true</code>.
* <p/>
* Note: A 'beforeunload' listener invoking {@link MatsSocket#close} is attached when running in a web browser,
* so that if the user navigates away, the current MatsSocketSession is closed.
*
* @type {(function|boolean)}
*/
this.outofbandclose = true;
/**
* "Pre Connection Operation" refers to a hack whereby the MatsSocket performs a specified operation - by default
* a {@link XMLHttpRequest} to the same URL as the WebSocket will be connected to - before initiating the
* WebSocket connection. The goal of this solution is to overcome a deficiency with the WebSocket Web API
* where it is impossible to add headers, in particular "Authorization": The XHR adds the Authorization header
* as normal, and the server side can transfer this header value over to a Cookie (e.g. named "MatsSocketAuthCookie").
* When the WebSocket connect is performed, the cookies will be transferred along with the initial "handshake"
* HTTP Request - and the AuthenticationPlugin on the server side can then validate the Authorization header -
* now present in a cookie. <i>Note: One could of course have supplied it in the URL of the WebSocket HTTP Handshake,
* but this is very far from ideal, as a live authentication then could be stored in several ACCESS LOG style
* logging systems along the path of the WebSocket HTTP Handshake Request call.</i>
* <p/>
* Values:
* <ul>
* <li>"Falsy", e.g. <code>false</code> <b>(default)</b>: Disables this functionality.</li>
* <li>A <code>string</code>: Performs a <code>XMLHttpRequest</code> with the URL set to the specified string, with the
* HTTP Header "<code>Authorization</code>" set to the current AuthorizationValue. Expects 200, 202 or 204
* as returned status code to go on.</li>
* <li>A <code>function</code>: Invokes the function with a parameter object containing <code>'webSocketUrl'</code>,
* which is the current WebSocket URL that we will connect to when this PreConnectionOperation has gone through,
* and <code>'authorization'</code>, which is the current Authorization Value. <b>Expects
* a two-element array returned</b>: [abortFunction, requestPromise]. The abortFunction is invoked when
* the connection-retry system deems the current attempt to have taken too long time. The requestPromise must
* be resolved by your code when the request has been successfully performed, or rejected if it didn't go through.
* In the latter case, a new invocation of the 'preconnectoperation' will be performed after a countdown,
* possibly with a different 'webSocketUrl' value if the MatsSocket is configured with multiple URLs.</li>
* <li>"Truthy", e.g. <code>true</code>: Performs a <code>XMLHttpRequest</code> to the same URL as
* the WebSocket URL, with "ws" replaced with "http", similar to {@link MatsSocket#outofbandclose}, and the HTTP
* Header "<code>Authorization</code>" set to the current Authorization Value. Expects 200, 202 or 204 as
* returned status code to go on.</li>
* </ul>
* The default is <code>false</code>.
* <p/>
* Note: For inspiration for the function-style value of this config, look in the source for the method
* <code>w_defaultXhrPromiseFactory(params)</code>.
* <p/>
* Note: A WebSocket is set up with a single HTTP Request, called the "Upgrade" or "Handshake" request. The
* point about being able to send Authorization along with the WebSocket connect only refers to this initial
* HTTP Request. Subsequent updates of the Authorization by means of invocation of
* {@link MatsSocket#setCurrentAuthorization} will not result in new HTTP calls - these new Authorization
* strings are sent in-band with WebSocket messages (MatsSocket envelopes).
*
* @type {(boolean|string|function)}
*/
this.preconnectoperation = false;
/**
* A bit field requesting different types of debug information from the server - the flags/bits are defined in
* {@link DebugOption}. The information concerns timings and which server nodes have handled the messages.
* <p/>
* This field is used as the default for requests sent to the server, but individual requests may also set
* the debug flags explicitly (i.e. override) by use of the optional "config" object on
* {@link MatsSocket#requestReplyTo} or {@link MatsSocket#request}.
* <p/>
* To facilitate debug information also on Server initiated messages, the <i>last sent</i> debug flags is
* also stored on the server and used when messages originate there (i.e. Server-to-Client SENDs and REQUESTs).
* This goes both if the default was used (this flag), or overridden-per-request config: The last flag sent over
* is used for any subsequent server-initiated message. This is arguably a pretty annoying way to control the server
* initiated debug flags - vote for <a href="https://github.com/centiservice/matssocket/issues/13">Issue 13</a>
* if you want something more explicit.
* <p/>
* The value is a bit field (values in {@link DebugOption}), so you bitwise-or (or simply add) together the
* different things you want.
* <p/>
* The value from the client is bitwise-and'ed together with the debug capabilities the authenticated user has
* gotten by the AuthenticationPlugin on the Server side. This means that the AuthenticationPlugin ultimately
* controls how much info the accessing user is allowed to get.
* <p/>
* Default is <code>0</code>, i.e. no debug.
*
* @type {number}
*/
this.debug = 0;
/**
* When performing a {@link MatsSocket#request Request} and {@link MatsSocket#requestReplyTo RequestReplyTo},
* you may not always get a (timely) answer: Either you can lose the connection, thus lagging potentially forever -
* or, depending on the Mats message handling on the server (i.e. using "non-persistent messaging" for blazing fast
* performance for non-state changing operations), there is a minuscule chance that the message may be lost - or, if
* there is a massive backlog of messages for the particular Mats endpoint that is interfaced, you might not get an
* answer for 20 minutes. This setting controls the default timeout in milliseconds for Requests, and is default
* 45000 milliseconds (45 seconds), but you may override this per Request by specifying a different timeout in the
* config object for the request. When the timeout is hit, the Promise of a {@link MatsSocket#request} - or the
* specified ReplyTo Terminator for a {@link MatsSocket#requestReplyTo} - will be rejected with a
* {@link MessageEvent} of type {@link MessageEventType.TIMEOUT}. In addition, if the Received acknowledgement has
* not gotten in either, this will also (<i>before</i> the Promise reject!) be NACK'ed with
* {@link ReceivedEventType.TIMEOUT}
*
* @type {number}
*/
this.requestTimeout = 45000;
/**
* Way to let integration tests checking failed connections take a bit less time..! Default is 'undefined', which
* yields a small number (60ish x 15 seconds) when we do not have SessionId (i.e. trying to connect, we have still
* not started the app), and a rather large one (a full day) if we do have a SessionId (implying that we're trying
* to reconnect).
*
* @type {number}
*/
this.maxConnectionAttempts = undefined;
/**
* Default is 3-7 seconds for the initial ping delay, and then 15 seconds for subsequent pings. Can be overridden
* for tests.
*
* @type {number}
*/
this.initialPingDelay = 3000 + Math.random() * 4000
/**
* Callback function for {@link MatsSocket#addSessionClosedEventListener}.
*
* @callback sessionClosedEventCallback
* @param {CloseEvent} closeEvent the WebSocket's {@link CloseEvent}.
*/
/**
* <b>Note: You <i>should</i> register a SessionClosedEvent listener, as any invocation of this listener by this
* client library means that you've either not managed to do initial authentication, or lost sync with the
* server, and you should crash or "reboot" the application employing the library to regain sync.</b>
* <p />
* The registered event listener functions are called when the Server kicks us off the socket and the session is
* closed due to a multitude of reasons, where most should never happen if you use the library correctly, in
* particular wrt. authentication. <b>It is NOT invoked when you explicitly invoke matsSocket.close() from
* the client yourself!</b>
* <p />
* The event object is the WebSocket's {@link CloseEvent}, adorned with properties 'codeName', giving the
* <i>key name</i> of the {@link MatsSocketCloseCodes} (as provided by {@link MatsSocketCloseCodes#nameFor}),
* and 'outstandingInitiations', giving the number of outstanding initiations when the session was closed.
* You can use the 'code' to "enum-compare" to <code>MatsSocketCloseCodes</code>, the enum keys are listed here:
* <ul>
* <li>{@link MatsSocketCloseCodes.UNEXPECTED_CONDITION UNEXPECTED_CONDITION}: Error on the Server side,
* typically that the data store (DB) was unavailable, and the MatsSocketServer could not reliably recover
* the processing of your message.</li>
* <li>{@link MatsSocketCloseCodes.MATS_SOCKET_PROTOCOL_ERROR MATS_SOCKET_PROTOCOL_ERROR}: This client library
* has a bug!</li>
* <li>{@link MatsSocketCloseCodes.VIOLATED_POLICY VIOLATED_POLICY}: Initial Authorization was wrong. Always
* supply a correct and non-expired Authorization value, which has sufficient 'roomForLatency' wrt.
* the expiry time.</li>
* <li>{@link MatsSocketCloseCodes.CLOSE_SESSION CLOSE_SESSION}:
* <code>MatsSocketServer.closeSession(sessionId)</code> was invoked Server side for this MatsSocketSession</li>
* <li>{@link MatsSocketCloseCodes.SESSION_LOST SESSION_LOST}: A reconnect attempt was performed, but the
* MatsSocketSession was timed out on the Server. The Session will never time out if the WebSocket connection
* is open. Only if the Client has lost connection, the timer will start. The Session timeout is measured in
* hours or days. This could conceivably happen if you close the lid of a laptop, and open it again days later
* - but one would think that the Authentication session (the one giving you Authorization headers) had timed
* out long before.</li>
* </ul>
* Again, note: No such error should happen if this client is used properly, and the server does not get
* problems with its data store.
* <p />
* Note that when this event listener is invoked, the MatsSocketSession is just as closed as if you invoked
* {@link MatsSocket#close} on it: All outstanding send/requests are NACK'ed (with
* {@link ReceivedEventType.SESSION_CLOSED}), all request Promises are rejected
* (with {@link MessageEventType.SESSION_CLOSED}), and the MatsSocket object is as if just constructed and
* configured. You may "boot it up again" by sending a new message where you then will get a new MatsSocket
* SessionId. However, you should consider restarting the application if this happens, or otherwise "reboot"
* it as if it just started up (gather all required state and null out any other that uses lazy fetching).
* Realize that any outstanding "addOrder" request's Promise will now have been rejected - and you don't really
* know whether the order was placed or not, so you should get the entire order list. On the received event,
* the property 'outstandingInitiations' details the number of outstanding send/requests and Promises that was
* rejected: If this is zero, you <i>might</i> actually be in sync (barring failed/missing Server-to-Client
* SENDs or REQUESTs), and could <i>consider</i> to just "act as if nothing happened" - by sending a new message
* and thus get a new MatsSocket Session going.
*
* @param {sessionClosedEventCallback} sessionClosedEventListener a function that is invoked when the library gets the current
* MatsSocketSession closed from the server. The event object is the WebSocket's {@link CloseEvent}.
*/
this.addSessionClosedEventListener = function (sessionClosedEventListener) {
if (!(typeof sessionClosedEventListener === 'function')) {
throw Error("SessionClosedEvent listener must be a function");
}
_sessionClosedEventListeners.push(sessionClosedEventListener);
};
/**
* Callback function for {@link MatsSocket#addConnectionEventListener}.
*
* @callback connectionEventCallback
* @param {ConnectionEvent} connectionEvent giving information about what happened.
*/
/**
* <b>Note: You <i>could</i> register a ConnectionEvent listener, as these are only informational messages
* about the state of the Connection.</b> It is nice if the user gets a small notification about <i>"Connection
* Lost, trying to reconnect in 2 seconds"</i> to keep him in the loop of why the application's data fetching
* seems to be lagging. There are suggestions of how to approach this with each of the enum values of
* {@link ConnectionEventType}.
* <p />
* The registered event listener functions are called when this client library performs WebSocket connection
* operations, including connection closed events that are not "Session Close" style. This includes the simple
* situation of "lost connection, reconnecting" because you passed through an area with limited or no
* connectivity.
* <p />
* Read more at {@link ConnectionEvent} and {@link ConnectionEventType}.
*
* @param {connectionEventCallback} connectionEventListener a function that is invoked when the library issues
* {@link ConnectionEvent}s.
*/
this.addConnectionEventListener = function (connectionEventListener) {
if (!(typeof connectionEventListener === 'function')) {
throw Error("SessionClosedEvent listener must be a function");
}
_connectionEventListeners.push(connectionEventListener);
};
/**
* Callback function for {@link MatsSocket#addSubscriptionEventListener}.
*
* @callback subscriptionEventCallback
* @param {SubscriptionEvent} subscriptionEvent giving information about what the server had to say about
* subscriptions.
*/
/**
* <b>Note: If you use {@link #subscribe subscriptions}, you <i>should</i> register a
* {@link SubscriptionEvent} listener, as you should be concerned about {@link SubscriptionEventType.NOT_AUTHORIZED}
* and {@link SubscriptionEventType.LOST_MESSAGES}.</b>
* <p />
* Read more at {@link SubscriptionEvent} and {@link SubscriptionEventType}.
*
* @param {subscriptionEventCallback} subscriptionEventListener a function that is invoked when the library
* gets information from the Server wrt. subscriptions.
*/
this.addSubscriptionEventListener = function (subscriptionEventListener) {
if (!(typeof subscriptionEventListener === 'function')) {
throw Error("SubscriptionEvent listener must be a function");
}
_subscriptionEventListeners.push(subscriptionEventListener);
};
/**
* Callback function for {@link MatsSocket#addErrorEventListener}.
*
* @callback errorEventCallback
* @param {ErrorEvent} errorEvent information about what error happened.
*/
/**
* Some 25 places within the MatsSocket client catches errors of different kinds, typically where listeners
* cough up errors, or if the library catches mistakes with the protocol, or if the WebSocket emits an error.
* Add a ErrorEvent listener to get hold of these, and send them back to your server for
* inspection - it is best to do this via out-of-band means, e.g. via HTTP. For browsers, consider
* <code>navigator.sendBeacon(..)</code>.
* <p />
* The event object is {@link ErrorEvent}.
*
* @param {errorEventCallback} errorEventListener
*/
this.addErrorEventListener = function (errorEventListener) {
if (!(typeof errorEventListener === 'function')) {
throw Error("ErrorEvent listener must be a function");
}
_errorEventListeners.push(errorEventListener);
};
/**
* Callback function for {@link MatsSocket#setAuthorizationExpiredCallback}.
*
* @callback authorizationExpiredCallback
* @param {AuthorizationRequiredEvent} authorizationRequiredEvent information about why authorization information
* is requested.
*/
/**
* If this MatsSockets client realizes that the expiration time (minus the room for latency) of the authorization
* has passed when about to send a message, it will invoke this callback function. A new authorization must then
* be provided by invoking the 'setCurrentAuthorization' function - only when this is invoked, the MatsSocket
* will send messages. The MatsSocket will queue up any messages that are initiated while waiting for new
* authorization, and send them all at once in a single pipeline when the new authorization is in.
*
* @param {authorizationExpiredCallback} authorizationExpiredCallback function which will be invoked
* when about to send a new message <i>if</i>
* '<code>Date.now() > (expirationTimeMillisSinceEpoch - roomForLatencyMillis)</code>' from the paramaters of
* the last invocation of {@link MatsSocket#setCurrentAuthorization}.
*/
this.setAuthorizationExpiredCallback = function (authorizationExpiredCallback) {
if (!(typeof authorizationExpiredCallback === 'function')) {
throw Error("AuthorizationExpiredCallback must be a function");
}
_authorizationExpiredCallback = authorizationExpiredCallback;
// Evaluate whether there are stuff in the pipeline that should be sent now.
// (Not-yet-sent HELLO does not count..)
that.flush();
};
/**
* Sets an authorization String, which for several types of authorization must be invoked on a regular basis with
* fresh authorization - this holds for a OAuth/JWT/OIDC-type system where an access token will expire within a short time
* frame (e.g. expires within minutes). For an Oauth2-style authorization scheme, this could be "Bearer: ......".
* This must correspond to what the server side authorization plugin expects.
* <p />
* <b>NOTE: This SHALL NOT be used to CHANGE the user!</b> It should only refresh an existing authorization for the
* initially authenticated user. One MatsSocket (Session) shall only be used by a single user: If changing
* user, you should ditch the existing MatsSocket after invoking {@link MatsSocket#close} to properly clean up the
* current MatsSocketSession on the server side too, and then make a new MatsSocket thus getting a new Session.
* <p />
* Note: If the underlying WebSocket has not been established and HELLO sent, then invoking this method will NOT
* do that - only the first actual MatsSocket message will start the WebSocket and perform the HELLO/WELCOME
* handshake.
*
* @param {string} authorizationValue the string Value which will be transfered to the Server and there resolved
* to a Principal and UserId on the server side by the AuthorizationPlugin. Note that this value potentially
* also will be forwarded to other resources that requires authorization.
* @param {number} expirationTimestamp the millis-since-epoch at which this authorization expires
* (in case of OAuth-style tokens), or -1 if it never expires or otherwise has no defined expiration mechanism.
* <i>Notice that in a JWT token, the expiration time is in seconds, not millis: Multiply by 1000.</i>
* @param {number} roomForLatencyMillis the number of millis which is subtracted from the 'expirationTimestamp' to
* find the point in time where the MatsSocket will refuse to use the authorization and instead invoke the
* {@link #setAuthorizationExpiredCallback AuthorizationExpiredCallback} and wait for a new authorization
* being set by invocation of the present method. Depending on what the usage of the Authorization string
* is on server side is, this should probably <b>at least</b> be 10000, i.e. 10 seconds - but if the Mats
* endpoints uses the Authorization string to do further accesses, both latency and queue time must be
* taken into account (e.g. for calling into another API that also needs a valid token). If
* expirationTimestamp is '-1', then this parameter is not used. <i>Default value is 30000 (30 seconds).</i>
*/
this.setCurrentAuthorization = function (authorizationValue, expirationTimestamp= -1, roomForLatencyMillis = 30000) {
if (this.logging) log("Got Authorization which "
+ (expirationTimestamp !== -1 ? "Expires in [" + (expirationTimestamp - Date.now()) + " ms]" : "[Never expires]")
+ ", roomForLatencyMillis: " + roomForLatencyMillis);
_authorization = authorizationValue;
_expirationTimestamp = expirationTimestamp;
_roomForLatencyMillis = roomForLatencyMillis;
// ?: Should we send it now?
if (_authExpiredCallbackInvoked_EventType === AuthorizationRequiredEventType.REAUTHENTICATE) {
log("Immediate send of new authentication due to REAUTHENTICATE");
_forcePipelineProcessing = true;
}
// We're now back to "normal", i.e. not outstanding authorization request.
_authExpiredCallbackInvoked_EventType = undefined;
// Evaluate whether there are stuff in the pipeline that should be sent now.
// (Not-yet-sent HELLO does not count..)
that.flush();
};
/**
* Millis-since-epoch of last message enqueued. This can be used by the mechanism invoking
* {@link MatsSocket#setCurrentAuthorization} to decide whether it should keep the
* authorization fresh (i.e. no latency waiting for new authorization is introduced when a new message is
* enqueued), or fall back to relying on the 'authorizationExpiredCallback' being invoked when a new message needs
* it (thus introducing latency while waiting for authorization). One could envision keeping fresh auth for 5
* minutes, but if the user has not done anything requiring authentication (i.e. sending information bearing
* messages SEND, REQUEST or Replies) in that timespan, you stop doing continuous authentication refresh, falling
* back to the "on demand" based logic, where when a message is enqueued, the
* {@link MatsSocket#setAuthorizationExpiredCallback} is invoked if the authentication is expired.
*
* @member {number} lastMessageEnqueuedTimestamp
* @memberOf MatsSocket
* @readonly
*/
Object.defineProperty(this, "lastMessageEnqueuedTimestamp", {
get: function () {
return _lastMessageEnqueuedTimestamp;
}
});
/**
* Returns whether this MatsSocket <i>currently</i> have a WebSocket connection open. It can both go down
* by lost connection (driving through a tunnel), where it will start to do reconnection attempts, or because
* you (the Client) have {@link MatsSocket#close closed} this MatsSocketSession, or because the <i>Server</i> has
* closed the MatsSocketSession.
* <p/>
* Pretty much the same as <code>({@link MatsSocket.state} === {@link ConnectionState.CONNECTED})
* || ({@link MatsSocket.state} === {@link ConnectionState.SESSION_ESTABLISHED})</code> - however, in the face of
* {@link MessageType.DISCONNECT}, the state will not change, but the connection is dead ('connected' returns
* false).
*
* @member {string} connected
* @memberOf MatsSocket
* @readonly
*/
Object.defineProperty(this, "connected", {
get: function () {
return _webSocket != null;
}
});
/**
* Returns which one of the {@link ConnectionState} state enums the MatsSocket is in.
* <ul>
* <li>NO_SESSION - initial state, and after Session Close (both from client and server side)</li>
* <li>CONNECTING - when we're actively trying to connect, i.e. "new WebSocket(..)" has been invoked, but not yet either opened or closed.</li>
* <li>WAITING - if the "new WebSocket(..)" invocation ended in the socket closing, i.e. connection failed, but we're still counting down to next (re)connection attempt.</li>
* <li>CONNECTED - if the "new WebSocket(..)" resulted in the socket opening. We still have not established the MatsSocketSession with the server, though.</li>
* <li>SESSION_ESTABLISHED - when we're open for business: Connected, authenticated, and established MatsSocketSession with the server.</li>
* </ul>
*
* @member {string} state
* @memberOf MatsSocket
* @readonly
*/
Object.defineProperty(this, "state", {
get: function () {
return _state;
}
});
/**
* Metrics/Introspection: Returns an array of the 100 latest {@link PingPong}s. Note that a PingPong entry
* is added to this array <i>before</i> it gets the Pong, thus the latest may not have its
* {@link PingPong#roundTripMillis} set yet. Also, if a ping is performed right before the connection goes down,
* it will never get the Pong, thus there might be entries in the middle of the list too that does not have
* roundTripMillis set. This is opposed to the {@link #addPingPongListener}, which only gets invoked when
* the pong has arrived.
*
* @see MatsSocket#addPingPongListener
*
* @member {array<PingPong>}
* @memberOf MatsSocket
* @readonly
*/
Object.defineProperty(this, "pings", {
get: function () {
return _pings;
}
});
/**
* Callback function for {@link MatsSocket#addPingPongListener}.
*
* @callback addPingPongCallback
* @param {PingPong} pingPong information about the ping and the pong.
*/
/**
* A {@link PingPong} listener is invoked each time a {@link MessageType#PONG} message comes in, giving you
* information about the experienced {@link PingPong#roundTripMillis round-trip time}. The PINGs and PONGs are
* handled slightly special in that they always are handled ASAP with short-path code routes, and should thus
* give a good indication about experienced latency from the network. That said, they are sent on the same
* connection as all data, so if there is a gigabyte document "in the pipe", the PING will come behind that
* and thus get a big hit. Thus, you should consider this when interpreting the results - a high outlier should
* be seen in conjunction with a message that was sent at the same time.
*
* @param {addPingPongCallback} pingPongListener a function that is invoked when the library issues
*/
this.addPingPongListener = function (pingPongListener) {
if (!(typeof pingPongListener === 'function')) {
throw Error("PingPong listener must be a function");
}
_pingPongListeners.push(pingPongListener);
};
/**
* Metrics/Introspection: Returns an array of the {@link #numberOfInitiationsKept} latest
* {@link InitiationProcessedEvent}s.
* <p />
* Note: These objects will always have the {@link InitiationProcessedEvent#initiationMessage} and (if Request)
* {@link InitiationProcessedEvent#replyMessageEvent} set, as opposed to the events issued to
* {@link #addInitiationProcessedEventListener}, which can decide whether to include them.
*
* @see MatsSocket#addInitiationProcessedEventListener
*
* @member {InitiationProcessedEvent<InitiationProcessedEvent>}
* @memberOf MatsSocket
* @readonly
*/
Object.defineProperty(this, "initiations", {
get: function () {
return _initiationProcessedEvents;
}
});
/**
* Metrics/Introspection: How many {@link InitiationProcessedEvent}s to keep in {@link #initiations}.
* If the current number of initiations is more than what you set it to, it will be culled.
* You can use this to "reset" the {@link #initiations array of initiations} by setting it to 0, then right
* back up to whatever you fancy.
* <p />
* Default is 10.
*
* @member {number}
* @memberOf MatsSocket
* @readonly
*/
Object.defineProperty(this, "numberOfInitiationsKept", {
get: function () {
return _numberOfInitiationsKept;
},
set: function (numberOfInitiationsKept) {
if (numberOfInitiationsKept < 0) {
throw new Error("numberOfInitiationsKept must be >= 0");
}
_numberOfInitiationsKept = numberOfInitiationsKept;
while (_initiationProcessedEvents.length > numberOfInitiationsKept) {
_initiationProcessedEvents.shift();
}
}
});
/**
* Callback function for {@link MatsSocket#addInitiationProcessedEventListener}.
*
* @callback initiationProcessedEventCallback
* @param {InitiationProcessedEvent} initiationProcessedEvent information about the processing of the initiation.
*/
/**
* Registering an {@link InitiationProcessedEvent} listener will give you meta information about each Send
* and Request that is performed through the library when it is fully processed, thus also containing
* information about experienced round-trip times. The idea is that you thus can gather metrics of
* performance as experienced out on the client, by e.g. periodically sending this gathering to the Server.
* <b>Make sure that you understand that if you send to the server each time this listener is invoked, using
* the MatsSocket itself, you WILL end up in a tight loop!</b> This is because the sending of the statistics
* message itself will again trigger a new invocation of this listener. This can be avoided in two ways: Either
* instead send periodically - in which case you can include the statistics message itself, OR specify that
* you do NOT want a listener-invocation of these messages by use of the config object on the send, request
* and requestReplyTo methods.
* <p />
* Note: Each listener gets its own instance of {@link InitiationProcessedEvent}, which also is different from
* the ones in the {@link MatsSocket.initiations} array.
*
* @param {initiationProcessedEventCallback} initiationProcessedEventListener a function that is invoked when
* the library issues {@link InitiationProcessedEvent}s.
* @param {boolean} includeInitiationMessage whether to include the {@link InitiationProcessedEvent#initiationMessage}
* @param {boolean} includeReplyMessageEvent whether to include the {@link InitiationProcessedEvent#replyMessageEvent}
* Reply {@link MessageEvent}s.
*/
this.addInitiationProcessedEventListener = function (initiationProcessedEventListener, includeInitiationMessage, includeReplyMessageEvent) {
if (!(typeof initiationProcessedEventListener === 'function')) {
throw Error("InitiationProcessedEvent listener must be a function");
}
_initiationProcessedEventListeners.push({
listener: initiationProcessedEventListener,
includeInitiationMessage: includeInitiationMessage,
includeReplyMessageEvent: includeReplyMessageEvent
});
};
// ========== Terminator and Endpoint registration ==========
/**
* Registers a Terminator, on the specified terminatorId, and with the specified callbacks. A Terminator is
* the target for Server-to-Client SENDs, and the Server's REPLYs from invocations of
* <code>requestReplyTo(terminatorId ..)</code> where the terminatorId points to this Terminator.
* <p />
* Note: You cannot register any Terminators, Endpoints or Subscriptions starting with "MatsSocket".
*
* @param terminatorId the id of this client side Terminator.
* @param messageCallback receives an Event when everything went OK, containing the message on the "data" property.
* @param rejectCallback is relevant if this endpoint is set as the replyTo-target on a requestReplyTo(..) invocation, and will
* get invoked with the Event if the corresponding Promise-variant would have been rejected.
*/
this.terminator = function (terminatorId, messageCallback, rejectCallback) {
// :: Assert for double-registrations
if (_terminators[terminatorId] !== undefined) {
throw new Error("Cannot register more than one Terminator to same terminatorId [" + terminatorId + "], existing: " + _terminators[terminatorId]);
}
if (_endpoints[terminatorId] !== undefined) {
throw new Error("Cannot register a Terminator to same terminatorId [" + terminatorId + "] as an Endpoint's endpointId, existing: " + _endpoints[terminatorId]);
}
// :: Assert that the namespace "MatsSocket" is not used
if (terminatorId.startsWith("MatsSocket")) {
throw new Error('The namespace "MatsSocket" is reserved, terminatorId [' + terminatorId + '] is illegal.');
}
// :: Assert that the messageCallback is a function
if (typeof messageCallback !== 'function') {
throw new Error("The 'messageCallback' must be a function.");
}
// :: Assert that the rejectCallback is either undefined or a function
if ((rejectCallback !== undefined) && (typeof rejectCallback !== 'function')) {
throw new Error("The 'rejectCallback' must either be undefined or a function.");
}
log("Registering Terminator on id [" + terminatorId + "]:\n #messageCallback: " + messageCallback + "\n #rejectCallback: " + rejectCallback);
_terminators[terminatorId] = {
resolve: messageCallback,
reject: rejectCallback
};
};
/**
* Registers an Endpoint, on the specified endpointId, with the specified "promiseProducer". An Endpoint is
* the target for Server-to-Client REQUESTs. The promiseProducer is a function that takes a message event
* (the incoming REQUEST) and produces a Promise, whose return (resolve or reject) is the return value of the
* endpoint.
* <p />
* Note: You cannot register any Terminators, Endpoints or Subscriptions starting with "MatsSocket".
*
* @param endpointId the id of this client side Endpoint.
* @param {function} promiseProducer a function that takes a Message Event and returns a Promise which when
* later either Resolve or Reject will be the return value of the endpoint call.
*/
this.endpoint = function (endpointId, promiseProducer) {
// :: Assert for double-registrations
if (_endpoints[endpointId] !== undefined) {
throw new Error("Cannot register more than one Endpoint to same endpointId [" + endpointId + "], existing: " + _endpoints[endpointId]);
}
if (_terminators[endpointId] !== undefined) {
throw new Error("Cannot register an Endpoint to same endpointId [" + endpointId + "] as a Terminator, existing: " + _terminators[endpointId]);
}
// :: Assert that the namespace "MatsSocket" is not used
if (endpointId.startsWith("MatsSocket")) {
throw new Error('The namespace "MatsSocket" is reserved, EndpointId [' + endpointId + '] is illegal.');
}
// :: Assert that the promiseProducer is a function
if (typeof promiseProducer !== 'function') {
throw new Error("The 'promiseProducer' must be a function.");
}
log("Registering Endpoint on id [" + endpointId + "]:\n #promiseProducer: " + promiseProducer);
_endpoints[endpointId] = promiseProducer;
};
/**
* Subscribes to a Topic. The Server may do an authorization check for the subscription. If you are not allowed,
* a {@link SubscriptionEvent} of type {@link SubscriptionEventType.NOT_AUTHORIZED} is issued, and the callback
* will not get any messages. Otherwise, the event type is {@link SubscriptionEventType.OK}.
* <p />
* Note: If the 'messageCallback' was already registered, an error is emitted, but the method otherwise returns
* silently.
* <p />
* Note: You will not get messages that was issued before the subscription initially is registered with the
* server, which means that you by definition cannot get any messages issued earlier than the initial
* {@link ConnectionEventType.SESSION_ESTABLISHED}. Code accordingly. <i>Tip for a "ticker stream" or "cache
* update stream" or similar: Make sure you have some concept of event sequence number on updates. Do the MatsSocket
* connect with the Subscription in place, but for now just queue up any updates. Do the request for "full initial load", whose reply
* contains the last applied sequence number. Now process the queued events that arrived while getting the
* initial load (i.e. in front, or immediately after), taking into account which event sequence numbers that
* already was applied in the initial load: Discard the earlier and same, apply the later. Finally, go over to
* immediate processing of the events. If you get a reconnect telling you that messages was lost (next "Note"!),
* you could start this process over.</i>
* <p />
* Note: Reconnects are somewhat catered for, in that a "re-subscription" after re-establishing the session will
* contain the latest messageId the client has received, and the server will then send along all the messages
* <i>after</i> this that was lost - up to some limit specified on the server. If the messageId is not known by the server,
* implying that the client has been gone for too long time, a {@link SubscriptionEvent} of type
* {@link SubscriptionEventType.LOST_MESSAGES} is issued. Otherwise, the event type is
* {@link SubscriptionEventType.OK}.
* <p />
* Note: You should preferably add all "static" subscriptions in the "configuration phase" while setting up
* your MatsSocket, before starting it (i.e. sending first message). However, dynamic adding and
* {@link MatsSocket#deleteSubscription deleting} is also supported.
* <p />
* Note: Pub/sub is not designed to be as reliable as send/request - but it should be pretty ok anyway!
* <p />
* Wrt. to how many topics a client can subscribe to: Mainly bandwidth constrained wrt. to the total number of
* messages, although there is a slight memory and CPU usage to consider too (several hundred should not really
* be a problem). In addition, the client needs to send over the actual subscriptions, and if these number in
* the thousands, the connect and any reconnects could end up with tens or hundreds of kilobytes of "system
* information" passed over the WebSocket.
* <p />
* Wrt. to how many topics that can exist: Mainly memory constrained on the server based on the number of topics
* multiplied by the number of subscriptions per topic, in addition to the number of messages passed in total
* as each node in the cluster will have to listen to either the full total of messages, or at least a
* substantial subset of the messages - and it will also retain these messages for hours to allow for client
* reconnects.
* <p />
* Note: You cannot register any Terminators, Endpoints or Subscriptions starting with "MatsSocket".
*/
this.subscribe = function (topicId, messageCallback) {
// :: Assert that the namespace "MatsSocket" is not used
if (topicId.startsWith("MatsSocket")) {
throw new Error('The namespace "MatsSocket" is reserved, Topic [' + topicId + '] is illegal.');
}
if (topicId.startsWith("!")) {
throw new Error('Topic cannot start with "!"