@mblaney/holster 1.0.8 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mblaney/holster",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
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/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
- let retry = 0
233
- const interval = setInterval(() => {
234
- if (client && client.readyState === WebSocket.OPEN) {
235
- clearInterval(interval)
236
- client.send(data, {binary: isBinary})
237
- return
238
- }
239
-
240
- if (retry++ > 5) clearInterval(interval)
241
- }, 1000)
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
- ws.on("error", console.error)
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
- const msg = JSON.parse(data)
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
- dup.track(msg["#"])
253
- if (msg.get) get(msg, send)
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 id = msg["@"]
258
- const cb = queue[id]
259
- if (cb) {
260
- delete msg["#"]
261
- delete msg["@"]
262
- cb(msg)
460
+ const processMessage = () => {
461
+ dup.track(msg["#"])
263
462
 
264
- delete queue[id]
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
- let retry = 0
278
- const interval = setInterval(() => {
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
- if (retry++ > 5) clearInterval(interval)
286
- }, 1000)
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
- setTimeout(start, Math.floor(Math.random() * 5000))
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) put(msg, send)
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["@"]