Last active
November 8, 2022 05:41
-
-
Save HuakunShen/caa96804650f5990253aa5cdb0734a1a to your computer and use it in GitHub Desktop.
Socket.IO wrapper, easy reconnection, avoid re-registering event listeners when socket is changed (reconnected) using 2 layers of event listening design with "events" package.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const syncer = Syncer.getInstance({ | |
connectionURL: "https://crosscopy.io", | |
accessToken: "Bearer <token>", | |
events: eventNames.SOCKETIO_EVENTS, | |
}); | |
syncer | |
.onInit((data) => { | |
data.newRecords; | |
data.idMapping; | |
data.deletedRecInfo; | |
}) | |
.onDeleted((uuid) => { | |
console.log(`Deleted Record's ID is ${uuid}`); | |
}) | |
.onNotify((msg) => { | |
if (msg.error) { | |
console.error(`Error received: ${msg.message}`); | |
} else { | |
console.log(`Notification received: ${msg.message}`); | |
} | |
}) | |
.onUpdated((rec) => { | |
console.log(`Updated Record: ${rec}`); | |
}) | |
.onUploaded((rec) => { | |
console.log(`Uploaded Record: ${rec}`); | |
}) | |
.connect(new Date(), []); | |
// if you want to disconnect | |
syncer.disconnect(); | |
// later, if you want to reconnect with a new config (e.g. access token) | |
// no need to re-register event listeners, they are still registered on emitter | |
Syncer.getInstance().reconnect({ accessToken: "Bearer <token>" }); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import EventEmitter from "events"; | |
import { io, Socket } from "socket.io-client"; | |
import * as eventNames from "./events"; | |
import { requests as gqlReq } from "@crosscopy/graphql-schema"; | |
export type SyncerConfig = { | |
connectionURL?: string; | |
events?: string[]; | |
socket?: Socket; | |
accessToken?: string; | |
}; | |
/** | |
* Sample Usage | |
* This class is a wrapper layer around socket.io client, also using a event based design like socketio. | |
* So there are 2 layers of event listeners, one is the socketio event listener, the other is emitter listener. | |
* The reason for this complicated 2 layer design is to make reconnection easier, without having to add the event listeners again. | |
* All user-custom event listeners should be added to the emitter, and the emitter will forward the event to the socketio event listener. | |
* | |
* * // Sample Code | |
* const syncer = Syncer.getInstance({ | |
* connectionURL: "https://crosscopy.io", | |
* accessToken: "Bearer <token>", | |
* events: eventNames.SOCKETIO_EVENTS, | |
* }); | |
* syncer | |
* .onInit((data) => { | |
* data.newRecords; | |
* data.idMapping; | |
* data.deletedRecInfo; | |
* }) | |
* .onDeleted((uuid) => { | |
* console.log(`Deleted Record's ID is ${uuid}`); | |
* }) | |
* .onNotify((msg) => { | |
* if (msg.error) { | |
* console.error(`Error received: ${msg.message}`); | |
* } else { | |
* console.log(`Notification received: ${msg.message}`); | |
* } | |
* }) | |
* .onUpdated((rec) => { | |
* console.log(`Updated Record: ${rec}`); | |
* }) | |
* .onUploaded((rec) => { | |
* console.log(`Uploaded Record: ${rec}`); | |
* }) | |
* .connect(new Date(), []); | |
* * // if you want to disconnect | |
* syncer.disconnect(); | |
* | |
* * // later, if you want to reconnect with a new config (e.g. access token) | |
* * // no need to re-register event listeners, they are still registered on emitter | |
* Syncer.getInstance().reconnect({ accessToken: "Bearer <token>" }); | |
*/ | |
export default class Syncer { | |
socket?: Socket; | |
connectionURL?: string; | |
accessToken?: string; | |
events: string[] = []; | |
emitter = new EventEmitter(); | |
private static instance: Syncer; | |
private constructor(config?: SyncerConfig) { | |
this.init(config); | |
} | |
public static getInstance(config?: SyncerConfig): Syncer { | |
if (!Syncer.instance) { | |
Syncer.instance = new Syncer(config); | |
} else { | |
Syncer.instance.init(config); | |
} | |
return Syncer.instance; | |
} | |
/** | |
* Remove all emitter's event listeners | |
* Be careful when using this function, it will remove all event listeners, but not the socketio event listeners | |
* If unsure, use reset(), and re-add listeners again | |
*/ | |
removeEmitterListeners(): Syncer { | |
this.emitter.removeAllListeners(); | |
return this; | |
} | |
/** | |
* remove socketio socket event listeners | |
* @param events socketio events remove listeners for, leave empty to remove all listeners | |
* @returns | |
*/ | |
removeSocketListeners(events?: string[]): Syncer { | |
if (events) { | |
for (const eventName of events) { | |
this.socket?.off(eventName); | |
} | |
} else { | |
this.socket?.removeAllListeners(); | |
} | |
return this; | |
} | |
reset(): Syncer { | |
this.disconnect(); | |
this.removeEmitterListeners(); | |
this.removeSocketListeners(); | |
this.events = []; | |
this.connectionURL = undefined; | |
this.accessToken = undefined; | |
this.emitter = new EventEmitter(); | |
return this; | |
} | |
init(config?: SyncerConfig): Syncer { | |
this.socket = config?.socket; | |
this.accessToken = config?.accessToken; | |
this.connectionURL = config?.connectionURL; | |
this.events = config?.events || []; | |
return this; | |
} | |
/** | |
* Socketio socket will listen on given events, and when triggered, this.emitter will emit the same event. | |
* The purpose of this is to make reconnection easier, without having to add the event listeners again. | |
* User-defined event listeners are registered on this class (Syncer) or this.emitter, not on the socketio socket. | |
* So everytime this.socket is changed, we just need to re-link emitter to socketio socket | |
* instead of re-registering all event listener functions to socketio socket. | |
* | |
* This function is the core of this class/algorithm. It should only be called in this.connect() as a new socket is created. | |
* Try not to run this yourself, unless you know what you are doing. | |
* If you want to add a new event, use addEventListener() instead, that will add the new event to this.emitter. | |
* Watch out of duplicate event listeners. | |
* @param events event names to link socketio event to emitter event | |
* @returns this | |
*/ | |
registerEvents(events: string[] = eventNames.SOCKETIO_EVENTS): Syncer { | |
if (!this.socket) throw new Error("Socket is not connected"); | |
// register event socketio event, when any of the event is received, emit the event | |
// e.g. when "updated" is received, emit "updated" event with the received data | |
for (const eventName of events) { | |
this.socket.on(eventName, (data: any) => { | |
this.emitter.emit(eventName, data); | |
}); | |
} | |
return this; | |
} | |
/** | |
* Add extra socketio events that where not added in the constructor | |
* Not recommended to use this function | |
* @param eventName socketio event name | |
* @param callback callback function to trigger when event is received | |
* @returns this | |
*/ | |
addEventListener( | |
eventName: string, | |
callback?: (...args: any[]) => void | |
): Syncer { | |
if (!this.socket) throw new Error("Socket is not connected"); | |
this.events.push(eventName); // prepare for reconnection to re-registerEvents() without needing to add listener again | |
if (callback) { | |
this.on(eventName, callback); | |
} | |
this.socket.on(eventName, (data: any) => { | |
this.emitter.emit(eventName, data); | |
}); | |
return this; | |
} | |
/** | |
* custom event listener for anything outside of the socketio event | |
* @param eventName socketio event name | |
* @param callback callback function to trigger when event is received | |
* @returns this | |
*/ | |
on(eventName: string, callback: (...args: any[]) => void): Syncer { | |
this.emitter.on(eventName, callback); | |
return this; | |
} | |
onUploaded(callback: (data: gqlReq.Rec) => void): Syncer { | |
this.emitter.on(eventNames.UPLOADED, callback); | |
return this; | |
} | |
onUpdated(callback: (data: gqlReq.Rec) => void): Syncer { | |
this.emitter.on(eventNames.UPDATED, callback); | |
return this; | |
} | |
/** | |
* data received is expected to be a string (uuid of the deleted record) | |
* @param callback callback function to trigger when "deleted" event is received, expect to receive the uuid of deleted record | |
*/ | |
onDeleted(callback: (data: string) => void): Syncer { | |
this.emitter.on(eventNames.DELETED, callback); | |
return this; | |
} | |
/** | |
* Expect to receive 3 data from the server: | |
* - idMapping: id mapping between db id and uuid of newly uploaded records on connection | |
* - deletedRecInfo: deleted record info (uuid and deletedAt) | |
* - newRecords: new records that are not in the local db but in cloud (uploaded from other devices) | |
* @param callback callback function to trigger when "init" event is received, expect to receive deleted records, new records and idMapping | |
*/ | |
onInit( | |
callback: (data: gqlReq.SyncByLatestCreationTimeResponse) => void | |
): Syncer { | |
this.emitter.on(eventNames.INIT, callback); | |
return this; | |
} | |
onNotify( | |
callback: (data: { message: string; error: boolean }) => void | |
): Syncer { | |
this.emitter.on(eventNames.NOTIFICATION_EVENT, callback); | |
return this; | |
} | |
/** | |
* Connect to the socketio server with lastSyncTime and recordsToUpload, set this.socket | |
* @param lastSyncTime last sync time, in Date, for syncing the unsynced records on db but not local | |
* @param recordsToUpload local-only records to upload to cloud | |
* @returns | |
*/ | |
connect( | |
lastSyncTime: Date = new Date(), | |
recordsToUpload: gqlReq.Rec[] = [] | |
): Syncer { | |
if (this.socket) return this; // already connected, use reconnect() if you want to change some config like accessToken | |
if (!this.connectionURL) | |
throw new Error("Connection URL is not provided, call init()"); | |
if (!this.accessToken) | |
throw new Error("Access token is not provided, call init()"); | |
this.socket = io(this.connectionURL, { | |
path: "/crosscopy/ws/", | |
query: { | |
lastSyncTime, | |
records: recordsToUpload, | |
}, | |
transportOptions: { | |
polling: { | |
extraHeaders: { | |
authorization: this.accessToken, | |
}, | |
}, | |
}, | |
}); | |
// link every event listener on this.emitter to this.socket | |
this.registerEvents(this.events); | |
return this; | |
} | |
/** | |
* Disconnect this.socket from server and set it to undefined. | |
* User's event listeners are still registered on this.emitter, | |
* once the socket is reconnected and reregistered with emitter, | |
* the listeners will be re-registered to socket as well | |
* @returns this | |
*/ | |
disconnect(): Syncer { | |
this.socket?.disconnect(); | |
this.socket = undefined; | |
return this; | |
} | |
/** | |
* This will disconnect the socket and reconnect with new config | |
* @param config new config to use for reconnecting | |
* @param lastSyncTime last sync time on local client | |
* @param recordsToUpload new records to upload to cloud (local-only records) | |
* @returns | |
*/ | |
reconnect( | |
config?: SyncerConfig, | |
lastSyncTime: Date = new Date(), | |
recordsToUpload: gqlReq.Rec[] = [] | |
): Syncer { | |
if (config) this.init(config); | |
this.disconnect(); | |
this.connect(lastSyncTime, recordsToUpload); | |
return this; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment