@rpcbase/server 0.204.0 → 0.205.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/package.json +4 -1
- package/rts/index.js +425 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rpcbase/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.205.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/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
|