mongo-realtime 1.1.3 → 2.0.0

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 +111 -69
  2. package/index.js +286 -142
  3. package/package.json +27 -26
package/README.md CHANGED
@@ -29,18 +29,19 @@ npm install mongo-realtime
29
29
  ```javascript
30
30
  const express = require("express");
31
31
  const http = require("http");
32
- const mongoose = require("mongoose");
33
32
  const MongoRealtime = require("mongo-realtime");
34
33
 
35
34
  const app = express();
36
35
  const server = http.createServer(app);
37
36
 
38
- mongoose.connect("mongodb://localhost:27017/mydb").then((c) => {
39
- console.log("Connected to db", c.connection.name);
40
- });
41
-
42
37
  MongoRealtime.init({
43
- connection: mongoose.connection,
38
+ dbUri: "mongodb://localhost:27017/mydb",
39
+ onDbConnect: (conn) => {
40
+ console.log("Connected to db", conn.name);
41
+ },
42
+ onDbError: (err) => {
43
+ console.log(err.message);
44
+ },
44
45
  server: server,
45
46
  ignore: ["posts"], // ignore 'posts' collection
46
47
  onSocket: (socket) => {
@@ -57,6 +58,44 @@ server.listen(3000, () => {
57
58
  });
58
59
  ```
59
60
 
61
+ ## Breaking Changes since v2.x.x
62
+
63
+ In older versions, streams receive all documents and send them to sockets. This can lead to performance issues with large collections.\
64
+ From version 2.x.x, streams now require a limit (default 50 documents) from the client side to avoid overloading the server and network.\
65
+ Receiving streamed docs now works like this:
66
+
67
+ ```javascript
68
+ socket.emit("realtime", {
69
+ limit: 100,
70
+ streamId: "users",
71
+ reverse: true,
72
+ registerId: "random-client-generated-id",
73
+ });
74
+ /*
75
+ * Request 100 documents from 'users' stream (streamId defined server-side or collection name if autoStream)
76
+ * 'reverse' to get the latest documents first
77
+ * 'registerId' is a unique id generated by the client to identify this request. It will be used to listen to the response event.
78
+ * The response return an object {results: [...], count: number, total:number, remaining:number, coll:string}
79
+ */
80
+
81
+ socket.on("realtime:users:random-client-generated-id", (data) => {
82
+ console.log("Received streamed documents:", data.results); // 100 max per
83
+ });
84
+
85
+ socket.emit("realtime", {
86
+ limit: 50,
87
+ streamId: "usersWithEmail", // make sure this stream is defined server-side
88
+ registerId: "specific-request-id",
89
+ });
90
+
91
+ socket.on("realtime:usersWithEmail:specific-request-id", (data) => {
92
+ console.log("Received streamed documents with email:", data.results); // 50 max per
93
+ });
94
+
95
+ // Make sure to listen before emitting the request to avoid missing the response
96
+ // To stop receiving streamed documents for a specific request just use socket.off
97
+ ```
98
+
60
99
  ## 📋 API
61
100
 
62
101
  ### `MongoRealtime.init(options)`
@@ -67,21 +106,25 @@ Initializes the socket system and MongoDB Change Streams.
67
106
 
68
107
  \* means required
69
108
 
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 |
109
+ | Parameter | Type | Description |
110
+ | --------------------- | ----------------- | -------------------------------------------------------------------------------- |
111
+ | `options.dbUri` | `String`\* | Database URI |
112
+ | `options.dbOptions` | `Object` | Mongoose connection options |
113
+ | `options.onDbConnect` | `Function` | Callback on successful database connection |
114
+ | `options.onDbError` | `Function` | Callback on database connection error |
115
+ | `options.server` | `http.Server`\* | HTTP server to attach Socket.IO |
116
+ | `options.authentify` | `Function` | Function to authenticate socket connections. Should return true if authenticated |
117
+ | `options.middlewares` | `Array[Function]` | Array of Socket.IO middlewares |
118
+ | `options.onSocket` | `Function` | Callback on socket connection |
119
+ | `options.offSocket` | `Function` | Callback on socket disconnection |
120
+ | `options.watch` | `Array[String]` | Collections to only watch. Listen to all when is empty |
121
+ | `options.ignore` | `Array[String]` | Collections to only ignore. Overrides watch array |
122
+ | `options.autoStream` | `Array[String]` | Collections to automatically stream to clients. Default is all |
123
+ | `options.debug` | `Boolean` | Enable debug logs |
81
124
 
82
125
  #### Static Properties and Methods
83
126
 
84
- - `MongoRealtime.addListStream(streamId, collection, filter)`: Manually add a list stream for a specific collection and filter
127
+ - `MongoRealtime.addStream(streamId, collection, filter)`: Manually add a list stream for a specific collection and filter
85
128
  - `MongoRealtime.connection`: MongoDB connection
86
129
  - `MongoRealtime.collections`: Array of database collections
87
130
  - `MongoRealtime.io`: Socket.IO server instance
@@ -99,15 +142,15 @@ The package automatically emits six types of events for each database change:
99
142
 
100
143
  ### Event Types
101
144
 
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` |
145
+ | Event | Description | Example |
146
+ | ---------------------------------- | ----------------- | ------------------------------------------ |
147
+ | `db:change` | All changes | Any collection change |
148
+ | `db:{type}` | By operation type | `db:insert`, `db:update`, `db:delete` |
149
+ | `db:change:{collection}` | By collection | `db:change:users`, `db:change:posts` |
150
+ | `db:{type}:{collection}` | Type + collection | `db:insert:users`, `db:update:posts` |
151
+ | `db:change:{collection}:{id}` | Specific document | `db:change:users:507f1f77bcf86cd799439011` |
152
+ | `db:{type}:{collection}:{id}` | Type + document | `db:insert:users:507f1f77bcf86cd799439011` |
153
+ | `realtime:{streamId}:{registerId}` | By stream | `realtime:myStreamId:registerId` |
111
154
 
112
155
  ### Event listeners
113
156
 
@@ -160,7 +203,7 @@ Each event contains the full MongoDB change object:
160
203
 
161
204
  ```javascript
162
205
  MongoRealtime.init({
163
- connection: connection,
206
+ dbUri: "mongodb://localhost:27017/mydb",
164
207
  server: server,
165
208
  onSocket: (socket) => {
166
209
  socket.on("subscribe:users", () => {
@@ -209,7 +252,7 @@ MongoRealtime.io.to("users-room").emit("custom-event", data);
209
252
 
210
253
  ```javascript
211
254
  MongoRealtime.init({
212
- connection: mongoose.connection,
255
+ dbUri: "mongodb://localhost:27017/mydb",
213
256
  server: server,
214
257
  onSocket: (socket) => {
215
258
  socket.on("error", (error) => {
@@ -228,6 +271,15 @@ MongoRealtime.init({
228
271
 
229
272
  ### Socket Authentication
230
273
 
274
+ You can provide an `authentify` function in the init options to authenticate socket connections.\
275
+ The function receives the token (from `socket.handshake.auth.token` or `socket.handshake.headers.authorization`) and the socket object.\
276
+ When setted, it rejects connections based on this logic:
277
+
278
+ - Token not provided -> error `NO_TOKEN_PROVIDED`
279
+ - Token invalid or returns `false` -> error `UNAUTHORIZED`
280
+ - Any other error -> error `AUTH_ERROR`
281
+ - Return `true` to accept the connection
282
+
231
283
  ```javascript
232
284
  function authenticateSocket(token, socket) {
233
285
  const verify = AuthService.verifyToken(token);
@@ -239,7 +291,7 @@ function authenticateSocket(token, socket) {
239
291
  }
240
292
 
241
293
  MongoRealtime.init({
242
- connection: mongoose.connection,
294
+ dbUri: "mongodb://localhost:27017/mydb",
243
295
  server: server,
244
296
  authentify: authenticateSocket,
245
297
  middlewares: [
@@ -254,64 +306,54 @@ MongoRealtime.init({
254
306
  });
255
307
  ```
256
308
 
257
- ### Setup list streams
309
+ ### Setup streams
258
310
 
259
311
  The server will automatically emit a list of filtered documents from the specified collections after each change.\
260
312
  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
313
  Clients receive the list on the event `db:stream:{streamId}`.\
262
314
 
263
- On init, when `safeListStream` is `true`(default), 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.
264
-
265
315
  ```javascript
266
316
  MongoRealtime.init({
267
- connection: mongoose.connection,
317
+ dbUri: "mongodb://localhost:27017/mydb",
268
318
  server: server,
269
- autoListStream: ["users"], // automatically stream users collection only
319
+ autoStream: ["users"], // automatically stream users collection only
270
320
  });
271
321
 
272
- MongoRealtime.addListStream("users", "users", (doc) => !!doc.email); // will throw an error as streamId 'users' already exists
322
+ MongoRealtime.addStream("users", "users", (doc) => !!doc.email); // will throw an error as streamId 'users' already exists
273
323
 
274
- MongoRealtime.removeListStream("users"); // remove the previous stream
275
- MongoRealtime.addListStream("users", "users", (doc) => !!doc.email); // client can listen to db:stream:users
324
+ MongoRealtime.removeStream("users"); // remove the previous stream
325
+ MongoRealtime.addStream("users", "users", (doc) => !!doc.email); // client can listen to db:stream:users
276
326
 
277
- MongoRealtime.addListStream("usersWithEmail", "users", (doc) => !!doc.email); // client can listen to db:stream:usersWithEmail
327
+ MongoRealtime.addStream("usersWithEmail", "users", (doc) => !!doc.email); // client can listen to db:stream:usersWithEmail
278
328
  ```
279
329
 
280
330
  #### ⚠️ NOTICE
281
331
 
282
- When `autoListStream` is not set, all collections are automatically streamed and WITHOUT any filter.\
332
+ When `autoStream` is not set, all collections are automatically streamed and WITHOUT any filter.\
283
333
  That means that if you have a `posts` collection, all documents from this collection will be sent to the clients on each change.\
284
334
 
285
- Also, `safeListStream` is enabled by default. So, you can't add a list stream with id `posts` if `autoListStream` contains `"posts"` or is not set.\
286
-
287
- If you want to add a filtered list stream with id `posts`, you must set `autoListStream` to an array NOT containing `"posts"`or call `MongoRealtime.removeListStream("posts")` after initialization.\
288
- Therefore, if you want to add a filtered list stream for all collections, you must set `autoListStream` to an empty array.
289
-
290
- To avoid all these issues, you can set `safeListStream` to `false` in the init options but be careful as this will allow you to override existing streams.
291
-
292
335
  ```javascript
293
336
  MongoRealtime.init({
294
- connection: mongoose.connection,
337
+ dbUri: "mongodb://localhost:27017/mydb",
295
338
  server: server,
296
- autoListStream: [], // stream no collection automatically (you can add your own filtered streams later)
339
+ autoStream: [], // stream no collection automatically (you can add your own filtered streams later)
297
340
  });
298
341
  // or
299
342
  MongoRealtime.init({
300
- connection: mongoose.connection,
343
+ dbUri: "mongodb://localhost:27017/mydb",
301
344
  server: server,
302
- safeListStream: false, // disable safe mode (you can override existing streams)
303
- // Still stream all collections automatically but you can override them
345
+ // Stream all collections automatically but you can override them
304
346
  }):
305
347
 
306
- MongoRealtime.addListStream("posts", "posts", (doc) => !!doc.title); // client can listen to db:stream:posts
307
- MongoRealtime.addListStream("users", "users", (doc) => !!doc.email); // will not throw an error
348
+ MongoRealtime.addStream("postsWithTitle", "posts", (doc) => !!doc.title); // client can listen to db:stream:posts
349
+ MongoRealtime.addStream("users", "users", (doc) => !!doc.email); // will not throw an error cause 'users' is already streamed but with no filter
308
350
  ```
309
351
 
310
352
  #### Usecase for id based streams
311
353
 
312
354
  ```javascript
313
355
  MongoRealtime.init({
314
- connection: mongoose.connection,
356
+ dbUri: "mongodb://localhost:27017/mydb",
315
357
  server: server,
316
358
  authentify: (token, socket) => {
317
359
  try {
@@ -323,7 +365,7 @@ MongoRealtime.init({
323
365
  },
324
366
  onSocket: (socket) => {
325
367
  // setup a personal stream for each connected user
326
- MongoRealtime.addListStream(
368
+ MongoRealtime.addStream(
327
369
  `userPost:${socket.uid}`,
328
370
  "posts",
329
371
  (doc) => doc._id == socket.uid
@@ -331,7 +373,7 @@ MongoRealtime.init({
331
373
  },
332
374
  offSocket: (socket) => {
333
375
  // clean up when user disconnects
334
- MongoRealtime.removeListStream(`userPost:${socket.uid}`);
376
+ MongoRealtime.removeStream(`userPost:${socket.uid}`);
335
377
  },
336
378
  });
337
379
 
@@ -339,31 +381,27 @@ MongoRealtime.init({
339
381
  // or activate stream from a controller or middleware
340
382
  app.get("/my-posts", (req, res) => {
341
383
  const { user } = req;
342
- try {
343
- MongoRealtime.addListStream(
344
- `userPosts:${user._id}`,
345
- "posts",
346
- (doc) => doc.authorId === user._id
347
- );
348
- } catch (e) {
349
- // stream already exists
350
- }
384
+ MongoRealtime.addStream(
385
+ `userPosts:${user._id}`,
386
+ "posts",
387
+ (doc) => doc.authorId === user._id
388
+ );
351
389
 
352
390
  res.send("Stream activated");
353
391
  });
354
392
  ```
355
393
 
356
- #### Usecase with async filter
394
+ ### Usecase with async filter
357
395
 
358
396
  ```javascript
359
397
  // MongoRealtime.init({...});
360
398
 
361
- MongoRealtime.addListStream("authorizedUsers", "users", async (doc) => {
399
+ MongoRealtime.addStream("authorizedUsers", "users", async (doc) => {
362
400
  const isAdmin = await UserService.isAdmin(doc._id);
363
401
  return isAdmin && doc.email.endsWith("@mydomain.com");
364
402
  });
365
403
 
366
- MongoRealtime.addListStream(
404
+ MongoRealtime.addStream(
367
405
  "bestPosts",
368
406
  "posts",
369
407
  async (doc) => doc.likes > (await PostService.getLikesThreshold())
@@ -379,12 +417,16 @@ MongoRealtime.addListStream(
379
417
 
380
418
  ### MongoDB must be in Replica Set mode
381
419
 
420
+ To use Change Streams, MongoDB must be running as a replica set. For local development, you can initiate a single-node replica set:
421
+
382
422
  ```bash
383
423
  mongod --replSet rs0
384
424
 
385
425
  rs.initiate()
386
426
  ```
387
427
 
428
+ For any other issues, open an issue on the GitHub repository.
429
+
388
430
  ## 📄 License
389
431
 
390
432
  MIT
package/index.js CHANGED
@@ -1,14 +1,7 @@
1
+ const mongoose = require("mongoose");
1
2
  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
- }
3
+ const { version } = require("./package.json");
4
+ const chalk = require("chalk");
12
5
 
13
6
  /**
14
7
  * @typedef {Object} ChangeStreamDocument
@@ -38,114 +31,123 @@ function sortObj(obj = {}) {
38
31
 
39
32
  class MongoRealtime {
40
33
  /** @type {import("socket.io").Server} */ static io;
41
- /** @type {import("mongoose").Connection} */ static connection;
34
+ /** @type {import("mongoose").Connection} */ static connection =
35
+ mongoose.connection;
42
36
  /** @type {Record<String, [(change:ChangeStreamDocument)=>void]>} */ static #listeners =
43
37
  {};
44
- /** @type {Record<String, [Object]>} */
45
- static #cache = {};
38
+
46
39
  static sockets = () => [...this.io.sockets.sockets.values()];
47
40
 
48
41
  /**@type {Record<String, {collection:String,filter: (doc:Object)=>Promise<boolean>}>} */
49
42
  static #streams = {};
50
43
 
44
+ /**@type {Record<String, {expiration:Date,result:Record<String,{}> }>} */
45
+ static #data = {};
46
+
51
47
  /** @type {[String]} - All DB collections */
52
48
  static collections = [];
53
49
 
54
- static #safeListStream = true;
50
+ static #debug = false;
51
+
52
+ static version = version;
53
+
54
+ static #check(fn, err) {
55
+ const result = fn();
56
+ if (!result) {
57
+ let src = fn.toString().trim();
58
+
59
+ let match =
60
+ src.match(/=>\s*([^{};]+)$/) ||
61
+ src.match(/return\s+([^;}]*)/) ||
62
+ src.match(/{([^}]*)}/);
63
+
64
+ const expr = err ?? (match ? match[1].trim() : src);
65
+
66
+ throw new Error(`MongoRealtime failed to check "${expr}"`);
67
+ }
68
+ }
69
+
70
+ static #log(message, type = 0) {
71
+ const text = `[REALTIME] ${message}`;
72
+ switch (type) {
73
+ case 1:
74
+ console.log(chalk.bold.hex("#11AA60FF")(text));
75
+ break;
76
+ case 2:
77
+ console.log(chalk.bold.bgHex("#257993")(text));
78
+ break;
79
+ case 3:
80
+ console.log(chalk.bold.yellow(text));
81
+ break;
82
+ case 4:
83
+ console.log(chalk.bold.red(text));
84
+ break;
85
+
86
+ case 5:
87
+ console.log(chalk.italic(text));
88
+ break;
89
+
90
+ default:
91
+ console.log(text);
92
+ break;
93
+ }
94
+ }
95
+
96
+ static #debugLog(message) {
97
+ if (this.#debug) this.#log("[DEBUG] " + message, 5);
98
+ }
55
99
 
56
100
  /**
57
101
  * Initializes the socket system.
58
102
  *
59
103
  * @param {Object} options
60
- * @param {import("mongoose").Connection} options.connection - Active Mongoose connection
104
+ * @param {String} options.dbUri - Database URI
105
+ * @param {mongoose.ConnectOptions | undefined} options.dbOptions - Database connect options
61
106
  * @param {(token:String, socket: import("socket.io").Socket) => boolean | Promise<boolean>} options.authentify - Auth function that should return true if `token` is valid
62
107
  * @param {[( socket: import("socket.io").Socket, next: (err?: ExtendedError) => void) => void]} options.middlewares - Register mmiddlewares on incoming socket
108
+ * @param {(conn:mongoose.Connection) => void} options.onDbConnect - Callback triggered when a socket connects
109
+ * @param {(err:Error) => void} options.onDbError - Callback triggered when a socket connects
63
110
  * @param {(socket: import("socket.io").Socket) => void} options.onSocket - Callback triggered when a socket connects
64
111
  * @param {(socket: import("socket.io").Socket, reason: import("socket.io").DisconnectReason) => void} options.offSocket - Callback triggered when a socket disconnects
65
112
  * @param {import("http").Server} options.server - HTTP server to attach Socket.IO to
66
- * @param {[String]} options.autoListStream - Collections to stream automatically. If empty, will stream no collection. If null, will stream all collections.
113
+ * @param {[String]} options.autoStream - Collections to stream automatically. If empty, will stream no collection. If null, will stream all collections.
67
114
  * @param {[String]} options.watch - Collections to watch. If empty, will watch all collections
68
115
  * @param {[String]} options.ignore - Collections to ignore. Can override `watch`
69
- * @param {bool} options.safeListStream - If true(default), declaring an existing streamId will throw an error
116
+ * @param {bool} options.debug - Enable debug mode
70
117
  *
71
118
  */
72
- static init({
73
- connection,
119
+ static async init({
120
+ dbUri,
121
+ dbOptions,
74
122
  server,
123
+ onDbConnect,
124
+ onDbError,
75
125
  authentify,
76
- middlewares = [],
77
- autoListStream,
78
126
  onSocket,
79
127
  offSocket,
80
- safeListStream = true,
128
+ debug = false,
129
+ autoStream,
130
+ middlewares = [],
81
131
  watch = [],
82
132
  ignore = [],
83
133
  }) {
84
- if (this.io) this.io.close();
85
- this.io = new Server(server);
86
- this.connection = connection;
87
- this.#safeListStream = !!safeListStream;
88
-
89
- watch = watch.map((s) => s.toLowerCase());
90
- ignore = ignore.map((s) => s.toLowerCase());
91
-
92
- this.io.use(async (socket, next) => {
93
- if (!!authentify) {
94
- try {
95
- const token =
96
- socket.handshake.auth.token ||
97
- socket.handshake.headers.authorization;
98
- if (!token) return next(new Error("NO_TOKEN_PROVIDED"));
99
-
100
- const authorized = await authentify(token, socket);
101
- if (authorized === true) return next(); // exactly returns true
102
-
103
- return next(new Error("UNAUTHORIZED"));
104
- } catch (error) {
105
- return next(new Error("AUTH_ERROR"));
106
- }
107
- } else {
108
- return next();
109
- }
110
- });
111
-
112
- for (let middleware of middlewares) {
113
- this.io.use(middleware);
114
- }
115
-
116
- this.io.on("connection", (socket) => {
117
- if (onSocket) onSocket(socket);
134
+ this.#log(`MongoRealtime version (${this.version})`, 2);
118
135
 
119
- socket.on("db:stream[register]", async (streamId, registerId) => {
120
- const stream = this.#streams[streamId];
121
- if (!stream) return;
122
- const coll = stream.collection;
123
-
124
- if (!this.#cache[coll]) {
125
- this.#cache[coll] = await connection.db
126
- .collection(coll)
127
- .find({})
128
- .toArray();
129
- }
130
- const filterResults = await Promise.allSettled(
131
- this.#cache[coll].map((doc) => stream.filter(doc))
132
- );
133
-
134
- const filtered = this.#cache[coll].filter(
135
- (_, i) => filterResults[i] && filterResults[i].value
136
- );
137
- this.io.emit(`db:stream[register][${registerId}]`, filtered);
138
- });
139
-
140
- socket.on("disconnect", (r) => {
141
- if (offSocket) offSocket(socket, r);
142
- });
143
- });
136
+ if (this.io) this.io.close();
137
+ this.#check(() => dbUri);
138
+ this.#check(() => server);
139
+ this.#debug = debug;
144
140
 
145
- connection.once("open", async () => {
146
- this.collections = (await connection.listCollections()).map(
141
+ this.io = new Server(server);
142
+ this.connection.once("open", async () => {
143
+ this.collections = (await this.connection.listCollections()).map(
147
144
  (c) => c.name
148
145
  );
146
+ this.#debugLog(
147
+ `${this.collections.length} collections found : ${this.collections.join(
148
+ ", "
149
+ )}`
150
+ );
149
151
 
150
152
  let pipeline = [];
151
153
  if (watch.length !== 0 && ignore.length === 0) {
@@ -165,76 +167,63 @@ class MongoRealtime {
165
167
  ];
166
168
  }
167
169
 
168
- const changeStream = connection.watch(pipeline, {
170
+ const changeStream = this.connection.watch(pipeline, {
169
171
  fullDocument: "updateLookup",
170
172
  fullDocumentBeforeChange: "whenAvailable",
171
173
  });
172
174
 
173
175
  /** Setup main streams */
174
176
  let collectionsToStream = [];
175
- if (autoListStream == null) collectionsToStream = this.collections;
177
+ if (autoStream == null) collectionsToStream = this.collections;
176
178
  else
177
179
  collectionsToStream = this.collections.filter((c) =>
178
- autoListStream.includes(c)
180
+ autoStream.includes(c)
179
181
  );
180
- for (let col of collectionsToStream) this.addListStream(col, col);
182
+ for (let col of collectionsToStream) this.addStream(col, col);
183
+ this.#debugLog(
184
+ `Auto stream on collections : ${collectionsToStream.join(", ")}`
185
+ );
181
186
 
182
- /** Emit streams on change */
187
+ /** Emit listen events on change */
183
188
  changeStream.on("change", async (change) => {
184
189
  const coll = change.ns.coll;
190
+ const colName = coll.toLowerCase();
191
+ const doc = change.fullDocument;
185
192
 
186
- if (!this.#cache[coll]) {
187
- this.#cache[coll] = await connection.db
188
- .collection(coll)
189
- .find({})
190
- .toArray();
191
- } else
192
- switch (change.operationType) {
193
- case "insert":
194
- this.#cache[coll].push(change.fullDocument);
195
- break;
193
+ this.#debugLog(`Collection '${colName}' changed`);
196
194
 
197
- case "update":
198
- case "replace":
199
- this.#cache[coll] = this.#cache[coll].map((doc) =>
200
- doc._id.toString() === change.documentKey._id.toString()
201
- ? change.fullDocument
202
- : doc
203
- );
204
- break;
195
+ change.col = colName;
205
196
 
197
+ const type = change.operationType;
198
+ const id = change.documentKey?._id.toString();
199
+
200
+ for (const k in this.#streams) {
201
+ const stream = this.#streams[k];
202
+ if (stream.collection != coll) continue;
203
+
204
+ Promise.resolve(stream.filter(doc)).then((ok) => {
205
+ if (ok) {
206
+ this.io.emit(`realtime:${k}`, {
207
+ results: [doc],
208
+ coll: coll,
209
+ count: 1,
210
+ total: 1,
211
+ remaining: 0,
212
+ });
213
+ }
214
+ });
215
+ }
216
+ for (let k in this.#data) {
217
+ if (!k.startsWith(`${coll}-`) || !this.#data[k].result[id]) continue;
218
+ doc._id = doc._id.toString();
219
+ switch (change.operationType) {
206
220
  case "delete":
207
- this.#cache[coll] = this.#cache[coll].filter(
208
- (doc) =>
209
- doc._id.toString() !== change.documentKey._id.toString()
210
- );
221
+ delete this.#data[k].result[id];
211
222
  break;
223
+ default:
224
+ this.#data[k].result[id] = doc;
212
225
  }
213
-
214
- Object.entries(this.#streams).forEach(async (e) => {
215
- const key = e[0];
216
- const value = e[1];
217
- if (value.collection != coll) return;
218
- const filterResults = await Promise.allSettled(
219
- this.#cache[coll].map((doc) => value.filter(doc))
220
- );
221
-
222
- const filtered = this.#cache[coll].filter(
223
- (_, i) => filterResults[i] && filterResults[i].value
224
- );
225
-
226
- this.io.emit(`db:stream:${key}`, filtered);
227
- this.notifyListeners(`db:stream:${key}`, filtered);
228
- });
229
- });
230
-
231
- /** Emit listen events on change */
232
- changeStream.on("change", async (change) => {
233
- const colName = change.ns.coll.toLowerCase();
234
- change.col = colName;
235
-
236
- const type = change.operationType;
237
- const id = change.documentKey?._id;
226
+ }
238
227
 
239
228
  const e_change = "db:change";
240
229
  const e_change_type = `db:${type}`;
@@ -260,6 +249,163 @@ class MongoRealtime {
260
249
  }
261
250
  });
262
251
  });
252
+
253
+ try {
254
+ await mongoose.connect(dbUri, dbOptions);
255
+ this.#log(`Connected to db '${mongoose.connection.name}'`, 1);
256
+ onDbConnect?.call(this, mongoose.connection);
257
+ } catch (error) {
258
+ onDbError?.call(this, error);
259
+ this.#log("Failed to init", 4);
260
+ return;
261
+ }
262
+
263
+ this.#check(() => mongoose.connection.db, "No database found");
264
+
265
+ watch = watch.map((s) => s.toLowerCase());
266
+ ignore = ignore.map((s) => s.toLowerCase());
267
+
268
+ this.io.use(async (socket, next) => {
269
+ if (!!authentify) {
270
+ try {
271
+ const token =
272
+ socket.handshake.auth.token ||
273
+ socket.handshake.headers.authorization;
274
+ if (!token) return next(new Error("NO_TOKEN_PROVIDED"));
275
+
276
+ const authorized = await authentify(token, socket);
277
+ if (authorized === true) return next(); // exactly returns true
278
+
279
+ return next(new Error("UNAUTHORIZED"));
280
+ } catch (error) {
281
+ return next(new Error("AUTH_ERROR"));
282
+ }
283
+ } else {
284
+ return next();
285
+ }
286
+ });
287
+
288
+ for (let middleware of middlewares) {
289
+ this.io.use(middleware);
290
+ }
291
+
292
+ this.io.on("connection", (socket) => {
293
+ socket.emit("version", version);
294
+
295
+ socket.on(
296
+ "realtime",
297
+ async ({ streamId, limit, reverse, registerId }) => {
298
+ if (!streamId || !this.#streams[streamId]) return;
299
+
300
+ const stream = this.#streams[streamId];
301
+ const coll = stream.collection;
302
+
303
+ const default_limit = 50;
304
+ limit ??= default_limit;
305
+ try {
306
+ limit = parseInt(limit);
307
+ } catch (_) {
308
+ limit = default_limit;
309
+ }
310
+
311
+ reverse = reverse == true;
312
+ registerId ??= "";
313
+ this.#debugLog(
314
+ `Socket ${socket.id} registred for realtime '${coll}:${registerId}'. Limit ${limit}. Reversed ${reverse}`
315
+ );
316
+
317
+ let total;
318
+ const ids = [];
319
+
320
+ do {
321
+ total = await this.connection.db
322
+ .collection(coll)
323
+ .estimatedDocumentCount();
324
+
325
+ const length = ids.length;
326
+ const range = [length, Math.min(total, length + limit)];
327
+ const now = new Date();
328
+
329
+ let cachedKey = `${coll}-${range}`;
330
+
331
+ let cachedResult = this.#data[cachedKey];
332
+ if (cachedResult && cachedResult.expiration < now) {
333
+ delete this.#data[cachedKey];
334
+ }
335
+
336
+ cachedResult = this.#data[cachedKey];
337
+
338
+ const result = cachedResult
339
+ ? Object.values(cachedResult.result)
340
+ : await this.connection.db
341
+ .collection(coll)
342
+ .find({
343
+ _id: {
344
+ $nin: ids,
345
+ },
346
+ })
347
+ .limit(limit)
348
+ .sort({ _id: reverse ? -1 : 1 })
349
+ .toArray();
350
+
351
+ ids.push(...result.map((d) => d._id));
352
+
353
+ const delayInMin = 1;
354
+ const expiration = new Date(now.getTime() + delayInMin * 60 * 1000);
355
+ const resultMap = result.reduce((acc, item) => {
356
+ item._id = item._id.toString();
357
+ acc[item._id] = item;
358
+ return acc;
359
+ }, {});
360
+
361
+ if (!cachedResult) {
362
+ this.#data[cachedKey] = {
363
+ expiration,
364
+ result: resultMap,
365
+ };
366
+ }
367
+
368
+ const filtered = (
369
+ await Promise.all(
370
+ result.map(async (doc) => {
371
+ try {
372
+ return {
373
+ doc,
374
+ ok: await stream.filter(doc),
375
+ };
376
+ } catch (e) {
377
+ return {
378
+ doc,
379
+ ok: false,
380
+ };
381
+ }
382
+ })
383
+ )
384
+ )
385
+ .filter((item) => item.ok)
386
+ .map((item) => item.doc);
387
+
388
+ const data = {
389
+ results: filtered,
390
+ coll,
391
+ count: filtered.length,
392
+ total,
393
+ remaining: total - ids.length,
394
+ };
395
+
396
+ socket.emit(`realtime:${streamId}:${registerId}`, data);
397
+ } while (ids.length < total);
398
+ }
399
+ );
400
+
401
+ socket.on("disconnect", (r) => {
402
+ if (offSocket) offSocket(socket, r);
403
+ });
404
+
405
+ if (onSocket) onSocket(socket);
406
+ });
407
+
408
+ this.#log(`Initialized`, 1);
263
409
  }
264
410
 
265
411
  /**
@@ -295,16 +441,14 @@ class MongoRealtime {
295
441
  *
296
442
  * Register a new list stream to listen
297
443
  */
298
- static addListStream(streamId, collection, filter) {
444
+ static addStream(streamId, collection, filter) {
299
445
  if (!streamId) throw new Error("Stream id is required");
300
446
  if (!collection) throw new Error("Collection is required");
447
+ if (this.#streams[streamId] && this.collections.includes(streamId))
448
+ throw new Error(`streamId '${streamId}' cannot be a collection`);
301
449
 
302
450
  filter ??= (_, __) => true;
303
- if (this.#safeListStream && this.#streams[streamId]) {
304
- throw new Error(
305
- `Stream '${streamId}' already registered or is reserved.`
306
- );
307
- }
451
+
308
452
  this.#streams[streamId] = {
309
453
  collection,
310
454
  filter,
@@ -316,7 +460,7 @@ class MongoRealtime {
316
460
  *
317
461
  * Delete a registered stream
318
462
  */
319
- static removeListStream(streamId) {
463
+ static removeStream(streamId) {
320
464
  delete this.#streams[streamId];
321
465
  }
322
466
 
package/package.json CHANGED
@@ -1,26 +1,27 @@
1
- {
2
- "name": "mongo-realtime",
3
- "version": "1.1.3",
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": "2.0.0",
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
+ "chalk": "^4.1.2",
24
+ "mongoose": "^8.17.0",
25
+ "socket.io": "^4.8.1"
26
+ }
27
+ }