mongo-realtime 1.0.4 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +380 -270
  2. package/index.js +317 -177
  3. package/package.json +26 -26
package/README.md CHANGED
@@ -1,270 +1,380 @@
1
- # Mongo Realtime
2
-
3
- A Node.js package that combines Socket.IO and MongoDB Change Streams to deliver real-time database updates to your WebSocket clients.
4
-
5
- ![Banner](logo.png)
6
-
7
- ## 🚀 Features
8
-
9
- - **Real-time updates**: Automatically detects changes in MongoDB and broadcasts them via Socket.IO
10
- - **Granular events**: Emits specific events by operation type, collection, and document
11
- - **Connection management**: Customizable callbacks for socket connections/disconnections
12
- - **TypeScript compatible**: JSDoc annotations for better development experience
13
-
14
- ## 📦 Installation
15
-
16
- ```bash
17
- npm install mongo-realtime
18
- ```
19
-
20
- ## Setup
21
-
22
- ### Prerequisites
23
-
24
- - MongoDB running as a replica set (required for Change Streams)
25
- - Node.js HTTP server (See below how to configure an HTTP server with Express)
26
-
27
- ### Example setup
28
-
29
- ```javascript
30
- const express = require('express');
31
- const http = require('http');
32
- const mongoose = require('mongoose');
33
- const MongoRealtime = require('mongo-realtime');
34
-
35
- const app = express();
36
- const server = http.createServer(app);
37
-
38
- mongoose.connect('mongodb://localhost:27017/mydb').then((c) => {
39
- console.log("Connected to db",c.connection.name);
40
- });
41
-
42
- MongoRealtime.init({
43
- connection: mongoose.connection,
44
- server: server,
45
- ignore: ["posts"], // ignore 'posts' collection
46
- onSocket: (socket) => {
47
- console.log(`Client connected: ${socket.id}`);
48
- socket.emit('welcome', { message: 'Connection successful!' });
49
- },
50
- offSocket: (socket, reason) => {
51
- console.log(`Client disconnected: ${socket.id}, reason: ${reason}`);
52
- }
53
- });
54
-
55
- server.listen(3000, () => {
56
- console.log('Server started on port 3000');
57
- });
58
- ```
59
-
60
- ## 📋 API
61
-
62
- ### `MongoRealtime.init(options)`
63
-
64
- Initializes the socket system and MongoDB Change Streams.
65
-
66
- #### Parameters
67
-
68
- \* means required
69
-
70
- | Parameter | Type | Description |
71
- |-----------|------|-------------|
72
- | `options.connection` | `mongoose.Connection`* | Active Mongoose connection |
73
- | `options.server` | `http.Server`* | HTTP server to attach Socket.IO |
74
- | `options.authentify` | `Function` | Function to authenticate socket connections. Should return true if authenticated |
75
- | `options.middlewares` | `Array[Function]` | Array of Socket.IO middlewares |
76
- | `options.onSocket` | `Function` | Callback on socket connection |
77
- | `options.offSocket` | `Function` | Callback on socket disconnection |
78
- | `options.watch` | `Array[String]` | Collections to only watch. Listen to all when is empty |
79
- | `options.ignore` | `Array[String]` | Collections to only ignore. Overrides watch array |
80
-
81
- #### Static Properties
82
-
83
- - `MongoRealtime.io`: Socket.IO server instance
84
- - `MongoRealtime.connection`: MongoDB connection
85
- - `MongoRealtime.sockets`: Array of connected sockets
86
-
87
- ## 🎯 Emitted Events
88
-
89
- The package automatically emits six types of events for each database change:
90
-
91
- ### Event Types
92
-
93
- | Event | Description | Example |
94
- |-------|-------------|---------|
95
- | `db:change` | All changes | Any collection change |
96
- | `db:{type}` | By operation type | `db:insert`, `db:update`, `db:delete` |
97
- | `db:change:{collection}` | By collection | `db:change:users`, `db:change:posts` |
98
- | `db:{type}:{collection}` | Type + collection | `db:insert:users`, `db:update:posts` |
99
- | `db:change:{collection}:{id}` | Specific document | `db:change:users:507f1f77bcf86cd799439011` |
100
- | `db:{type}:{collection}:{id}` | Type + document | `db:insert:users:507f1f77bcf86cd799439011` |
101
-
102
- ### Event listeners
103
-
104
- You can add serverside listeners to those db events to trigger specific actions on the server:
105
-
106
- ```js
107
- function sendNotification(change){
108
- const userId = change.docId; // or change.documentKey._id
109
- NotificationService.send(userId,"Welcome to DB");
110
- }
111
-
112
- MongoRealtime.listen("db:insert:users",sendNotification);
113
- ```
114
-
115
- #### Adding many callback to one event
116
-
117
- ```js
118
- MongoRealtime.listen("db:insert:users",anotherAction);
119
- MongoRealtime.listen("db:insert:users",anotherAction2);
120
- ```
121
-
122
- #### Removing event listeners
123
-
124
- ```js
125
- MongoRealtime.removeListener("db:insert:users",sendNotification); // remove this specific action from this event
126
- MongoRealtime.removeListener("db:insert:users"); // remove all actions from this event
127
- MongoRealtime.removeAllListeners(); // remove all listeners
128
-
129
- ```
130
-
131
- ### Event Payload Structure
132
-
133
- Each event contains the full MongoDB change object:
134
-
135
- ```javascript
136
- {
137
- "_id": {...},
138
- "col":"users", // same as ns.coll
139
- "docId":"...", // same as documentKey._id
140
- "operationType": "insert|update|delete|replace",
141
- "documentKey": { "_id": "..." },
142
- "ns": { "db": "mydb", "coll": "users" },
143
- "fullDocument": {...},
144
- "fullDocumentBeforeChange": {...}
145
- }
146
- ```
147
-
148
- ## 🔨 Usage Examples
149
-
150
- ### Server-side - Listening to specific events
151
-
152
- ```javascript
153
- MongoRealtime.init({
154
- connection: connection,
155
- server: server,
156
- onSocket: (socket) => {
157
- socket.on('subscribe:users', () => {
158
- socket.join('users-room');
159
- });
160
- },
161
-
162
- });
163
-
164
- MongoRealtime.io.to('users-room').emit('custom-event', data);
165
- ```
166
-
167
- ### Client-side - Receiving updates
168
-
169
- ```html
170
- <!DOCTYPE html>
171
- <html>
172
- <head>
173
- <script src="/socket.io/socket.io.js"></script>
174
- </head>
175
- <body>
176
- <script>
177
- const socket = io();
178
-
179
- socket.on('db:change', (change) => {
180
- console.log('Detected change:', change);
181
- });
182
-
183
- socket.on('db:insert:users', (change) => {
184
- console.log('New user:', change.fullDocument);
185
- });
186
-
187
- const userId = '507f1f77bcf86cd799439011';
188
- socket.on(`db:update:users:${userId}`, (change) => {
189
- console.log('Updated user:', change.fullDocument);
190
- });
191
-
192
- socket.on('db:delete', (change) => {
193
- console.log('Deleted document:', change.documentKey);
194
- });
195
- </script>
196
- </body>
197
- </html>
198
- ```
199
-
200
- ## Error Handling
201
-
202
- ```javascript
203
- MongoRealtime.init({
204
- connection: mongoose.connection,
205
- server: server,
206
- onSocket: (socket) => {
207
- socket.on('error', (error) => {
208
- console.error('Socket error:', error);
209
- });
210
- },
211
- offSocket: (socket, reason) => {
212
- if (reason === 'transport error') {
213
- console.log('Transport error detected');
214
- }
215
- }
216
- });
217
- ```
218
-
219
- ## 🔒 Security
220
-
221
- ### Socket Authentication
222
-
223
- ```javascript
224
- function authenticateSocket(token, socket){
225
- const verify = AuthService.verifyToken(token);
226
- if(verify){
227
- socket.user = verify.user; // attach user info to socket
228
- return true; // should return true to accept the connection
229
- }
230
- return false;
231
- }
232
-
233
- MongoRealtime.init({
234
- connection: mongoose.connection,
235
- server: server,
236
- authentify: authenticateSocket,
237
- middlewares: [
238
- (socket, next) => {
239
- console.log(`User is authenticated: ${socket.user.email}`);
240
- next();
241
- }
242
- ],
243
- offSocket: (socket, reason) => {
244
- console.log(`Socket ${socket.id} disconnected: ${reason}`);
245
- }
246
- });
247
- ```
248
-
249
- ## 📚 Dependencies
250
-
251
- - `socket.io`: WebSocket management
252
- - `mongoose`: MongoDB ODM with Change Streams support
253
-
254
- ## 🐛 Troubleshooting
255
-
256
- ### MongoDB must be in Replica Set mode
257
-
258
- ```bash
259
- mongod --replSet rs0
260
-
261
- rs.initiate()
262
- ```
263
-
264
- ## 📄 License
265
-
266
- MIT
267
-
268
- ## 🤝 Contributing
269
-
270
- Contributions are welcome! Feel free to open an issue or submit a pull request.****
1
+ # Mongo Realtime
2
+
3
+ A Node.js package that combines Socket.IO and MongoDB Change Streams to deliver real-time database updates to your WebSocket clients.
4
+
5
+ ![Banner](logo.png)
6
+
7
+ ## 🚀 Features
8
+
9
+ - **Real-time updates**: Automatically detects changes in MongoDB and broadcasts them via Socket.IO
10
+ - **Granular events**: Emits specific events by operation type, collection, and document
11
+ - **Connection management**: Customizable callbacks for socket connections/disconnections
12
+ - **TypeScript compatible**: JSDoc annotations for better development experience
13
+
14
+ ## 📦 Installation
15
+
16
+ ```bash
17
+ npm install mongo-realtime
18
+ ```
19
+
20
+ ## Setup
21
+
22
+ ### Prerequisites
23
+
24
+ - MongoDB running as a replica set (required for Change Streams)
25
+ - Node.js HTTP server (See below how to configure an HTTP server with Express)
26
+
27
+ ### Example setup
28
+
29
+ ```javascript
30
+ const express = require("express");
31
+ const http = require("http");
32
+ const mongoose = require("mongoose");
33
+ const MongoRealtime = require("mongo-realtime");
34
+
35
+ const app = express();
36
+ const server = http.createServer(app);
37
+
38
+ mongoose.connect("mongodb://localhost:27017/mydb").then((c) => {
39
+ console.log("Connected to db", c.connection.name);
40
+ });
41
+
42
+ MongoRealtime.init({
43
+ connection: mongoose.connection,
44
+ server: server,
45
+ ignore: ["posts"], // ignore 'posts' collection
46
+ onSocket: (socket) => {
47
+ console.log(`Client connected: ${socket.id}`);
48
+ socket.emit("welcome", { message: "Connection successful!" });
49
+ },
50
+ offSocket: (socket, reason) => {
51
+ console.log(`Client disconnected: ${socket.id}, reason: ${reason}`);
52
+ },
53
+ });
54
+
55
+ server.listen(3000, () => {
56
+ console.log("Server started on port 3000");
57
+ });
58
+ ```
59
+
60
+ ## 📋 API
61
+
62
+ ### `MongoRealtime.init(options)`
63
+
64
+ Initializes the socket system and MongoDB Change Streams.
65
+
66
+ #### Parameters
67
+
68
+ \* means required
69
+
70
+ | Parameter | Type | Description |
71
+ | ------------------------ | ----------------------- | -------------------------------------------------------------------------------- |
72
+ | `options.connection` | `mongoose.Connection`\* | Active Mongoose connection |
73
+ | `options.server` | `http.Server`\* | HTTP server to attach Socket.IO |
74
+ | `options.authentify` | `Function` | Function to authenticate socket connections. Should return true if authenticated |
75
+ | `options.middlewares` | `Array[Function]` | Array of Socket.IO middlewares |
76
+ | `options.onSocket` | `Function` | Callback on socket connection |
77
+ | `options.offSocket` | `Function` | Callback on socket disconnection |
78
+ | `options.watch` | `Array[String]` | Collections to only watch. Listen to all when is empty |
79
+ | `options.ignore` | `Array[String]` | Collections to only ignore. Overrides watch array |
80
+ | `options.autoListStream` | `Array[String]` | Collections to automatically stream to clients. Default is all |
81
+
82
+ #### Static Properties and Methods
83
+
84
+ - `MongoRealtime.addListStream(streamId, collection, filter)`: Manually add a list stream for a specific collection and filter
85
+ - `MongoRealtime.connection`: MongoDB connection
86
+ - `MongoRealtime.collections`: Array of database collections
87
+ - `MongoRealtime.io`: Socket.IO server instance
88
+ - `MongoRealtime.init(options)`: Initialize the package with options
89
+ - `MongoRealtime.listen(event, callback)`: Add an event listener for database changes
90
+ - `MongoRealtime.notifyListeners(event, data)`: Manually emit an event to all listeners
91
+ - `MongoRealtime.removeStream(streamId)`: Remove a previously added stream by id
92
+ - `MongoRealtime.removeListener(event, callback)`: Remove a specific event listener or all listeners for an event
93
+ - `MongoRealtime.removeAllListeners()`: Remove all event listeners
94
+ - `MongoRealtime.sockets()`: Returns an array of connected sockets
95
+
96
+ ## 🎯 Emitted Events
97
+
98
+ The package automatically emits six types of events for each database change:
99
+
100
+ ### Event Types
101
+
102
+ | Event | Description | Example |
103
+ | ----------------------------- | ----------------- | ------------------------------------------ |
104
+ | `db:change` | All changes | Any collection change |
105
+ | `db:{type}` | By operation type | `db:insert`, `db:update`, `db:delete` |
106
+ | `db:change:{collection}` | By collection | `db:change:users`, `db:change:posts` |
107
+ | `db:{type}:{collection}` | Type + collection | `db:insert:users`, `db:update:posts` |
108
+ | `db:change:{collection}:{id}` | Specific document | `db:change:users:507f1f77bcf86cd799439011` |
109
+ | `db:{type}:{collection}:{id}` | Type + document | `db:insert:users:507f1f77bcf86cd799439011` |
110
+ | `db:stream:{streamId}` | By stream | `db:stream:myStreamId` |
111
+
112
+ ### Event listeners
113
+
114
+ You can add serverside listeners to those db events to trigger specific actions on the server:
115
+
116
+ ```js
117
+ function sendNotification(change) {
118
+ const userId = change.docId; // or change.documentKey._id
119
+ NotificationService.send(userId, "Welcome to DB");
120
+ }
121
+
122
+ MongoRealtime.listen("db:insert:users", sendNotification);
123
+ ```
124
+
125
+ #### Adding many callback to one event
126
+
127
+ ```js
128
+ MongoRealtime.listen("db:insert:users", anotherAction);
129
+ MongoRealtime.listen("db:insert:users", anotherAction2);
130
+ ```
131
+
132
+ #### Removing event listeners
133
+
134
+ ```js
135
+ MongoRealtime.removeListener("db:insert:users", sendNotification); // remove this specific action from this event
136
+ MongoRealtime.removeListener("db:insert:users"); // remove all actions from this event
137
+ MongoRealtime.removeAllListeners(); // remove all listeners
138
+ ```
139
+
140
+ ### Event Payload Structure
141
+
142
+ Each event contains the full MongoDB change object:
143
+
144
+ ```javascript
145
+ {
146
+ "_id": {...},
147
+ "col":"users", // same as ns.coll
148
+ "docId":"...", // same as documentKey._id
149
+ "operationType": "insert|update|delete|replace",
150
+ "documentKey": { "_id": "..." },
151
+ "ns": { "db": "mydb", "coll": "users" },
152
+ "fullDocument": {...},
153
+ "fullDocumentBeforeChange": {...}
154
+ }
155
+ ```
156
+
157
+ ## 🔨 Usage Examples
158
+
159
+ ### Server-side - Listening to specific events
160
+
161
+ ```javascript
162
+ MongoRealtime.init({
163
+ connection: connection,
164
+ server: server,
165
+ onSocket: (socket) => {
166
+ socket.on("subscribe:users", () => {
167
+ socket.join("users-room");
168
+ });
169
+ },
170
+ });
171
+
172
+ MongoRealtime.io.to("users-room").emit("custom-event", data);
173
+ ```
174
+
175
+ ### Client-side - Receiving updates
176
+
177
+ ```html
178
+ <!DOCTYPE html>
179
+ <html>
180
+ <head>
181
+ <script src="/socket.io/socket.io.js"></script>
182
+ </head>
183
+ <body>
184
+ <script>
185
+ const socket = io();
186
+
187
+ socket.on("db:change", (change) => {
188
+ console.log("Detected change:", change);
189
+ });
190
+
191
+ socket.on("db:insert:users", (change) => {
192
+ console.log("New user:", change.fullDocument);
193
+ });
194
+
195
+ const userId = "507f1f77bcf86cd799439011";
196
+ socket.on(`db:update:users:${userId}`, (change) => {
197
+ console.log("Updated user:", change.fullDocument);
198
+ });
199
+
200
+ socket.on("db:delete", (change) => {
201
+ console.log("Deleted document:", change.documentKey);
202
+ });
203
+ </script>
204
+ </body>
205
+ </html>
206
+ ```
207
+
208
+ ## Error Handling
209
+
210
+ ```javascript
211
+ MongoRealtime.init({
212
+ connection: mongoose.connection,
213
+ server: server,
214
+ onSocket: (socket) => {
215
+ socket.on("error", (error) => {
216
+ console.error("Socket error:", error);
217
+ });
218
+ },
219
+ offSocket: (socket, reason) => {
220
+ if (reason === "transport error") {
221
+ console.log("Transport error detected");
222
+ }
223
+ },
224
+ });
225
+ ```
226
+
227
+ ## 🔒 Security
228
+
229
+ ### Socket Authentication
230
+
231
+ ```javascript
232
+ function authenticateSocket(token, socket) {
233
+ const verify = AuthService.verifyToken(token);
234
+ if (verify) {
235
+ socket.user = verify.user; // attach user info to socket
236
+ return true; // should return true to accept the connection
237
+ }
238
+ return false;
239
+ }
240
+
241
+ MongoRealtime.init({
242
+ connection: mongoose.connection,
243
+ server: server,
244
+ authentify: authenticateSocket,
245
+ middlewares: [
246
+ (socket, next) => {
247
+ console.log(`User is authenticated: ${socket.user.email}`);
248
+ next();
249
+ },
250
+ ],
251
+ offSocket: (socket, reason) => {
252
+ console.log(`Socket ${socket.id} disconnected: ${reason}`);
253
+ },
254
+ });
255
+ ```
256
+
257
+ ### Setup list streams
258
+
259
+ The server will automatically emit a list of filtered documents from the specified collections after each change.\
260
+ Each list stream requires an unique `streamId`, the `collection` name, and an optional `filter` function that returns a boolean or a promise resolving to a boolean.
261
+ Clients receive the list on the event `db:stream:{streamId}`.\
262
+ For best practice, two list streams can't have the same `streamId` or else an error will be thrown. This will prevent accidental overrides. You can still remove a stream with `MongoRealtime.removeListStream(streamId)` and add it again or use inside a `try-catch` scope.
263
+
264
+ ```javascript
265
+ MongoRealtime.init({
266
+ connection: mongoose.connection,
267
+ server: server,
268
+ autoListStream: ["users"], // automatically stream users collection only
269
+ });
270
+
271
+ MongoRealtime.addListStream("users", "users", (doc) => !!doc.email); // will throw an error as streamId 'users' already exists
272
+
273
+ MongoRealtime.removeListStream("users"); // remove the previous stream
274
+ MongoRealtime.addListStream("users", "users", (doc) => !!doc.email); // client can listen to db:stream:users
275
+
276
+ MongoRealtime.addListStream("usersWithEmail", "users", (doc) => !!doc.email); // client can listen to db:stream:usersWithEmail
277
+ ```
278
+
279
+ #### ⚠️ NOTICE
280
+
281
+ When `autoListStream` is not set, all collections are automatically streamed and WITHOUT any filter.\
282
+ That means that if you have a `posts` collection, all documents from this collection will be sent to the clients on each change.\
283
+ And as two list streams can't have the same `streamId`, if you want to add a filtered list stream with id `posts`, you must set `autoListStream` to an array NOT containing `"posts"`or `MongoRealtime.removeListStream("posts")` after initialization.\
284
+ Therefore, if you want to add a filtered list stream for all collections, you must set `autoListStream` to an empty array.
285
+
286
+ ```javascript
287
+ MongoRealtime.init({
288
+ connection: mongoose.connection,
289
+ server: server,
290
+ autoListStream: [], // stream no collection automatically (you can add your own filtered streams later)
291
+ });
292
+ MongoRealtime.addListStream("posts", "posts", (doc) => !!doc.title); // client can listen to db:stream:posts
293
+ MongoRealtime.addListStream("users", "users", (doc) => !!doc.email); // will not throw an error
294
+ ```
295
+
296
+ #### Usecase for id based streams
297
+
298
+ ```javascript
299
+ MongoRealtime.init({
300
+ connection: mongoose.connection,
301
+ server: server,
302
+ authentify: (token, socket) => {
303
+ try {
304
+ socket.uid = decodeToken(token).uid; // setup user id from token
305
+ return true;
306
+ } catch (error) {
307
+ return false;
308
+ }
309
+ },
310
+ onSocket: (socket) => {
311
+ // setup a personal stream for each connected user
312
+ MongoRealtime.addListStream(
313
+ `userPost:${socket.uid}`,
314
+ "posts",
315
+ (doc) => doc._id == socket.uid
316
+ );
317
+ },
318
+ offSocket: (socket) => {
319
+ // clean up when user disconnects
320
+ MongoRealtime.removeListStream(`userPost:${socket.uid}`);
321
+ },
322
+ });
323
+
324
+ // ...
325
+ // or activate stream from a controller or middleware
326
+ app.get("/my-posts", (req, res) => {
327
+ const { user } = req;
328
+ try {
329
+ MongoRealtime.addListStream(
330
+ `userPosts:${user._id}`,
331
+ "posts",
332
+ (doc) => doc.authorId === user._id
333
+ );
334
+ } catch (e) {
335
+ // stream already exists
336
+ }
337
+
338
+ res.send("Stream activated");
339
+ });
340
+ ```
341
+
342
+ #### Usecase with async filter
343
+
344
+ ```javascript
345
+ // MongoRealtime.init({...});
346
+
347
+ MongoRealtime.addListStream("authorizedUsers", "users", async (doc) => {
348
+ const isAdmin = await UserService.isAdmin(doc._id);
349
+ return isAdmin && doc.email.endsWith("@mydomain.com");
350
+ });
351
+
352
+ MongoRealtime.addListStream(
353
+ "bestPosts",
354
+ "posts",
355
+ async (doc) => doc.likes > (await PostService.getLikesThreshold())
356
+ );
357
+ ```
358
+
359
+ ## 📚 Dependencies
360
+
361
+ - `socket.io`: WebSocket management
362
+ - `mongoose`: MongoDB ODM with Change Streams support
363
+
364
+ ## 🐛 Troubleshooting
365
+
366
+ ### MongoDB must be in Replica Set mode
367
+
368
+ ```bash
369
+ mongod --replSet rs0
370
+
371
+ rs.initiate()
372
+ ```
373
+
374
+ ## 📄 License
375
+
376
+ MIT
377
+
378
+ ## 🤝 Contributing
379
+
380
+ Contributions are welcome! Feel free to open an issue or submit a pull request.
package/index.js CHANGED
@@ -1,177 +1,317 @@
1
- const { Server } = require("socket.io");
2
-
3
- class MongoRealtime {
4
- /** @type {import("socket.io").Server} */ static io;
5
- /** @type {import("mongoose").Connection} */ static connection;
6
- /** @type {[import("socket.io").Socket]} */ static sockets = [];
7
- /** @type {Record<String, [(change:ChangeStreamDocument)=>void]>} */ static #listeners =
8
- {};
9
-
10
- /**
11
- * Initializes the socket system.
12
- *
13
- * @param {Object} options
14
- * @param {import("mongoose").Connection} options.connection - Active Mongoose connection
15
- * @param {(token:String, socket: import("socket.io").Socket) => boolean | Promise<boolean>} options.authentify - Auth function that should return true if `token` is valid
16
- * @param {[( socket: import("socket.io").Socket, next: (err?: ExtendedError) => void) => void]} options.middlewares - Register mmiddlewares on incoming socket
17
- * @param {(socket: import("socket.io").Socket) => void} options.onSocket - Callback triggered when a socket connects
18
- * @param {(socket: import("socket.io").Socket, reason: import("socket.io").DisconnectReason) => void} options.offSocket - Callback triggered when a socket disconnects
19
- * @param {import("http").Server} options.server - HTTP server to attach Socket.IO to
20
- * @param {[String]} options.watch - Collections to watch. If empty, will watch all collections
21
- * @param {[String]} options.ignore - Collections to ignore. Can override `watch`
22
- *
23
- */
24
- static init({
25
- connection,
26
- server,
27
- authentify,
28
- middlewares = [],
29
- onSocket,
30
- offSocket,
31
- watch = [],
32
- ignore = [],
33
- }) {
34
- if (this.io)
35
- this.io.close(() => {
36
- this.sockets = [];
37
- });
38
- this.io = new Server(server);
39
- this.connection = connection;
40
-
41
- watch = watch.map((s) => s.toLowerCase());
42
- ignore = ignore.map((s) => s.toLowerCase());
43
-
44
- this.io.use(async (socket, next) => {
45
- if (!!authentify) {
46
- try {
47
- const token = socket.handshake.auth.token || socket.handshake.headers.authorization;
48
- if (!token) return next(new Error("No token provided"));
49
-
50
- const authorized =await authentify(token, socket);
51
- if (authorized===true) return next(); // exactly returns true
52
-
53
- return next(new Error("Unauthorized"));
54
- } catch (error) {
55
- return next(new Error("Authentication error"));
56
- }
57
- } else {
58
- return next();
59
- }
60
- });
61
-
62
- for (let middleware of middlewares) {
63
- this.io.use(middleware);
64
- }
65
-
66
- this.io.on("connection", (socket) => {
67
- this.sockets = [...this.io.sockets.sockets.values()];
68
- if (onSocket) onSocket(socket);
69
-
70
- socket.on("disconnect", (r) => {
71
- this.sockets = [...this.io.sockets.sockets.values()];
72
- if (offSocket) offSocket(socket, r);
73
- });
74
- });
75
-
76
- connection.once("open", () => {
77
- let pipeline = [];
78
- if (watch.length !== 0 && ignore.length === 0) {
79
- pipeline = [{ $match: { "ns.coll": { $in: watch } } }];
80
- } else if (watch.length === 0 && ignore.length !== 0) {
81
- pipeline = [{ $match: { "ns.coll": { $nin: ignore } } }];
82
- } else if (watch.length !== 0 && ignore.length !== 0) {
83
- pipeline = [
84
- {
85
- $match: {
86
- $and: [
87
- { "ns.coll": { $in: watch } },
88
- { "ns.coll": { $nin: ignore } },
89
- ],
90
- },
91
- },
92
- ];
93
- }
94
-
95
- const changeStream = connection.watch(pipeline, {
96
- fullDocument: "updateLookup",
97
- fullDocumentBeforeChange: "whenAvailable",
98
- });
99
-
100
- changeStream.on("change", (change) => {
101
- const colName = change.ns.coll.toLowerCase();
102
- change.col = colName;
103
-
104
- const type = change.operationType;
105
- const id = change.documentKey?._id;
106
-
107
- const e_change = "db:change";
108
- const e_change_type = `db:${type}`;
109
- const e_change_col = `${e_change}:${colName}`;
110
- const e_change_type_col = `${e_change_type}:${colName}`;
111
-
112
- const events = [
113
- e_change,
114
- e_change_type,
115
- e_change_col,
116
- e_change_type_col,
117
- ];
118
-
119
- if (id) {
120
- change.docId = id;
121
- const e_change_doc = `${e_change_col}:${id}`;
122
- const e_change_type_doc = `${e_change_type_col}:${id}`;
123
- events.push(e_change_doc, e_change_type_doc);
124
- }
125
- for (let e of events) {
126
- this.io.emit(e, change);
127
- this.notifyListeners(e, change);
128
- }
129
- });
130
- });
131
- }
132
-
133
- /**
134
- * Notify all event listeners
135
- *
136
- * @param {String} e - Name of the event
137
- * @param {ChangeStreamDocument} change - Change Stream
138
- */
139
- static notifyListeners(e, change) {
140
- if (this.#listeners[e]) {
141
- for (let c of this.#listeners[e]) {
142
- c(change);
143
- }
144
- }
145
- }
146
-
147
- /**
148
- * Subscribe to an event
149
- *
150
- * @param {String} key - Name of the event
151
- * @param {(change:ChangeStreamDocument)=>void} cb - Callback
152
- */
153
- static listen(key, cb) {
154
- if (!this.#listeners[key]) this.#listeners[key] = [];
155
- this.#listeners[key].push(cb);
156
- }
157
-
158
- /**
159
- * Remove one or all listeners of an event
160
- *
161
- * @param {String} key - Name of the event
162
- * @param {(change:ChangeStreamDocument)=>void} cb - Callback
163
- */
164
- static removeListener(key, cb) {
165
- if (cb) this.#listeners[key] = this.#listeners[key].filter((c) => c != cb);
166
- else this.#listeners[key] = [];
167
- }
168
-
169
- /**
170
- * Unsubscribe to all events
171
- */
172
- static removeAllListeners() {
173
- this.#listeners = {};
174
- }
175
- }
176
-
177
- module.exports = MongoRealtime;
1
+ const { Server } = require("socket.io");
2
+
3
+ function sortObj(obj = {}) {
4
+ const out = {};
5
+ for (let k of Object.keys(obj).sort()) {
6
+ const v = obj[k];
7
+ out[k] = typeof v == "object" && !Array.isArray(v) ? sortObj(v) : v;
8
+ }
9
+
10
+ return out;
11
+ }
12
+
13
+ /**
14
+ * @typedef {Object} ChangeStreamDocument
15
+ * @property {"insert"|"update"|"replace"|"delete"|"invalidate"|"drop"|"dropDatabase"|"rename"} operationType
16
+ * The type of operation that triggered the event.
17
+ *
18
+ * @property {Object} ns
19
+ * @property {string} ns.db - Database name
20
+ * @property {string} ns.coll - Collection name
21
+ *
22
+ * @property {Object} documentKey
23
+ * @property {import("bson").ObjectId|string} documentKey._id - The document’s identifier
24
+ *
25
+ * @property {Object} [fullDocument]
26
+ * The full document after the change (only present if `fullDocument: "updateLookup"` is enabled).
27
+ *
28
+ * @property {Object} [updateDescription]
29
+ * @property {Object.<string, any>} [updateDescription.updatedFields]
30
+ * Fields that were updated during an update operation.
31
+ * @property {string[]} [updateDescription.removedFields]
32
+ * Fields that were removed during an update operation.
33
+ *
34
+ * @property {Object} [rename] - Info about the collection rename (if operationType is "rename").
35
+ *
36
+ * @property {Date} [clusterTime] - Logical timestamp of the event.
37
+ */
38
+
39
+ class MongoRealtime {
40
+ /** @type {import("socket.io").Server} */ static io;
41
+ /** @type {import("mongoose").Connection} */ static connection;
42
+ /** @type {Record<String, [(change:ChangeStreamDocument)=>void]>} */ static #listeners =
43
+ {};
44
+ /** @type {Record<String, [Object]>} */
45
+ static #cache = {};
46
+ static sockets = () => [...this.io.sockets.sockets.values()];
47
+
48
+ /**@type {Record<String, {collection:String,filter: (doc:Object)=>Promise<boolean>}>} */
49
+ static #streams = {};
50
+
51
+ /** @type {[String]} - All DB collections */
52
+ static collections = [];
53
+
54
+ /**
55
+ * Initializes the socket system.
56
+ *
57
+ * @param {Object} options
58
+ * @param {import("mongoose").Connection} options.connection - Active Mongoose connection
59
+ * @param {(token:String, socket: import("socket.io").Socket) => boolean | Promise<boolean>} options.authentify - Auth function that should return true if `token` is valid
60
+ * @param {[( socket: import("socket.io").Socket, next: (err?: ExtendedError) => void) => void]} options.middlewares - Register mmiddlewares on incoming socket
61
+ * @param {(socket: import("socket.io").Socket) => void} options.onSocket - Callback triggered when a socket connects
62
+ * @param {(socket: import("socket.io").Socket, reason: import("socket.io").DisconnectReason) => void} options.offSocket - Callback triggered when a socket disconnects
63
+ * @param {import("http").Server} options.server - HTTP server to attach Socket.IO to
64
+ * @param {[String]} options.autoListStream - Collections to stream automatically. If empty, will stream no collection. If null, will stream all collections.
65
+ * @param {[String]} options.watch - Collections to watch. If empty, will watch all collections
66
+ * @param {[String]} options.ignore - Collections to ignore. Can override `watch`
67
+ *
68
+ */
69
+ static init({
70
+ connection,
71
+ server,
72
+ authentify,
73
+ middlewares = [],
74
+ autoListStream,
75
+ onSocket,
76
+ offSocket,
77
+ watch = [],
78
+ ignore = [],
79
+ }) {
80
+ if (this.io) this.io.close();
81
+ this.io = new Server(server);
82
+ this.connection = connection;
83
+
84
+ watch = watch.map((s) => s.toLowerCase());
85
+ ignore = ignore.map((s) => s.toLowerCase());
86
+
87
+ this.io.use(async (socket, next) => {
88
+ if (!!authentify) {
89
+ try {
90
+ const token =
91
+ socket.handshake.auth.token ||
92
+ socket.handshake.headers.authorization;
93
+ if (!token) return next(new Error("NO_TOKEN_PROVIDED"));
94
+
95
+ const authorized = await authentify(token, socket);
96
+ if (authorized === true) return next(); // exactly returns true
97
+
98
+ return next(new Error("UNAUTHORIZED"));
99
+ } catch (error) {
100
+ return next(new Error("AUTH_ERROR"));
101
+ }
102
+ } else {
103
+ return next();
104
+ }
105
+ });
106
+
107
+ for (let middleware of middlewares) {
108
+ this.io.use(middleware);
109
+ }
110
+
111
+ this.io.on("connection", (socket) => {
112
+ if (onSocket) onSocket(socket);
113
+
114
+ socket.on("disconnect", (r) => {
115
+ if (offSocket) offSocket(socket, r);
116
+ });
117
+ });
118
+
119
+ connection.once("open", async () => {
120
+ this.collections = (await connection.listCollections()).map(
121
+ (c) => c.name
122
+ );
123
+
124
+ let pipeline = [];
125
+ if (watch.length !== 0 && ignore.length === 0) {
126
+ pipeline = [{ $match: { "ns.coll": { $in: watch } } }];
127
+ } else if (watch.length === 0 && ignore.length !== 0) {
128
+ pipeline = [{ $match: { "ns.coll": { $nin: ignore } } }];
129
+ } else if (watch.length !== 0 && ignore.length !== 0) {
130
+ pipeline = [
131
+ {
132
+ $match: {
133
+ $and: [
134
+ { "ns.coll": { $in: watch } },
135
+ { "ns.coll": { $nin: ignore } },
136
+ ],
137
+ },
138
+ },
139
+ ];
140
+ }
141
+
142
+ const changeStream = connection.watch(pipeline, {
143
+ fullDocument: "updateLookup",
144
+ fullDocumentBeforeChange: "whenAvailable",
145
+ });
146
+
147
+ /** Setup main streams */
148
+ let collectionsToStream = [];
149
+ if (autoListStream == null) collectionsToStream = this.collections;
150
+ else
151
+ collectionsToStream = this.collections.filter((c) =>
152
+ autoListStream.includes(c)
153
+ );
154
+ for (let col of collectionsToStream) this.addListStream(col, col);
155
+
156
+ /** Emit streams on change */
157
+ changeStream.on("change", async (change) => {
158
+ const coll = change.ns.coll;
159
+
160
+ if (!this.#cache[coll]) {
161
+ this.#cache[coll] = await connection.db
162
+ .collection(coll)
163
+ .find({})
164
+ .toArray();
165
+ } else
166
+ switch (change.operationType) {
167
+ case "insert":
168
+ this.#cache[coll].push(change.fullDocument);
169
+ break;
170
+
171
+ case "update":
172
+ case "replace":
173
+ this.#cache[coll] = this.#cache[coll].map((doc) =>
174
+ doc._id.toString() === change.documentKey._id.toString()
175
+ ? change.fullDocument
176
+ : doc
177
+ );
178
+ break;
179
+
180
+ case "delete":
181
+ this.#cache[coll] = this.#cache[coll].filter(
182
+ (doc) =>
183
+ doc._id.toString() !== change.documentKey._id.toString()
184
+ );
185
+ break;
186
+ }
187
+
188
+ Object.entries(this.#streams).forEach(async (e) => {
189
+ const key = e[0];
190
+ const value = e[1];
191
+ if (value.collection != coll) return;
192
+ const filterResults = await Promise.allSettled(
193
+ this.#cache[coll].map((doc) => value.filter(doc))
194
+ );
195
+
196
+ const filtered = this.#cache[coll].filter(
197
+ (_, i) => filterResults[i] && filterResults[i].value
198
+ );
199
+
200
+ this.io.emit(`db:stream:${key}`, filtered);
201
+ this.notifyListeners(`db:stream:${key}`, filtered);
202
+
203
+ });
204
+ });
205
+
206
+ /** Emit listen events on change */
207
+ changeStream.on("change", async (change) => {
208
+ const colName = change.ns.coll.toLowerCase();
209
+ change.col = colName;
210
+
211
+ const type = change.operationType;
212
+ const id = change.documentKey?._id;
213
+
214
+ const e_change = "db:change";
215
+ const e_change_type = `db:${type}`;
216
+ const e_change_col = `${e_change}:${colName}`;
217
+ const e_change_type_col = `${e_change_type}:${colName}`;
218
+
219
+ const events = [
220
+ e_change,
221
+ e_change_type,
222
+ e_change_col,
223
+ e_change_type_col,
224
+ ];
225
+
226
+ if (id) {
227
+ change.docId = id;
228
+ const e_change_doc = `${e_change_col}:${id}`;
229
+ const e_change_type_doc = `${e_change_type_col}:${id}`;
230
+ events.push(e_change_doc, e_change_type_doc);
231
+ }
232
+ for (let e of events) {
233
+ this.io.emit(e, change);
234
+ this.notifyListeners(e, change);
235
+ }
236
+ });
237
+ });
238
+ }
239
+
240
+ /**
241
+ * Notify all event listeners
242
+ *
243
+ * @param {String} e - Name of the event
244
+ * @param {ChangeStreamDocument} change - Change Stream
245
+ */
246
+ static notifyListeners(e, change) {
247
+ if (this.#listeners[e]) {
248
+ for (let c of this.#listeners[e]) {
249
+ c(change);
250
+ }
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Subscribe to an event
256
+ *
257
+ * @param {String} key - Name of the event
258
+ * @param {(change:ChangeStreamDocument)=>void} cb - Callback
259
+ */
260
+ static listen(key, cb) {
261
+ if (!this.#listeners[key]) this.#listeners[key] = [];
262
+ this.#listeners[key].push(cb);
263
+ }
264
+
265
+ /**
266
+ *
267
+ * @param {String} streamId - StreamId of the list stream
268
+ * @param {String} collection - Name of the collection to stream
269
+ * @param { (doc:Object )=>Promise<boolean>} filter - Collection filter
270
+ *
271
+ * Register a new list stream to listen
272
+ */
273
+ static addListStream(streamId, collection, filter) {
274
+ if (!streamId) throw new Error("Stream id is required");
275
+ if (!collection) throw new Error("Collection is required");
276
+
277
+ filter ??= (_, __) => true;
278
+ if (this.#streams[streamId]) {
279
+ throw new Error(
280
+ `Stream '${streamId}' already registered or is reserved.`
281
+ );
282
+ }
283
+ this.#streams[streamId] = {
284
+ collection,
285
+ filter,
286
+ };
287
+ }
288
+
289
+ /**
290
+ * @param {String} streamId - StreamId of the stream
291
+ *
292
+ * Delete a registered stream
293
+ */
294
+ static removeListStream(streamId) {
295
+ delete this.#streams[streamId];
296
+ }
297
+
298
+ /**
299
+ * Remove one or all listeners of an event
300
+ *
301
+ * @param {String} key - Name of the event
302
+ * @param {(change:ChangeStreamDocument)=>void} cb - Callback
303
+ */
304
+ static removeListener(key, cb) {
305
+ if (cb) this.#listeners[key] = this.#listeners[key].filter((c) => c != cb);
306
+ else this.#listeners[key] = [];
307
+ }
308
+
309
+ /**
310
+ * Unsubscribe to all events
311
+ */
312
+ static removeAllListeners() {
313
+ this.#listeners = {};
314
+ }
315
+ }
316
+
317
+ module.exports = MongoRealtime;
package/package.json CHANGED
@@ -1,26 +1,26 @@
1
- {
2
- "name": "mongo-realtime",
3
- "version": "1.0.4",
4
- "main": "index.js",
5
- "scripts": {},
6
- "keywords": [
7
- "mongo",
8
- "mongoose",
9
- "replica",
10
- "stream",
11
- "realtime",
12
- "mongodb",
13
- "socket",
14
- "db"
15
- ],
16
- "author": "D3R50N",
17
- "license": "MIT",
18
- "repository": {
19
- "url": "git+https://github.com/D3R50N/mongo-realtime.git"
20
- },
21
- "description": "A Node.js package that combines Socket.IO and MongoDB Change Streams to deliver real-time database updates to your WebSocket clients.",
22
- "dependencies": {
23
- "mongoose": "^8.17.0",
24
- "socket.io": "^4.8.1"
25
- }
26
- }
1
+ {
2
+ "name": "mongo-realtime",
3
+ "version": "1.1.1",
4
+ "main": "index.js",
5
+ "scripts": {},
6
+ "keywords": [
7
+ "mongo",
8
+ "mongoose",
9
+ "replica",
10
+ "stream",
11
+ "realtime",
12
+ "mongodb",
13
+ "socket",
14
+ "db"
15
+ ],
16
+ "author": "D3R50N",
17
+ "license": "MIT",
18
+ "repository": {
19
+ "url": "git+https://github.com/D3R50N/mongo-realtime.git"
20
+ },
21
+ "description": "A Node.js package that combines Socket.IO and MongoDB Change Streams to deliver real-time database updates to your WebSocket clients.",
22
+ "dependencies": {
23
+ "mongoose": "^8.17.0",
24
+ "socket.io": "^4.8.1"
25
+ }
26
+ }