@mblaney/holster 1.0.12 → 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.12",
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({
@@ -516,6 +565,7 @@ const Wire = opt => {
516
565
  let throttleUntil = 0
517
566
  let messageQueue = []
518
567
  let queueProcessor = null
568
+ const maxQueueLength = opt.maxQueueLength || 1000
519
569
 
520
570
  const processQueue = () => {
521
571
  if (messageQueue.length === 0) {
@@ -580,14 +630,20 @@ const Wire = opt => {
580
630
 
581
631
  const send = data => {
582
632
  // Check if client is throttled or queue is active
583
- const now = Date.now()
584
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
+
585
642
  // Add message to queue instead of sending immediately
586
643
  messageQueue.push(data)
587
644
  console.log(
588
- `[CLIENT-THROTTLED] Queuing message (queue length: ${messageQueue.length})`,
645
+ `Queuing message (queue length: ${messageQueue.length}/${maxQueueLength})`,
589
646
  )
590
-
591
647
  // Start queue processor if not already running
592
648
  if (!queueProcessor) {
593
649
  queueProcessor = setTimeout(processQueue, 100)