import io from 'socket.io' ; import * as Automerge from 'automerge' ; import { Node } from 'slate' ; import { Server } from 'http' ; import throttle from 'lodash/throttle' ; import { SyncDoc , CollabAction , toJS } from '@slate-collaborative/bridge' ; import { getClients } from './utils' ; import AutomergeBackend from './AutomergeBackend' ; export interface SocketIOCollaborationOptions { entry : number | Server ; connectOpts ? : SocketIO . ServerOptions ; defaultValue ? : Node [ ] ; saveFrequency ? : number ; onAuthRequest ? : ( query : Object , socket ? : SocketIO . Socket ) => Promise < boolean > | boolean ; onDocumentLoad ? : ( pathname : string , query ? : Object ) => Promise < Node [ ] > | Node [ ] ; onDocumentSave ? : ( pathname : string , doc : Node [ ] ) => Promise < void > | void ; } export default class SocketIOCollaboration { private io : SocketIO . Server ; private options : SocketIOCollaborationOptions ; private backend : AutomergeBackend ; private autoSaveDoc : ( docId : string ) => void ; /**
     * Constructor
     */ constructor ( options ) { this . io = io ( options . entry as Server , options . connectOpts ) ; this . backend = new AutomergeBackend ( ) ; this . options = options ; this . configure ( ) ; /**
         * Save document with throttle
         */ this . autoSaveDoc = throttle ( async ( docId ) => this . backend . getDocument ( docId ) && this . saveDocument ( docId ) , this . options ?. saveFrequency || 2000 ) ; return this ; } /**
     * Initial IO configuration
     */ private configure = ( ) => this . io . of ( this . nspMiddleware ) . use ( this . authMiddleware ) . on ( 'connect' , this . onConnect ) ; /**
     * Namespace SocketIO middleware. Load document value and append it to CollaborationBackend.
     */ private nspMiddleware = async ( path , query , next ) => { const { onDocumentLoad } = this . options ; if ( ! this . backend . getDocument ( path ) ) { const doc = onDocumentLoad ? await onDocumentLoad ( path , query ) : this . options . defaultValue ; if ( ! doc ) return next ( null , false ) ; this . backend . appendDocument ( path , doc ) ; } return next ( null , true ) ; } ; /**
     * SocketIO auth middleware. Used for user authentification.
     */ private authMiddleware = async ( socket , next ) => { const { query } = socket . handshake ; const { onAuthRequest } = this . options ; if ( onAuthRequest ) { const permit = await onAuthRequest ( query , socket ) ; if ( ! permit ) return next ( new Error ( `Authentification error: ${ socket . id }` ) ) ; } return next ( ) ; } ; /**
     * On 'connect' handler.
     */ private onConnect = ( socket ) => { const { id , conn } = socket ; const { name } = socket . nsp ; this . backend . createConnection ( id , ( { type , payload } ) => { if ( payload . docId === name ) { socket . emit ( 'msg' , { type , payload : { id : conn . id , ... payload } } ) ; } } ) ; socket . on ( 'msg' , this . onMessage ( id , name ) ) ; socket . on ( 'disconnect' , this . onDisconnect ( id , socket ) ) ; const doc = this . backend . getDocument ( name ) ; socket . emit ( 'msg' , { type : 'document' , payload : Automerge . save < SyncDoc > ( doc ) } ) ; this . backend . openConnection ( id ) ; this . garbageCursors ( name ) ; } ; /**
     * On 'message' handler
     */ private onMessage = ( id , name ) => ( data ) => { switch ( data . type ) { case 'operation' : try { this . backend . receiveOperation ( id , data ) ; this . autoSaveDoc ( name ) ; this . garbageCursors ( name ) ; } catch ( e ) { console . log ( e ) ; } } } ; /**
     * Save document
     */ private saveDocument = async ( docId ) => { try { const { onDocumentSave } = this . options ; const doc = this . backend . getDocument ( docId ) ; if ( ! doc ) { throw new Error ( `Can't receive document by id: ${ docId }` ) ; } onDocumentSave && ( await onDocumentSave ( docId , toJS ( doc . children ) ) ) ; } catch ( e ) { console . error ( e , docId ) ; } } ; /**
     * On 'disconnect' handler
     */ private onDisconnect = ( id , socket ) => async ( ) => { socket . leave ( socket . nsp . name ) ; this . backend . closeConnection ( id ) ; await this . saveDocument ( socket . nsp . name ) ; this . garbageCursors ( socket . nsp . name ) ; socket . leave ( id ) ; this . garbageNsp ( ) ; } ; /**
     * Clean up unused SocketIO namespaces.
     */ garbageNsp = ( ) => { Object . keys ( this . io . nsps ) . filter ( n => n !== '/' ) . forEach ( nsp => { getClients ( this . io , nsp ) . then ( ( clientsList ) => { if ( ! clientsList . length ) { this . backend . removeDocument ( nsp ) ; delete this . io . nsps [ nsp ] ; } } ) ; } ) ; } ; /**
     * Clean up unused cursor data.
     */ garbageCursors = ( nsp ) => { const doc = this . backend . getDocument ( nsp ) ; if ( ! doc || ! doc . cursors ) return ; const namespace = this . io . of ( nsp ) ; Object . keys ( doc ?. cursors ) ?. forEach ( key => { if ( ! namespace . sockets [ key ] ) { this . backend . garbageCursor ( nsp , key ) ; } } ) ; } ; /**
     * Destroy SocketIO connection
     */ destroy = async ( ) => { this . io . close ( ) ; } ; }