@mblaney/holster 1.0.7 → 1.0.9
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/build/holster.js +3 -3
- package/build/holster.js.map +3 -3
- package/package.json +1 -1
- package/src/array.js +21 -10
- package/src/buffer.js +388 -55
- package/src/dup.js +2 -0
- package/src/get.js +11 -0
- package/src/ham.js +31 -5
- package/src/store.js +21 -7
- package/src/utils.js +23 -14
- package/src/wire.js +314 -38
package/src/utils.js
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
export const num = {
|
|
2
|
-
is: n =>
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
is: n => {
|
|
3
|
+
if (n instanceof Array) return false
|
|
4
|
+
|
|
5
|
+
if (typeof n === "number") return !isNaN(n)
|
|
6
|
+
|
|
7
|
+
if (typeof n === "string") {
|
|
8
|
+
const parsed = parseFloat(n)
|
|
9
|
+
return !isNaN(parsed) && isFinite(parsed)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return false
|
|
13
|
+
},
|
|
5
14
|
}
|
|
6
15
|
|
|
7
16
|
export const obj = {
|
|
8
17
|
is: o => {
|
|
9
|
-
if (!o) return false
|
|
18
|
+
if (!o || typeof o !== "object") return false
|
|
10
19
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"Object"
|
|
15
|
-
)
|
|
20
|
+
const toString = Object.prototype.toString.call(o)
|
|
21
|
+
const match = toString.match(/^\[object (\w+)\]$/)
|
|
22
|
+
return o.constructor === Object || (match && match[1] === "Object")
|
|
16
23
|
},
|
|
17
24
|
map: (list, cb, o) => {
|
|
18
25
|
var keys = Object.keys(list)
|
|
@@ -74,11 +81,13 @@ export const userPublicKey = "_holster_user_public_key"
|
|
|
74
81
|
// graph converts objects to graph format with updated states,
|
|
75
82
|
// with optional meta data to verify signed data.
|
|
76
83
|
export const graph = (soul, data, sig, pub) => {
|
|
84
|
+
const timestamp = Date.now()
|
|
77
85
|
const g = {[soul]: {_: {"#": soul, ">": {}}}}
|
|
86
|
+
|
|
78
87
|
for (const [key, value] of Object.entries(data)) {
|
|
79
|
-
if (key !== "_" && key
|
|
88
|
+
if (key !== "_" && key !== userPublicKey && key !== userSignature) {
|
|
80
89
|
g[soul][key] = value
|
|
81
|
-
g[soul]._[">"][key] =
|
|
90
|
+
g[soul]._[">"][key] = timestamp
|
|
82
91
|
}
|
|
83
92
|
}
|
|
84
93
|
// If a signature and public key are provided they also need to be stored on
|
|
@@ -87,9 +96,9 @@ export const graph = (soul, data, sig, pub) => {
|
|
|
87
96
|
// broadcast the data as a put, which other devices will need to verify.
|
|
88
97
|
if (sig && pub) {
|
|
89
98
|
g[soul][userSignature] = sig
|
|
90
|
-
g[soul]._[">"][userSignature] =
|
|
99
|
+
g[soul]._[">"][userSignature] = timestamp
|
|
91
100
|
g[soul][userPublicKey] = pub
|
|
92
|
-
g[soul]._[">"][userPublicKey] =
|
|
101
|
+
g[soul]._[">"][userPublicKey] = timestamp
|
|
93
102
|
}
|
|
94
103
|
return g
|
|
95
104
|
}
|
|
@@ -121,7 +130,7 @@ export const match = (lex, key) => {
|
|
|
121
130
|
export const text = {
|
|
122
131
|
random: length => {
|
|
123
132
|
var s = ""
|
|
124
|
-
const c = "
|
|
133
|
+
const c = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
125
134
|
if (!length) length = 24
|
|
126
135
|
for (let i = 0; i < length; i++) {
|
|
127
136
|
s += c.charAt(Math.floor(Math.random() * c.length))
|
package/src/wire.js
CHANGED
|
@@ -12,6 +12,142 @@ if (typeof globalThis.WebSocket === "undefined") {
|
|
|
12
12
|
globalThis.WebSocket = wsModule?.WebSocket
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// Rate limiting with throttling
|
|
16
|
+
const createRateLimiter = opt => {
|
|
17
|
+
const clients = new Map()
|
|
18
|
+
const maxRequests = opt.maxRequestsPerMinute || 100
|
|
19
|
+
const windowMs = opt.rateLimitWindow || 60000
|
|
20
|
+
// Check if test environment (mock-socket usage)
|
|
21
|
+
const isTestEnv = opt.wss && opt.wss.constructor.name === "Server"
|
|
22
|
+
let cleanupInterval = null
|
|
23
|
+
|
|
24
|
+
const cleanup = () => {
|
|
25
|
+
const now = Date.now()
|
|
26
|
+
for (const [clientId, data] of clients.entries()) {
|
|
27
|
+
if (now - data.lastCleanup > windowMs) {
|
|
28
|
+
data.requests = []
|
|
29
|
+
data.lastCleanup = now
|
|
30
|
+
}
|
|
31
|
+
data.requests = data.requests.filter(time => now - time < windowMs)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!isTestEnv) {
|
|
36
|
+
cleanupInterval = setInterval(cleanup, windowMs / 4)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
getDelay: clientId => {
|
|
41
|
+
const now = Date.now()
|
|
42
|
+
const client = clients.get(clientId) || {
|
|
43
|
+
requests: [],
|
|
44
|
+
lastCleanup: now,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Filter old requests
|
|
48
|
+
client.requests = client.requests.filter(time => now - time < windowMs)
|
|
49
|
+
|
|
50
|
+
if (client.requests.length >= maxRequests) {
|
|
51
|
+
// Calculate delay based on oldest request that will expire
|
|
52
|
+
const oldestRequest = Math.min(...client.requests)
|
|
53
|
+
const delay = windowMs - (now - oldestRequest)
|
|
54
|
+
return Math.max(0, delay)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// No delay needed, track this request
|
|
58
|
+
client.requests.push(now)
|
|
59
|
+
clients.set(clientId, client)
|
|
60
|
+
return 0
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
getRemainingRequests: clientId => {
|
|
64
|
+
const client = clients.get(clientId)
|
|
65
|
+
if (!client) return maxRequests
|
|
66
|
+
|
|
67
|
+
const now = Date.now()
|
|
68
|
+
const validRequests = client.requests.filter(
|
|
69
|
+
time => now - time < windowMs,
|
|
70
|
+
)
|
|
71
|
+
return Math.max(0, maxRequests - validRequests.length)
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
destroy: () => {
|
|
75
|
+
if (cleanupInterval) {
|
|
76
|
+
clearInterval(cleanupInterval)
|
|
77
|
+
cleanupInterval = null
|
|
78
|
+
}
|
|
79
|
+
clients.clear()
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Safe JSON parser with size limit
|
|
85
|
+
const safeJSONParse = (data, maxSize = 1024 * 1024) => {
|
|
86
|
+
try {
|
|
87
|
+
if (typeof data === "string" && data.length > maxSize) {
|
|
88
|
+
throw new Error("Message too large")
|
|
89
|
+
}
|
|
90
|
+
return {success: true, data: JSON.parse(data)}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return {success: false, error: error.message}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Message size validator
|
|
97
|
+
const validateMessage = (data, maxSize = 1024 * 1024) => {
|
|
98
|
+
// 1MB default
|
|
99
|
+
if (typeof data === "string" && data.length > maxSize) {
|
|
100
|
+
return {valid: false, error: "Message too large"}
|
|
101
|
+
}
|
|
102
|
+
if (Buffer.isBuffer(data) && data.length > maxSize) {
|
|
103
|
+
return {valid: false, error: "Message too large"}
|
|
104
|
+
}
|
|
105
|
+
return {valid: true}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Connection manager to limit concurrent connections
|
|
109
|
+
const createConnectionManager = (maxConnections = 1000) => {
|
|
110
|
+
const connections = new Set()
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
add: ws => {
|
|
114
|
+
if (connections.size >= maxConnections) {
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
connections.add(ws)
|
|
118
|
+
|
|
119
|
+
// Clean up on close
|
|
120
|
+
const originalClose = ws.close
|
|
121
|
+
ws.close = (...args) => {
|
|
122
|
+
connections.delete(ws)
|
|
123
|
+
return originalClose.apply(ws, args)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return true
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
remove: ws => {
|
|
130
|
+
connections.delete(ws)
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
count: () => connections.size,
|
|
134
|
+
|
|
135
|
+
isFull: () => connections.size >= maxConnections,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Enhanced retry mechanism with exponential backoff
|
|
140
|
+
const createRetryHandler = (maxRetries = 5) => {
|
|
141
|
+
let retryCount = 0
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
shouldRetry: () => retryCount < maxRetries,
|
|
145
|
+
getDelay: () => Math.min(1000 * Math.pow(2, retryCount), 30000), // Max 30s
|
|
146
|
+
increment: () => retryCount++,
|
|
147
|
+
reset: () => (retryCount = 0),
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
15
151
|
// Wire starts a websocket client or server and returns get and put methods
|
|
16
152
|
// for access to the wire spec and storage.
|
|
17
153
|
const Wire = opt => {
|
|
@@ -21,6 +157,23 @@ const Wire = opt => {
|
|
|
21
157
|
const queue = {}
|
|
22
158
|
const listen = {}
|
|
23
159
|
|
|
160
|
+
// Track references we want but don't have yet
|
|
161
|
+
const pendingReferences = new Set()
|
|
162
|
+
|
|
163
|
+
// Helper to check if we have a soul in memory or storage
|
|
164
|
+
const hasSoul = async soul => {
|
|
165
|
+
if (graph[soul]) return true
|
|
166
|
+
return new Promise(resolve => {
|
|
167
|
+
store.get({"#": soul}, (err, data) => {
|
|
168
|
+
resolve(!err && data && data[soul])
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Initialize rate limiting and connection management
|
|
174
|
+
const rateLimiter = createRateLimiter(opt)
|
|
175
|
+
const connectionManager = createConnectionManager(opt.maxConnections || 1000)
|
|
176
|
+
|
|
24
177
|
// The check function is required because user data must provide a public key
|
|
25
178
|
// so that it can be verified. The public key might verify the provided
|
|
26
179
|
// signature but not actually match the user under which the data is being
|
|
@@ -155,6 +308,10 @@ const Wire = opt => {
|
|
|
155
308
|
const api = send => {
|
|
156
309
|
return {
|
|
157
310
|
get: (lex, cb, _opt) => {
|
|
311
|
+
// Mark requested soul as something we want to store
|
|
312
|
+
if (lex && lex["#"]) {
|
|
313
|
+
pendingReferences.add(lex["#"])
|
|
314
|
+
}
|
|
158
315
|
getWithCallback(lex, cb, send, _opt)
|
|
159
316
|
},
|
|
160
317
|
put: async (data, cb) => {
|
|
@@ -164,12 +321,29 @@ const Wire = opt => {
|
|
|
164
321
|
// across the network.
|
|
165
322
|
const update = await Ham.mix(data, graph, opt.secure, listen)
|
|
166
323
|
const none = Object.keys(update.now).length === 0
|
|
167
|
-
if (none
|
|
324
|
+
if (none) {
|
|
325
|
+
// Nothing to do.
|
|
326
|
+
cb(null)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!(await check(update.now, send, cb))) return
|
|
331
|
+
|
|
332
|
+
// Seed pendingReferences with any new references from API calls
|
|
333
|
+
for (const [soul, node] of Object.entries(update.now)) {
|
|
334
|
+
if (node && typeof node === "object") {
|
|
335
|
+
for (const [key, value] of Object.entries(node)) {
|
|
336
|
+
const soulId = utils.rel.is(value)
|
|
337
|
+
if (soulId) {
|
|
338
|
+
// Add referenced soul to pending list
|
|
339
|
+
pendingReferences.add(soulId)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
168
344
|
|
|
169
345
|
store.put(update.now, cb)
|
|
170
346
|
// Also put data on the wire spec.
|
|
171
|
-
// TODO: Note that this means all clients now receive all updates, so
|
|
172
|
-
// need to filter what should be stored, both in graph and on disk.
|
|
173
347
|
send(
|
|
174
348
|
JSON.stringify({
|
|
175
349
|
"#": dup.track(utils.text.random(9)),
|
|
@@ -223,61 +397,117 @@ const Wire = opt => {
|
|
|
223
397
|
if (client && client.readyState === WebSocket.OPEN) {
|
|
224
398
|
client.send(data, {binary: isBinary})
|
|
225
399
|
} else {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
400
|
+
const retryHandler = client._retryHandler || createRetryHandler()
|
|
401
|
+
client._retryHandler = retryHandler
|
|
402
|
+
|
|
403
|
+
if (retryHandler.shouldRetry()) {
|
|
404
|
+
const delay = retryHandler.getDelay()
|
|
405
|
+
retryHandler.increment()
|
|
406
|
+
|
|
407
|
+
setTimeout(() => {
|
|
408
|
+
if (client && client.readyState === WebSocket.OPEN) {
|
|
409
|
+
retryHandler.reset()
|
|
410
|
+
client.send(data, {binary: isBinary})
|
|
411
|
+
}
|
|
412
|
+
}, delay)
|
|
413
|
+
}
|
|
236
414
|
}
|
|
237
415
|
})
|
|
238
416
|
}
|
|
239
417
|
wss.on("connection", ws => {
|
|
240
|
-
|
|
418
|
+
// Check connection limit
|
|
419
|
+
if (!connectionManager.add(ws)) {
|
|
420
|
+
console.log("Connection limit reached, rejecting connection")
|
|
421
|
+
ws.close(1013, "Connection limit reached - try again later")
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Generate unique client ID for rate limiting
|
|
426
|
+
const clientId = utils.text.random(9)
|
|
427
|
+
|
|
428
|
+
ws.on("error", error => {
|
|
429
|
+
console.error("WebSocket error:", error)
|
|
430
|
+
connectionManager.remove(ws)
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
ws.on("close", () => {
|
|
434
|
+
connectionManager.remove(ws)
|
|
435
|
+
})
|
|
241
436
|
|
|
242
437
|
ws.on("message", (data, isBinary) => {
|
|
243
|
-
|
|
438
|
+
// Validate message size
|
|
439
|
+
const validation = validateMessage(data, opt.maxMessageSize)
|
|
440
|
+
if (!validation.valid) {
|
|
441
|
+
console.warn(`Invalid message: ${validation.error}`)
|
|
442
|
+
ws.send(JSON.stringify({error: validation.error}))
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Safe JSON parsing
|
|
447
|
+
const parseResult = safeJSONParse(data, opt.maxMessageSize)
|
|
448
|
+
if (!parseResult.success) {
|
|
449
|
+
console.warn(`JSON parse error: ${parseResult.error}`)
|
|
450
|
+
ws.send(JSON.stringify({error: "Invalid JSON"}))
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const msg = parseResult.data
|
|
244
455
|
if (dup.check(msg["#"])) return
|
|
245
456
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (msg.put) put(msg, send)
|
|
249
|
-
send(data, isBinary)
|
|
457
|
+
// Check rate limit and get delay
|
|
458
|
+
const delay = rateLimiter.getDelay(clientId)
|
|
250
459
|
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
if (cb) {
|
|
254
|
-
delete msg["#"]
|
|
255
|
-
delete msg["@"]
|
|
256
|
-
cb(msg)
|
|
460
|
+
const processMessage = () => {
|
|
461
|
+
dup.track(msg["#"])
|
|
257
462
|
|
|
258
|
-
|
|
463
|
+
if (msg.get) get(msg, send)
|
|
464
|
+
if (msg.put) put(msg, send)
|
|
465
|
+
send(data, isBinary)
|
|
466
|
+
|
|
467
|
+
const id = msg["@"]
|
|
468
|
+
const cb = queue[id]
|
|
469
|
+
if (cb) {
|
|
470
|
+
delete msg["#"]
|
|
471
|
+
delete msg["@"]
|
|
472
|
+
cb(msg)
|
|
473
|
+
|
|
474
|
+
delete queue[id]
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (delay > 0) {
|
|
479
|
+
// Throttle the client by delaying message processing
|
|
480
|
+
setTimeout(processMessage, delay)
|
|
481
|
+
} else {
|
|
482
|
+
// Process immediately
|
|
483
|
+
processMessage()
|
|
259
484
|
}
|
|
260
485
|
})
|
|
261
486
|
})
|
|
262
487
|
return api(send)
|
|
263
488
|
}
|
|
264
489
|
|
|
490
|
+
// Browser logic.
|
|
265
491
|
const peers = []
|
|
266
492
|
const send = data => {
|
|
267
493
|
peers.forEach(peer => {
|
|
268
494
|
if (peer && peer.readyState === WebSocket.OPEN) {
|
|
269
495
|
peer.send(data)
|
|
270
496
|
} else {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if (peer && peer.readyState === WebSocket.OPEN) {
|
|
274
|
-
clearInterval(interval)
|
|
275
|
-
peer.send(data)
|
|
276
|
-
return
|
|
277
|
-
}
|
|
497
|
+
const retryHandler = peer._retryHandler || createRetryHandler()
|
|
498
|
+
peer._retryHandler = retryHandler
|
|
278
499
|
|
|
279
|
-
|
|
280
|
-
|
|
500
|
+
if (retryHandler.shouldRetry()) {
|
|
501
|
+
const delay = retryHandler.getDelay()
|
|
502
|
+
retryHandler.increment()
|
|
503
|
+
|
|
504
|
+
setTimeout(() => {
|
|
505
|
+
if (peer && peer.readyState === WebSocket.OPEN) {
|
|
506
|
+
retryHandler.reset()
|
|
507
|
+
peer.send(data)
|
|
508
|
+
}
|
|
509
|
+
}, delay)
|
|
510
|
+
}
|
|
281
511
|
}
|
|
282
512
|
})
|
|
283
513
|
}
|
|
@@ -288,23 +518,69 @@ const Wire = opt => {
|
|
|
288
518
|
const start = () => {
|
|
289
519
|
let ws = new WebSocket(peer)
|
|
290
520
|
peers.push(ws)
|
|
521
|
+
const retryHandler = createRetryHandler()
|
|
522
|
+
|
|
291
523
|
ws.onclose = c => {
|
|
292
524
|
if (peers.indexOf(ws) !== -1) {
|
|
293
525
|
peers.splice(peers.indexOf(ws), 1)
|
|
294
526
|
}
|
|
295
527
|
ws = null
|
|
296
|
-
|
|
528
|
+
|
|
529
|
+
if (retryHandler.shouldRetry()) {
|
|
530
|
+
const delay = retryHandler.getDelay()
|
|
531
|
+
retryHandler.increment()
|
|
532
|
+
setTimeout(start, delay)
|
|
533
|
+
}
|
|
297
534
|
}
|
|
535
|
+
|
|
536
|
+
ws.onopen = () => {
|
|
537
|
+
retryHandler.reset()
|
|
538
|
+
}
|
|
539
|
+
|
|
298
540
|
ws.onerror = e => {
|
|
299
541
|
console.error(e)
|
|
300
542
|
}
|
|
301
|
-
ws.onmessage = m => {
|
|
543
|
+
ws.onmessage = async m => {
|
|
302
544
|
const msg = JSON.parse(m.data)
|
|
303
545
|
if (dup.check(msg["#"])) return
|
|
304
546
|
|
|
305
547
|
dup.track(msg["#"])
|
|
306
548
|
if (msg.get) get(msg, send)
|
|
307
|
-
if (msg.put)
|
|
549
|
+
if (msg.put) {
|
|
550
|
+
// Handle selective storage for client WebSocket messages
|
|
551
|
+
const filteredPut = {}
|
|
552
|
+
for (const [soul, node] of Object.entries(msg.put)) {
|
|
553
|
+
let shouldStore = false
|
|
554
|
+
// Case 1: We already have this soul - always update
|
|
555
|
+
if (await hasSoul(soul)) {
|
|
556
|
+
shouldStore = true
|
|
557
|
+
// Check if this update adds new references
|
|
558
|
+
if (node && typeof node === "object") {
|
|
559
|
+
for (const [key, value] of Object.entries(node)) {
|
|
560
|
+
const soulId = utils.rel.is(value)
|
|
561
|
+
if (soulId) {
|
|
562
|
+
// Add referenced soul to pending list
|
|
563
|
+
pendingReferences.add(soulId)
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// Case 2: This soul was referenced by something we have
|
|
569
|
+
if (pendingReferences.has(soul)) {
|
|
570
|
+
shouldStore = true
|
|
571
|
+
pendingReferences.delete(soul) // Got it, remove from pending
|
|
572
|
+
}
|
|
573
|
+
if (shouldStore) {
|
|
574
|
+
filteredPut[soul] = node
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
// Store filtered data if any
|
|
578
|
+
if (Object.keys(filteredPut).length > 0) {
|
|
579
|
+
// Create filtered message for Ham.mix
|
|
580
|
+
const filteredMsg = {put: filteredPut}
|
|
581
|
+
put(filteredMsg, send)
|
|
582
|
+
}
|
|
583
|
+
}
|
|
308
584
|
send(m.data)
|
|
309
585
|
|
|
310
586
|
const id = msg["@"]
|