mongo-realtime 3.0.0 → 3.0.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 +33 -9
  2. package/package.json +3 -3
  3. package/src/server.js +73 -30
package/README.md CHANGED
@@ -219,16 +219,16 @@ or:
219
219
 
220
220
  ### Server internal events
221
221
 
222
- The server emit internal events that can be listened inside the backend code using `server.on('MY_INTERNAL_EVENT', handler)`.\
222
+ The server emit internal events that can be listened inside the backend code using `server.on('MY_INTERNAL_EVENT', handler)`. Registring the same event will override the previous handler.\
223
223
  `MY_INTERNAL_EVENT` follows these patterns:
224
224
 
225
225
  - `db:OPERATION_TYPE` : For any operation type on any collection
226
226
  - `db:OPERATION_TYPE:COLLECTION_NAME` : For a specific operation type on a specific collection
227
227
  - `db:OPERATION_TYPE:COLLECTION_NAME:DOCUMENT_ID` : For a specific operation type on a specific document
228
228
 
229
- `OPERATION_TYPE` can be `insert`, `update`, `delete` or `change` (matches all).\
230
- `COLLECTION_NAME` is the name of the collection, e.g. `users`.\
231
- `DOCUMENT_ID` is the string representation of the document's `_id`, e.g. `507f1f77bcf86cd799439011`.
229
+ **`OPERATION_TYPE`** can be `insert`, `update`, `delete` or `change` (matches all).\
230
+ **`COLLECTION_NAME`** is the name of the collection, e.g. `users`.\
231
+ **`DOCUMENT_ID`** is the string representation of the document's `_id`, e.g. `507f1f77bcf86cd799439011`.
232
232
 
233
233
  Example:
234
234
 
@@ -244,6 +244,26 @@ server.on("db:update:orders:507f1f77bcf86cd799439011", (change) => {
244
244
  });
245
245
  ```
246
246
 
247
+ It can also be listened inside the client on the event `realtime:db:change`.
248
+
249
+ ```js
250
+ /* Client-side
251
+ Receives this object
252
+ {
253
+ operationType: 'insert' | 'update' | 'delete',
254
+ collection: 'users',
255
+ docId: '507f1f77bcf86cd799439011',
256
+ fullDocument: { ... }, // for insert and update
257
+ }*/
258
+
259
+ socket.addEventListener("message", (event) => {
260
+ const message = JSON.parse(event.data);
261
+ if (message.type === "realtime:db:change") {
262
+ console.log("Database change:", message);
263
+ }
264
+ });
265
+ ```
266
+
247
267
  ### Error responses
248
268
 
249
269
  If a request fails, the server sends:
@@ -306,16 +326,20 @@ Returns a MongoDB collection handle for direct access.
306
326
 
307
327
  ## Authentication
308
328
 
309
- Provide an `authenticate` function to validate WebSocket connections. The incoming payload is read from the `auth` request header and parsed as JSON when possible.
329
+ Provide an `authenticate` function to validate WebSocket connections. The incoming payload is read from the `auth` request header and parsed as JSON when possible. If the `auth` header is missing, the server falls back to the `token` query parameter from the WebSocket URL.
310
330
 
311
331
  Example:
312
332
 
313
333
  ```js
314
334
  const server = new MongoRealTimeServer({
315
- authenticate: async (authData) => {
316
- // Perform your authentication logic here, e.g. check a token or session.
317
- // In this example, we simply check if the authData matches a hardcoded token.
318
- return authData === "my-auth-token";
335
+ authenticate: async (authData, request) => {
336
+ // `authData` is the parsed `auth` header when present,
337
+ // otherwise it falls back to the `token` query parameter.
338
+ const token = new URL(
339
+ request.url,
340
+ "http://localhost:3000",
341
+ ).searchParams.get("token");
342
+ return authData?.session === "ok" || token === "my-auth-token";
319
343
  },
320
344
  });
321
345
  ```
package/package.json CHANGED
@@ -1,8 +1,7 @@
1
1
  {
2
2
  "name": "mongo-realtime",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "main": "src/index.js",
5
- "scripts": {},
6
5
  "exports": {
7
6
  ".": "./src/index.js"
8
7
  },
@@ -35,5 +34,6 @@
35
34
  "dotenv": "^17.4.2",
36
35
  "mongodb": "^7.2.0",
37
36
  "ws": "^8.20.0"
38
- }
37
+ },
38
+ "scripts": {}
39
39
  }
package/src/server.js CHANGED
@@ -13,7 +13,6 @@ const {
13
13
  isPlainObject,
14
14
  matchesFilter,
15
15
  } = require("./query");
16
- const Stream = require("node:stream");
17
16
 
18
17
  /**
19
18
  * MongoRealTime WebSocket server backed by MongoDB.
@@ -46,7 +45,7 @@ class MongoRealTimeServer {
46
45
  * @param {string} [options.mongoUri] MongoDB connection URI.
47
46
  * @param {string} [options.dbName] MongoDB database name.
48
47
  * @param {number} [options.cacheTtlMs] Cache TTL in milliseconds.
49
- * @param {(authData:any)=>Promise<boolean>} [options.authenticate] Cache TTL in milliseconds.
48
+ * @param {(authData:any, request: import('node:http').IncomingMessage)=>boolean|Promise<boolean>} [options.authenticate] Optional connection authenticator.
50
49
  * @param {import('node:http').Server} [options.server] Existing HTTP server to attach to.
51
50
  * @param {import('mongodb').MongoClient} [options.mongoClient] Existing Mongo client to reuse.
52
51
  * @param {import('mongodb').Db} [options.db] Existing Mongo database handle to reuse.
@@ -127,9 +126,7 @@ class MongoRealTimeServer {
127
126
 
128
127
  if (!this.#connectionAttached) {
129
128
  this.#connectionAttached = true;
130
- this.#wss.on("connection", (socket, req, stream) =>
131
- this.#handleConnection(socket, req, stream),
132
- );
129
+ this.#wss.on("connection", (socket) => this.#handleConnection(socket));
133
130
  }
134
131
 
135
132
  if (!this.#upgradeAttached) {
@@ -140,6 +137,25 @@ class MongoRealTimeServer {
140
137
  return;
141
138
  }
142
139
 
140
+ if (typeof this.#authenticate === "function") {
141
+ const authData =
142
+ parseAuthHeader(request.headers.auth) ??
143
+ parseTokenFromUrl(request.url);
144
+ let authenticated = false;
145
+
146
+ try {
147
+ authenticated = await this.#authenticate(authData, request);
148
+ } catch (error) {
149
+ this.logger.warn?.("Authenticate exception", error);
150
+ }
151
+
152
+ if (!authenticated) {
153
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
154
+ socket.destroy();
155
+ return;
156
+ }
157
+ }
158
+
143
159
  this.#wss.handleUpgrade(request, socket, head, (webSocket) => {
144
160
  this.#wss.emit("connection", webSocket, request, socket);
145
161
  });
@@ -201,9 +217,20 @@ class MongoRealTimeServer {
201
217
  eventName += `:${c.name}`;
202
218
  if (!!docId) eventName += `:${docId}`;
203
219
  }
220
+
204
221
  const handler = this.#eventHandlers.get(eventName);
205
222
  try {
206
223
  handler?.(change);
224
+ for (let socket of this.#socketSubscriptions.keys()) {
225
+ this.#send(socket, {
226
+ type: "realtime:db:change",
227
+ key: eventName,
228
+ collection: c.name,
229
+ docId: change.documentKey._id,
230
+ operationType: change.operationType,
231
+ fullDocument: change.fullDocument,
232
+ });
233
+ }
207
234
  } catch (_) {}
208
235
  };
209
236
 
@@ -260,33 +287,10 @@ class MongoRealTimeServer {
260
287
  /**
261
288
  *
262
289
  * @param {import('ws').WebSocket} socket
263
- * @param {http.IncomingMessage} req
264
- * @param {Stream.Duplex} stream
265
290
  */
266
- async #handleConnection(socket, req, stream) {
291
+ async #handleConnection(socket) {
267
292
  this.#socketSubscriptions.set(socket, new Set());
268
293
 
269
- if (typeof this.#authenticate === "function") {
270
- let authData = req.headers.auth;
271
- try {
272
- authData = JSON.parse(authData);
273
- } catch (_) {}
274
-
275
- let authenticated = false;
276
- try {
277
- authenticated = await this.#authenticate(authData);
278
- } catch (e) {
279
- this.logger.warn("Authenticate exception", e);
280
- }
281
-
282
- if (!authenticated) {
283
- this.#sendError(socket, "Socket authentification failed");
284
- stream.destroy();
285
- socket.close();
286
- return;
287
- }
288
- }
289
-
290
294
  socket.on("message", (buffer) => {
291
295
  Promise.resolve(this.#handleMessage(socket, buffer)).catch((error) => {
292
296
  this.#sendError(socket, error);
@@ -789,6 +793,45 @@ function normalizeQuery(message) {
789
793
  };
790
794
  }
791
795
 
796
+ function parseAuthHeader(headerValue) {
797
+ if (Array.isArray(headerValue)) {
798
+ return parseAuthHeader(headerValue[0]);
799
+ }
800
+
801
+ if (typeof headerValue !== "string") {
802
+ return headerValue;
803
+ }
804
+
805
+ try {
806
+ return JSON.parse(headerValue);
807
+ } catch (_) {
808
+ return headerValue;
809
+ }
810
+ }
811
+
812
+ function parseTokenFromUrl(url) {
813
+ if (!url) {
814
+ return undefined;
815
+ }
816
+
817
+ try {
818
+ const searchParams = new URL(url, "http://localhost:3000").searchParams;
819
+ return (
820
+ searchParams.get("auth-token") ??
821
+ searchParams.get("authToken") ??
822
+ undefined
823
+ );
824
+ } catch {
825
+ const [, rawQuery = ""] = String(url).split("?");
826
+ const searchParams = new URLSearchParams(rawQuery);
827
+ return (
828
+ searchParams.get("auth-token") ??
829
+ searchParams.get("authToken") ??
830
+ undefined
831
+ );
832
+ }
833
+ }
834
+
792
835
  function requiredString(value, field) {
793
836
  if (typeof value !== "string" || value.trim() === "") {
794
837
  throw new TypeError(`Expected "${field}" to be a non-empty string.`);
@@ -917,7 +960,7 @@ function toPathname(url) {
917
960
  }
918
961
 
919
962
  try {
920
- return new URL(url, "http://localhost").pathname;
963
+ return new URL(url, "http://localhost:3000").pathname;
921
964
  } catch {
922
965
  return String(url).split("?")[0] || "/";
923
966
  }