@jenkins-cd/sse-gateway
Version:
Client API for the Jenkins SSE Gateway plugin. Browser UI push events from Jenkins.
636 lines (572 loc) • 25.3 kB
JavaScript
/*
* The MIT License
*
* Copyright (c) 2016, CloudBees, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
var jsModules = require('@jenkins-cd/js-modules');
var ajax = require('./ajax');
var json = require('./json');
// See https://github.com/tfennelly/jenkins-js-logging - will move to jenskinsci org
var logging = require('@jenkins-cd/logging');
var LOGGER = logging.logger('org.jenkinsci.sse');
// If no clientId is specified, then we generate one with
// an incrementing ID on the end of it. This var holds the
// next Id.
var nextGeneratedClientId = 1;
// A map of client connection by clientId.
var clientConnections = {};
var eventSourceSupported = (window !== undefined && window.EventSource !== undefined);
/* eslint-disable no-use-before-define */
/* eslint-disable quotes */
module.exports = SSEConnection;
function SSEConnection(clientId, configuration) {
if (typeof clientId === 'string') {
this.clientId = clientId;
} else {
this.clientId = 'sse-client-' + nextGeneratedClientId;
nextGeneratedClientId++;
}
this.configuration = extend({}, SSEConnection.DEFAULT_CONFIGURATION, configuration);
this.jenkinsUrl = this.configuration.jenkinsUrl;
this.eventSource = undefined;
this.eventSourceListenerQueue = [];
this.connectable = true;
this.jenkinsSessionInfo = undefined;
this.subscriptions = [];
this.channelListeners = {};
this.configurationBatchId = 0;
this.configurationQueue = {
subscribe: [],
unsubscribe: []
};
this.configurationListeners = {};
this.nextDoConfigureTimeout = undefined;
this.doPingTimeout = undefined;
// Initialize the queue config batch tracking
this._resetConfigQueue();
}
SSEConnection.DEFAULT_CONFIGURATION = {
batchConfigDelay: 100,
sendSessionId: false
};
SSEConnection.prototype = {
connect: function (onConnect) {
if (this.eventSource) {
return;
}
if (clientConnections[this.clientId]) {
LOGGER.error('A connection to client having ID ' + this.clientId
+ ' already exists. You must first disconnect if you want to reconnect.');
return;
}
// If the browser supports HTML5 sessionStorage, then lets append a tab specific
// random ID to the client ID. This allows us to cleanly connect to a backend session,
// but to do it on a per tab basis i.e. reloading from the same tab reconnects that tab
// to the same backend dispatcher but allows each tab to have their own dispatcher,
// avoiding weirdness when multiple tabs are open to the same "clientId".
var tabClientId = this.clientId;
if (window.sessionStorage) {
var storeKey = 'jenkins-sse-gateway-client-' + this.clientId;
tabClientId = window.sessionStorage.getItem(storeKey);
if (!tabClientId) {
tabClientId = this.clientId + '-' + generateId();
window.sessionStorage.setItem(storeKey, tabClientId);
}
}
if (this.jenkinsUrl === undefined) {
try {
this.jenkinsUrl = jsModules.getRootURL();
} catch (e) {
LOGGER.warn("Jenkins SSE client initialization failed. Unable to connect to " +
"Jenkins because we are unable to determine the Jenkins Root URL. SSE events " +
"will not be received. Probable cause: no 'data-rooturl' on the page <head> " +
"element e.g. running in a test, or running headless without specifying a " +
"Jenkins URL.");
}
}
if (this.jenkinsUrl !== undefined) {
this.jenkinsUrl = normalizeUrl(this.jenkinsUrl);
}
this.pingUrl = this.jenkinsUrl + '/sse-gateway/ping';
// Used to keep track of connection errors.
var errorTracking = {
errors: [],
reset: function () {
if (errorTracking.waitForHealingTimeout) {
clearTimeout(errorTracking.waitForHealingTimeout);
delete errorTracking.waitForHealingTimeout;
}
if (errorTracking.pingbackTimeout) {
clearTimeout(errorTracking.pingbackTimeout);
delete errorTracking.pingbackTimeout;
}
errorTracking.errors = [];
}
};
if (!eventSourceSupported) {
LOGGER.warn("This browser does not support EventSource. Where's the polyfill?");
} else if (this.jenkinsUrl !== undefined) {
var connectUrl = this.jenkinsUrl + '/sse-gateway/connect?clientId='
+ encodeURIComponent(tabClientId);
var sseConnection = this;
ajax.get(connectUrl, function (response) {
var listenUrl = sseConnection.jenkinsUrl + '/sse-gateway/listen/'
+ encodeURIComponent(tabClientId);
if (sseConnection.configuration.sendSessionId) {
// Sending the jsessionid helps headless clients to maintain
// the session with the backend.
var jsessionid = response.data.jsessionid;
listenUrl += ';jsessionid=' + jsessionid;
}
var EventSource = window.EventSource;
var source = new EventSource(listenUrl);
source.addEventListener('open', function (e) {
LOGGER.debug('SSE channel "open" event.', e);
errorTracking.reset();
if (e.data) {
sseConnection.jenkinsSessionInfo = JSON.parse(e.data);
if (onConnect) {
onConnect(sseConnection.jenkinsSessionInfo);
}
}
}, false);
source.addEventListener('error', function (e) {
LOGGER.debug('SSE channel "error" event.', e);
if (errorTracking.errors.length === 0) {
// First give the connection a chance to heal itself.
errorTracking.waitForHealingTimeout = setTimeout(function () {
if (errorTracking.errors.length !== 0) {
// The connection is still not ok. Lets fire a ping request.
// If the connection becomes ok, we should get a pingback
// ack and the timeouts etc should get cleared etc.
// See 'pingback' below
errorTracking.pingbackTimeout = setTimeout(function () {
delete errorTracking.pingbackTimeout;
if (typeof sseConnection._onerror === 'function'
&& errorTracking.errors.length > 0) {
var errorToSend = errorTracking.errors[0];
errorTracking.reset();
try {
sseConnection._onerror(errorToSend);
} catch (error) {
LOGGER.error('SSEConnection "onError" event handler ' +
'threw unexpected error.', error);
}
} else {
errorTracking.reset();
}
}, 3000); // TODO: magic num ... what's realistic ?
ajax.get(sseConnection.pingUrl + '?dispatcherId=' +
encodeURIComponent(
sseConnection.jenkinsSessionInfo.dispatcherId));
}
}, 4000); // TODO: magic num ... what's realistic ?
}
errorTracking.errors.push(e);
}, false);
source.addEventListener('pingback', function (e) {
LOGGER.debug('SSE channel "pingback" event received.', e);
errorTracking.reset();
}, false);
source.addEventListener('configure', function (e) {
LOGGER.debug('SSE channel "configure" ACK event (see batchId on event).', e);
if (e.data) {
var configureInfo = JSON.parse(e.data);
sseConnection._notifyConfigQueueListeners(configureInfo.batchId);
}
}, false);
source.addEventListener('reload', function (e) {
LOGGER.debug('SSE channel "reload" event received. Reloading page now.', e);
window.location.reload(true);
}, false);
// Add any listeners that have been requested to be added.
for (var i = 0; i < sseConnection.eventSourceListenerQueue.length; i++) {
var config = sseConnection.eventSourceListenerQueue[i];
source.addEventListener(config.channelName, config.listener, false);
}
sseConnection.eventSource = source;
if (sseConnection.connectable === false) {
sseConnection.disconnect();
}
}, function (httpObject) {
LOGGER.error('SSEConnection failure (' + httpObject.status
+ '): ' + httpObject.responseText, httpObject);
sseConnection.connectable = false;
sseConnection._clearDoConfigure();
});
}
clientConnections[this.clientId] = this;
},
isConnected: function () {
// We are connected if we have an EventSource object.
return (this.eventSource !== undefined);
},
onError: function (handler) {
this._onerror = handler;
},
waitConnectionOk: function (handler) {
if (!this.eventSource) {
throw new Error('Not connected.');
}
if (typeof handler !== 'function') {
throw new Error('No waitServerRunning callback function provided.');
}
var sseConnection = this;
var connection = this;
var connectErrorCount = 0;
function doPingWait() {
ajax.isAlive(connection.pingUrl, function (status) {
// Ok to schedule another ping.
sseConnection.doPingTimeout = undefined;
var connectError = false;
// - status 0 "typically" means timed out. Anything less than 100
// is meaningless anyway, so lets just go with that.
// - status 500+ errors mean that the server (or intermediary) are
// unable to handle the request, which from a users point of view
// is equivalent to not being able to connect to the server.
if (status < 100 || status >= 500) {
connectError = true;
connectErrorCount++;
// Try again in few seconds
LOGGER.debug('Server connection error %s (%s).', status, connection.jenkinsUrl);
sseConnection.doPingTimeout = setTimeout(doPingWait, 3000);
} else {
// Ping worked ... we connected.
LOGGER.debug('Server connection ok.');
}
handler({
statusCode: status,
connectError: connectError,
connectErrorCount: connectErrorCount
});
});
}
if (!sseConnection.doPingTimeout) {
doPingWait();
}
},
disconnect: function () {
try {
if (this.eventSource) {
try {
if (typeof this.eventSource.removeEventListener === 'function') {
for (var channelName in this.channelListeners) {
if (this.channelListeners.hasOwnProperty(channelName)) {
try {
this.eventSource.removeEventListener(channelName,
this.channelListeners[channelName]);
} catch (e) {
LOGGER.error('Unexpected error removing listners', e);
}
}
}
}
} finally {
try {
this.eventSource.close();
} finally {
this.eventSource = undefined;
this.channelListeners = {};
delete clientConnections[this.clientId];
}
}
}
} finally {
this.connectable = false;
this._clearDoConfigure();
}
},
subscribe: function () {
this._clearDoConfigure();
if (!this.connectable) {
return undefined;
}
var channelName;
var filter;
var callback;
var onSubscribed;
// sort out the args.
if (arguments.length === 1 && typeof arguments[0] === 'object') {
var configObj = arguments[0];
channelName = configObj.channelName;
callback = configObj.onEvent;
filter = configObj.filter;
onSubscribed = configObj.onSubscribed;
} else {
for (var i = 0; i < arguments.length; i++) {
var arg = arguments[i];
if (typeof arg === 'string') {
channelName = arg;
} else if (typeof arg === 'function') {
callback = arg;
} else if (typeof arg === 'object') {
filter = arg;
}
}
}
if (channelName === undefined) {
throw new Error('No channelName arg provided.');
}
if (callback === undefined) {
throw new Error('No callback arg provided.');
}
var config;
if (filter) {
// Clone the filter as the config.
config = JSON.parse(json.stringify(filter));
} else {
config = {};
}
config.jenkins_channel = channelName;
this.subscriptions.push({
config: config,
callback: callback
});
if (!this.configurationQueue.subscribe) {
this.configurationQueue.subscribe = [];
}
this.configurationQueue.subscribe.push(config);
if (!this.channelListeners[channelName]) {
this._addChannelListener(channelName);
}
this._scheduleDoConfigure();
if (onSubscribed) {
this._addConfigQueueListener(onSubscribed);
}
return callback;
},
unsubscribe: function (callback, onUnsubscribed) {
this._clearDoConfigure();
// callback is the only mandatory param
if (callback === undefined) {
throw new Error('No callback provided');
}
var newSubscriptionList = [];
for (var i = 0; i < this.subscriptions.length; i++) {
var subscription = this.subscriptions[i];
if (subscription.callback === callback) {
if (!this.configurationQueue.unsubscribe) {
this.configurationQueue.unsubscribe = [];
}
this.configurationQueue.unsubscribe.push(subscription.config);
} else {
newSubscriptionList.push(subscription);
}
}
this.subscriptions = newSubscriptionList;
this._scheduleDoConfigure();
if (onUnsubscribed) {
this._addConfigQueueListener(onUnsubscribed);
}
},
_resetConfigQueue: function (skipPendingCheck) {
if (!skipPendingCheck && this._hasPendingConfigs()) {
throw new Error('Invalid call to reset the SSE config queue ' +
'while there are pending configs.', this.configurationQueue);
}
this.configurationBatchId++;
this.configurationQueue = {
subscribe: [],
unsubscribe: []
};
this.configurationListeners[this.configurationBatchId.toString()] = [];
},
_addConfigQueueListener: function (listener) {
// Config queue listeners are always added against the current batchId.
// When that config batch is sent, these listeners will be notified on
// receipt of the "configure" SSE event, which will contain that batchId.
// See the notifyConfigQueueListeners function below.
var batchListeners =
this.configurationListeners[this.configurationBatchId.toString()];
if (batchListeners) {
batchListeners.push(listener);
} else {
LOGGER.error(new Error('Unexpected call to addConfigQueueListener for an ' +
'obsolete/unknown batchId ' + this.configurationBatchId
+ '. This should never happen!!'));
}
},
_notifyConfigQueueListeners: function (batchId) {
var batchListeners = this.configurationListeners[batchId.toString()];
if (batchListeners) {
delete this.configurationListeners[batchId.toString()];
for (var i = 0; i < batchListeners.length; i++) {
try {
batchListeners[i]();
} catch (e) {
LOGGER.error('Unexpected error calling config queue listener.', e);
}
}
}
},
_clearDoConfigure: function () {
if (this.nextDoConfigureTimeout) {
clearTimeout(this.nextDoConfigureTimeout);
}
this.nextDoConfigureTimeout = undefined;
},
_scheduleDoConfigure: function (delay) {
this._clearDoConfigure();
var timeoutDelay = delay;
if (timeoutDelay === undefined) {
timeoutDelay = this.configuration.batchConfigDelay;
}
var self = this;
this.nextDoConfigureTimeout = setTimeout(function () {
self._doConfigure();
}, timeoutDelay);
},
_addChannelListener: function (channelName) {
var sseConnection = this;
var listener = function (event) {
if (LOGGER.isLogEnabled()) {
var channelEvent = JSON.parse(event.data);
LOGGER.log('Received event "' + channelEvent.jenkins_channel
+ '/' + channelEvent.jenkins_event + ':', channelEvent);
}
// Iterate through all of the subscriptions, looking for
// subscriptions on the channel that match the filter/config.
var processCount = 0;
for (var i = 0; i < sseConnection.subscriptions.length; i++) {
var subscription = sseConnection.subscriptions[i];
if (subscription.config.jenkins_channel === channelName) {
// Parse the data every time, in case the
// callback modifies it.
var parsedData = JSON.parse(event.data);
// Make sure the data matches the config, which is the filter
// plus the channel name (and the message should have the
// channel name in it).
if (containsAll(parsedData, subscription.config)) {
try {
processCount++;
subscription.callback(parsedData);
} catch (e) {
LOGGER.debug(e);
}
}
}
}
if (processCount === 0 && LOGGER.isWarnEnabled()) {
LOGGER.warn('Event not processed by any active listeners ('
+ sseConnection.subscriptions.length + ' of). Check event ' +
'payload against subscription ' +
'filters - see earlier "notification configuration" request(s).');
}
};
this.channelListeners[channelName] = listener;
if (this.eventSource) {
this.eventSource.addEventListener(channelName, listener, false);
} else {
this.eventSourceListenerQueue.push({
channelName: channelName,
listener: listener
});
}
},
_doConfigure: function () {
this.nextDoConfigureTimeout = undefined;
var sessionInfo = this.jenkinsSessionInfo;
if (!sessionInfo && eventSourceSupported) {
// Can't do it yet. Need to wait for the SSE Gateway to
// open the SSE channel + send the jenkins session info.
this._scheduleDoConfigure(100);
} else if (this._hasPendingConfigs()) {
var configureUrl = this.jenkinsUrl + '/sse-gateway/configure?batchId='
+ this.configurationBatchId;
if (LOGGER.isDebugEnabled()) {
LOGGER.debug('Sending notification configuration request for configuration batch '
+ this.configurationBatchId + '.', this.configurationQueue);
}
this.configurationQueue.dispatcherId = sessionInfo.dispatcherId;
// clone the config, just in case of bad change later.
var configurationQueue = JSON.parse(JSON.stringify(this.configurationQueue));
var sseConnection = this;
ajax.post(configurationQueue, configureUrl, sessionInfo, function (data, http) {
LOGGER.error('Error configuring SSE connection.', data, http);
if (sseConnection.configuration.onConfigError) {
sseConnection.configuration.onConfigError(data, http);
}
});
this._resetConfigQueue(true);
}
},
_hasPendingConfigs: function () {
return (this.configurationQueue.subscribe.length > 0
|| this.configurationQueue.unsubscribe.length > 0);
}
};
/* eslint-disable no-param-reassign */
function containsAll(object, filter) {
for (var property in filter) {
if (filter.hasOwnProperty(property)) {
var objVal = object[property];
var filterVal = filter[property];
if (objVal === undefined) {
return false;
}
// String comparison i.e. ignore type
if (objVal.toString() !== filterVal.toString()) {
return false;
}
}
}
return true;
}
function normalizeUrl(url) {
if (!url) {
return '';
}
// remove trailing slashes
var newUrl = url;
while (newUrl.charAt(newUrl.length - 1) === '/') {
newUrl = newUrl.substring(0, newUrl.length - 1);
}
return newUrl;
}
/**
* Generate a random "enough" string from the current time in
* millis + a random generated number string.
* @returns {string}
*/
function generateId() {
return (new Date().getTime()) + '-' + (Math.random() + 1).toString(36).substring(7);
}
/**
* Simple Object extend utility function.
* <p/>
* Extends the 1st argument object by mapping the following arg objects onto it.
* @returns {object} The first argument (the target).
*/
function extend() {
if (arguments.length < 2) {
throw new Error('There must be at least 2 arguments.');
}
var target = arguments[0];
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i];
for (var prop in source) {
if (source.hasOwnProperty(prop)) {
target[prop] = source[prop];
}
}
}
return target;
}