@mblaney/holster 1.0.11 → 1.0.13

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mblaney/holster",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "description": "Holster is a service that allows synchronising data between devices using Node, Deno, Bun or the browser.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/src/radisk.js CHANGED
@@ -15,6 +15,8 @@ const Radisk = opt => {
15
15
 
16
16
  // Multi-file cache for all parsed radix trees
17
17
  const cache = new Map()
18
+ // Pending reads queue to avoid duplicate reads for the same key
19
+ const pendingReads = new Map()
18
20
 
19
21
  if (!opt) opt = {}
20
22
  if (!opt.log) opt.log = console.log
@@ -43,8 +45,8 @@ const Radisk = opt => {
43
45
  // Performance logging
44
46
  const perfLog = (operation, startTime, key, size) => {
45
47
  const duration = Date.now() - startTime
46
- if (duration > 500) {
47
- // Log operations taking more than 500ms
48
+ if (duration > 1000) {
49
+ // Log operations taking more than 1000ms
48
50
  console.log(
49
51
  `[RADISK-SLOW] ${operation}: ${duration}ms for ${key}${size ? ` (${size} bytes)` : ""}`,
50
52
  )
@@ -76,10 +78,23 @@ const Radisk = opt => {
76
78
  }
77
79
  }
78
80
 
81
+ // Check if there's already a pending read for this key
82
+ if (pendingReads.has(key)) {
83
+ // Add callback to existing pending read
84
+ pendingReads.get(key).push(cb)
85
+ return
86
+ }
87
+
88
+ // Start new read and create callback queue
89
+ pendingReads.set(key, [cb])
90
+
79
91
  const readStart = Date.now()
80
92
  return radisk.read(key, (err, result) => {
81
93
  perfLog("read", readStart, key)
82
- cb(err, result)
94
+ // Execute all pending callbacks for this key
95
+ const callbacks = pendingReads.get(key) || []
96
+ pendingReads.delete(key)
97
+ callbacks.forEach(callback => callback(err, result))
83
98
  })
84
99
  }
85
100
 
@@ -123,7 +138,7 @@ const Radisk = opt => {
123
138
  // file needs to be split.
124
139
  if (++i > 1) return
125
140
 
126
- perfLog("thrash", thrashStart, `batch-${radisk.batch.ed}`)
141
+ perfLog("thrash", thrashStart, `batch-${batch.ed}`)
127
142
  if (err) opt.log(err)
128
143
  batch.acks.forEach(cb => cb(err))
129
144
  radisk.thrash.at = null
@@ -319,6 +334,7 @@ const Radisk = opt => {
319
334
  if (!data) return cb(u, parse.disk)
320
335
 
321
336
  let pre = []
337
+ let preString = ""
322
338
  // Work though data by splitting into 3 values. The first value says
323
339
  // if the second value is one of: the radix level for a key, the key
324
340
  // iteself, or a value. The third is the rest of the data to work with.
@@ -330,15 +346,23 @@ const Radisk = opt => {
330
346
  tmp = parse.split(tmp[2]) || ""
331
347
  if (tmp[0] === "#") {
332
348
  key = tmp[1]
333
- pre = pre.slice(0, i)
334
- if (i <= pre.length) pre.push(key)
349
+ // Optimize prefix building - avoid repeated joins
350
+ if (i < pre.length) {
351
+ pre.length = i
352
+ }
353
+ if (i <= pre.length) {
354
+ pre[i] = key
355
+ pre.length = i + 1
356
+ }
357
+ // Only rebuild preString when prefix changes
358
+ preString = pre.join("")
335
359
  }
336
360
  tmp = parse.split(tmp[2]) || ""
337
361
  if (tmp[0] === "\n") continue
338
362
 
339
363
  if (tmp[0] === "=") value = tmp[1]
340
364
  if (typeof key !== "undefined" && typeof value !== "undefined") {
341
- parse.disk(pre.join(""), value)
365
+ parse.disk(preString, value)
342
366
  }
343
367
  tmp = parse.split(tmp[2])
344
368
  }
package/src/wire.js CHANGED
@@ -17,6 +17,7 @@ const createRateLimiter = opt => {
17
17
  const clients = new Map()
18
18
  const maxRequests = opt.maxRequestsPerMinute || 100
19
19
  const windowMs = opt.rateLimitWindow || 60000
20
+ const disconnectThreshold = opt.disconnectThreshold || 10
20
21
  // Check if test environment (mock-socket usage)
21
22
  const isTestEnv = opt.wss && opt.wss.constructor.name === "Server"
22
23
  let cleanupInterval = null
@@ -29,6 +30,10 @@ const createRateLimiter = opt => {
29
30
  data.lastCleanup = now
30
31
  }
31
32
  data.requests = data.requests.filter(time => now - time < windowMs)
33
+ // Reset throttle counts periodically
34
+ if (now - data.lastCleanup > windowMs * 10) {
35
+ data.throttleCount = 0
36
+ }
32
37
  }
33
38
  }
34
39
 
@@ -42,6 +47,7 @@ const createRateLimiter = opt => {
42
47
  const client = clients.get(clientId) || {
43
48
  requests: [],
44
49
  lastCleanup: now,
50
+ throttleCount: 0,
45
51
  }
46
52
 
47
53
  // Filter old requests
@@ -51,6 +57,9 @@ const createRateLimiter = opt => {
51
57
  // Calculate delay based on oldest request that will expire
52
58
  const oldestRequest = Math.min(...client.requests)
53
59
  const delay = windowMs - (now - oldestRequest)
60
+ // Increment throttle count and update client data
61
+ client.throttleCount = (client.throttleCount || 0) + 1
62
+ clients.set(clientId, client)
54
63
  return Math.max(0, delay)
55
64
  }
56
65
 
@@ -71,6 +80,17 @@ const createRateLimiter = opt => {
71
80
  return Math.max(0, maxRequests - validRequests.length)
72
81
  },
73
82
 
83
+ getThrottleCount: clientId => {
84
+ const client = clients.get(clientId)
85
+ return client ? client.throttleCount || 0 : 0
86
+ },
87
+
88
+ shouldDisconnect: clientId => {
89
+ const client = clients.get(clientId)
90
+ if (!client) return false
91
+ return client.throttleCount >= disconnectThreshold
92
+ },
93
+
74
94
  destroy: () => {
75
95
  if (cleanupInterval) {
76
96
  clearInterval(cleanupInterval)
@@ -274,7 +294,11 @@ const Wire = opt => {
274
294
 
275
295
  if (ack) {
276
296
  // Also send request on the wire to check for updates.
277
- send(request)
297
+ const sendResult = send(request)
298
+ if (sendResult && sendResult.err) {
299
+ cb({err: sendResult.err})
300
+ return
301
+ }
278
302
  cb({put: ack})
279
303
  return
280
304
  }
@@ -282,7 +306,11 @@ const Wire = opt => {
282
306
  store.get(lex, (err, ack) => {
283
307
  if (ack) {
284
308
  // Also send request on the wire to check for updates.
285
- send(request)
309
+ const sendResult = send(request)
310
+ if (sendResult && sendResult.err) {
311
+ cb({err: sendResult.err})
312
+ return
313
+ }
286
314
  cb({put: ack, err: err})
287
315
  return
288
316
  }
@@ -290,7 +318,13 @@ const Wire = opt => {
290
318
  if (err) console.log(err)
291
319
 
292
320
  queue[track] = cb
293
- send(request)
321
+ const sendResult = send(request)
322
+ if (sendResult && sendResult.err) {
323
+ cb({err: sendResult.err})
324
+ delete queue[track]
325
+ return
326
+ }
327
+
294
328
  // Respond to callback with null if no response.
295
329
  setTimeout(() => {
296
330
  const cb = queue[track]
@@ -344,12 +378,17 @@ const Wire = opt => {
344
378
 
345
379
  store.put(update.now, cb)
346
380
  // Also put data on the wire spec.
347
- send(
381
+ const sendResult = send(
348
382
  JSON.stringify({
349
383
  "#": dup.track(utils.text.random(9)),
350
384
  put: data,
351
385
  }),
352
386
  )
387
+ // Handle queue overflow error
388
+ if (sendResult && sendResult.err) {
389
+ if (cb) cb(sendResult.err)
390
+ return
391
+ }
353
392
  },
354
393
  on: (lex, cb, _get, _opt) => {
355
394
  const soul = lex && lex["#"]
@@ -464,9 +503,19 @@ const Wire = opt => {
464
503
  const delay = rateLimiter.getDelay(clientId)
465
504
  // Log rate limiting activity and enforce stricter limits
466
505
  if (delay > 0) {
506
+ const throttleCount = rateLimiter.getThrottleCount(clientId)
467
507
  console.log(
468
- `[HOLSTER-THROTTLE] Client ${clientId}: rate limit exceeded, delay would be ${delay}ms, dropping message`,
508
+ `[HOLSTER-THROTTLE] Client ${clientId}: rate limit exceeded, delay would be ${delay}ms, dropping message (throttle count: ${throttleCount})`,
469
509
  )
510
+ // Check if we should disconnect the bad actor
511
+ if (rateLimiter.shouldDisconnect(clientId)) {
512
+ console.error(
513
+ `[HOLSTER-DISCONNECT] Client ${clientId}: Disconnecting bad actor after ${throttleCount} throttle violations`,
514
+ )
515
+ ws.close(1008, "Rate limit violations - bad actor")
516
+ return
517
+ }
518
+
470
519
  // Send throttle warning back to client instead of processing
471
520
  ws.send(
472
521
  JSON.stringify({
@@ -514,6 +563,47 @@ const Wire = opt => {
514
563
  // Client-side throttling state
515
564
  let clientThrottled = false
516
565
  let throttleUntil = 0
566
+ let messageQueue = []
567
+ let queueProcessor = null
568
+ const maxQueueLength = opt.maxQueueLength || 1000
569
+
570
+ const processQueue = () => {
571
+ if (messageQueue.length === 0) {
572
+ queueProcessor = null
573
+ return
574
+ }
575
+
576
+ const now = Date.now()
577
+ if (clientThrottled && now < throttleUntil) {
578
+ // Still throttled, schedule next queue check
579
+ queueProcessor = setTimeout(
580
+ processQueue,
581
+ Math.min(1000, throttleUntil - now),
582
+ )
583
+ return
584
+ }
585
+
586
+ // Throttle period ended, resume processing
587
+ if (clientThrottled && now >= throttleUntil) {
588
+ console.log(
589
+ `[CLIENT-THROTTLED] Throttle period ended, resuming queue processing`,
590
+ )
591
+ clientThrottled = false
592
+ throttleUntil = 0
593
+ }
594
+
595
+ // Process next message in queue
596
+ const data = messageQueue.shift()
597
+ sendToPeers(data)
598
+
599
+ // Schedule processing of next message if queue not empty
600
+ if (messageQueue.length > 0) {
601
+ // Add small delay between messages to avoid immediate re-throttling
602
+ queueProcessor = setTimeout(processQueue, 100)
603
+ } else {
604
+ queueProcessor = null
605
+ }
606
+ }
517
607
 
518
608
  const sendToPeers = data => {
519
609
  peers.forEach(peer => {
@@ -539,22 +629,29 @@ const Wire = opt => {
539
629
  }
540
630
 
541
631
  const send = data => {
542
- // Check if client is throttled before sending
543
- const now = Date.now()
544
- if (clientThrottled && now < throttleUntil) {
545
- const delay = throttleUntil - now
632
+ // Check if client is throttled or queue is active
633
+ if (clientThrottled || messageQueue.length > 0) {
634
+ if (messageQueue.length >= maxQueueLength) {
635
+ return {
636
+ err: `Message queue exceeded maximum length (${maxQueueLength}). Update query logic to request less data.`,
637
+ queueLength: messageQueue.length,
638
+ maxQueueLength: maxQueueLength,
639
+ }
640
+ }
641
+
642
+ // Add message to queue instead of sending immediately
643
+ messageQueue.push(data)
546
644
  console.log(
547
- `[CLIENT-THROTTLED] Delaying message for ${delay}ms, throttled until ${new Date(throttleUntil).toLocaleTimeString()}`,
645
+ `Queuing message (queue length: ${messageQueue.length}/${maxQueueLength})`,
548
646
  )
549
- setTimeout(() => sendToPeers(data), delay)
647
+ // Start queue processor if not already running
648
+ if (!queueProcessor) {
649
+ queueProcessor = setTimeout(processQueue, 100)
650
+ }
550
651
  return
551
- } else if (clientThrottled && now >= throttleUntil) {
552
- console.log(
553
- `[CLIENT-THROTTLED] Throttle period ended, resuming normal operation`,
554
- )
555
- clientThrottled = false
556
- throttleUntil = 0
557
652
  }
653
+
654
+ // Normal case - send immediately
558
655
  sendToPeers(data)
559
656
  }
560
657
  if (!(opt.peers instanceof Array)) {