@mblaney/holster 1.0.15 → 1.0.17

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.15",
3
+ "version": "1.0.17",
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/holster.js CHANGED
@@ -67,12 +67,22 @@ const Holster = opt => {
67
67
  for (const key of Object.keys(msg.put[soul])) {
68
68
  const id = utils.rel.is(msg.put[soul][key])
69
69
  if (id) {
70
- const data = await new Promise(res => {
71
- const _ctxid = utils.text.random()
72
- allctx.set(_ctxid, {chain: [{item: null, soul: id}]})
73
- api(_ctxid).next(null, res, _opt)
74
- })
75
- msg.put[soul][key] = data
70
+ // Due to message queuing, the related node might not be available yet
71
+ // Retry with delays if we get null
72
+ const attemptRead = async (retries = 0) => {
73
+ const data = await new Promise(res => {
74
+ const _ctxid = utils.text.random()
75
+ allctx.set(_ctxid, {chain: [{item: null, soul: id}]})
76
+ api(_ctxid).next(null, res, _opt)
77
+ })
78
+ if (data !== null || retries >= 5) {
79
+ return data
80
+ }
81
+ // Data not ready, retry after delay
82
+ await new Promise(resolve => setTimeout(resolve, 50))
83
+ return attemptRead(retries + 1)
84
+ }
85
+ msg.put[soul][key] = await attemptRead()
76
86
  }
77
87
  }
78
88
  ack(msg.put[soul])
@@ -547,8 +557,57 @@ const Holster = opt => {
547
557
  allctx.set(ctxid, {chain: [{item: item, soul: soul}], on: true})
548
558
  // Map the user's callback because it can also be passed to off,
549
559
  // so need a reference to it to compare them.
550
- map.set(cb, () => api(ctxid).next(null, cb, _opt))
551
- // Check if item is a rel and add event listener for the node.
560
+ // Create a new context for each listener invocation to avoid mutation
561
+ map.set(cb, () => {
562
+ // When listener fires, re-check if the item is now a rel.
563
+ wire.get(
564
+ {"#": soul, ".": item},
565
+ msg => {
566
+ const current = msg.put && msg.put[soul] && msg.put[soul][item]
567
+ const id = utils.rel.is(current)
568
+ if (id) {
569
+ // It's a rel, read the related node. It might not be in the graph
570
+ // yet when the listener fires, so we retry with delays.
571
+ const attemptRead = (retries = 0) => {
572
+ wire.get(
573
+ {"#": id},
574
+ relMsg => {
575
+ if (relMsg.put && relMsg.put[id]) {
576
+ delete relMsg.put[id]._
577
+ delete relMsg.put[id][utils.userPublicKey]
578
+ delete relMsg.put[id][utils.userSignature]
579
+ cb(relMsg.put[id])
580
+ } else if (retries < 5) {
581
+ // Data not ready yet, retry after a delay
582
+ setTimeout(() => attemptRead(retries + 1), 50)
583
+ } else {
584
+ // Give up after retries
585
+ cb(null)
586
+ }
587
+ },
588
+ _opt,
589
+ )
590
+ }
591
+ attemptRead()
592
+ } else {
593
+ // It's a direct property or null
594
+ cb(current !== undefined ? current : null)
595
+ }
596
+ },
597
+ _opt,
598
+ )
599
+ })
600
+
601
+ // Register listener immediately to avoid missing updates due to
602
+ // queueing. Initially register the soul without _get, then update if
603
+ // it's a rel.
604
+ let initialLex
605
+ if (lex) initialLex = utils.obj.put(lex, "#", soul)
606
+ else initialLex = {"#": soul, ".": item}
607
+ wire.on(initialLex, map.get(cb), false, _opt)
608
+
609
+ // Check if item is a rel and update listener if needed.
610
+ // This happens async but the listener is already registered above.
552
611
  wire.get(
553
612
  {"#": soul, ".": item},
554
613
  msg => {
@@ -560,13 +619,18 @@ const Holster = opt => {
560
619
  const current = msg.put && msg.put[soul] && msg.put[soul][item]
561
620
  const id = utils.rel.is(current)
562
621
  if (id) {
563
- if (lex) lex = utils.obj.put(lex, "#", id)
564
- else lex = {"#": id, ".": null}
565
- } else {
566
- if (lex) lex = utils.obj.put(lex, "#", soul)
567
- else lex = {"#": soul, ".": item}
622
+ // It's a rel, need to switch listener to the related node
623
+ // First remove the initial listener
624
+ wire.off(initialLex, map.get(cb))
625
+ // Then add listener on the related node
626
+ let relLex
627
+ if (lex) relLex = utils.obj.put(lex, "#", id)
628
+ else relLex = {"#": id, ".": null}
629
+ wire.on(relLex, map.get(cb), _get, _opt)
630
+ } else if (_get) {
631
+ // Not a rel, but _get was requested, so trigger callback.
632
+ map.get(cb)()
568
633
  }
569
- wire.on(lex, map.get(cb), _get, _opt)
570
634
  },
571
635
  _opt,
572
636
  )
package/src/radisk.js CHANGED
@@ -58,7 +58,7 @@ const Radisk = opt => {
58
58
  // Performance logging
59
59
  const perfLog = (operation, startTime, key, size) => {
60
60
  const duration = Date.now() - startTime
61
- if (duration > 1000) {
61
+ if (duration > 2000) {
62
62
  console.log(
63
63
  `[RADISK-SLOW] ${operation}: ${duration}ms for ${key}${size ? ` (${size} bytes)` : ""}`,
64
64
  )
package/src/wire.js CHANGED
@@ -13,13 +13,11 @@ if (typeof globalThis.WebSocket === "undefined") {
13
13
  }
14
14
 
15
15
  // Rate limiting with throttling
16
- const createRateLimiter = opt => {
16
+ const createRateLimiter = isTestEnv => {
17
17
  const clients = new Map()
18
- const maxRequests = opt.maxRequestsPerMinute || 100
19
- const windowMs = opt.rateLimitWindow || 60000
20
- const disconnectThreshold = opt.disconnectThreshold || 10
21
- // Check if test environment (mock-socket usage)
22
- const isTestEnv = opt.wss && opt.wss.constructor.name === "Server"
18
+ const maxRequests = 300 // 5 requests per second
19
+ const windowMs = 60000 // 1 minute window
20
+ const disconnectThreshold = 10 // Disconnect after 10 violations
23
21
  let cleanupInterval = null
24
22
 
25
23
  const cleanup = () => {
@@ -180,6 +178,9 @@ const Wire = opt => {
180
178
  // Track references we want but don't have yet
181
179
  const pendingReferences = new Set()
182
180
 
181
+ // Track pending timeouts that should start when message is sent
182
+ const pendingTimeouts = new Map()
183
+
183
184
  // Helper to check if we have a soul in memory or storage
184
185
  const hasSoul = async soul => {
185
186
  if (graph[soul]) return true
@@ -191,7 +192,9 @@ const Wire = opt => {
191
192
  }
192
193
 
193
194
  // Initialize rate limiting and connection management
194
- const rateLimiter = createRateLimiter(opt)
195
+ // Check if test environment (mock-socket usage)
196
+ const isTestEnv = opt.wss && opt.wss.constructor.name === "Server"
197
+ const rateLimiter = createRateLimiter(isTestEnv)
195
198
  const connectionManager = createConnectionManager(opt.maxConnections || 1000)
196
199
 
197
200
  // The check function is required because user data must provide a public key
@@ -318,24 +321,20 @@ const Wire = opt => {
318
321
  if (err) console.log(err)
319
322
 
320
323
  queue[track] = cb
324
+
325
+ // Store timeout config to start after message is sent from queue
326
+ pendingTimeouts.set(track, {
327
+ lex: lex,
328
+ wait: _opt.wait || 100,
329
+ })
330
+
321
331
  const sendResult = send(request)
322
332
  if (sendResult && sendResult.err) {
323
333
  cb({err: sendResult.err})
324
334
  delete queue[track]
335
+ pendingTimeouts.delete(track)
325
336
  return
326
337
  }
327
-
328
- // Respond to callback with null if no response.
329
- setTimeout(() => {
330
- const cb = queue[track]
331
- if (cb) {
332
- const id = lex["#"]
333
- const ack = {[id]: null}
334
- if (typeof lex["."] === "string") ack[id] = {[lex["."]]: null}
335
- cb({put: ack})
336
- delete queue[track]
337
- }
338
- }, _opt.wait || 100)
339
338
  })
340
339
  }
341
340
 
@@ -384,7 +383,7 @@ const Wire = opt => {
384
383
  put: data,
385
384
  }),
386
385
  )
387
- // Handle queue overflow error
386
+ // Handle queue overflow error.
388
387
  if (sendResult && sendResult.err) {
389
388
  if (cb) cb(sendResult.err)
390
389
  return
@@ -432,6 +431,28 @@ const Wire = opt => {
432
431
  }
433
432
 
434
433
  const send = (data, isBinary) => {
434
+ // Start timeout for this message now that it's being sent
435
+ const msg = JSON.parse(data)
436
+ const trackId = msg["#"]
437
+ if (trackId && pendingTimeouts.has(trackId)) {
438
+ const timeoutConfig = pendingTimeouts.get(trackId)
439
+ pendingTimeouts.delete(trackId)
440
+
441
+ // Respond to callback with null if no response.
442
+ timeoutConfig.timeoutId = setTimeout(() => {
443
+ const cb = queue[trackId]
444
+ if (cb) {
445
+ const id = timeoutConfig.lex["#"]
446
+ const ack = {[id]: null}
447
+ if (typeof timeoutConfig.lex["."] === "string") {
448
+ ack[id] = {[timeoutConfig.lex["."]]: null}
449
+ }
450
+ cb({put: ack})
451
+ delete queue[trackId]
452
+ }
453
+ }, timeoutConfig.wait)
454
+ }
455
+
435
456
  clients().forEach(client => {
436
457
  if (client && client.readyState === WebSocket.OPEN) {
437
458
  client.send(data, {binary: isBinary})
@@ -463,9 +484,7 @@ const Wire = opt => {
463
484
 
464
485
  // Generate unique client ID for rate limiting
465
486
  const clientId = utils.text.random(9)
466
- console.log(
467
- `[HOLSTER-CONNECT] New WebSocket client connected: ${clientId}`,
468
- )
487
+ console.log(`New WebSocket client connected: ${clientId}`)
469
488
 
470
489
  ws.on("error", error => {
471
490
  console.log("WebSocket error:", error)
@@ -473,9 +492,7 @@ const Wire = opt => {
473
492
  })
474
493
 
475
494
  ws.on("close", () => {
476
- console.log(
477
- `[HOLSTER-DISCONNECT] WebSocket client disconnected: ${clientId}`,
478
- )
495
+ console.log(`WebSocket client disconnected: ${clientId}`)
479
496
  connectionManager.remove(ws)
480
497
  })
481
498
 
@@ -505,14 +522,14 @@ const Wire = opt => {
505
522
  if (delay > 0) {
506
523
  const throttleCount = rateLimiter.getThrottleCount(clientId)
507
524
  console.log(
508
- `[HOLSTER-THROTTLE] Client ${clientId}: rate limit exceeded, delay would be ${delay}ms, dropping message (throttle count: ${throttleCount})`,
525
+ `Client ${clientId}: rate limit exceeded, delay would be ${delay}ms, dropping message (throttle count: ${throttleCount})`,
509
526
  )
510
527
  // Check if we should disconnect the bad actor
511
528
  if (rateLimiter.shouldDisconnect(clientId)) {
512
529
  console.log(
513
- `[HOLSTER-DISCONNECT] Client ${clientId}: Disconnecting bad actor after ${throttleCount} throttle violations`,
530
+ `Client ${clientId}: Disconnecting after ${throttleCount} throttle violations`,
514
531
  )
515
- ws.close(1008, "Rate limit violations - bad actor")
532
+ ws.close(1008, "Rate limit violations")
516
533
  return
517
534
  }
518
535
 
@@ -565,7 +582,7 @@ const Wire = opt => {
565
582
  let throttleUntil = 0
566
583
  let messageQueue = []
567
584
  let queueProcessor = null
568
- const maxQueueLength = opt.maxQueueLength || 1000
585
+ const maxQueueLength = opt.maxQueueLength || 10000
569
586
 
570
587
  const processQueue = () => {
571
588
  if (messageQueue.length === 0) {
@@ -585,9 +602,7 @@ const Wire = opt => {
585
602
 
586
603
  // Throttle period ended, resume processing
587
604
  if (clientThrottled && now >= throttleUntil) {
588
- console.log(
589
- `[CLIENT-THROTTLED] Throttle period ended, resuming queue processing`,
590
- )
605
+ console.log(`Throttle period ended, resuming queue processing`)
591
606
  clientThrottled = false
592
607
  throttleUntil = 0
593
608
  }
@@ -596,10 +611,32 @@ const Wire = opt => {
596
611
  const data = messageQueue.shift()
597
612
  sendToPeers(data)
598
613
 
614
+ // Start timeout for this message now that it's been sent
615
+ const msg = JSON.parse(data)
616
+ const trackId = msg["#"]
617
+ if (trackId && pendingTimeouts.has(trackId)) {
618
+ const timeoutConfig = pendingTimeouts.get(trackId)
619
+ pendingTimeouts.delete(trackId)
620
+
621
+ // Respond to callback with null if no response.
622
+ timeoutConfig.timeoutId = setTimeout(() => {
623
+ const cb = queue[trackId]
624
+ if (cb) {
625
+ const id = timeoutConfig.lex["#"]
626
+ const ack = {[id]: null}
627
+ if (typeof timeoutConfig.lex["."] === "string") {
628
+ ack[id] = {[timeoutConfig.lex["."]]: null}
629
+ }
630
+ cb({put: ack})
631
+ delete queue[trackId]
632
+ }
633
+ }, timeoutConfig.wait)
634
+ }
635
+
599
636
  // Schedule processing of next message if queue not empty
600
637
  if (messageQueue.length > 0) {
601
- // Add small delay between messages to avoid immediate re-throttling
602
- queueProcessor = setTimeout(processQueue, 100)
638
+ // Add delay between messages to respect rate limits (300 req/min = 200ms)
639
+ queueProcessor = setTimeout(processQueue, 250)
603
640
  } else {
604
641
  queueProcessor = null
605
642
  }
@@ -629,30 +666,19 @@ const Wire = opt => {
629
666
  }
630
667
 
631
668
  const send = data => {
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
- }
669
+ if (messageQueue.length >= maxQueueLength) {
670
+ return {
671
+ err: `Message queue exceeded maximum length (${maxQueueLength}). Update query logic to request less data.`,
672
+ queueLength: messageQueue.length,
673
+ maxQueueLength: maxQueueLength,
640
674
  }
641
-
642
- // Add message to queue instead of sending immediately
643
- messageQueue.push(data)
644
- console.log(
645
- `Queuing message (queue length: ${messageQueue.length}/${maxQueueLength})`,
646
- )
647
- // Start queue processor if not already running
648
- if (!queueProcessor) {
649
- queueProcessor = setTimeout(processQueue, 100)
650
- }
651
- return
652
675
  }
653
676
 
654
- // Normal case - send immediately
655
- sendToPeers(data)
677
+ messageQueue.push(data)
678
+ // Start queue processor if not already running
679
+ if (!queueProcessor) {
680
+ queueProcessor = setTimeout(processQueue, 250)
681
+ }
656
682
  }
657
683
  if (!(opt.peers instanceof Array)) {
658
684
  opt.peers = [`ws://localhost:${opt.port || 8765}`]
@@ -689,7 +715,7 @@ const Wire = opt => {
689
715
 
690
716
  // Handle throttle messages from server
691
717
  if (msg.throttle && msg.err) {
692
- console.log(`[CLIENT-THROTTLED] Server throttling: ${msg.err}`)
718
+ console.log(`Server throttling: ${msg.err}`)
693
719
  clientThrottled = true
694
720
  throttleUntil = Date.now() + msg.throttle
695
721
  return
@@ -35,11 +35,12 @@ describe("holster.user.on", () => {
35
35
  user.get("key").put("value", err => {
36
36
  assert.equal(err, null)
37
37
 
38
- user.get("key").on(data => {
38
+ const callback = data => {
39
39
  assert.equal(data, "update")
40
- user.get("key").off()
40
+ user.get("key").off(callback)
41
41
  done()
42
- })
42
+ }
43
+ user.get("key").on(callback)
43
44
 
44
45
  user.get("key").put("update", err => {
45
46
  assert.equal(err, null)
@@ -64,15 +65,16 @@ describe("holster.user.on", () => {
64
65
  assert.equal(err, null)
65
66
 
66
67
  let first = true
67
- user.get("key3").on(data => {
68
+ const callback = data => {
68
69
  if (first) {
69
70
  assert.equal(data, "update1")
70
71
  } else {
71
72
  assert.equal(data, "update2")
72
- user.get("key3").off()
73
+ user.get("key3").off(callback)
73
74
  done()
74
75
  }
75
- })
76
+ }
77
+ user.get("key3").on(callback)
76
78
 
77
79
  user.get("key3").put("update1", err => {
78
80
  assert.equal(err, null)
@@ -103,11 +105,13 @@ describe("holster.user.on", () => {
103
105
  setTimeout(async () => {
104
106
  let count = 0
105
107
  for (let i = 0; i < 5; i++) {
106
- user.get("for" + i).on(data => {
107
- assert.equal(data, "update" + i)
108
- user.get("for" + i).off()
108
+ const idx = i // Capture value
109
+ const callback = data => {
110
+ assert.equal(data, "update" + idx)
111
+ user.get("for" + idx).off(callback)
109
112
  count++
110
- })
113
+ }
114
+ user.get("for" + idx).on(callback)
111
115
  }
112
116
 
113
117
  setTimeout(async () => {
@@ -130,11 +134,12 @@ describe("holster.user.on", () => {
130
134
  assert.equal(err, null)
131
135
 
132
136
  setTimeout(() => {
133
- user.get("key4").on(data => {
137
+ const callback = data => {
134
138
  assert.equal(data, "value4")
135
- user.get("key4").off()
139
+ user.get("key4").off(callback)
136
140
  done()
137
- }, true)
141
+ }
142
+ user.get("key4").on(callback, true)
138
143
  }, 100)
139
144
  })
140
145
  })
@@ -147,14 +152,12 @@ describe("holster.user.on", () => {
147
152
  .put("ok", err => {
148
153
  assert.equal(err, null)
149
154
 
150
- user
151
- .get("hello")
152
- .next("world!")
153
- .on(data => {
154
- assert.equal(data, "update")
155
- user.get("hello").next("world!").off()
156
- done()
157
- })
155
+ const callback = data => {
156
+ assert.equal(data, "update")
157
+ user.get("hello").next("world!").off(callback)
158
+ done()
159
+ }
160
+ user.get("hello").next("world!").on(callback)
158
161
 
159
162
  setTimeout(() => {
160
163
  user
@@ -181,10 +184,12 @@ describe("holster.user.on", () => {
181
184
  number: 42,
182
185
  }
183
186
 
184
- user.get("plain").on(data => {
187
+ const callback = data => {
185
188
  assert.deepEqual(data, update)
189
+ user.get("plain").off(callback)
186
190
  done()
187
- })
191
+ }
192
+ user.get("plain").on(callback)
188
193
 
189
194
  setTimeout(() => {
190
195
  user.get("plain").put(update, err => {
@@ -212,17 +217,18 @@ describe("holster.user.on", () => {
212
217
  },
213
218
  }
214
219
 
215
- user.get("nested").on(data => {
220
+ const parentCallback = data => {
216
221
  assert.deepEqual(data, update)
217
- })
222
+ }
223
+ user.get("nested").on(parentCallback)
218
224
 
219
- user
220
- .get("nested")
221
- .next("child")
222
- .on(data => {
223
- assert.deepEqual(data, update.child)
224
- done()
225
- })
225
+ const childCallback = data => {
226
+ assert.deepEqual(data, update.child)
227
+ user.get("nested").off(parentCallback)
228
+ user.get("nested").next("child").off(childCallback)
229
+ done()
230
+ }
231
+ user.get("nested").next("child").on(childCallback)
226
232
 
227
233
  setTimeout(() => {
228
234
  user.get("nested").put(update, err => {
@@ -0,0 +1,29 @@
1
+ import fs from "fs"
2
+ import {Server} from "mock-socket"
3
+ import {describe, test} from "node:test"
4
+ import assert from "node:assert/strict"
5
+ import Holster from "../../src/holster.js"
6
+
7
+ describe("system - basic setup", () => {
8
+ const wss = new Server("ws://localhost:9001")
9
+ const holster = Holster({file: "test/system/basic", wss: wss})
10
+
11
+ test("put and get data", (t, done) => {
12
+ holster.get("test").put({message: "hello"}, err => {
13
+ assert.equal(err, null)
14
+
15
+ holster.get("test", data => {
16
+ assert.notEqual(data, null)
17
+ assert.equal(data.message, "hello")
18
+ done()
19
+ })
20
+ })
21
+ })
22
+
23
+ test("cleanup", (t, done) => {
24
+ fs.rm("test/system/basic", {recursive: true, force: true}, err => {
25
+ assert.equal(err, null)
26
+ done()
27
+ })
28
+ })
29
+ })
@@ -0,0 +1,41 @@
1
+ import fs from "fs"
2
+ import {Server} from "mock-socket"
3
+ import {describe, test} from "node:test"
4
+ import assert from "node:assert/strict"
5
+ import Holster from "../../src/holster.js"
6
+
7
+ describe("system - chained get with on", () => {
8
+ const wss = new Server("ws://localhost:9002")
9
+ const holster = Holster({file: "test/system/chained-on", wss: wss})
10
+
11
+ test("on listener fires with null when no data exists", (t, done) => {
12
+ holster.get("key").on(data => {
13
+ // This is the actual behavior - on fires with null for non-existent keys
14
+ assert.equal(data, null)
15
+ done()
16
+ }, true)
17
+ })
18
+
19
+ test("put then on listener receives data", (t, done) => {
20
+ // Put data first
21
+ holster.get("key2").put({value: "test"}, err => {
22
+ assert.equal(err, null)
23
+
24
+ // Then set up listener - should get the existing data
25
+ setTimeout(() => {
26
+ holster.get("key2").on(data => {
27
+ assert.notEqual(data, null)
28
+ assert.equal(data.value, "test")
29
+ done()
30
+ }, true)
31
+ }, 50)
32
+ })
33
+ })
34
+
35
+ test("cleanup", (t, done) => {
36
+ fs.rm("test/system/chained-on", {recursive: true, force: true}, err => {
37
+ assert.equal(err, null)
38
+ done()
39
+ })
40
+ })
41
+ })