diff --git a/packages/@uppy/companion-client/src/Socket.js b/packages/@uppy/companion-client/src/Socket.js new file mode 100644 index 0000000000..0a98200d72 --- /dev/null +++ b/packages/@uppy/companion-client/src/Socket.js @@ -0,0 +1,87 @@ +import ee from 'namespace-emitter' + +export default class UppySocket { + #queued = [] + + #emitter = ee() + + #isOpen = false + + #socket + + constructor (opts) { + this.opts = opts + + if (!opts || opts.autoOpen !== false) { + this.open() + } + } + + get isOpen () { return this.#isOpen } + + [Symbol.for('uppy test: getSocket')] () { return this.#socket } + + [Symbol.for('uppy test: getQueued')] () { return this.#queued } + + open () { + if (this.#socket != null) return + + this.#socket = new WebSocket(this.opts.target) + + this.#socket.onopen = () => { + this.#isOpen = true + + while (this.#queued.length > 0 && this.#isOpen) { + const first = this.#queued.shift() + this.send(first.action, first.payload) + } + } + + this.#socket.onclose = () => { + this.#isOpen = false + this.#socket = null + } + + this.#socket.onmessage = this.#handleMessage + } + + close () { + this.#socket?.close() + } + + send (action, payload) { + // attach uuid + + if (!this.#isOpen) { + this.#queued.push({ action, payload }) + return + } + + this.#socket.send(JSON.stringify({ + action, + payload, + })) + } + + on (action, handler) { + this.#emitter.on(action, handler) + } + + emit (action, payload) { + this.#emitter.emit(action, payload) + } + + once (action, handler) { + this.#emitter.once(action, handler) + } + + #handleMessage = (e) => { + try { + const message = JSON.parse(e.data) + this.emit(message.action, message.payload) + } catch (err) { + // TODO: use a more robust error handler. + console.log(err) // eslint-disable-line no-console + } + } +} diff --git a/packages/@uppy/companion-client/src/Socket.test.js b/packages/@uppy/companion-client/src/Socket.test.js new file mode 100644 index 0000000000..97e46dbd2a --- /dev/null +++ b/packages/@uppy/companion-client/src/Socket.test.js @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, vi, describe, it, expect } from 'vitest' +import UppySocket from './Socket.js' + +describe('Socket', () => { + let webSocketConstructorSpy + let webSocketCloseSpy + let webSocketSendSpy + + beforeEach(() => { + webSocketConstructorSpy = vi.fn() + webSocketCloseSpy = vi.fn() + webSocketSendSpy = vi.fn() + + globalThis.WebSocket = class WebSocket { + constructor (target) { + webSocketConstructorSpy(target) + } + + // eslint-disable-next-line class-methods-use-this + close (args) { + webSocketCloseSpy(args) + } + + // eslint-disable-next-line class-methods-use-this + send (json) { + webSocketSendSpy(json) + } + + triggerOpen () { + this.onopen() + } + + triggerClose () { + this.onclose() + } + } + }) + afterEach(() => { + globalThis.WebSocket = undefined + }) + + it('should expose a class', () => { + expect(UppySocket.name).toEqual('UppySocket') + expect( + new UppySocket({ + target: 'foo', + }) instanceof UppySocket, + ) + }) + + it('should setup a new WebSocket', () => { + new UppySocket({ target: 'foo' }) // eslint-disable-line no-new + expect(webSocketConstructorSpy.mock.calls[0][0]).toEqual('foo') + }) + + it('should send a message via the websocket if the connection is open', () => { + const uppySocket = new UppySocket({ target: 'foo' }) + const webSocketInstance = uppySocket[Symbol.for('uppy test: getSocket')]() + webSocketInstance.triggerOpen() + + uppySocket.send('bar', 'boo') + expect(webSocketSendSpy.mock.calls.length).toEqual(1) + expect(webSocketSendSpy.mock.calls[0]).toEqual([ + JSON.stringify({ action: 'bar', payload: 'boo' }), + ]) + }) + + it('should queue the message for the websocket if the connection is not open', () => { + const uppySocket = new UppySocket({ target: 'foo' }) + + uppySocket.send('bar', 'boo') + expect(uppySocket[Symbol.for('uppy test: getQueued')]()).toEqual([{ action: 'bar', payload: 'boo' }]) + expect(webSocketSendSpy.mock.calls.length).toEqual(0) + }) + + it('should queue any messages for the websocket if the connection is not open, then send them when the connection is open', () => { + const uppySocket = new UppySocket({ target: 'foo' }) + const webSocketInstance = uppySocket[Symbol.for('uppy test: getSocket')]() + + uppySocket.send('bar', 'boo') + uppySocket.send('moo', 'baa') + expect(uppySocket[Symbol.for('uppy test: getQueued')]()).toEqual([ + { action: 'bar', payload: 'boo' }, + { action: 'moo', payload: 'baa' }, + ]) + expect(webSocketSendSpy.mock.calls.length).toEqual(0) + + webSocketInstance.triggerOpen() + + expect(uppySocket[Symbol.for('uppy test: getQueued')]()).toEqual([]) + expect(webSocketSendSpy.mock.calls.length).toEqual(2) + expect(webSocketSendSpy.mock.calls[0]).toEqual([ + JSON.stringify({ action: 'bar', payload: 'boo' }), + ]) + expect(webSocketSendSpy.mock.calls[1]).toEqual([ + JSON.stringify({ action: 'moo', payload: 'baa' }), + ]) + }) + + it('should start queuing any messages when the websocket connection is closed', () => { + const uppySocket = new UppySocket({ target: 'foo' }) + const webSocketInstance = uppySocket[Symbol.for('uppy test: getSocket')]() + webSocketInstance.triggerOpen() + uppySocket.send('bar', 'boo') + expect(uppySocket[Symbol.for('uppy test: getQueued')]()).toEqual([]) + + webSocketInstance.triggerClose() + uppySocket.send('bar', 'boo') + expect(uppySocket[Symbol.for('uppy test: getQueued')]()).toEqual([{ action: 'bar', payload: 'boo' }]) + }) + + it('should close the websocket when it is force closed', () => { + const uppySocket = new UppySocket({ target: 'foo' }) + const webSocketInstance = uppySocket[Symbol.for('uppy test: getSocket')]() + webSocketInstance.triggerOpen() + + uppySocket.close() + expect(webSocketCloseSpy.mock.calls.length).toEqual(1) + }) + + it('should be able to subscribe to messages received on the websocket', () => { + const uppySocket = new UppySocket({ target: 'foo' }) + const webSocketInstance = uppySocket[Symbol.for('uppy test: getSocket')]() + + const emitterListenerMock = vi.fn() + uppySocket.on('hi', emitterListenerMock) + + webSocketInstance.triggerOpen() + webSocketInstance.onmessage({ + data: JSON.stringify({ action: 'hi', payload: 'ho' }), + }) + expect(emitterListenerMock.mock.calls).toEqual([ + ['ho', undefined, undefined, undefined, undefined, undefined], + ]) + }) + + it('should be able to emit messages and subscribe to them', () => { + const uppySocket = new UppySocket({ target: 'foo' }) + + const emitterListenerMock = vi.fn() + uppySocket.on('hi', emitterListenerMock) + + uppySocket.emit('hi', 'ho') + uppySocket.emit('hi', 'ho') + uppySocket.emit('hi', 'off to work we go') + + expect(emitterListenerMock.mock.calls).toEqual([ + ['ho', undefined, undefined, undefined, undefined, undefined], + ['ho', undefined, undefined, undefined, undefined, undefined], + [ + 'off to work we go', + undefined, + undefined, + undefined, + undefined, + undefined, + ], + ]) + }) + + it('should be able to subscribe to the first event for a particular action', () => { + const uppySocket = new UppySocket({ target: 'foo' }) + + const emitterListenerMock = vi.fn() + uppySocket.once('hi', emitterListenerMock) + + uppySocket.emit('hi', 'ho') + uppySocket.emit('hi', 'ho') + uppySocket.emit('hi', 'off to work we go') + + expect(emitterListenerMock.mock.calls.length).toEqual(1) + expect(emitterListenerMock.mock.calls).toEqual([ + ['ho', undefined, undefined, undefined, undefined, undefined], + ]) + }) +}) diff --git a/packages/@uppy/companion-client/src/index.js b/packages/@uppy/companion-client/src/index.js index 6dc7cb19e0..f3dddcaf65 100644 --- a/packages/@uppy/companion-client/src/index.js +++ b/packages/@uppy/companion-client/src/index.js @@ -7,3 +7,6 @@ export { default as RequestClient } from './RequestClient.js' export { default as Provider } from './Provider.js' export { default as SearchProvider } from './SearchProvider.js' + +// TODO: remove in the next major +export { default as Socket } from './Socket.js'