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.
- package/README.md +33 -9
- package/package.json +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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
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.
|
|
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]
|
|
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
|
|
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
|
|
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
|
}
|