UNPKG

matssocket

Version:
847 lines (799 loc) 180 kB
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 "!"