mongo-realtime 2.0.4 → 3.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.
- package/.env.example +5 -0
- package/README.md +362 -438
- package/package.json +19 -7
- package/src/env.js +87 -0
- package/src/index.js +15 -0
- package/src/query.js +308 -0
- package/src/server.js +928 -0
- package/index.js +0 -570
- package/logo.png +0 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const http = require("node:http");
|
|
4
|
+
const { randomUUID } = require("node:crypto");
|
|
5
|
+
|
|
6
|
+
const { MongoClient, ObjectId } = require("mongodb");
|
|
7
|
+
const { WebSocketServer } = require("ws");
|
|
8
|
+
|
|
9
|
+
const { readEnvironmentOptions } = require("./env");
|
|
10
|
+
const {
|
|
11
|
+
deepCopy,
|
|
12
|
+
isMongoOperatorUpdate,
|
|
13
|
+
isPlainObject,
|
|
14
|
+
matchesFilter,
|
|
15
|
+
} = require("./query");
|
|
16
|
+
const Stream = require("node:stream");
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* MongoRealTime WebSocket server backed by MongoDB.
|
|
20
|
+
*
|
|
21
|
+
* By default the server reads its configuration from `.env` through `dotenv`.
|
|
22
|
+
* You can either let it create and own its HTTP server, or attach it to an
|
|
23
|
+
* existing HTTP server such as one created for Express.
|
|
24
|
+
*/
|
|
25
|
+
class MongoRealTimeServer {
|
|
26
|
+
#mongoClient;
|
|
27
|
+
#ownsMongoClient;
|
|
28
|
+
#db;
|
|
29
|
+
#ownsHttpServer;
|
|
30
|
+
#httpServer;
|
|
31
|
+
#wss;
|
|
32
|
+
#started;
|
|
33
|
+
#authenticate;
|
|
34
|
+
#upgradeAttached;
|
|
35
|
+
#connectionAttached;
|
|
36
|
+
#socketSubscriptions;
|
|
37
|
+
#subscriptions;
|
|
38
|
+
#eventHandlers;
|
|
39
|
+
#queryCache;
|
|
40
|
+
#cacheTtlMs;
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} [options={}] Server configuration.
|
|
43
|
+
* @param {string} [options.host] Host used when this package owns the HTTP server.
|
|
44
|
+
* @param {number} [options.port] Port used when this package owns the HTTP server.
|
|
45
|
+
* @param {string} [options.path] WebSocket upgrade path. Defaults to `/`.
|
|
46
|
+
* @param {string} [options.mongoUri] MongoDB connection URI.
|
|
47
|
+
* @param {string} [options.dbName] MongoDB database name.
|
|
48
|
+
* @param {number} [options.cacheTtlMs] Cache TTL in milliseconds.
|
|
49
|
+
* @param {(authData:any)=>Promise<boolean>} [options.authenticate] Cache TTL in milliseconds.
|
|
50
|
+
* @param {import('node:http').Server} [options.server] Existing HTTP server to attach to.
|
|
51
|
+
* @param {import('mongodb').MongoClient} [options.mongoClient] Existing Mongo client to reuse.
|
|
52
|
+
* @param {import('mongodb').Db} [options.db] Existing Mongo database handle to reuse.
|
|
53
|
+
* @param {{info?: Function, warn?: Function}} [options.logger] Logger compatible with `console`.
|
|
54
|
+
*/
|
|
55
|
+
constructor(options = {}) {
|
|
56
|
+
const resolved = readEnvironmentOptions(options);
|
|
57
|
+
|
|
58
|
+
this.host = resolved.host;
|
|
59
|
+
this.port = resolved.port;
|
|
60
|
+
this.path = resolved.path;
|
|
61
|
+
this.mongoUri = resolved.mongoUri;
|
|
62
|
+
this.dbName = resolved.dbName;
|
|
63
|
+
this.logger = options.logger ?? console;
|
|
64
|
+
this.#authenticate = options.authenticate;
|
|
65
|
+
this.#mongoClient = options.mongoClient ?? null;
|
|
66
|
+
this.#ownsMongoClient = !options.mongoClient;
|
|
67
|
+
this.#db =
|
|
68
|
+
options.db ??
|
|
69
|
+
(options.mongoClient ? options.mongoClient.db(this.dbName) : null);
|
|
70
|
+
|
|
71
|
+
this.#ownsHttpServer = !options.server;
|
|
72
|
+
this.#httpServer = options.server ?? http.createServer();
|
|
73
|
+
this.#wss = new WebSocketServer({ noServer: true });
|
|
74
|
+
this.#started = false;
|
|
75
|
+
this.#upgradeAttached = false;
|
|
76
|
+
this.#connectionAttached = false;
|
|
77
|
+
|
|
78
|
+
this.#socketSubscriptions = new Map();
|
|
79
|
+
this.#subscriptions = new Map();
|
|
80
|
+
this.#eventHandlers = new Map();
|
|
81
|
+
this.#queryCache = new Map();
|
|
82
|
+
this.#cacheTtlMs = Number.isInteger(resolved.cacheTtlMs)
|
|
83
|
+
? resolved.cacheTtlMs
|
|
84
|
+
: 5 * 60 * 1000;
|
|
85
|
+
|
|
86
|
+
if (
|
|
87
|
+
!this.#ownsHttpServer &&
|
|
88
|
+
(options.host != null || options.port != null)
|
|
89
|
+
) {
|
|
90
|
+
this.logger.warn?.(
|
|
91
|
+
'MongoRealTimeServer received "host" or "port" with an external HTTP server; those options are ignored.',
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Registers a custom event handler for `realtime:emit` messages.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} eventName Custom event name.
|
|
100
|
+
* @param {(payload: any, context: {socket: any, server: MongoRealTimeServer, requestId?: string}) => any | Promise<any>} handler
|
|
101
|
+
* @returns {MongoRealTimeServer}
|
|
102
|
+
*/
|
|
103
|
+
on(eventName, handler) {
|
|
104
|
+
if (typeof eventName !== "string" || eventName.trim() === "") {
|
|
105
|
+
throw new TypeError('Expected "eventName" to be a non-empty string.');
|
|
106
|
+
}
|
|
107
|
+
if (typeof handler !== "function") {
|
|
108
|
+
throw new TypeError('Expected "handler" to be a function.');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this.#eventHandlers.set(eventName, handler);
|
|
112
|
+
return this;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Connects MongoDB, attaches WebSocket handlers, and starts listening when
|
|
117
|
+
* the package owns the HTTP server.
|
|
118
|
+
*
|
|
119
|
+
* @returns {Promise<MongoRealTimeServer>}
|
|
120
|
+
*/
|
|
121
|
+
async start() {
|
|
122
|
+
if (this.#started) {
|
|
123
|
+
return this;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await this.#connectMongo();
|
|
127
|
+
|
|
128
|
+
if (!this.#connectionAttached) {
|
|
129
|
+
this.#connectionAttached = true;
|
|
130
|
+
this.#wss.on("connection", (socket, req, stream) =>
|
|
131
|
+
this.#handleConnection(socket, req, stream),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!this.#upgradeAttached) {
|
|
136
|
+
this.#upgradeAttached = true;
|
|
137
|
+
this.#httpServer.on("upgrade", async (request, socket, head) => {
|
|
138
|
+
if (toPathname(request.url) !== this.path) {
|
|
139
|
+
socket.destroy();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this.#wss.handleUpgrade(request, socket, head, (webSocket) => {
|
|
144
|
+
this.#wss.emit("connection", webSocket, request, socket);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (this.#ownsHttpServer) {
|
|
150
|
+
await new Promise((resolve, reject) => {
|
|
151
|
+
const onError = (error) => {
|
|
152
|
+
this.#httpServer.off("listening", onListening);
|
|
153
|
+
reject(error);
|
|
154
|
+
};
|
|
155
|
+
const onListening = () => {
|
|
156
|
+
this.#httpServer.off("error", onError);
|
|
157
|
+
resolve();
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
this.#httpServer.once("error", onError);
|
|
161
|
+
this.#httpServer.once("listening", onListening);
|
|
162
|
+
this.#httpServer.listen(this.port, this.host);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await this.#listenInternHandlers();
|
|
167
|
+
|
|
168
|
+
this.#started = true;
|
|
169
|
+
if (this.#ownsHttpServer) {
|
|
170
|
+
this.logger.info?.(
|
|
171
|
+
`MongoRealTime server listening on ws://${this.host}:${this.port}${this.path}`,
|
|
172
|
+
);
|
|
173
|
+
} else {
|
|
174
|
+
this.logger.info?.(
|
|
175
|
+
`MongoRealTime server attached to an external HTTP server on path ${this.path}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return this;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async #listenInternHandlers() {
|
|
183
|
+
const collections = await this.#db.listCollections().toArray();
|
|
184
|
+
for (let c of collections) {
|
|
185
|
+
this.collection(c.name)
|
|
186
|
+
.watch([], {
|
|
187
|
+
fullDocument: "updateLookup",
|
|
188
|
+
})
|
|
189
|
+
.on("change", (change) => {
|
|
190
|
+
Promise.resolve(this.#handleCacheChange(c.name, change)).catch(
|
|
191
|
+
() => {},
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const callHandler = (
|
|
195
|
+
type = "change",
|
|
196
|
+
withColl = true,
|
|
197
|
+
docId = "",
|
|
198
|
+
) => {
|
|
199
|
+
let eventName = `db:${type}`;
|
|
200
|
+
if (withColl) {
|
|
201
|
+
eventName += `:${c.name}`;
|
|
202
|
+
if (!!docId) eventName += `:${docId}`;
|
|
203
|
+
}
|
|
204
|
+
const handler = this.#eventHandlers.get(eventName);
|
|
205
|
+
try {
|
|
206
|
+
handler?.(change);
|
|
207
|
+
} catch (_) {}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
callHandler(change.operationType, true, change.documentKey._id);
|
|
211
|
+
callHandler("change", true, change.documentKey._id);
|
|
212
|
+
callHandler(change.operationType);
|
|
213
|
+
callHandler("change");
|
|
214
|
+
callHandler(change.operationType, false);
|
|
215
|
+
callHandler("change", false);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Stops subscriptions, closes sockets, and releases owned Mongo/HTTP resources.
|
|
222
|
+
*
|
|
223
|
+
* @returns {Promise<void>}
|
|
224
|
+
*/
|
|
225
|
+
async stop() {
|
|
226
|
+
const activeSubscriptions = Array.from(this.#subscriptions.keys());
|
|
227
|
+
await Promise.all(
|
|
228
|
+
activeSubscriptions.map((queryId) => this.#unsubscribe(queryId)),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
for (const socket of this.#socketSubscriptions.keys()) {
|
|
232
|
+
try {
|
|
233
|
+
socket.close();
|
|
234
|
+
} catch {}
|
|
235
|
+
}
|
|
236
|
+
this.#socketSubscriptions.clear();
|
|
237
|
+
|
|
238
|
+
await new Promise((resolve) => this.#wss.close(() => resolve()));
|
|
239
|
+
|
|
240
|
+
if (this.#ownsHttpServer) {
|
|
241
|
+
await new Promise((resolve, reject) => {
|
|
242
|
+
this.#httpServer.close((error) => {
|
|
243
|
+
if (error) {
|
|
244
|
+
reject(error);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
resolve();
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (this.#ownsMongoClient && this.#mongoClient) {
|
|
253
|
+
await this.#mongoClient.close();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
this.#clearQueryCache();
|
|
257
|
+
this.#started = false;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
*
|
|
262
|
+
* @param {import('ws').WebSocket} socket
|
|
263
|
+
* @param {http.IncomingMessage} req
|
|
264
|
+
* @param {Stream.Duplex} stream
|
|
265
|
+
*/
|
|
266
|
+
async #handleConnection(socket, req, stream) {
|
|
267
|
+
this.#socketSubscriptions.set(socket, new Set());
|
|
268
|
+
|
|
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
|
+
socket.on("message", (buffer) => {
|
|
291
|
+
Promise.resolve(this.#handleMessage(socket, buffer)).catch((error) => {
|
|
292
|
+
this.#sendError(socket, error);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
socket.on("close", () => {
|
|
297
|
+
Promise.resolve(this.#cleanupSocketSubscriptions(socket)).catch(
|
|
298
|
+
(error) => {
|
|
299
|
+
this.logger.warn?.(
|
|
300
|
+
`MongoRealTime socket cleanup failed: ${error.message}`,
|
|
301
|
+
);
|
|
302
|
+
},
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
socket.on("error", (error) => {
|
|
307
|
+
this.logger.warn?.(`MongoRealTime socket error: ${error.message}`);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async #handleMessage(socket, buffer) {
|
|
312
|
+
const message = parsePayload(buffer);
|
|
313
|
+
|
|
314
|
+
switch (message.type) {
|
|
315
|
+
case "realtime:subscribe":
|
|
316
|
+
await this.#subscribe(socket, message);
|
|
317
|
+
return;
|
|
318
|
+
case "realtime:unsubscribe":
|
|
319
|
+
await this.#unsubscribe(String(message.queryId ?? ""));
|
|
320
|
+
return;
|
|
321
|
+
case "realtime:fetch":
|
|
322
|
+
await this.#fetch(socket, message);
|
|
323
|
+
return;
|
|
324
|
+
case "realtime:insert":
|
|
325
|
+
await this.#insert(message);
|
|
326
|
+
return;
|
|
327
|
+
case "realtime:update":
|
|
328
|
+
await this.#update(message);
|
|
329
|
+
return;
|
|
330
|
+
case "realtime:delete":
|
|
331
|
+
await this.#delete(message);
|
|
332
|
+
return;
|
|
333
|
+
case "realtime:emit":
|
|
334
|
+
await this.#emit(socket, message);
|
|
335
|
+
return;
|
|
336
|
+
default:
|
|
337
|
+
throw new Error(`Unsupported message type "${message.type}".`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async #subscribe(socket, message) {
|
|
342
|
+
const query = normalizeQuery(message);
|
|
343
|
+
await this.#unsubscribe(query.queryId);
|
|
344
|
+
|
|
345
|
+
const collection = this.collection(query.collection);
|
|
346
|
+
const documents = await this.#findDocuments(collection, query);
|
|
347
|
+
const changeStream = collection.watch([], { fullDocument: "updateLookup" });
|
|
348
|
+
|
|
349
|
+
const subscription = {
|
|
350
|
+
socket,
|
|
351
|
+
query,
|
|
352
|
+
collection,
|
|
353
|
+
changeStream,
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
this.#subscriptions.set(query.queryId, subscription);
|
|
357
|
+
this.#socketSubscriptions.get(socket)?.add(query.queryId);
|
|
358
|
+
|
|
359
|
+
changeStream.on("change", (change) => {
|
|
360
|
+
Promise.resolve(this.#forwardChange(query.queryId, change)).catch(
|
|
361
|
+
(error) => {
|
|
362
|
+
this.#sendError(socket, error, query.queryId);
|
|
363
|
+
},
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
changeStream.on("error", (error) => {
|
|
368
|
+
this.#sendError(socket, error, query.queryId);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
this.#send(socket, {
|
|
372
|
+
type: "realtime:initial",
|
|
373
|
+
collection: query.collection,
|
|
374
|
+
queryId: query.queryId,
|
|
375
|
+
documents,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async #fetch(socket, message) {
|
|
380
|
+
const query = normalizeQuery(message);
|
|
381
|
+
const documents = await this.#findDocuments(
|
|
382
|
+
this.collection(query.collection),
|
|
383
|
+
query,
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
this.#send(socket, {
|
|
387
|
+
type: "realtime:initial",
|
|
388
|
+
collection: query.collection,
|
|
389
|
+
queryId: query.queryId,
|
|
390
|
+
documents,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async #unsubscribe(queryId) {
|
|
395
|
+
if (!queryId) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const subscription = this.#subscriptions.get(queryId);
|
|
400
|
+
if (!subscription) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
this.#subscriptions.delete(queryId);
|
|
405
|
+
this.#socketSubscriptions.get(subscription.socket)?.delete(queryId);
|
|
406
|
+
await subscription.changeStream.close();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async #cleanupSocketSubscriptions(socket) {
|
|
410
|
+
const queryIds = Array.from(this.#socketSubscriptions.get(socket) ?? []);
|
|
411
|
+
this.#socketSubscriptions.delete(socket);
|
|
412
|
+
await Promise.all(queryIds.map((queryId) => this.#unsubscribe(queryId)));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async #insert(message) {
|
|
416
|
+
const collection = this.collection(
|
|
417
|
+
requiredString(message.collection, "collection"),
|
|
418
|
+
);
|
|
419
|
+
const document = prepareDocumentForWrite(
|
|
420
|
+
requiredObject(message.document, "document"),
|
|
421
|
+
);
|
|
422
|
+
await collection.insertOne(document);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async #update(message) {
|
|
426
|
+
const collection = this.collection(
|
|
427
|
+
requiredString(message.collection, "collection"),
|
|
428
|
+
);
|
|
429
|
+
const filter = prepareFilter(optionalObject(message.filter));
|
|
430
|
+
const update = normalizeMongoUpdate(
|
|
431
|
+
requiredObject(message.update, "update"),
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
ensureUpdateDoesNotChangeId(update);
|
|
435
|
+
await collection.updateMany(filter, update);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async #delete(message) {
|
|
439
|
+
const collection = this.collection(
|
|
440
|
+
requiredString(message.collection, "collection"),
|
|
441
|
+
);
|
|
442
|
+
const filter = prepareFilter(optionalObject(message.filter));
|
|
443
|
+
await collection.deleteMany(filter);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async #emit(socket, message) {
|
|
447
|
+
const eventName = requiredString(message.event, "event");
|
|
448
|
+
let requestId =
|
|
449
|
+
typeof message.requestId === "string" ? message.requestId : undefined;
|
|
450
|
+
const handler = this.#eventHandlers.get(eventName);
|
|
451
|
+
|
|
452
|
+
if (!handler) {
|
|
453
|
+
if (requestId) {
|
|
454
|
+
this.#send(socket, {
|
|
455
|
+
type: "realtime:emit:error",
|
|
456
|
+
event: eventName,
|
|
457
|
+
requestId,
|
|
458
|
+
error: `No handler registered for event "${eventName}".`,
|
|
459
|
+
});
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
throw new Error(`No handler registered for event "${eventName}".`);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
const result = await handler(message.payload, {
|
|
468
|
+
socket,
|
|
469
|
+
server: this,
|
|
470
|
+
requestId,
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
requestId ||= "";
|
|
474
|
+
this.#send(socket, {
|
|
475
|
+
type: "realtime:emit:result",
|
|
476
|
+
event: eventName,
|
|
477
|
+
requestId,
|
|
478
|
+
data: result ?? null,
|
|
479
|
+
});
|
|
480
|
+
} catch (error) {
|
|
481
|
+
if (requestId) {
|
|
482
|
+
this.#send(socket, {
|
|
483
|
+
type: "realtime:emit:error",
|
|
484
|
+
event: eventName,
|
|
485
|
+
requestId,
|
|
486
|
+
error: error instanceof Error ? error.message : String(error),
|
|
487
|
+
});
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
throw error;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async #forwardChange(queryId, change) {
|
|
496
|
+
const subscription = this.#subscriptions.get(queryId);
|
|
497
|
+
if (!subscription) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const { socket, query, collection } = subscription;
|
|
502
|
+
if (socket.readyState !== 1) {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const payload = await this.#buildChangePayload(collection, query, change);
|
|
507
|
+
if (!payload) {
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
this.#send(socket, payload);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async #buildChangePayload(collection, query, change) {
|
|
515
|
+
const collectionName = query.collection;
|
|
516
|
+
|
|
517
|
+
switch (change.operationType) {
|
|
518
|
+
case "insert": {
|
|
519
|
+
const document = serializeDocument(change.fullDocument);
|
|
520
|
+
if (!document || !matchesFilter(document, query.filter)) {
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
type: "realtime:insert",
|
|
526
|
+
collection: collectionName,
|
|
527
|
+
document,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
case "replace":
|
|
531
|
+
case "update": {
|
|
532
|
+
const id = serializeId(change.documentKey?._id);
|
|
533
|
+
if (!id) {
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const before = await this.#findOneById(collection, id);
|
|
538
|
+
const current = serializeDocument(change.fullDocument);
|
|
539
|
+
const matchedBefore = before
|
|
540
|
+
? matchesFilter(before, query.filter)
|
|
541
|
+
: false;
|
|
542
|
+
const matchedAfter = current
|
|
543
|
+
? matchesFilter(current, query.filter)
|
|
544
|
+
: false;
|
|
545
|
+
|
|
546
|
+
if (!matchedBefore && !matchedAfter) {
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
type: "realtime:update",
|
|
552
|
+
collection: collectionName,
|
|
553
|
+
document: current,
|
|
554
|
+
before,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
case "delete": {
|
|
558
|
+
const id = serializeId(change.documentKey?._id);
|
|
559
|
+
if (!id) {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return {
|
|
564
|
+
type: "realtime:delete",
|
|
565
|
+
collection: collectionName,
|
|
566
|
+
documentId: id,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
default:
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
collection(collectionName) {
|
|
575
|
+
return this.#db.collection(collectionName);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
#setQueryCacheEntry(collectionName, cacheKey, query, documents) {
|
|
579
|
+
const collectionCache = this.#getQueryCacheForCollection(collectionName);
|
|
580
|
+
const existing = collectionCache.get(cacheKey);
|
|
581
|
+
|
|
582
|
+
if (existing?.timeoutId) {
|
|
583
|
+
clearTimeout(existing.timeoutId);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const timeoutId = setTimeout(() => {
|
|
587
|
+
this.#deleteQueryCacheEntry(collectionName, cacheKey);
|
|
588
|
+
}, this.#cacheTtlMs);
|
|
589
|
+
|
|
590
|
+
collectionCache.set(cacheKey, {
|
|
591
|
+
query,
|
|
592
|
+
documents,
|
|
593
|
+
expiresAt: Date.now() + this.#cacheTtlMs,
|
|
594
|
+
timeoutId,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
#deleteQueryCacheEntry(collectionName, cacheKey) {
|
|
599
|
+
const collectionCache = this.#queryCache.get(collectionName);
|
|
600
|
+
if (!collectionCache) {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const entry = collectionCache.get(cacheKey);
|
|
605
|
+
if (!entry) {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (entry.timeoutId) {
|
|
610
|
+
clearTimeout(entry.timeoutId);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
collectionCache.delete(cacheKey);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
#clearQueryCacheForCollection(collectionName) {
|
|
617
|
+
const collectionCache = this.#queryCache.get(collectionName);
|
|
618
|
+
if (!collectionCache) {
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
for (const [cacheKey] of collectionCache.entries()) {
|
|
623
|
+
this.#deleteQueryCacheEntry(collectionName, cacheKey);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
#clearQueryCache() {
|
|
628
|
+
for (const collectionName of this.#queryCache.keys()) {
|
|
629
|
+
this.#clearQueryCacheForCollection(collectionName);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
#getQueryCacheForCollection(collectionName) {
|
|
634
|
+
let collectionCache = this.#queryCache.get(collectionName);
|
|
635
|
+
if (!collectionCache) {
|
|
636
|
+
collectionCache = new Map();
|
|
637
|
+
this.#queryCache.set(collectionName, collectionCache);
|
|
638
|
+
}
|
|
639
|
+
return collectionCache;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async #handleCacheChange(collectionName, change) {
|
|
643
|
+
const collectionCache = this.#queryCache.get(collectionName);
|
|
644
|
+
if (!collectionCache || collectionCache.size === 0) {
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
for (const [cacheKey, cached] of collectionCache.entries()) {
|
|
649
|
+
const query = cached.query;
|
|
650
|
+
if (this.#shouldRebuildCacheForQuery(query)) {
|
|
651
|
+
await this.#rebuildCachedQueryEntry(collectionName, cacheKey, query);
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const existingIndex = cached.documents.findIndex(
|
|
656
|
+
(document) => document._id === serializeId(change.documentKey?._id),
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
switch (change.operationType) {
|
|
660
|
+
case "insert": {
|
|
661
|
+
const document = serializeDocument(change.fullDocument);
|
|
662
|
+
if (!document || !matchesFilter(document, query.filter)) {
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
cached.documents.push(document);
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
case "replace":
|
|
669
|
+
case "update": {
|
|
670
|
+
const document = serializeDocument(change.fullDocument);
|
|
671
|
+
const matchesAfter = document
|
|
672
|
+
? matchesFilter(document, query.filter)
|
|
673
|
+
: false;
|
|
674
|
+
|
|
675
|
+
if (existingIndex >= 0) {
|
|
676
|
+
if (matchesAfter) {
|
|
677
|
+
cached.documents[existingIndex] = document;
|
|
678
|
+
} else {
|
|
679
|
+
cached.documents.splice(existingIndex, 1);
|
|
680
|
+
}
|
|
681
|
+
} else if (matchesAfter) {
|
|
682
|
+
cached.documents.push(document);
|
|
683
|
+
}
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
case "delete": {
|
|
687
|
+
if (existingIndex >= 0) {
|
|
688
|
+
cached.documents.splice(existingIndex, 1);
|
|
689
|
+
}
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
default:
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
#shouldRebuildCacheForQuery(query) {
|
|
699
|
+
return (
|
|
700
|
+
Object.keys(query.sort).length > 0 || typeof query.limit === "number"
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
async #rebuildCachedQueryEntry(collectionName, cacheKey, query) {
|
|
705
|
+
const collection = this.collection(collectionName);
|
|
706
|
+
const documents = await this.#findDocuments(collection, query, {
|
|
707
|
+
useCache: false,
|
|
708
|
+
});
|
|
709
|
+
this.#setQueryCacheEntry(collectionName, cacheKey, query, documents);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
#getQueryCacheKey(collection, query) {
|
|
713
|
+
return JSON.stringify({
|
|
714
|
+
collection,
|
|
715
|
+
filter: query.filter ?? {},
|
|
716
|
+
sort: query.sort ?? {},
|
|
717
|
+
limit: query.limit,
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async #findDocuments(collection, query, options = { useCache: true }) {
|
|
722
|
+
const cacheKey = this.#getQueryCacheKey(collection.collectionName, query);
|
|
723
|
+
const collectionCache = this.#getQueryCacheForCollection(
|
|
724
|
+
collection.collectionName,
|
|
725
|
+
);
|
|
726
|
+
const cached = options.useCache ? collectionCache.get(cacheKey) : undefined;
|
|
727
|
+
if (cached) {
|
|
728
|
+
if (cached.expiresAt == null || cached.expiresAt > Date.now()) {
|
|
729
|
+
return cached.documents.map((doc) => deepCopy(doc));
|
|
730
|
+
}
|
|
731
|
+
this.#deleteQueryCacheEntry(collection.collectionName, cacheKey);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
let cursor = collection.find(prepareFilter(query.filter));
|
|
735
|
+
if (Object.keys(query.sort).length > 0) {
|
|
736
|
+
cursor = cursor.sort(query.sort);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (typeof query.limit === "number") {
|
|
740
|
+
cursor = cursor.limit(query.limit);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const documents = await cursor.toArray();
|
|
744
|
+
const serialized = documents.map(serializeDocument);
|
|
745
|
+
this.#setQueryCacheEntry(
|
|
746
|
+
collection.collectionName,
|
|
747
|
+
cacheKey,
|
|
748
|
+
query,
|
|
749
|
+
serialized,
|
|
750
|
+
);
|
|
751
|
+
return serialized;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async #findOneById(collection, id) {
|
|
755
|
+
const document = await collection.findOne({ _id: toMongoId(id) });
|
|
756
|
+
return serializeDocument(document);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async #connectMongo() {
|
|
760
|
+
if (this.#db) {
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
this.#mongoClient = new MongoClient(this.mongoUri);
|
|
765
|
+
await this.#mongoClient.connect();
|
|
766
|
+
this.#db = this.#mongoClient.db(this.dbName);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
#send(socket, payload) {
|
|
770
|
+
socket.send(JSON.stringify(payload));
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
#sendError(socket, error, queryId) {
|
|
774
|
+
this.#send(socket, {
|
|
775
|
+
type: "realtime:error",
|
|
776
|
+
error: error instanceof Error ? error.message : String(error),
|
|
777
|
+
...(queryId ? { queryId } : {}),
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function normalizeQuery(message) {
|
|
783
|
+
return {
|
|
784
|
+
collection: requiredString(message.collection, "collection"),
|
|
785
|
+
filter: optionalObject(message.filter),
|
|
786
|
+
sort: optionalObject(message.sort),
|
|
787
|
+
limit: Number.isInteger(message.limit) ? message.limit : undefined,
|
|
788
|
+
queryId: String(message.queryId ?? randomUUID()),
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function requiredString(value, field) {
|
|
793
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
794
|
+
throw new TypeError(`Expected "${field}" to be a non-empty string.`);
|
|
795
|
+
}
|
|
796
|
+
return value;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function requiredObject(value, field) {
|
|
800
|
+
if (!isPlainObject(value)) {
|
|
801
|
+
throw new TypeError(`Expected "${field}" to be a plain object.`);
|
|
802
|
+
}
|
|
803
|
+
return deepCopy(value);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function optionalObject(value) {
|
|
807
|
+
return isPlainObject(value) ? deepCopy(value) : {};
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function parsePayload(buffer) {
|
|
811
|
+
const text = Buffer.isBuffer(buffer)
|
|
812
|
+
? buffer.toString("utf8")
|
|
813
|
+
: String(buffer);
|
|
814
|
+
let payload;
|
|
815
|
+
|
|
816
|
+
try {
|
|
817
|
+
payload = JSON.parse(text);
|
|
818
|
+
} catch (_) {}
|
|
819
|
+
|
|
820
|
+
if (!isPlainObject(payload)) {
|
|
821
|
+
throw new TypeError("Expected a JSON object payload.");
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return payload;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function normalizeMongoUpdate(update) {
|
|
828
|
+
if (isMongoOperatorUpdate(update)) {
|
|
829
|
+
return update;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
return {
|
|
833
|
+
$set: update,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function ensureUpdateDoesNotChangeId(update) {
|
|
838
|
+
if (!isPlainObject(update)) {
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (Object.prototype.hasOwnProperty.call(update, "_id")) {
|
|
843
|
+
throw new TypeError('Updating "_id" is not supported.');
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (
|
|
847
|
+
isPlainObject(update.$set) &&
|
|
848
|
+
Object.prototype.hasOwnProperty.call(update.$set, "_id")
|
|
849
|
+
) {
|
|
850
|
+
throw new TypeError('Updating "_id" is not supported.');
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function prepareFilter(filter) {
|
|
855
|
+
return transformMongoIds(filter);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function prepareDocumentForWrite(document) {
|
|
859
|
+
return transformMongoIds(document);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function transformMongoIds(value, path = "") {
|
|
863
|
+
if (Array.isArray(value)) {
|
|
864
|
+
return value.map((entry) => transformMongoIds(entry, path));
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if (!isPlainObject(value)) {
|
|
868
|
+
if (path.endsWith("._id") || path === "_id") {
|
|
869
|
+
return toMongoId(value);
|
|
870
|
+
}
|
|
871
|
+
return value;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const next = {};
|
|
875
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
876
|
+
const nextPath = path ? `${path}.${key}` : key;
|
|
877
|
+
next[key] = transformMongoIds(entry, nextPath);
|
|
878
|
+
}
|
|
879
|
+
return next;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function toMongoId(value) {
|
|
883
|
+
if (typeof value === "string" && ObjectId.isValid(value)) {
|
|
884
|
+
return new ObjectId(value);
|
|
885
|
+
}
|
|
886
|
+
return value;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function serializeDocument(document) {
|
|
890
|
+
if (!document) {
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
return JSON.parse(
|
|
895
|
+
JSON.stringify(document, (_, value) => {
|
|
896
|
+
if (value instanceof ObjectId) {
|
|
897
|
+
return value.toHexString();
|
|
898
|
+
}
|
|
899
|
+
if (value instanceof Date) {
|
|
900
|
+
return value.toISOString();
|
|
901
|
+
}
|
|
902
|
+
return value;
|
|
903
|
+
}),
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function serializeId(value) {
|
|
908
|
+
if (!value) {
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
return value instanceof ObjectId ? value.toHexString() : String(value);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function toPathname(url) {
|
|
915
|
+
if (!url) {
|
|
916
|
+
return "/";
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
try {
|
|
920
|
+
return new URL(url, "http://localhost").pathname;
|
|
921
|
+
} catch {
|
|
922
|
+
return String(url).split("?")[0] || "/";
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
module.exports = {
|
|
927
|
+
MongoRealTimeServer,
|
|
928
|
+
};
|