mongo-realtime
Version:
A Node.js package that combines Socket.IO and MongoDB Change Streams to deliver real-time database updates to your WebSocket clients.
343 lines (300 loc) • 11.3 kB
JavaScript
const { Server } = require("socket.io");
function sortObj(obj = {}) {
const out = {};
for (let k of Object.keys(obj).sort()) {
const v = obj[k];
out[k] = typeof v == "object" && !Array.isArray(v) ? sortObj(v) : v;
}
return out;
}
/**
* @typedef {Object} ChangeStreamDocument
* @property {"insert"|"update"|"replace"|"delete"|"invalidate"|"drop"|"dropDatabase"|"rename"} operationType
* The type of operation that triggered the event.
*
* @property {Object} ns
* @property {string} ns.db - Database name
* @property {string} ns.coll - Collection name
*
* @property {Object} documentKey
* @property {import("bson").ObjectId|string} documentKey._id - The document’s identifier
*
* @property {Object} [fullDocument]
* The full document after the change (only present if `fullDocument: "updateLookup"` is enabled).
*
* @property {Object} [updateDescription]
* @property {Object.<string, any>} [updateDescription.updatedFields]
* Fields that were updated during an update operation.
* @property {string[]} [updateDescription.removedFields]
* Fields that were removed during an update operation.
*
* @property {Object} [rename] - Info about the collection rename (if operationType is "rename").
*
* @property {Date} [clusterTime] - Logical timestamp of the event.
*/
class MongoRealtime {
/** @type {import("socket.io").Server} */ static io;
/** @type {import("mongoose").Connection} */ static connection;
/** @type {Record<String, [(change:ChangeStreamDocument)=>void]>} */ static #listeners =
{};
/** @type {Record<String, [Object]>} */
static #cache = {};
static sockets = () => [...this.io.sockets.sockets.values()];
/**@type {Record<String, {collection:String,filter: (doc:Object)=>Promise<boolean>}>} */
static #streams = {};
/** @type {[String]} - All DB collections */
static collections = [];
static #safeListStream = true;
/**
* Initializes the socket system.
*
* @param {Object} options
* @param {import("mongoose").Connection} options.connection - Active Mongoose connection
* @param {(token:String, socket: import("socket.io").Socket) => boolean | Promise<boolean>} options.authentify - Auth function that should return true if `token` is valid
* @param {[( socket: import("socket.io").Socket, next: (err?: ExtendedError) => void) => void]} options.middlewares - Register mmiddlewares on incoming socket
* @param {(socket: import("socket.io").Socket) => void} options.onSocket - Callback triggered when a socket connects
* @param {(socket: import("socket.io").Socket, reason: import("socket.io").DisconnectReason) => void} options.offSocket - Callback triggered when a socket disconnects
* @param {import("http").Server} options.server - HTTP server to attach Socket.IO to
* @param {[String]} options.autoListStream - Collections to stream automatically. If empty, will stream no collection. If null, will stream all collections.
* @param {[String]} options.watch - Collections to watch. If empty, will watch all collections
* @param {[String]} options.ignore - Collections to ignore. Can override `watch`
* @param {bool} options.safeListStream - If true(default), declaring an existing streamId will throw an error
*
*/
static init({
connection,
server,
authentify,
middlewares = [],
autoListStream,
onSocket,
offSocket,
safeListStream = true,
watch = [],
ignore = [],
}) {
if (this.io) this.io.close();
this.io = new Server(server);
this.connection = connection;
this.#safeListStream = !!safeListStream;
watch = watch.map((s) => s.toLowerCase());
ignore = ignore.map((s) => s.toLowerCase());
this.io.use(async (socket, next) => {
if (!!authentify) {
try {
const token =
socket.handshake.auth.token ||
socket.handshake.headers.authorization;
if (!token) return next(new Error("NO_TOKEN_PROVIDED"));
const authorized = await authentify(token, socket);
if (authorized === true) return next(); // exactly returns true
return next(new Error("UNAUTHORIZED"));
} catch (error) {
return next(new Error("AUTH_ERROR"));
}
} else {
return next();
}
});
for (let middleware of middlewares) {
this.io.use(middleware);
}
this.io.on("connection", (socket) => {
if (onSocket) onSocket(socket);
socket.on("db:stream[register]", async (streamId, registerId) => {
const stream = this.#streams[streamId];
if (!stream) return;
const coll = stream.collection;
if (!this.#cache[coll]) {
this.#cache[coll] = await connection.db
.collection(coll)
.find({})
.toArray();
}
const filterResults = await Promise.allSettled(
this.#cache[coll].map((doc) => stream.filter(doc))
);
const filtered = this.#cache[coll].filter(
(_, i) => filterResults[i] && filterResults[i].value
);
this.io.emit(`db:stream[register][${registerId}]`, filtered);
});
socket.on("disconnect", (r) => {
if (offSocket) offSocket(socket, r);
});
});
connection.once("open", async () => {
this.collections = (await connection.listCollections()).map(
(c) => c.name
);
let pipeline = [];
if (watch.length !== 0 && ignore.length === 0) {
pipeline = [{ $match: { "ns.coll": { $in: watch } } }];
} else if (watch.length === 0 && ignore.length !== 0) {
pipeline = [{ $match: { "ns.coll": { $nin: ignore } } }];
} else if (watch.length !== 0 && ignore.length !== 0) {
pipeline = [
{
$match: {
$and: [
{ "ns.coll": { $in: watch } },
{ "ns.coll": { $nin: ignore } },
],
},
},
];
}
const changeStream = connection.watch(pipeline, {
fullDocument: "updateLookup",
fullDocumentBeforeChange: "whenAvailable",
});
/** Setup main streams */
let collectionsToStream = [];
if (autoListStream == null) collectionsToStream = this.collections;
else
collectionsToStream = this.collections.filter((c) =>
autoListStream.includes(c)
);
for (let col of collectionsToStream) this.addListStream(col, col);
/** Emit streams on change */
changeStream.on("change", async (change) => {
const coll = change.ns.coll;
if (!this.#cache[coll]) {
this.#cache[coll] = await connection.db
.collection(coll)
.find({})
.toArray();
} else
switch (change.operationType) {
case "insert":
this.#cache[coll].push(change.fullDocument);
break;
case "update":
case "replace":
this.#cache[coll] = this.#cache[coll].map((doc) =>
doc._id.toString() === change.documentKey._id.toString()
? change.fullDocument
: doc
);
break;
case "delete":
this.#cache[coll] = this.#cache[coll].filter(
(doc) =>
doc._id.toString() !== change.documentKey._id.toString()
);
break;
}
Object.entries(this.#streams).forEach(async (e) => {
const key = e[0];
const value = e[1];
if (value.collection != coll) return;
const filterResults = await Promise.allSettled(
this.#cache[coll].map((doc) => value.filter(doc))
);
const filtered = this.#cache[coll].filter(
(_, i) => filterResults[i] && filterResults[i].value
);
this.io.emit(`db:stream:${key}`, filtered);
this.notifyListeners(`db:stream:${key}`, filtered);
});
});
/** Emit listen events on change */
changeStream.on("change", async (change) => {
const colName = change.ns.coll.toLowerCase();
change.col = colName;
const type = change.operationType;
const id = change.documentKey?._id;
const e_change = "db:change";
const e_change_type = `db:${type}`;
const e_change_col = `${e_change}:${colName}`;
const e_change_type_col = `${e_change_type}:${colName}`;
const events = [
e_change,
e_change_type,
e_change_col,
e_change_type_col,
];
if (id) {
change.docId = id;
const e_change_doc = `${e_change_col}:${id}`;
const e_change_type_doc = `${e_change_type_col}:${id}`;
events.push(e_change_doc, e_change_type_doc);
}
for (let e of events) {
this.io.emit(e, change);
this.notifyListeners(e, change);
}
});
});
}
/**
* Notify all event listeners
*
* @param {String} e - Name of the event
* @param {ChangeStreamDocument} change - Change Stream
*/
static notifyListeners(e, change) {
if (this.#listeners[e]) {
for (let c of this.#listeners[e]) {
c(change);
}
}
}
/**
* Subscribe to an event
*
* @param {String} key - Name of the event
* @param {(change:ChangeStreamDocument)=>void} cb - Callback
*/
static listen(key, cb) {
if (!this.#listeners[key]) this.#listeners[key] = [];
this.#listeners[key].push(cb);
}
/**
*
* @param {String} streamId - StreamId of the list stream
* @param {String} collection - Name of the collection to stream
* @param { (doc:Object )=>Promise<boolean>} filter - Collection filter
*
* Register a new list stream to listen
*/
static addListStream(streamId, collection, filter) {
if (!streamId) throw new Error("Stream id is required");
if (!collection) throw new Error("Collection is required");
filter ??= (_, __) => true;
if (this.#safeListStream && this.#streams[streamId]) {
throw new Error(
`Stream '${streamId}' already registered or is reserved.`
);
}
this.#streams[streamId] = {
collection,
filter,
};
}
/**
* @param {String} streamId - StreamId of the stream
*
* Delete a registered stream
*/
static removeListStream(streamId) {
delete this.#streams[streamId];
}
/**
* Remove one or all listeners of an event
*
* @param {String} key - Name of the event
* @param {(change:ChangeStreamDocument)=>void} cb - Callback
*/
static removeListener(key, cb) {
if (cb) this.#listeners[key] = this.#listeners[key].filter((c) => c != cb);
else this.#listeners[key] = [];
}
/**
* Unsubscribe to all events
*/
static removeAllListeners() {
this.#listeners = {};
}
}
module.exports = MongoRealtime;