diff --git a/resources/assets/scripts/mixins/socketio/connector.ts b/resources/assets/scripts/mixins/socketio/connector.ts new file mode 100644 index 000000000..97aee0a5f --- /dev/null +++ b/resources/assets/scripts/mixins/socketio/connector.ts @@ -0,0 +1,115 @@ +import * as io from 'socket.io-client'; +import {camelCase} from 'lodash'; +import SocketEmitter from './emitter'; +import {Store} from "vuex"; + +const SYSTEM_EVENTS: Array = [ + 'connect', + 'error', + 'disconnect', + 'reconnect', + 'reconnect_attempt', + 'reconnecting', + 'reconnect_error', + 'reconnect_failed', + 'connect_error', + 'connect_timeout', + 'connecting', + 'ping', + 'pong', +]; + +export default class SocketioConnector { + /** + * The socket instance. + */ + socket: null | SocketIOClient.Socket; + + /** + * The vuex store being used to persist data and socket state. + */ + store: Store | undefined; + + constructor(store: Store | undefined) { + this.socket = null; + this.store = store; + } + + /** + * Initialize a new Socket connection. + * + * @param {io} socket + */ + connect(socket: SocketIOClient.Socket) { + this.socket = socket; + this.registerEventListeners(); + } + + /** + * Return the socket instance we are working with. + */ + instance(): SocketIOClient.Socket | null { + return this.socket; + } + + /** + * Register the event listeners for this socket including user-defined ones in the store as + * well as global system events from Socekt.io. + */ + registerEventListeners() { + if (!this.socket) { + return; + } + + // @ts-ignore + this.socket['onevent'] = (packet: { data: Array }): void => { + const [event, ...args] = packet.data; + SocketEmitter.emit(event, ...args); + + this.passToStore(event, args); + }; + + SYSTEM_EVENTS.forEach((event: string): void => { + if (!this.socket) { + return; + } + + this.socket.on(event, (payload: any) => { + SocketEmitter.emit(event, payload); + + this.passToStore(event, payload); + }); + }); + } + + /** + * Pass event calls off to the Vuex store if there is a corresponding function. + */ + passToStore(event: string | number, payload: Array) { + if (!this.store) { + return; + } + + const s: Store = this.store; + const mutation = `SOCKET_${String(event).toUpperCase()}`; + const action = `socket_${camelCase(String(event))}`; + + // @ts-ignore + Object.keys(this.store._mutations).filter((namespaced: string): boolean => { + return namespaced.split('/').pop() === mutation; + }).forEach((namespaced: string): void => { + s.commit(namespaced, this.unwrap(payload)); + }); + + // @ts-ignore + Object.keys(this.store._actions).filter((namespaced: string): boolean => { + return namespaced.split('/').pop() === action; + }).forEach((namespaced: string): void => { + s.dispatch(namespaced, this.unwrap(payload)).catch(console.error); + }); + } + + unwrap(args: Array) { + return (args && args.length <= 1) ? args[0] : args; + } +} diff --git a/resources/assets/scripts/mixins/socketio/emitter.ts b/resources/assets/scripts/mixins/socketio/emitter.ts new file mode 100644 index 000000000..5728de467 --- /dev/null +++ b/resources/assets/scripts/mixins/socketio/emitter.ts @@ -0,0 +1,59 @@ +import {isFunction} from 'lodash'; +import {ComponentOptions} from "vue"; +import {Vue} from "vue/types/vue"; + +export default new class SocketEmitter { + listeners: Map) => void, + vm: ComponentOptions, + }>>; + + constructor() { + this.listeners = new Map(); + } + + /** + * Add an event listener for socket events. + */ + addListener(event: string | number, callback: (data: any) => void, vm: ComponentOptions) { + if (!isFunction(callback)) { + return; + } + + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + + // @ts-ignore + this.listeners.get(event).push({callback, vm}); + } + + /** + * Remove an event listener for socket events based on the context passed through. + */ + removeListener(event: string | number, callback: (data: any) => void, vm: ComponentOptions) { + if (!isFunction(callback) || !this.listeners.has(event)) { + return; + } + + // @ts-ignore + const filtered = this.listeners.get(event).filter((listener) => { + return listener.callback !== callback || listener.vm !== vm; + }); + + if (filtered.length > 0) { + this.listeners.set(event, filtered); + } else { + this.listeners.delete(event); + } + } + + /** + * Emit a socket event. + */ + emit(event: string | number, ...args: any) { + (this.listeners.get(event) || []).forEach((listener) => { + listener.callback.call(listener.vm, args); + }); + } +} diff --git a/resources/assets/scripts/mixins/socketio/index.ts b/resources/assets/scripts/mixins/socketio/index.ts new file mode 100644 index 000000000..3f03d6966 --- /dev/null +++ b/resources/assets/scripts/mixins/socketio/index.ts @@ -0,0 +1,60 @@ +import SocketEmitter from './emitter'; +import SocketioConnector from './connector'; +import {ComponentOptions} from 'vue'; +import {Vue} from "vue/types/vue"; + +let connector: SocketioConnector | null = null; + +export const Socketio: ComponentOptions = { + /** + * Setup the socket when we create the first component using the mixin. This is the Server.vue + * file, unless you mess up all of this code. Subsequent components to use this mixin will + * receive the existing connector instance, so it is very important that the top-most component + * calls the connectors destroy function when it is destroyed. + */ + created: function () { + if (!connector) { + connector = new SocketioConnector(this.$store); + } + + const sockets = (this.$options || {}).sockets || {}; + Object.keys(sockets).forEach((event) => { + SocketEmitter.addListener(event, sockets[event], this); + }); + }, + + /** + * Before destroying the component we need to remove any event listeners registered for it. + */ + beforeDestroy: function () { + const sockets = (this.$options || {}).sockets || {}; + Object.keys(sockets).forEach((event) => { + SocketEmitter.removeListener(event, sockets[event], this); + }); + }, + + methods: { + /** + * @return {SocketioConnector} + */ + '$socket': function () { + return connector; + }, + + /** + * Disconnects from the active socket and sets the connector to null. + */ + removeSocket: function () { + if (!connector) { + return; + } + + const instance: SocketIOClient.Socket | null = connector.instance(); + if (instance) { + instance.close(); + } + + connector = null; + }, + }, +};