@mblaney/holster 1.0.8 → 1.0.10
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/ham.js +1 -1
- package/src/wire.js +307 -37
package/package.json
CHANGED
package/src/ham.js
CHANGED
|
@@ -164,7 +164,7 @@ Ham.mix = async (change, graph, secure, listen) => {
|
|
|
164
164
|
return {soul, maxState}
|
|
165
165
|
})
|
|
166
166
|
.sort((a, b) => a.maxState - b.maxState)
|
|
167
|
-
const remove = soulsByAge.slice(0, souls.length -
|
|
167
|
+
const remove = soulsByAge.slice(0, souls.length - MAX_GRAPH_SIZE)
|
|
168
168
|
remove.forEach(({soul}) => delete graph[soul])
|
|
169
169
|
}
|
|
170
170
|
|
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) => {
|
|
@@ -172,10 +329,21 @@ const Wire = opt => {
|
|
|
172
329
|
|
|
173
330
|
if (!(await check(update.now, send, cb))) return
|
|
174
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
|
+
}
|
|
344
|
+
|
|
175
345
|
store.put(update.now, cb)
|
|
176
346
|
// Also put data on the wire spec.
|
|
177
|
-
// TODO: Note that this means all clients now receive all updates, so
|
|
178
|
-
// need to filter what should be stored, both in graph and on disk.
|
|
179
347
|
send(
|
|
180
348
|
JSON.stringify({
|
|
181
349
|
"#": dup.track(utils.text.random(9)),
|
|
@@ -229,61 +397,117 @@ const Wire = opt => {
|
|
|
229
397
|
if (client && client.readyState === WebSocket.OPEN) {
|
|
230
398
|
client.send(data, {binary: isBinary})
|
|
231
399
|
} else {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
+
}
|
|
242
414
|
}
|
|
243
415
|
})
|
|
244
416
|
}
|
|
245
417
|
wss.on("connection", ws => {
|
|
246
|
-
|
|
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
|
+
})
|
|
247
436
|
|
|
248
437
|
ws.on("message", (data, isBinary) => {
|
|
249
|
-
|
|
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
|
|
250
455
|
if (dup.check(msg["#"])) return
|
|
251
456
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (msg.put) put(msg, send)
|
|
255
|
-
send(data, isBinary)
|
|
457
|
+
// Check rate limit and get delay
|
|
458
|
+
const delay = rateLimiter.getDelay(clientId)
|
|
256
459
|
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
if (cb) {
|
|
260
|
-
delete msg["#"]
|
|
261
|
-
delete msg["@"]
|
|
262
|
-
cb(msg)
|
|
460
|
+
const processMessage = () => {
|
|
461
|
+
dup.track(msg["#"])
|
|
263
462
|
|
|
264
|
-
|
|
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()
|
|
265
484
|
}
|
|
266
485
|
})
|
|
267
486
|
})
|
|
268
487
|
return api(send)
|
|
269
488
|
}
|
|
270
489
|
|
|
490
|
+
// Browser logic.
|
|
271
491
|
const peers = []
|
|
272
492
|
const send = data => {
|
|
273
493
|
peers.forEach(peer => {
|
|
274
494
|
if (peer && peer.readyState === WebSocket.OPEN) {
|
|
275
495
|
peer.send(data)
|
|
276
496
|
} else {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
if (peer && peer.readyState === WebSocket.OPEN) {
|
|
280
|
-
clearInterval(interval)
|
|
281
|
-
peer.send(data)
|
|
282
|
-
return
|
|
283
|
-
}
|
|
497
|
+
const retryHandler = peer._retryHandler || createRetryHandler()
|
|
498
|
+
peer._retryHandler = retryHandler
|
|
284
499
|
|
|
285
|
-
|
|
286
|
-
|
|
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
|
+
}
|
|
287
511
|
}
|
|
288
512
|
})
|
|
289
513
|
}
|
|
@@ -294,23 +518,69 @@ const Wire = opt => {
|
|
|
294
518
|
const start = () => {
|
|
295
519
|
let ws = new WebSocket(peer)
|
|
296
520
|
peers.push(ws)
|
|
521
|
+
const retryHandler = createRetryHandler()
|
|
522
|
+
|
|
297
523
|
ws.onclose = c => {
|
|
298
524
|
if (peers.indexOf(ws) !== -1) {
|
|
299
525
|
peers.splice(peers.indexOf(ws), 1)
|
|
300
526
|
}
|
|
301
527
|
ws = null
|
|
302
|
-
|
|
528
|
+
|
|
529
|
+
if (retryHandler.shouldRetry()) {
|
|
530
|
+
const delay = retryHandler.getDelay()
|
|
531
|
+
retryHandler.increment()
|
|
532
|
+
setTimeout(start, delay)
|
|
533
|
+
}
|
|
303
534
|
}
|
|
535
|
+
|
|
536
|
+
ws.onopen = () => {
|
|
537
|
+
retryHandler.reset()
|
|
538
|
+
}
|
|
539
|
+
|
|
304
540
|
ws.onerror = e => {
|
|
305
541
|
console.error(e)
|
|
306
542
|
}
|
|
307
|
-
ws.onmessage = m => {
|
|
543
|
+
ws.onmessage = async m => {
|
|
308
544
|
const msg = JSON.parse(m.data)
|
|
309
545
|
if (dup.check(msg["#"])) return
|
|
310
546
|
|
|
311
547
|
dup.track(msg["#"])
|
|
312
548
|
if (msg.get) get(msg, send)
|
|
313
|
-
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
|
+
}
|
|
314
584
|
send(m.data)
|
|
315
585
|
|
|
316
586
|
const id = msg["@"]
|