@mblaney/holster 1.0.7 → 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/src/utils.js CHANGED
@@ -1,18 +1,25 @@
1
1
  export const num = {
2
- is: n =>
3
- !(n instanceof Array) &&
4
- (n - parseFloat(n) + 1 >= 0 || Infinity === n || -Infinity === n),
2
+ is: n => {
3
+ if (n instanceof Array) return false
4
+
5
+ if (typeof n === "number") return !isNaN(n)
6
+
7
+ if (typeof n === "string") {
8
+ const parsed = parseFloat(n)
9
+ return !isNaN(parsed) && isFinite(parsed)
10
+ }
11
+
12
+ return false
13
+ },
5
14
  }
6
15
 
7
16
  export const obj = {
8
17
  is: o => {
9
- if (!o) return false
18
+ if (!o || typeof o !== "object") return false
10
19
 
11
- return (
12
- (o instanceof Object && o.constructor === Object) ||
13
- Object.prototype.toString.call(o).match(/^\[object (\w+)\]$/)[1] ===
14
- "Object"
15
- )
20
+ const toString = Object.prototype.toString.call(o)
21
+ const match = toString.match(/^\[object (\w+)\]$/)
22
+ return o.constructor === Object || (match && match[1] === "Object")
16
23
  },
17
24
  map: (list, cb, o) => {
18
25
  var keys = Object.keys(list)
@@ -74,11 +81,13 @@ export const userPublicKey = "_holster_user_public_key"
74
81
  // graph converts objects to graph format with updated states,
75
82
  // with optional meta data to verify signed data.
76
83
  export const graph = (soul, data, sig, pub) => {
84
+ const timestamp = Date.now()
77
85
  const g = {[soul]: {_: {"#": soul, ">": {}}}}
86
+
78
87
  for (const [key, value] of Object.entries(data)) {
79
- if (key !== "_" && key != userPublicKey && key != userSignature) {
88
+ if (key !== "_" && key !== userPublicKey && key !== userSignature) {
80
89
  g[soul][key] = value
81
- g[soul]._[">"][key] = Date.now()
90
+ g[soul]._[">"][key] = timestamp
82
91
  }
83
92
  }
84
93
  // If a signature and public key are provided they also need to be stored on
@@ -87,9 +96,9 @@ export const graph = (soul, data, sig, pub) => {
87
96
  // broadcast the data as a put, which other devices will need to verify.
88
97
  if (sig && pub) {
89
98
  g[soul][userSignature] = sig
90
- g[soul]._[">"][userSignature] = Date.now()
99
+ g[soul]._[">"][userSignature] = timestamp
91
100
  g[soul][userPublicKey] = pub
92
- g[soul]._[">"][userPublicKey] = Date.now()
101
+ g[soul]._[">"][userPublicKey] = timestamp
93
102
  }
94
103
  return g
95
104
  }
@@ -121,7 +130,7 @@ export const match = (lex, key) => {
121
130
  export const text = {
122
131
  random: length => {
123
132
  var s = ""
124
- const c = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXZabcdefghijklmnopqrstuvwxyz"
133
+ const c = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
125
134
  if (!length) length = 24
126
135
  for (let i = 0; i < length; i++) {
127
136
  s += c.charAt(Math.floor(Math.random() * c.length))
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) => {
@@ -164,12 +321,29 @@ const Wire = opt => {
164
321
  // across the network.
165
322
  const update = await Ham.mix(data, graph, opt.secure, listen)
166
323
  const none = Object.keys(update.now).length === 0
167
- if (none || !(await check(update.now, send, cb))) return
324
+ if (none) {
325
+ // Nothing to do.
326
+ cb(null)
327
+ return
328
+ }
329
+
330
+ if (!(await check(update.now, send, cb))) return
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
+ }
168
344
 
169
345
  store.put(update.now, cb)
170
346
  // Also put data on the wire spec.
171
- // TODO: Note that this means all clients now receive all updates, so
172
- // need to filter what should be stored, both in graph and on disk.
173
347
  send(
174
348
  JSON.stringify({
175
349
  "#": dup.track(utils.text.random(9)),
@@ -223,61 +397,117 @@ const Wire = opt => {
223
397
  if (client && client.readyState === WebSocket.OPEN) {
224
398
  client.send(data, {binary: isBinary})
225
399
  } else {
226
- let retry = 0
227
- const interval = setInterval(() => {
228
- if (client && client.readyState === WebSocket.OPEN) {
229
- clearInterval(interval)
230
- client.send(data, {binary: isBinary})
231
- return
232
- }
233
-
234
- if (retry++ > 5) clearInterval(interval)
235
- }, 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
+ }
236
414
  }
237
415
  })
238
416
  }
239
417
  wss.on("connection", ws => {
240
- 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
+ })
241
436
 
242
437
  ws.on("message", (data, isBinary) => {
243
- 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
244
455
  if (dup.check(msg["#"])) return
245
456
 
246
- dup.track(msg["#"])
247
- if (msg.get) get(msg, send)
248
- if (msg.put) put(msg, send)
249
- send(data, isBinary)
457
+ // Check rate limit and get delay
458
+ const delay = rateLimiter.getDelay(clientId)
250
459
 
251
- const id = msg["@"]
252
- const cb = queue[id]
253
- if (cb) {
254
- delete msg["#"]
255
- delete msg["@"]
256
- cb(msg)
460
+ const processMessage = () => {
461
+ dup.track(msg["#"])
257
462
 
258
- 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()
259
484
  }
260
485
  })
261
486
  })
262
487
  return api(send)
263
488
  }
264
489
 
490
+ // Browser logic.
265
491
  const peers = []
266
492
  const send = data => {
267
493
  peers.forEach(peer => {
268
494
  if (peer && peer.readyState === WebSocket.OPEN) {
269
495
  peer.send(data)
270
496
  } else {
271
- let retry = 0
272
- const interval = setInterval(() => {
273
- if (peer && peer.readyState === WebSocket.OPEN) {
274
- clearInterval(interval)
275
- peer.send(data)
276
- return
277
- }
497
+ const retryHandler = peer._retryHandler || createRetryHandler()
498
+ peer._retryHandler = retryHandler
278
499
 
279
- if (retry++ > 5) clearInterval(interval)
280
- }, 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
+ }
281
511
  }
282
512
  })
283
513
  }
@@ -288,23 +518,69 @@ const Wire = opt => {
288
518
  const start = () => {
289
519
  let ws = new WebSocket(peer)
290
520
  peers.push(ws)
521
+ const retryHandler = createRetryHandler()
522
+
291
523
  ws.onclose = c => {
292
524
  if (peers.indexOf(ws) !== -1) {
293
525
  peers.splice(peers.indexOf(ws), 1)
294
526
  }
295
527
  ws = null
296
- 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
+ }
297
534
  }
535
+
536
+ ws.onopen = () => {
537
+ retryHandler.reset()
538
+ }
539
+
298
540
  ws.onerror = e => {
299
541
  console.error(e)
300
542
  }
301
- ws.onmessage = m => {
543
+ ws.onmessage = async m => {
302
544
  const msg = JSON.parse(m.data)
303
545
  if (dup.check(msg["#"])) return
304
546
 
305
547
  dup.track(msg["#"])
306
548
  if (msg.get) get(msg, send)
307
- 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
+ }
308
584
  send(m.data)
309
585
 
310
586
  const id = msg["@"]