@mblaney/holster 2.0.2 → 2.1.0

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/wire.js CHANGED
@@ -7,6 +7,7 @@ import * as utils from "./utils.js"
7
7
  const isNode = typeof process !== "undefined" && process.versions?.node != null
8
8
 
9
9
  const wsModule = isNode ? await import("ws") : undefined
10
+ const fs = isNode ? await import(/*webpackIgnore: true*/ "node:fs") : undefined
10
11
 
11
12
  if (typeof globalThis.WebSocket === "undefined") {
12
13
  globalThis.WebSocket = wsModule?.WebSocket
@@ -210,6 +211,107 @@ const Wire = opt => {
210
211
  const rateLimiter = createRateLimiter(isTestEnv)
211
212
  const connectionManager = createConnectionManager(opt.maxConnections || 1000)
212
213
 
214
+ // User storage limiting — server-side only, requires opt.userLimit: true.
215
+ const DEFAULT_STORAGE_LIMIT = 1 // 1 MB
216
+ const defaultLimit =
217
+ (typeof opt.defaultLimit === "number"
218
+ ? opt.defaultLimit
219
+ : DEFAULT_STORAGE_LIMIT) * 1048576
220
+ const userStorage = new Map() // Map<pubkey, totalBytes>
221
+ const userLimits = new Map() // Map<pubkey, maxBytes> converted from MB in .user_limit.json
222
+ let saveStorageTimer = null
223
+
224
+ const loadUserStorage = cb => {
225
+ fs.readFile(`${opt.file}/../.user_storage.json`, "utf8", (err, raw) => {
226
+ if (err) {
227
+ if (cb) cb()
228
+ return
229
+ }
230
+
231
+ try {
232
+ const data = JSON.parse(raw)
233
+ userStorage.clear()
234
+ for (const [pub, total] of Object.entries(data)) {
235
+ if (typeof total === "number") userStorage.set(pub, total)
236
+ }
237
+ } catch {
238
+ // Invalid JSON — start with empty map.
239
+ }
240
+ if (cb) cb()
241
+ })
242
+ }
243
+
244
+ const saveUserStorage = () => {
245
+ if (saveStorageTimer) return
246
+
247
+ const t = setTimeout(() => {
248
+ saveStorageTimer = null
249
+ const data = {}
250
+ for (const [pub, total] of userStorage) {
251
+ data[pub] = total
252
+ }
253
+ fs.writeFile(
254
+ `${opt.file}/../.user_storage.json`,
255
+ JSON.stringify(data),
256
+ err => {
257
+ if (err) console.warn("saveUserStorage error:", err)
258
+ },
259
+ )
260
+ }, 5000)
261
+ if (t.unref) t.unref()
262
+ saveStorageTimer = t
263
+ }
264
+
265
+ const loadUserLimits = cb => {
266
+ fs.readFile(`${opt.file}/../.user_limit.json`, "utf8", (err, raw) => {
267
+ if (err) {
268
+ if (cb) cb()
269
+ return
270
+ }
271
+ try {
272
+ const data = JSON.parse(raw)
273
+ userLimits.clear()
274
+ for (const [pub, limit] of Object.entries(data)) {
275
+ if (typeof limit === "number") userLimits.set(pub, limit * 1048576)
276
+ // Non-numeric values ignored, falls back to defaultLimit.
277
+ }
278
+ } catch {
279
+ // Invalid JSON — userLimits stays empty.
280
+ }
281
+ if (cb) cb()
282
+ })
283
+ }
284
+
285
+ const formatBytes = bytes => {
286
+ if (bytes < 1024) return `${bytes} B`
287
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`
288
+ if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`
289
+ return `${(bytes / 1073741824).toFixed(1)} GB`
290
+ }
291
+
292
+ const logStorageSummary = () => {
293
+ const userCount = userStorage.size
294
+ const defLimitStr =
295
+ defaultLimit === 0 ? "unlisted users blocked" : formatBytes(defaultLimit)
296
+ console.log(
297
+ `[HOLSTER] Per-user storage limits enabled (${userCount} user${userCount !== 1 ? "s" : ""}, default: ${defLimitStr})`,
298
+ )
299
+ const sorted = [...userStorage.entries()].sort(([, a], [, b]) => b - a)
300
+ const formatUser = pub => {
301
+ const total = userStorage.get(pub) ?? 0
302
+ const limit = userLimits.get(pub) ?? defaultLimit
303
+ const limitStr = limit === 0 ? "blocked" : formatBytes(limit)
304
+ return ` ${pub} — used: ${formatBytes(total)} / limit: ${limitStr}`
305
+ }
306
+ if (userCount <= 5) {
307
+ sorted.forEach(([pub]) => console.log(formatUser(pub)))
308
+ } else {
309
+ console.log(" Top 5 by storage used:")
310
+ sorted.slice(0, 5).forEach(([pub]) => console.log(formatUser(pub)))
311
+ console.log(` Run \`node examples/user-storage.js\` to view all users`)
312
+ }
313
+ }
314
+
213
315
  // The check function is required because user data must provide a public key
214
316
  // so that it can be verified. The public key might verify the provided
215
317
  // signature but not actually match the user under which the data is being
@@ -236,11 +338,7 @@ const Wire = opt => {
236
338
  // matching public keys, as the provided soul also needs a rel on the
237
339
  // parent node which then also requires checking. Otherwise public keys
238
340
  // need to match for existing data.
239
- if (
240
- !msg.put ||
241
- !msg.put[soul] ||
242
- msg.put[soul][key] === node[key]
243
- ) {
341
+ if (!msg.put || !msg.put[soul] || msg.put[soul][key] === node[key]) {
244
342
  continue
245
343
  }
246
344
 
@@ -284,8 +382,25 @@ const Wire = opt => {
284
382
  }
285
383
 
286
384
  const put = async (msg, send) => {
385
+ // Capture old graph sizes before Ham.mix updates graph in place.
386
+ const oldGraphBytes = {}
387
+ if (opt.userLimit) {
388
+ for (const [soul, node] of Object.entries(msg.put)) {
389
+ if (node && node[utils.userPublicKey] && graph[soul]) {
390
+ oldGraphBytes[soul] = JSON.stringify(graph[soul]).length
391
+ }
392
+ }
393
+ }
394
+
287
395
  // Store updates returned from Ham.mix and defer updates if required.
288
- const update = await Ham.mix(msg.put, graph, hasNode, opt.secure, listen)
396
+ const update = await Ham.mix(
397
+ msg.put,
398
+ graph,
399
+ hasNode,
400
+ opt.secure,
401
+ listen,
402
+ opt.maxGraphSize,
403
+ )
289
404
  if (Object.keys(update.now).length === 0) {
290
405
  // No updates to store, check deferred.
291
406
  if (Object.keys(update.defer).length !== 0) {
@@ -299,7 +414,7 @@ const Wire = opt => {
299
414
 
300
415
  if (!(await check(update.now, send))) return
301
416
 
302
- store.put(update.now, err => {
417
+ const finish = err => {
303
418
  if (err) console.warn("store.put", err)
304
419
  send(
305
420
  JSON.stringify({
@@ -310,7 +425,35 @@ const Wire = opt => {
310
425
  )
311
426
  // Fire listeners after data is stored
312
427
  update.listeners.forEach(cb => cb())
313
- })
428
+ }
429
+
430
+ if (!opt.userLimit) {
431
+ store.put(update.now, finish)
432
+ } else {
433
+ const toStore = {}
434
+ for (const [soul, node] of Object.entries(update.now)) {
435
+ const pub = node[utils.userPublicKey]
436
+ if (!pub) {
437
+ toStore[soul] = node
438
+ continue
439
+ }
440
+ const limit = userLimits.get(pub) ?? defaultLimit
441
+ if ((userStorage.get(pub) ?? 0) >= limit) continue
442
+ toStore[soul] = node
443
+ const oldBytes = oldGraphBytes[soul] ?? 0
444
+ const newBytes = JSON.stringify(node).length
445
+ userStorage.set(
446
+ pub,
447
+ Math.max(0, (userStorage.get(pub) ?? 0) - oldBytes + newBytes),
448
+ )
449
+ saveUserStorage()
450
+ }
451
+ if (Object.keys(toStore).length > 0) {
452
+ store.put(toStore, finish)
453
+ } else {
454
+ finish(null)
455
+ }
456
+ }
314
457
 
315
458
  if (Object.keys(update.defer).length !== 0) {
316
459
  setTimeout(
@@ -365,103 +508,105 @@ const Wire = opt => {
365
508
  return
366
509
  }
367
510
 
368
- store.get(
369
- lex,
370
- (err, ack) => {
371
- if (ack) {
372
- // Populate in-memory graph from store data before sending the server
373
- // request, so stale server responses with older state are correctly
374
- // rejected by ham.mix.
375
- for (const [soul, node] of Object.entries(ack)) {
376
- if (!node) continue
377
- if (!graph[soul]) {
378
- graph[soul] = {...node}
379
- continue
380
- }
381
- // Merge properties absent from the current graph entry. Only add
382
- // missing keys — existing entries were set by ham.mix and represent
383
- // the authoritative merged state for those properties.
384
- for (const [key, value] of Object.entries(node)) {
385
- if (key === "_") continue
386
- if (typeof graph[soul][key] !== "undefined") continue
387
- graph[soul][key] = value
388
- if (node._ && node._[">"] && typeof node._[">"][key] !== "undefined") {
389
- const state = node._[">"][key]
390
- graph[soul]._[">"][key] = state
391
- if (node._["s"] && node._["s"][state]) {
392
- if (!graph[soul]._["s"]) graph[soul]._["s"] = {}
393
- graph[soul]._["s"][state] = node._["s"][state]
394
- }
511
+ store.get(lex, (err, ack) => {
512
+ if (ack) {
513
+ // Populate in-memory graph from store data before sending the server
514
+ // request, so stale server responses with older state are correctly
515
+ // rejected by ham.mix.
516
+ for (const [soul, node] of Object.entries(ack)) {
517
+ if (!node) continue
518
+ if (!graph[soul]) {
519
+ graph[soul] = {...node}
520
+ continue
521
+ }
522
+ // Merge properties absent from the current graph entry. Only add
523
+ // missing keys — existing entries were set by ham.mix and represent
524
+ // the authoritative merged state for those properties.
525
+ for (const [key, value] of Object.entries(node)) {
526
+ if (key === "_") continue
527
+ if (typeof graph[soul][key] !== "undefined") continue
528
+ graph[soul][key] = value
529
+ if (
530
+ node._ &&
531
+ node._[">"] &&
532
+ typeof node._[">"][key] !== "undefined"
533
+ ) {
534
+ const state = node._[">"][key]
535
+ graph[soul]._[">"][key] = state
536
+ if (node._["s"] && node._["s"][state]) {
537
+ if (!graph[soul]._["s"]) graph[soul]._["s"] = {}
538
+ graph[soul]._["s"][state] = node._["s"][state]
395
539
  }
396
540
  }
397
541
  }
398
- // Full-node fetch: the graph is now complete for this soul.
399
- if (!lex["."] && ack[lex["#"]]) hasNode.add(lex["#"])
400
- // Only serve from store if the requested property is present.
401
- const node = ack[lex["#"]]
402
- const hasProperty =
403
- node &&
404
- Object.keys(node).some(
405
- key => key !== "_" && utils.match(lex["."], key),
406
- )
407
- if (hasProperty) {
408
- // Public keys are immutable once written — never need a wire check.
409
- if (!isNode && lex["."] !== utils.userPublicKey) {
410
- if (!lex["."] || typeof lex["."] === "string") {
411
- const checkKey = lex["#"] + (lex["."] ? "." + lex["."] : "")
412
- const now = Date.now()
413
- const lastCheck = recentChecks.get(checkKey)
414
- if (!lastCheck || now - lastCheck > recentCheckTTL) {
415
- recentChecks.set(checkKey, now)
416
- const sendResult = send(request)
417
- if (sendResult && sendResult.err) {
418
- cb({err: sendResult.err})
419
- return
420
- }
421
- }
422
- } else {
542
+ }
543
+ // Full-node fetch: the graph is now complete for this soul.
544
+ if (!lex["."] && ack[lex["#"]]) hasNode.add(lex["#"])
545
+ // Only serve from store if the requested property is present.
546
+ const node = ack[lex["#"]]
547
+ const hasProperty =
548
+ node &&
549
+ Object.keys(node).some(
550
+ key => key !== "_" && utils.match(lex["."], key),
551
+ )
552
+ if (hasProperty) {
553
+ // Public keys are immutable once written — never need a wire check.
554
+ if (!isNode && lex["."] !== utils.userPublicKey) {
555
+ if (!lex["."] || typeof lex["."] === "string") {
556
+ const checkKey = lex["#"] + (lex["."] ? "." + lex["."] : "")
557
+ const now = Date.now()
558
+ const lastCheck = recentChecks.get(checkKey)
559
+ if (!lastCheck || now - lastCheck > recentCheckTTL) {
560
+ recentChecks.set(checkKey, now)
423
561
  const sendResult = send(request)
424
562
  if (sendResult && sendResult.err) {
425
563
  cb({err: sendResult.err})
426
564
  return
427
565
  }
428
566
  }
567
+ } else {
568
+ const sendResult = send(request)
569
+ if (sendResult && sendResult.err) {
570
+ cb({err: sendResult.err})
571
+ return
572
+ }
429
573
  }
430
- cb(err ? {put: ack, err: err} : {put: ack})
431
- return
432
574
  }
575
+ cb(err ? {put: ack, err: err} : {put: ack})
576
+ return
433
577
  }
578
+ }
434
579
 
435
- // Data wasn't found locally, log errors and call back after a timeout.
436
- if (err) console.log(err)
580
+ // Data wasn't found locally, log errors and call back after a timeout.
581
+ if (err) console.log(err)
437
582
 
438
- // Put requests only need a local answer — skip the wire round-trip.
439
- if (_opt && _opt.put) {
440
- const id = lex["#"]
441
- const ack = {[id]: null}
442
- if (typeof lex["."] === "string") {
443
- ack[id] = {[lex["."]]: null}
444
- }
445
- cb({put: ack})
446
- return
583
+ // Put requests only need a local answer — skip the wire round-trip.
584
+ if (_opt && _opt.put) {
585
+ const id = lex["#"]
586
+ const ack = {[id]: null}
587
+ if (typeof lex["."] === "string") {
588
+ ack[id] = {[lex["."]]: null}
447
589
  }
590
+ cb({put: ack})
591
+ return
592
+ }
448
593
 
449
- queue[track] = cb
594
+ queue[track] = cb
450
595
 
451
- // Store lex to construct the null ack when the timeout fires
452
- pendingTimeouts.set(track, lex)
596
+ // Store lex to construct the null ack when the timeout fires
597
+ pendingTimeouts.set(track, lex)
453
598
 
454
- // Ensure the requested soul is stored when the response arrives.
455
- pendingReferences.add(lex["#"])
599
+ // Ensure the requested soul is stored when the response arrives.
600
+ pendingReferences.add(lex["#"])
456
601
 
457
- const sendResult = send(request)
458
- if (sendResult && sendResult.err) {
459
- cb({err: sendResult.err})
460
- delete queue[track]
461
- pendingTimeouts.delete(track)
462
- return
463
- }
464
- })
602
+ const sendResult = send(request)
603
+ if (sendResult && sendResult.err) {
604
+ cb({err: sendResult.err})
605
+ delete queue[track]
606
+ pendingTimeouts.delete(track)
607
+ return
608
+ }
609
+ })
465
610
  }
466
611
 
467
612
  const api = send => {
@@ -481,7 +626,22 @@ const Wire = opt => {
481
626
  // here using the api. This is ok because correct timestamps should be
482
627
  // used whereas wire spec needs to handle clock skew for updates
483
628
  // across the network.
484
- const update = await Ham.mix(data, graph, hasNode, opt.secure, listen)
629
+ const oldGraphBytes = {}
630
+ if (opt.userLimit) {
631
+ for (const [soul, node] of Object.entries(data)) {
632
+ if (node && node[utils.userPublicKey] && graph[soul]) {
633
+ oldGraphBytes[soul] = JSON.stringify(graph[soul]).length
634
+ }
635
+ }
636
+ }
637
+ const update = await Ham.mix(
638
+ data,
639
+ graph,
640
+ hasNode,
641
+ opt.secure,
642
+ listen,
643
+ opt.maxGraphSize,
644
+ )
485
645
  if (Object.keys(update.now).length === 0) {
486
646
  // No updates, still respond to callback.
487
647
  if (cb) cb(null)
@@ -492,6 +652,24 @@ const Wire = opt => {
492
652
  return
493
653
  }
494
654
 
655
+ if (opt.userLimit) {
656
+ for (const [soul, node] of Object.entries(update.now)) {
657
+ const pub = node[utils.userPublicKey]
658
+ if (!pub) continue
659
+ const limit = userLimits.get(pub) ?? defaultLimit
660
+ const current = userStorage.get(pub) ?? 0
661
+ if (current >= limit) {
662
+ if (cb) cb("storage limit exceeded")
663
+ return
664
+ }
665
+
666
+ const oldBytes = oldGraphBytes[soul] ?? 0
667
+ const newBytes = JSON.stringify(node).length
668
+ userStorage.set(pub, Math.max(0, current - oldBytes + newBytes))
669
+ saveUserStorage()
670
+ }
671
+ }
672
+
495
673
  // Seed pendingReferences with any new references from API calls
496
674
  for (const [soul, node] of Object.entries(update.now)) {
497
675
  if (node && typeof node === "object") {
@@ -561,9 +739,7 @@ const Wire = opt => {
561
739
  // mock-sockets provides clients as a function that returns an array.
562
740
  let clients = () => wss.clients()
563
741
  if (!wss) {
564
- const config = opt.server
565
- ? {server: opt.server}
566
- : {port: opt.port}
742
+ const config = opt.server ? {server: opt.server} : {port: opt.port}
567
743
  wss = new wsModule.WebSocketServer(config)
568
744
  clients = () => wss.clients
569
745
  }
@@ -576,18 +752,21 @@ const Wire = opt => {
576
752
  const lex = pendingTimeouts.get(trackId)
577
753
  pendingTimeouts.delete(trackId)
578
754
 
579
- setTimeout(() => {
580
- const cb = queue[trackId]
581
- if (cb) {
582
- const id = lex["#"]
583
- const ack = {[id]: null}
584
- if (typeof lex["."] === "string") {
585
- ack[id] = {[lex["."]]: null}
755
+ setTimeout(
756
+ () => {
757
+ const cb = queue[trackId]
758
+ if (cb) {
759
+ const id = lex["#"]
760
+ const ack = {[id]: null}
761
+ if (typeof lex["."] === "string") {
762
+ ack[id] = {[lex["."]]: null}
763
+ }
764
+ cb({put: ack})
765
+ delete queue[trackId]
586
766
  }
587
- cb({put: ack})
588
- delete queue[trackId]
589
- }
590
- }, isTestEnv ? 100 : 10000)
767
+ },
768
+ isTestEnv ? 100 : 10000,
769
+ )
591
770
  }
592
771
 
593
772
  clients().forEach(client => {
@@ -611,6 +790,11 @@ const Wire = opt => {
611
790
  }
612
791
  })
613
792
  }
793
+ if (opt.userLimit) {
794
+ loadUserStorage(() => loadUserLimits(() => logStorageSummary()))
795
+ setInterval(loadUserLimits, 60000).unref()
796
+ }
797
+
614
798
  wss.on("connection", ws => {
615
799
  // Check connection limit
616
800
  if (!connectionManager.add(ws)) {
@@ -722,7 +906,11 @@ const Wire = opt => {
722
906
  let queueProcessor = null
723
907
  const maxQueueLength = opt.maxQueueLength || 10000
724
908
 
725
- const startResponseTimeout = (trackId, lex, delay = isTestEnv ? 100 : 10000) => {
909
+ const startResponseTimeout = (
910
+ trackId,
911
+ lex,
912
+ delay = isTestEnv ? 100 : 10000,
913
+ ) => {
726
914
  return setTimeout(() => {
727
915
  offlineTimeouts.delete(trackId)
728
916
  pendingTimeouts.delete(trackId)
@@ -772,7 +960,8 @@ const Wire = opt => {
772
960
 
773
961
  // Drop GET messages with no pending callback — these were already served
774
962
  // from local store so no caller is waiting for the wire response.
775
- const isStaleCheckMessage = !sent && msg.get && !pendingTimeouts.has(trackId)
963
+ const isStaleCheckMessage =
964
+ !sent && msg.get && !pendingTimeouts.has(trackId)
776
965
 
777
966
  if (sent || isStaleCheckMessage) {
778
967
  messageQueue.shift()
@@ -908,7 +1097,12 @@ const Wire = opt => {
908
1097
  for (const [soul, node] of Object.entries(msg.put)) {
909
1098
  const known = await hasSoul(soul)
910
1099
  hasSoulMap.set(soul, known)
911
- if ((!known && !pendingReferences.has(soul)) || !node || typeof node !== "object") continue
1100
+ if (
1101
+ (!known && !pendingReferences.has(soul)) ||
1102
+ !node ||
1103
+ typeof node !== "object"
1104
+ )
1105
+ continue
912
1106
  for (const [, value] of Object.entries(node)) {
913
1107
  const soulId = utils.rel.is(value)
914
1108
  if (soulId) pendingReferences.add(soulId)
package/ts/src/array.ts CHANGED
@@ -25,22 +25,22 @@ class SeaArray extends Array<number> {
25
25
  override toString(enc?: Encoding, start?: number, end?: number): string {
26
26
  const encoding = enc || "utf8"
27
27
  const startIdx = start || 0
28
- const endIdx = end ? Math.min(Math.max(end, startIdx), this.length) : this.length
28
+ const endIdx = end
29
+ ? Math.min(Math.max(end, startIdx), this.length)
30
+ : this.length
29
31
 
30
32
  if (encoding === "hex") {
31
33
  const buf = new Uint8Array(this.slice(startIdx, endIdx))
32
- return Array.from(buf, byte => byte.toString(16).padStart(2, "0")).join("")
34
+ return Array.from(buf, byte => byte.toString(16).padStart(2, "0")).join(
35
+ "",
36
+ )
33
37
  }
34
38
 
35
39
  if (encoding === "utf8") {
36
- return Array.from({ length: endIdx - startIdx }, (_, i) => {
40
+ return Array.from({length: endIdx - startIdx}, (_, i) => {
37
41
  const charCode = this[i + startIdx]
38
42
  // Use unicode replacement character for invalid character codes
39
- if (
40
- charCode === undefined ||
41
- charCode < 0 ||
42
- charCode > 0x10ffff
43
- ) {
43
+ if (charCode === undefined || charCode < 0 || charCode > 0x10ffff) {
44
44
  return String.fromCharCode(0xfffd)
45
45
  }
46
46
  return String.fromCharCode(charCode)
@@ -48,7 +48,7 @@ class SeaArray extends Array<number> {
48
48
  }
49
49
 
50
50
  if (encoding === "base64") {
51
- const utf8String = Array.from({ length: endIdx - startIdx }, (_, i) => {
51
+ const utf8String = Array.from({length: endIdx - startIdx}, (_, i) => {
52
52
  const charCode = this[i + startIdx]
53
53
  if (charCode === undefined || charCode < 0 || charCode > 255) {
54
54
  return String.fromCharCode(0xfffd)
@@ -65,9 +65,7 @@ class SeaArray extends Array<number> {
65
65
  /**
66
66
  * Create SeaArray from iterable or array-like
67
67
  */
68
- static override from<T>(
69
- arrayLike: ArrayLike<T> | Iterable<T>
70
- ): SeaArray {
68
+ static override from<T>(arrayLike: ArrayLike<T> | Iterable<T>): SeaArray {
71
69
  const arr = Array.from(arrayLike) as number[]
72
70
  const seaArray = new SeaArray(...arr)
73
71
  return seaArray
@@ -75,4 +73,3 @@ class SeaArray extends Array<number> {
75
73
  }
76
74
 
77
75
  export default SeaArray
78
-