diff --git a/services/web/frontend/js/ide/connection/ConnectionManager.js b/services/web/frontend/js/ide/connection/ConnectionManager.js index 63e6884f0d..54d4fe40ed 100644 --- a/services/web/frontend/js/ide/connection/ConnectionManager.js +++ b/services/web/frontend/js/ide/connection/ConnectionManager.js @@ -10,7 +10,7 @@ /* global io */ -define([], function() { +define(['./SocketIoShim'], function(SocketIoShim) { let ConnectionManager const ONEHOUR = 1000 * 60 * 60 @@ -43,7 +43,7 @@ define([], function() { console.error( 'Socket.io javascript not loaded. Please check that the real-time service is running and accessible.' ) - this.ide.socket = { on() {} } + this.ide.socket = SocketIoShim.stub() this.$scope.$apply(() => { return (this.$scope.state.error = 'Could not connect to websocket server :(') @@ -116,7 +116,7 @@ define([], function() { pathname: '/socket.io' } } - this.ide.socket = io.connect( + this.ide.socket = SocketIoShim.connect( parsedURL.origin, { resource: parsedURL.pathname.slice(1), diff --git a/services/web/frontend/js/ide/connection/SocketIoShim.js b/services/web/frontend/js/ide/connection/SocketIoShim.js new file mode 100644 index 0000000000..45edae5218 --- /dev/null +++ b/services/web/frontend/js/ide/connection/SocketIoShim.js @@ -0,0 +1,224 @@ +/* global io */ + +define([], function() { + class SocketShimBase { + static connect(url, options) { + return new SocketShimBase() + } + constructor(socket) { + this._socket = socket + } + } + const transparentMethods = [ + 'connect', + 'disconnect', + 'emit', + 'on', + 'removeListener' + ] + for (let method of transparentMethods) { + SocketShimBase.prototype[method] = function() { + this._socket[method].apply(this._socket, arguments) + } + } + + class SocketShimNoop extends SocketShimBase { + static connect() { + return new SocketShimNoop() + } + constructor(socket) { + super(socket) + this.socket = { + get connected() { + return false + }, + get sessionid() { + return undefined + }, + get transport() { + return {} + }, + + connect() {}, + disconnect(reason) {} + } + } + connect() {} + disconnect(reason) {} + emit() {} + on() {} + removeListener() {} + } + + class SocketShimV0 extends SocketShimBase { + static connect(url, options) { + return new SocketShimV0( + io.connect( + url, + options + ) + ) + } + constructor(socket) { + super(socket) + this.socket = this._socket.socket + } + } + + class SocketShimV2 extends SocketShimBase { + static connect(url, options) { + options.forceNew = options['force new connection'] + // .resource has no leading slash, path wants to see one. + options.path = '/' + options.resource + options.reconnection = options.reconnect + options.timeout = options['connect timeout'] + return new SocketShimV2(url, options) + } + static get EVENT_MAP() { + // Use the v2 event names transparently to the frontend. + const connectionFailureEvents = [ + 'connect_error', + 'connect_timeout', + 'error' + ] + return new Map([ + ['connect_failed', connectionFailureEvents], + ['error', connectionFailureEvents] + ]) + } + _on(event, handler) { + // Keep track of our event listeners. + // We move them to a new socket in ._replaceSocketWithNewInstance() + if (!this._events.has(event)) { + this._events.set(event, [handler]) + } else { + this._events.get(event).push(handler) + } + this._socket.on(event, handler) + } + on(event, handler) { + if (SocketShimV2.EVENT_MAP.has(event)) { + for (const v2Event of SocketShimV2.EVENT_MAP.get(event)) { + this._on(v2Event, handler) + } + } else { + this._on(event, handler) + } + } + _removeListener(event, handler) { + // Keep track of our event listeners. + // We move them to a new socket in ._replaceSocketWithNewInstance() + if (this._events.has(event)) { + const listeners = this._events.get(event) + const pos = listeners.indexOf(handler) + if (pos !== -1) { + listeners.splice(pos, 1) + } + } + this._socket.removeListener(event, handler) + } + removeListener(event, handler) { + if (SocketShimV2.EVENT_MAP.has(event)) { + for (const v2Event of SocketShimV2.EVENT_MAP.get(event)) { + this._removeListener(v2Event, handler) + } + } else { + this._removeListener(event, handler) + } + } + + static createNewSocket(url, options) { + // open a brand new connection for the default namespace '/' + // The old socket can still leak 'disconnect' events from the teardown + // of the old transport. The leaking 'disconnect' events interfere with + // the _new_ connection and cancel the new connect attempt. + // Also skip the caching in these locations: + // - `io.connect()` caches `io.Manager`s in `io.managers` + // - `io.Manager().socket()` caches `io.Socket`s in its `this.nsps` + return io.Manager(url, options).socket('/', options) + } + + _replaceSocketWithNewInstance() { + const oldSocket = this._socket + const newSocket = SocketShimV2.createNewSocket(this._url, this._options) + + // move our existing event handlers to the new socket + this._events.forEach((listeners, event) => { + for (const listener of listeners) { + oldSocket.removeListener(event, listener) + newSocket.on(event, listener) + } + }) + + if (oldSocket.connected) { + // We overwrite the reference to oldSocket soon. + // Make sure we are disconnected. + oldSocket.disconnect() + } + this._socket = newSocket + } + + connect() { + // have the same logic behind socket.connect and socket.socket.connect + this._replaceSocketWithNewInstance() + } + + constructor(url, options) { + super(SocketShimV2.createNewSocket(url, options)) + this._url = url + this._options = options + this._events = new Map() + + const self = this + function _getEngine() { + return (self._socket.io && self._socket.io.engine) || {} + } + + this.socket = { + get connected() { + return self._socket.connected + }, + get sessionid() { + if (self._socket.id) { + return self._socket.id + } + // socket.id is discarded upon disconnect + // the id is still available in the internal state + return _getEngine().id + }, + get transport() { + return _getEngine().transport + }, + + connect() { + self._replaceSocketWithNewInstance() + }, + disconnect(reason) { + return self._socket.disconnect(reason) + } + } + } + } + + let current + if (typeof io === 'undefined' || !io) { + sl_console.log('[socket.io] Shim: socket.io is not loaded, returning noop') + current = SocketShimNoop + } else if (typeof io.version === 'string' && io.version.slice(0, 1) === '0') { + sl_console.log('[socket.io] Shim: detected v0') + current = SocketShimV0 + } else { + // socket.io v2 does not have a global io.version attribute. + sl_console.log('[socket.io] Shim: detected v2') + current = SocketShimV2 + } + + return { + SocketShimNoop, + SocketShimV0, + SocketShimV2, + current, + connect: current.connect, + stub: () => new SocketShimNoop() + } +})