Skip to content

Instantly share code, notes, and snippets.

@HuakunShen
Last active November 8, 2022 05:41
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save HuakunShen/caa96804650f5990253aa5cdb0734a1a to your computer and use it in GitHub Desktop.
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.
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>" });
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