@rpcbase/server 0.204.0 → 0.206.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/mongoose.js ADDED
@@ -0,0 +1,8 @@
1
+ /* @flow */
2
+ const mongoose = require("mongoose")
3
+
4
+ // WARNING: this shouldn't be necessary as strictQuery is back to false by default
5
+ // https://mongoosejs.com/docs/migrating_to_7.html#strictquery
6
+ mongoose.set("strictQuery", false)
7
+
8
+ module.exports = mongoose
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpcbase/server",
3
- "version": "0.204.0",
3
+ "version": "0.206.0",
4
4
  "license": "SSPL-1.0",
5
5
  "main": "./index.js",
6
6
  "bin": {
@@ -12,6 +12,7 @@
12
12
  "dependencies": {
13
13
  "@rpcbase/agent": "0.30.0",
14
14
  "@rpcbase/std": "0.6.0",
15
+ "bluebird": "3.7.2",
15
16
  "body-parser": "1.20.2",
16
17
  "bull": "4.10.4",
17
18
  "connect-redis": "6.1.3",
@@ -28,6 +29,8 @@
28
29
  "postmark": "3.0.18",
29
30
  "redis": "4.6.6",
30
31
  "request-ip": "3.3.0",
32
+ "sift": "17.0.1",
33
+ "socket.io": "4.7.1",
31
34
  "validator": "13.9.0",
32
35
  "yargs": "17.7.2"
33
36
  }
package/redis.js ADDED
@@ -0,0 +1,2 @@
1
+ /* @flow */
2
+ module.exports = require("redis")
package/rts/index.js ADDED
@@ -0,0 +1,425 @@
1
+ /* @flow */
2
+ const Promise = require("bluebird")
3
+ const _remove = require("lodash/remove")
4
+ const _get = require("lodash/get")
5
+ const _set = require("lodash/set")
6
+ const {Server} = require("socket.io")
7
+ const sift = require("sift")
8
+ const debug = require("debug")
9
+ const colors = require("picocolors")
10
+
11
+ const log = debug("rb:rts:server")
12
+
13
+ const is_production = process.env.IS_PRODUCTION === "yes"
14
+
15
+
16
+ const QUERY_MAX_LIMIT = 4096
17
+
18
+ // TODO: there are edge cases in dev where the client reconnects to the server
19
+ // but the server already has the query results and sends it multiple times
20
+ // the server should ensure it is only registering queries once
21
+
22
+ // TODO: does this even support multiple clients ?
23
+ // TODO: investigate multiple change streams event triggers per client
24
+ // TODO: add tracing
25
+
26
+ const _sockets = {}
27
+ const _change_streams = {}
28
+ // we index both queries and socket queries
29
+ // to avoid going through all queries when a socket disconnects
30
+ const _queries = {}
31
+ const _socket_queries = {}
32
+
33
+
34
+ const get_query_options = (ctx, options) => {
35
+ // https://mongoosejs.com/docs/api/query.html#Query.prototype.setOptions()
36
+ const query_options = {
37
+ ctx,
38
+ is_client: true
39
+ //
40
+ }
41
+
42
+ if (options.sort) {
43
+ query_options.sort = options.sort
44
+ }
45
+
46
+ const limit = typeof options.limit === "number" ? Math.min(QUERY_MAX_LIMIT, Math.abs(options.limit)) : QUERY_MAX_LIMIT
47
+ // TODO: warn if limit greater than default ?
48
+ query_options.limit = options.limit
49
+
50
+ return query_options
51
+ }
52
+
53
+
54
+ // helper to send and update / refresh event to all subscribed queries
55
+ const dispatch_doc_change = (model, doc) => {
56
+ const model_name = model.modelName
57
+ const queries = _queries[model_name] || {}
58
+
59
+ // WARNING: INVESTIGATE: there are edge cases where you might want to delete an object
60
+ // that was matched, but now it's not anymore because it's been udpated or removed
61
+ // rts should still update the query and send a payload to the client
62
+ // we could use the new mongodb fullDocumentBeforeChange param in watch,
63
+ // but it doesn't seem to be guaranteed to be there
64
+
65
+ // filter queries that match the document
66
+ const target_queries = Object.keys(queries).filter((query_key) => {
67
+ const {query} = queries[query_key]
68
+ // console.log("query", query)
69
+ const test_fn = sift(query)
70
+ return test_fn(doc)
71
+ })
72
+
73
+ if (!doc.__txn_id) {
74
+ console.log(colors.yellow("Warning!"), "change on model:", model.modelName, "matching queries", target_queries, "did not include a valid", colors.yellow("__txn"), "field")
75
+ }
76
+
77
+ // TODO: we should not re-run each query here!!!!
78
+ // dispatch change to target query clients
79
+ target_queries.forEach(async(query_key) => {
80
+ const {query, options, sockets} = queries[query_key]
81
+
82
+ // TODO: remove this
83
+ await Promise.map(sockets, async(socket_id) => {
84
+ const s = _sockets[socket_id]
85
+ // socket was destroyed
86
+ // TODO: remove socket id from _queries when destroyed
87
+ if (!s) {
88
+ log("NO SOCKET RETURNING")
89
+ return
90
+ }
91
+ // console.log("dipatch change", query_key)
92
+ const ctx = {req: s.request}
93
+
94
+ if (!ctx.req.session.user_id) {
95
+ console.error("dispatch_doc_change::error", "no user id for client query")
96
+ return
97
+ }
98
+
99
+
100
+ const query_options = get_query_options(ctx, options)
101
+ const query_promise = model.find(query, options.projection, query_options)
102
+ if (query_options.sort) {
103
+ query_promise.sort(query_options.sort)
104
+ }
105
+ if (typeof query_options.limit === "number" && query_options.limit >= 0) {
106
+ query_promise.limit(query_options.limit)
107
+ }
108
+ // WARNING: DANGER
109
+ // TODO: we should not be doing a read here, use doc from event
110
+ const data = await query_promise
111
+
112
+ const data_buf = JSON.stringify(data)
113
+
114
+ log("emit payload")
115
+
116
+ s.emit("query_payload", {
117
+ model_name,
118
+ query_key,
119
+ data_buf,
120
+ txn_id: doc.__txn_id,
121
+ })
122
+ }, {concurrency: 1024}) // TODO: tweak concurrency it should be infinite
123
+
124
+ })
125
+ }
126
+
127
+
128
+ // mongoose middleware used to broadcast delete events to appropriate sockets
129
+ const mongoose_delete_plugin = (schema) => {
130
+ schema.pre("deleteOne", function(next) {
131
+ throw new Error("rts::deleteOne is not supported, use findOneAndDelete")
132
+ })
133
+
134
+ // TODO: add other delete operations
135
+ // https://mongoosejs.com/docs/queries.html
136
+ schema.post("findOneAndDelete", function(doc) {
137
+ dispatch_doc_change(this.model, doc)
138
+ })
139
+ }
140
+
141
+
142
+ // Mongoose transaction plugin to save txn on each doc
143
+ // WARNING: query middlewares are not supported yet
144
+ // https://mongoosejs.com/docs/middleware.html#types-of-middleware
145
+ const mongoose_txn_plugin = (schema, options) => {
146
+ // console.log("register TXN plugin on schema", schema)
147
+ // WARNING: possible middlewares here:
148
+ // https://mongoosejs.com/docs/middleware.html
149
+ // this function isn't async but could be?
150
+ const apply_txn = /* async */function(next, save_options) {
151
+ // TODO: keep an eye on this, this seems to happen for nested schemas that aren't models
152
+ if (!save_options) {
153
+ next()
154
+ return
155
+ }
156
+
157
+ const {ctx} = save_options
158
+ if (!ctx) {
159
+ console.log(colors.yellow("Warning!"), "{ctx} not attached to save")
160
+ }
161
+
162
+ const txn_id = _get(ctx, ["req", "headers", "rts-txn-id"])
163
+
164
+ if (!txn_id) {
165
+ console.log(colors.yellow("Warning!"), "mongoose save request is missing txn_id this will cause issues with rts-client")
166
+ } else {
167
+ this.set("__txn_id", txn_id)
168
+ }
169
+
170
+ next()
171
+ }
172
+
173
+
174
+ schema.pre("save", apply_txn)
175
+ }
176
+
177
+
178
+ // responds to a change stream "change" event
179
+ const get_dispatch_change_handler = (mongoose, model_name) => async(change) => {
180
+ const queries = _queries[model_name]
181
+ // console.log("onchange::queries", queries)
182
+
183
+ const model = mongoose.model(model_name)
184
+
185
+ let doc
186
+ if (["insert", "update"].includes(change.operationType)) {
187
+ // console.log("rts onChange", change)
188
+ doc = change.fullDocument
189
+ } else if (change.operationType === "delete") {
190
+ console.log("op is delete")
191
+ } else if (!["delete", "drop", "invalidate"].includes(change.operationType)) {
192
+ console.log("SKIPPING: unknown operation type", change.operationType, change)
193
+ return
194
+ }
195
+
196
+ if (!doc) {
197
+ return
198
+ }
199
+
200
+ dispatch_doc_change(model, doc)
201
+ }
202
+
203
+
204
+ const add_change_stream = (mongoose, socket_id, {model_name, query, query_key, options}) => {
205
+ if (!model_name) throw new Error("empty model name")
206
+
207
+ if (!_change_streams[model_name]) {
208
+ log("rts: initializing change stream for model:", model_name)
209
+ let model
210
+ try {
211
+ model = mongoose.model(model_name)
212
+ } catch (err) {
213
+ console.error("ERROR registering model:", model_name)
214
+ console.error(err)
215
+ return
216
+ }
217
+
218
+ // TODO: fix this, this has been released
219
+ // TODO: implement delete operation fullDocument retrieve,
220
+ // when this is released https://jira.mongodb.org/browse/SERVER-36941
221
+ const emitter = model.watch({
222
+ fullDocument: "updateLookup",
223
+ fullDocumentBeforeChange: "whenAvailable",
224
+ })
225
+
226
+ emitter.on("change", get_dispatch_change_handler(mongoose, model_name))
227
+
228
+ emitter.on("error", (err) => {
229
+ console.log("change listener emitter got error", err)
230
+ })
231
+
232
+ emitter.on("close", () => {
233
+ delete _change_streams[model_name]
234
+ })
235
+
236
+ _change_streams[model_name] = emitter
237
+ }
238
+
239
+ // save query
240
+ if (!_queries[model_name]) {
241
+ _queries[model_name] = {}
242
+ }
243
+
244
+ if (!_queries[model_name][query_key]) {
245
+ _queries[model_name][query_key] = {query, options, sockets: []}
246
+ }
247
+ if (!_queries[model_name][query_key].sockets.includes(socket_id)) {
248
+ _queries[model_name][query_key].sockets.push(socket_id)
249
+ }
250
+
251
+ // save socket_query
252
+ if (!_socket_queries[socket_id]) {
253
+ _socket_queries[socket_id] = {}
254
+ }
255
+ if (!_socket_queries[socket_id][model_name]) {
256
+ _socket_queries[socket_id][model_name] = {}
257
+ }
258
+ _socket_queries[socket_id][model_name][query_key] = true
259
+ }
260
+
261
+
262
+ const run_query = async(mongoose, socket_id, {model_name, query, query_key, options}) => {
263
+ const model = mongoose.model(model_name)
264
+
265
+ if (options) {
266
+ log("options", options)
267
+ }
268
+
269
+ if (!query) throw new Error("run_query empty query")
270
+
271
+ const s = _sockets[socket_id]
272
+ const ctx = {req: s.request}
273
+
274
+ log("initial run_query AUTH", ctx.req.session.user_id)
275
+ if (!ctx.req.session.user_id) {
276
+ // TODO: retry in node16! why is the throw not caught in try catch block
277
+ // throw new Error("no user ID for client query")
278
+ console.log("no user ID for client query")
279
+ // TODO: this happens right after sign in, investigate
280
+ s.emit("query_payload", {model_name, query_key, error: "error no user id"})
281
+ return
282
+ }
283
+
284
+ const query_options = get_query_options(ctx, options)
285
+
286
+ const query_promise = model.find(query, options.projection, query_options)
287
+
288
+ if (query_options.sort) {
289
+ query_promise.sort(query_options.sort)
290
+ }
291
+
292
+ if (typeof query_options.limit === "number" && query_options.limit >= 0) {
293
+ query_promise.limit(query_options.limit)
294
+ }
295
+
296
+ const data = await query_promise
297
+
298
+ // console.log("TMP Artificial delay")
299
+ // await Promise.delay(500)
300
+
301
+ const data_buf = JSON.stringify(data)
302
+
303
+ s.emit("query_payload", {model_name, query_key, data_buf})
304
+ }
305
+
306
+
307
+ const remove_query = (socket_id, {model_name, query, query_key}) => {
308
+ // const query_key = JSON.stringify(query)
309
+ const sockets = _queries[model_name]?.[query_key]?.sockets || []
310
+
311
+ let new_sockets
312
+ if (sockets.includes(socket_id)) {
313
+ new_sockets = _remove(sockets, (s) => s === socket_id)
314
+ } else new_sockets = sockets
315
+
316
+ _set(_queries, `${model_name}.${query_key}.sockets`, new_sockets)
317
+ }
318
+
319
+ const disconnect_handler = (socket_id, reason) => {
320
+ if (reason !== "transport close") {
321
+ console.log("client disconnected, reason:", reason, "socket:", socket_id)
322
+ }
323
+
324
+
325
+ const local_queries = _socket_queries[socket_id] || {}
326
+
327
+ // remove all queries matching that socket id
328
+ Object.keys(local_queries)
329
+ .forEach((model_name) => {
330
+
331
+ Object.keys(local_queries[model_name]).forEach((query_key) => {
332
+ const active_sockets = _queries[model_name]?.[query_key]?.sockets || []
333
+
334
+ const socket_index = active_sockets.indexOf(socket_id)
335
+
336
+ // remove the socket id from the array
337
+ if (socket_index > -1) {
338
+ // _queries[model_name][query_key].sockets.splice(socket_index, 1)
339
+ active_sockets.splice(socket_index, 1)
340
+ }
341
+
342
+ // remove query if there are no more listeners
343
+ if (active_sockets.length === 0) {
344
+ delete _queries[model_name]?.[query_key]
345
+ }
346
+
347
+ // TODO: why the || {} here ?
348
+ // remove change stream if there are no more queries on this collection
349
+ if (Object.keys(_queries[model_name] || {}).length === 0) {
350
+ delete _queries[model_name]
351
+ _change_streams[model_name]?.close()
352
+ }
353
+
354
+ })
355
+ })
356
+
357
+ // remove socket
358
+ delete _sockets[socket_id]
359
+ delete _socket_queries[socket_id]
360
+ }
361
+
362
+
363
+ const rts_server = (mongoose, session_middleware) => {
364
+
365
+ // register the delete plugin
366
+ mongoose.plugin(mongoose_delete_plugin)
367
+ // txn plugin
368
+ mongoose.plugin(mongoose_txn_plugin)
369
+
370
+
371
+ return async(server) => {
372
+ //
373
+ const io = new Server(server, {
374
+ transports: is_production ? ["websocket", "polling"] : ["websocket"],
375
+ allowUpgrades: true,
376
+ cors: {
377
+ // TODO: dynamic client ports
378
+ // TODO: check if is production
379
+ origin: `http://localhost:${process.env.CLIENT_PORT}`,
380
+ methods: ["GET", "POST"],
381
+ credentials: true,
382
+ },
383
+ })
384
+
385
+ // socket io session middleware
386
+ io.use((socket, next) => {
387
+ session_middleware(socket.request, {}, next)
388
+ })
389
+
390
+ // WARNING: TODO: socket session tmp disabled
391
+ // io.on('connection', (socket) => {
392
+ // const session = socket.request.session;
393
+ // session.connections++;
394
+ // session.save();
395
+ // });
396
+
397
+ io.on("connection", (socket) => {
398
+ _sockets[socket.id] = socket
399
+ console.log("socket connected", socket.id)
400
+ //
401
+ socket.on("register_query", (payload) => {
402
+ add_change_stream(mongoose, socket.id, payload)
403
+ })
404
+
405
+ socket.on("remove_query", (payload) => {
406
+ remove_query(socket.id, payload)
407
+ })
408
+
409
+ socket.on("run_query", (payload) => {
410
+ try {
411
+ run_query(mongoose, socket.id, payload)
412
+ } catch (err) {
413
+ console.log("run_query caught error", err)
414
+ }
415
+ })
416
+
417
+ socket.on("disconnect", (reason) => {
418
+ disconnect_handler(socket.id, reason)
419
+ })
420
+ })
421
+ }
422
+ }
423
+
424
+
425
+ module.exports = rts_server
package/mongoose/index.js DELETED
@@ -1,6 +0,0 @@
1
- /* @flow */
2
- const mongoose = require("mongoose")
3
-
4
- mongoose.set("strictQuery", false)
5
-
6
- module.exports = mongoose