@mblaney/holster 2.0.2 → 2.1.1

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/ts/src/wire.ts CHANGED
@@ -23,13 +23,18 @@ import type {
23
23
  type UnifiedWebSocket = WebSocket & {
24
24
  _retryHandler?: ReturnType<typeof createRetryHandler>
25
25
  on?: (event: string, cb: (...args: unknown[]) => void) => void
26
- send: (data: string | Buffer | ArrayBuffer | Uint8Array, options?: unknown, cb?: (err?: Error) => void) => void
26
+ send: (
27
+ data: string | Buffer | ArrayBuffer | Uint8Array,
28
+ options?: unknown,
29
+ cb?: (err?: Error) => void,
30
+ ) => void
27
31
  }
28
32
 
29
33
  const isNode = typeof process !== "undefined" && process.versions?.node != null
30
34
 
31
35
  // Dynamic import for Node.js ws module - won't execute in browser/service worker
32
36
  const wsModule = isNode ? await import("ws") : undefined
37
+ const fs = isNode ? await import(/*webpackIgnore: true*/ "node:fs") : undefined
33
38
 
34
39
  if (typeof globalThis.WebSocket === "undefined" && wsModule) {
35
40
  globalThis.WebSocket = wsModule.WebSocket as unknown as typeof WebSocket
@@ -99,7 +104,9 @@ const createRateLimiter = (isTestEnv: boolean) => {
99
104
  if (!client) return maxRequests
100
105
 
101
106
  const now = Date.now()
102
- const validRequests = client.requests.filter(time => now - time < windowMs)
107
+ const validRequests = client.requests.filter(
108
+ time => now - time < windowMs,
109
+ )
103
110
  return Math.max(0, maxRequests - validRequests.length)
104
111
  },
105
112
 
@@ -130,29 +137,29 @@ const createRateLimiter = (isTestEnv: boolean) => {
130
137
 
131
138
  const safeJSONParse = (
132
139
  data: string | Buffer,
133
- maxSize = 1024 * 1024
134
- ): { success: boolean; data?: WireMessage; error?: string } => {
140
+ maxSize = 1024 * 1024,
141
+ ): {success: boolean; data?: WireMessage; error?: string} => {
135
142
  try {
136
143
  if (typeof data === "string" && data.length > maxSize) {
137
144
  throw new Error("Message too large")
138
145
  }
139
- return { success: true, data: JSON.parse(data.toString()) }
146
+ return {success: true, data: JSON.parse(data.toString())}
140
147
  } catch (error) {
141
- return { success: false, error: (error as Error).message }
148
+ return {success: false, error: (error as Error).message}
142
149
  }
143
150
  }
144
151
 
145
152
  const validateMessage = (
146
153
  data: string | Buffer,
147
- maxSize = 1024 * 1024
148
- ): { valid: boolean; error?: string } => {
154
+ maxSize = 1024 * 1024,
155
+ ): {valid: boolean; error?: string} => {
149
156
  if (typeof data === "string" && data.length > maxSize) {
150
- return { valid: false, error: "Message too large" }
157
+ return {valid: false, error: "Message too large"}
151
158
  }
152
159
  if (Buffer.isBuffer && Buffer.isBuffer(data) && data.length > maxSize) {
153
- return { valid: false, error: "Message too large" }
160
+ return {valid: false, error: "Message too large"}
154
161
  }
155
- return { valid: true }
162
+ return {valid: true}
156
163
  }
157
164
 
158
165
  // ============================================================================
@@ -240,20 +247,131 @@ const Wire = (opt: HolsterOptions): WireAPI => {
240
247
  const hasSoul = async (soul: string): Promise<boolean> => {
241
248
  if (graph[soul]) return true
242
249
  return new Promise(resolve => {
243
- store.get({ "#": soul }, (err, data) => {
250
+ store.get({"#": soul}, (err, data) => {
244
251
  resolve(!err && !!data && !!data[soul])
245
252
  })
246
253
  })
247
254
  }
248
255
 
249
- const isTestEnv = isNode && (globalThis.WebSocket as unknown) !== wsModule?.WebSocket
256
+ const isTestEnv =
257
+ isNode && (globalThis.WebSocket as unknown) !== wsModule?.WebSocket
250
258
  const rateLimiter = createRateLimiter(isTestEnv)
251
- const connectionManager = createConnectionManager(options.maxConnections || 1000)
259
+ const connectionManager = createConnectionManager(
260
+ options.maxConnections || 1000,
261
+ )
262
+
263
+ // User storage limiting — server-side only, requires options.userLimit: true.
264
+ const DEFAULT_STORAGE_LIMIT = 1 // 1 MB
265
+ const defaultLimit =
266
+ (typeof options.defaultLimit === "number"
267
+ ? options.defaultLimit
268
+ : DEFAULT_STORAGE_LIMIT) * 1048576
269
+ const userStorage = new Map<string, number>() // Map<pubkey, totalBytes>
270
+ const userLimits = new Map<string, number>() // Map<pubkey, maxBytes> converted from MB in .user_limit.json
271
+ let saveStorageTimer: ReturnType<typeof setTimeout> | null = null
272
+
273
+ const loadUserStorage = (cb?: () => void): void => {
274
+ fs!.readFile(
275
+ `${options.file}/../.user_storage.json`,
276
+ "utf8",
277
+ (err: NodeJS.ErrnoException | null, raw: string) => {
278
+ if (err) {
279
+ if (cb) cb()
280
+ return
281
+ }
282
+ try {
283
+ const data = JSON.parse(raw) as Record<string, unknown>
284
+ userStorage.clear()
285
+ for (const [pub, total] of Object.entries(data)) {
286
+ if (typeof total === "number") userStorage.set(pub, total)
287
+ }
288
+ } catch {
289
+ // Invalid JSON — start with empty map.
290
+ }
291
+ if (cb) cb()
292
+ },
293
+ )
294
+ }
295
+
296
+ const saveUserStorage = (): void => {
297
+ if (saveStorageTimer) return
298
+ const t = setTimeout(() => {
299
+ saveStorageTimer = null
300
+ const data: Record<string, number> = {}
301
+ for (const [pub, total] of userStorage) {
302
+ data[pub] = total
303
+ }
304
+ fs!.writeFile(
305
+ `${options.file}/../.user_storage.json`,
306
+ JSON.stringify(data),
307
+ (err: NodeJS.ErrnoException | null) => {
308
+ if (err) console.warn("saveUserStorage error:", err)
309
+ },
310
+ )
311
+ }, 5000)
312
+ if ((t as unknown as {unref?: () => void}).unref)
313
+ (t as unknown as {unref: () => void}).unref()
314
+ saveStorageTimer = t
315
+ }
316
+
317
+ const loadUserLimits = (cb?: () => void): void => {
318
+ fs!.readFile(
319
+ `${options.file}/../.user_limit.json`,
320
+ "utf8",
321
+ (err: NodeJS.ErrnoException | null, raw: string) => {
322
+ if (err) {
323
+ if (cb) cb()
324
+ return
325
+ }
326
+ try {
327
+ const data = JSON.parse(raw) as Record<string, unknown>
328
+ userLimits.clear()
329
+ for (const [pub, limit] of Object.entries(data)) {
330
+ if (typeof limit === "number") userLimits.set(pub, limit * 1048576)
331
+ // Non-numeric values silently ignored — fall back to defaultLimit at use time.
332
+ }
333
+ } catch {
334
+ // Invalid JSON — userLimits stays empty.
335
+ }
336
+ if (cb) cb()
337
+ },
338
+ )
339
+ }
340
+
341
+ const formatBytes = (bytes: number): string => {
342
+ if (bytes < 1024) return `${bytes} B`
343
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`
344
+ if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`
345
+ return `${(bytes / 1073741824).toFixed(1)} GB`
346
+ }
347
+
348
+ const logStorageSummary = (): void => {
349
+ const userCount = userStorage.size
350
+ const defLimitStr =
351
+ defaultLimit === 0 ? "unlisted users blocked" : formatBytes(defaultLimit)
352
+ console.log(
353
+ `[HOLSTER] Per-user storage limits enabled (${userCount} user${userCount !== 1 ? "s" : ""}, default: ${defLimitStr})`,
354
+ )
355
+ const sorted = [...userStorage.entries()].sort(([, a], [, b]) => b - a)
356
+ const formatUser = (pub: string): string => {
357
+ const total = userStorage.get(pub) ?? 0
358
+ const limit = userLimits.get(pub) ?? defaultLimit
359
+ const limitStr = limit === 0 ? "blocked" : formatBytes(limit)
360
+ return ` ${pub} — used: ${formatBytes(total)} / limit: ${limitStr}`
361
+ }
362
+ if (userCount <= 5) {
363
+ sorted.forEach(([pub]) => console.log(formatUser(pub)))
364
+ } else {
365
+ console.log(" Top 5 by storage used:")
366
+ sorted.slice(0, 5).forEach(([pub]) => console.log(formatUser(pub)))
367
+ console.log(` Run \`node examples/user-storage.js\` to view all users`)
368
+ }
369
+ }
252
370
 
253
371
  const check = async (
254
372
  data: Graph,
255
- send: (msg: string) => { err?: string } | void,
256
- cb?: (err?: string | null) => void
373
+ send: (msg: string) => {err?: string} | void,
374
+ cb?: (err?: string | null) => void,
257
375
  ): Promise<boolean> => {
258
376
  const key = utils.userPublicKey
259
377
 
@@ -264,7 +382,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
264
382
  if (node[key] === undefined) continue
265
383
 
266
384
  const msg = await new Promise<WireMessage>(res => {
267
- getWithCallback({ "#": soul, ".": key }, res, send, {put: true})
385
+ getWithCallback({"#": soul, ".": key}, res, send, {put: true})
268
386
  })
269
387
  if (msg.err) {
270
388
  if (cb) cb(msg.err)
@@ -275,11 +393,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
275
393
  // matching public keys, as the provided soul also needs a rel on the
276
394
  // parent node which then also requires checking. Otherwise public keys
277
395
  // need to match for existing data.
278
- if (
279
- !msg.put ||
280
- !msg.put[soul] ||
281
- msg.put[soul]![key] === node[key]
282
- ) {
396
+ if (!msg.put || !msg.put[soul] || msg.put[soul]![key] === node[key]) {
283
397
  continue
284
398
  }
285
399
 
@@ -292,7 +406,10 @@ const Wire = (opt: HolsterOptions): WireAPI => {
292
406
  return true
293
407
  }
294
408
 
295
- const get = (msg: { get: Lex; "#": string }, send: (msg: string) => void): void => {
409
+ const get = (
410
+ msg: {get: Lex; "#": string},
411
+ send: (msg: string) => void,
412
+ ): void => {
296
413
  const ack = Get(msg.get, graph)
297
414
  if (ack) {
298
415
  send(
@@ -300,7 +417,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
300
417
  "#": dup.track(utils.text.random(9)),
301
418
  "@": msg["#"],
302
419
  put: ack,
303
- })
420
+ }),
304
421
  )
305
422
  } else {
306
423
  store.get(msg.get, (err, ack) => {
@@ -310,48 +427,99 @@ const Wire = (opt: HolsterOptions): WireAPI => {
310
427
  "@": msg["#"],
311
428
  put: ack,
312
429
  err: err,
313
- })
430
+ }),
314
431
  )
315
432
  })
316
433
  }
317
434
  }
318
435
 
319
436
  const put = async (
320
- msg: { put: Graph; "#": string },
321
- send: (msg: string) => void
437
+ msg: {put: Graph; "#": string},
438
+ send: (msg: string) => void,
322
439
  ): Promise<void> => {
323
- const update = await Ham.mix(msg.put, graph, hasNode, options.secure || false, listen)
440
+ // Capture old graph sizes for user souls before Ham.mix updates graph in place.
441
+ const oldGraphBytes: Record<string, number> = {}
442
+ if (options.userLimit) {
443
+ for (const [soul, node] of Object.entries(msg.put)) {
444
+ if (node && node[utils.userPublicKey] && graph[soul]) {
445
+ oldGraphBytes[soul] = JSON.stringify(graph[soul]).length
446
+ }
447
+ }
448
+ }
449
+
450
+ const update = await Ham.mix(
451
+ msg.put,
452
+ graph,
453
+ hasNode,
454
+ options.secure || false,
455
+ listen,
456
+ options.maxGraphSize,
457
+ )
324
458
  if (Object.keys(update.now).length === 0) {
325
459
  if (Object.keys(update.defer).length !== 0) {
326
- setTimeout(() => put({ put: update.defer, "#": msg["#"] }, send), update.wait)
460
+ setTimeout(
461
+ () => put({put: update.defer, "#": msg["#"]}, send),
462
+ update.wait,
463
+ )
327
464
  }
328
465
  return
329
466
  }
330
467
 
331
468
  if (!(await check(update.now, send as never))) return
332
469
 
333
- store.put(update.now, err => {
470
+ const finish = (err?: string | null): void => {
334
471
  if (err) console.warn("store.put", err)
335
472
  send(
336
473
  JSON.stringify({
337
474
  "#": dup.track(utils.text.random(9)),
338
475
  "@": msg["#"],
339
476
  err: err,
340
- })
477
+ }),
341
478
  )
342
479
  update.listeners.forEach(cb => cb())
343
- })
480
+ }
481
+
482
+ if (!options.userLimit) {
483
+ store.put(update.now, finish)
484
+ } else {
485
+ const toStore: Graph = {}
486
+ for (const [soul, node] of Object.entries(update.now)) {
487
+ const pub = node![utils.userPublicKey] as string | undefined
488
+ if (!pub) {
489
+ toStore[soul] = node
490
+ continue
491
+ }
492
+ const limit = userLimits.get(pub) ?? defaultLimit
493
+ if ((userStorage.get(pub) ?? 0) >= limit) continue
494
+ toStore[soul] = node
495
+ const oldBytes = oldGraphBytes[soul] ?? 0
496
+ const newBytes = graph[soul] ? JSON.stringify(graph[soul]).length : 0
497
+ userStorage.set(
498
+ pub,
499
+ Math.max(0, (userStorage.get(pub) ?? 0) - oldBytes + newBytes),
500
+ )
501
+ saveUserStorage()
502
+ }
503
+ if (Object.keys(toStore).length > 0) {
504
+ store.put(toStore, finish)
505
+ } else {
506
+ finish(null)
507
+ }
508
+ }
344
509
 
345
510
  if (Object.keys(update.defer).length !== 0) {
346
- setTimeout(() => put({ put: update.defer, "#": msg["#"] }, send), update.wait)
511
+ setTimeout(
512
+ () => put({put: update.defer, "#": msg["#"]}, send),
513
+ update.wait,
514
+ )
347
515
  }
348
516
  }
349
517
 
350
518
  const getWithCallback = (
351
519
  lex: Lex,
352
520
  cb: (msg: WireMessage) => void,
353
- send: (msg: string) => { err?: string } | void,
354
- _opt?: WireOptions
521
+ send: (msg: string) => {err?: string} | void,
522
+ _opt?: WireOptions,
355
523
  ): void => {
356
524
  if (!cb) return
357
525
 
@@ -377,7 +545,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
377
545
  recentChecks.set(checkKey, now)
378
546
  const sendResult = send(request)
379
547
  if (sendResult && sendResult.err) {
380
- cb({ err: sendResult.err })
548
+ cb({err: sendResult.err})
381
549
  return
382
550
  }
383
551
  }
@@ -385,117 +553,115 @@ const Wire = (opt: HolsterOptions): WireAPI => {
385
553
  // Don't skip checks for other types of lex queries.
386
554
  const sendResult = send(request)
387
555
  if (sendResult && sendResult.err) {
388
- cb({ err: sendResult.err })
556
+ cb({err: sendResult.err})
389
557
  return
390
558
  }
391
559
  }
392
560
  }
393
- cb({ put: ack })
561
+ cb({put: ack})
394
562
  return
395
563
  }
396
564
 
397
- store.get(
398
- lex,
399
- (err, ack) => {
400
- if (ack) {
401
- // Populate in-memory graph from store data before sending the server
402
- // request, so stale server responses with older state are correctly
403
- // rejected by ham.mix.
404
- for (const [soul, node] of Object.entries(ack)) {
405
- if (!node) continue
406
- if (!graph[soul]) {
407
- graph[soul] = {...node} as unknown as Graph[string]
408
- continue
409
- }
410
- for (const [key, value] of Object.entries(node)) {
411
- if (key === "_") continue
412
- const gNode = graph[soul] as unknown as Record<string, unknown>
413
- if (typeof gNode[key] !== "undefined") continue
414
- gNode[key] = value
415
- const meta = (node as unknown as GraphNode)._
416
- if (meta?.[">"] && typeof meta[">"][key] !== "undefined") {
417
- const state = meta[">"][key]
418
- const gMeta = (graph[soul] as unknown as GraphNode)._
419
- gMeta[">"][key] = state
420
- if (meta["s"]?.[state]) {
421
- if (!gMeta["s"]) gMeta["s"] = {}
422
- gMeta["s"]![state] = meta["s"][state]
423
- }
565
+ store.get(lex, (err, ack) => {
566
+ if (ack) {
567
+ // Populate in-memory graph from store data before sending the server
568
+ // request, so stale server responses with older state are correctly
569
+ // rejected by ham.mix.
570
+ for (const [soul, node] of Object.entries(ack)) {
571
+ if (!node) continue
572
+ if (!graph[soul]) {
573
+ graph[soul] = {...node} as unknown as Graph[string]
574
+ continue
575
+ }
576
+ for (const [key, value] of Object.entries(node)) {
577
+ if (key === "_") continue
578
+ const gNode = graph[soul] as unknown as Record<string, unknown>
579
+ if (typeof gNode[key] !== "undefined") continue
580
+ gNode[key] = value
581
+ const meta = (node as unknown as GraphNode)._
582
+ if (meta?.[">"] && typeof meta[">"][key] !== "undefined") {
583
+ const state = meta[">"][key]
584
+ const gMeta = (graph[soul] as unknown as GraphNode)._
585
+ gMeta[">"][key] = state
586
+ if (meta["s"]?.[state]) {
587
+ if (!gMeta["s"]) gMeta["s"] = {}
588
+ gMeta["s"]![state] = meta["s"][state]
424
589
  }
425
590
  }
426
591
  }
427
- // Full-node fetch: the graph is now complete for this soul.
428
- if (!lex["."] && ack[lex["#"]]) hasNode.add(lex["#"])
429
- // Only serve from store if the requested property is present.
430
- const node = ack[lex["#"]]
431
- const hasProperty =
432
- node &&
433
- Object.keys(node).some(
434
- key => key !== "_" && utils.match(lex["."], key),
435
- )
436
- if (hasProperty) {
437
- // Public keys are immutable once written — never need a wire check.
438
- if (!isNode && lex["."] !== utils.userPublicKey) {
439
- if (!lex["."] || typeof lex["."] === "string") {
440
- const checkKey = lex["#"] + (lex["."] ? "." + lex["."] : "")
441
- const now = Date.now()
442
- const lastCheck = recentChecks.get(checkKey)
443
- if (!lastCheck || now - lastCheck > recentCheckTTL) {
444
- recentChecks.set(checkKey, now)
445
- const sendResult = send(request)
446
- if (sendResult && sendResult.err) {
447
- cb({ err: sendResult.err })
448
- return
449
- }
450
- }
451
- } else {
592
+ }
593
+ // Full-node fetch: the graph is now complete for this soul.
594
+ if (!lex["."] && ack[lex["#"]]) hasNode.add(lex["#"])
595
+ // Only serve from store if the requested property is present.
596
+ const node = ack[lex["#"]]
597
+ const hasProperty =
598
+ node &&
599
+ Object.keys(node).some(
600
+ key => key !== "_" && utils.match(lex["."], key),
601
+ )
602
+ if (hasProperty) {
603
+ // Public keys are immutable once written — never need a wire check.
604
+ if (!isNode && lex["."] !== utils.userPublicKey) {
605
+ if (!lex["."] || typeof lex["."] === "string") {
606
+ const checkKey = lex["#"] + (lex["."] ? "." + lex["."] : "")
607
+ const now = Date.now()
608
+ const lastCheck = recentChecks.get(checkKey)
609
+ if (!lastCheck || now - lastCheck > recentCheckTTL) {
610
+ recentChecks.set(checkKey, now)
452
611
  const sendResult = send(request)
453
612
  if (sendResult && sendResult.err) {
454
- cb({ err: sendResult.err })
613
+ cb({err: sendResult.err})
455
614
  return
456
615
  }
457
616
  }
617
+ } else {
618
+ const sendResult = send(request)
619
+ if (sendResult && sendResult.err) {
620
+ cb({err: sendResult.err})
621
+ return
622
+ }
458
623
  }
459
- cb(err ? { put: ack, err: err } : { put: ack })
460
- return
461
624
  }
625
+ cb(err ? {put: ack, err: err} : {put: ack})
626
+ return
462
627
  }
628
+ }
463
629
 
464
- if (err) console.log(err)
630
+ if (err) console.log(err)
465
631
 
466
- // Put requests only need a local answer — skip the wire round-trip.
467
- if (opts && opts.put) {
468
- const id = lex["#"]
469
- const ack: Record<string, null | Record<string, null>> = {[id]: null}
470
- if (typeof lex["."] === "string") {
471
- ack[id] = {[lex["."]]: null}
472
- }
473
- cb({put: ack as never})
474
- return
632
+ // Put requests only need a local answer — skip the wire round-trip.
633
+ if (opts && opts.put) {
634
+ const id = lex["#"]
635
+ const ack: Record<string, null | Record<string, null>> = {[id]: null}
636
+ if (typeof lex["."] === "string") {
637
+ ack[id] = {[lex["."]]: null}
475
638
  }
639
+ cb({put: ack as never})
640
+ return
641
+ }
476
642
 
477
- queue[track] = cb
643
+ queue[track] = cb
478
644
 
479
- pendingTimeouts.set(track, lex)
645
+ pendingTimeouts.set(track, lex)
480
646
 
481
- // Ensure the requested soul is stored when the response arrives.
482
- pendingReferences.add(lex["#"])
647
+ // Ensure the requested soul is stored when the response arrives.
648
+ pendingReferences.add(lex["#"])
483
649
 
484
- const sendResult = send(request)
485
- if (sendResult && sendResult.err) {
486
- cb({ err: sendResult.err })
487
- delete queue[track]
488
- pendingTimeouts.delete(track)
489
- return
490
- }
491
- })
650
+ const sendResult = send(request)
651
+ if (sendResult && sendResult.err) {
652
+ cb({err: sendResult.err})
653
+ delete queue[track]
654
+ pendingTimeouts.delete(track)
655
+ return
656
+ }
657
+ })
492
658
  }
493
659
 
494
- const api = (send: (msg: string) => { err?: string } | void): WireAPI => {
660
+ const api = (send: (msg: string) => {err?: string} | void): WireAPI => {
495
661
  return {
496
662
  get: (lex, cb, _opt) => {
497
663
  if (lex && typeof lex["."] === "number") {
498
- lex = { ...lex, ".": String(lex["."]) }
664
+ lex = {...lex, ".": String(lex["."])}
499
665
  }
500
666
  if (lex && lex["#"]) {
501
667
  pendingReferences.add(lex["#"])
@@ -503,7 +669,22 @@ const Wire = (opt: HolsterOptions): WireAPI => {
503
669
  getWithCallback(lex, cb, send, _opt)
504
670
  },
505
671
  put: async (data, cb) => {
506
- const update = await Ham.mix(data, graph, hasNode, options.secure || false, listen)
672
+ const oldGraphBytes: Record<string, number> = {}
673
+ if (options.userLimit) {
674
+ for (const [soul, node] of Object.entries(data)) {
675
+ if (node && node[utils.userPublicKey] && graph[soul]) {
676
+ oldGraphBytes[soul] = JSON.stringify(graph[soul]).length
677
+ }
678
+ }
679
+ }
680
+ const update = await Ham.mix(
681
+ data,
682
+ graph,
683
+ hasNode,
684
+ options.secure || false,
685
+ listen,
686
+ options.maxGraphSize,
687
+ )
507
688
  if (Object.keys(update.now).length === 0) {
508
689
  if (cb) cb(null)
509
690
  return
@@ -513,6 +694,25 @@ const Wire = (opt: HolsterOptions): WireAPI => {
513
694
  return
514
695
  }
515
696
 
697
+ if (options.userLimit) {
698
+ for (const [soul, node] of Object.entries(update.now)) {
699
+ const pub = node![utils.userPublicKey] as string | undefined
700
+ if (!pub) continue
701
+ const limit = userLimits.get(pub) ?? defaultLimit
702
+ const current = userStorage.get(pub) ?? 0
703
+ if (current >= limit) {
704
+ if (cb) cb("storage limit exceeded")
705
+ return
706
+ }
707
+ const oldBytes = oldGraphBytes[soul] ?? 0
708
+ const newBytes = graph[soul]
709
+ ? JSON.stringify(graph[soul]).length
710
+ : 0
711
+ userStorage.set(pub, Math.max(0, current - oldBytes + newBytes))
712
+ saveUserStorage()
713
+ }
714
+ }
715
+
516
716
  for (const [_soul, node] of Object.entries(update.now)) {
517
717
  if (node && typeof node === "object") {
518
718
  for (const [_key, value] of Object.entries(node)) {
@@ -533,7 +733,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
533
733
  JSON.stringify({
534
734
  "#": dup.track(utils.text.random(9)),
535
735
  put: data,
536
- })
736
+ }),
537
737
  )
538
738
  if (sendResult && sendResult.err) {
539
739
  if (cb) cb(sendResult.err)
@@ -542,15 +742,15 @@ const Wire = (opt: HolsterOptions): WireAPI => {
542
742
  },
543
743
  on: (lex, cb, _get, _opt) => {
544
744
  if (lex && typeof lex["."] === "number") {
545
- lex = { ...lex, ".": String(lex["."]) }
745
+ lex = {...lex, ".": String(lex["."])}
546
746
  }
547
747
  const soul = lex && lex["#"]
548
748
  if (!soul || !cb) return
549
749
 
550
750
  if (listen[soul]) {
551
- listen[soul]!.push({ ".": lex["."], cb: cb as never })
751
+ listen[soul]!.push({".": lex["."], cb: cb as never})
552
752
  } else {
553
- listen[soul] = [{ ".": lex["."], cb: cb as never }]
753
+ listen[soul] = [{".": lex["."], cb: cb as never}]
554
754
  }
555
755
  if (_get) getWithCallback(lex, cb as never, send, _opt)
556
756
  },
@@ -573,13 +773,14 @@ const Wire = (opt: HolsterOptions): WireAPI => {
573
773
  if (isNode && (options.wss || options.server || options.port != null)) {
574
774
  let wss = options.wss
575
775
  let clients = (): WebSocket[] =>
576
- (wss as { clients?: () => WebSocket[] }).clients?.() || []
776
+ (wss as {clients?: () => WebSocket[]}).clients?.() || []
577
777
  if (!wss) {
578
778
  const config = options.server
579
- ? { server: options.server }
580
- : { port: options.port }
779
+ ? {server: options.server}
780
+ : {port: options.port}
581
781
  wss = new wsModule!.WebSocketServer(config)
582
- clients = () => Array.from((wss as { clients: Set<WebSocket> }).clients || [])
782
+ clients = () =>
783
+ Array.from((wss as {clients: Set<WebSocket>}).clients || [])
583
784
  }
584
785
 
585
786
  const send = (data: string, isBinary?: boolean): void => {
@@ -589,24 +790,27 @@ const Wire = (opt: HolsterOptions): WireAPI => {
589
790
  const lex = pendingTimeouts.get(trackId)!
590
791
  pendingTimeouts.delete(trackId)
591
792
 
592
- setTimeout(() => {
593
- const cb = queue[trackId]
594
- if (cb) {
595
- const id = lex["#"]
596
- const ack: Graph = { [id]: null as never }
597
- if (typeof lex["."] === "string") {
598
- ack[id] = { [lex["."]]: null } as never
793
+ setTimeout(
794
+ () => {
795
+ const cb = queue[trackId]
796
+ if (cb) {
797
+ const id = lex["#"]
798
+ const ack: Graph = {[id]: null as never}
799
+ if (typeof lex["."] === "string") {
800
+ ack[id] = {[lex["."]]: null} as never
801
+ }
802
+ cb({put: ack})
803
+ delete queue[trackId]
599
804
  }
600
- cb({ put: ack })
601
- delete queue[trackId]
602
- }
603
- }, isTestEnv ? 100 : 10000)
805
+ },
806
+ isTestEnv ? 100 : 10000,
807
+ )
604
808
  }
605
809
 
606
810
  clients().forEach(client => {
607
811
  const ws = client as UnifiedWebSocket
608
812
  if (ws && ws.readyState === WebSocket.OPEN) {
609
- ws.send(data, { binary: isBinary })
813
+ ws.send(data, {binary: isBinary})
610
814
  } else if (ws) {
611
815
  const retryHandler = ws._retryHandler || createRetryHandler()
612
816
  ws._retryHandler = retryHandler
@@ -618,7 +822,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
618
822
  setTimeout(() => {
619
823
  if (ws && ws.readyState === WebSocket.OPEN) {
620
824
  retryHandler.reset()
621
- ws.send(data, { binary: isBinary })
825
+ ws.send(data, {binary: isBinary})
622
826
  }
623
827
  }, delay)
624
828
  }
@@ -626,100 +830,106 @@ const Wire = (opt: HolsterOptions): WireAPI => {
626
830
  })
627
831
  }
628
832
 
629
- ;(wss as unknown as { on: (event: string, cb: (ws: UnifiedWebSocket) => void) => void }).on(
630
- "connection",
631
- (ws: UnifiedWebSocket) => {
632
- if (!connectionManager.add(ws)) {
633
- console.log("Connection limit reached, rejecting connection")
634
- ws.close(1013, "Connection limit reached - try again later")
635
- return
636
- }
637
-
638
- const clientId = utils.text.random(9)
639
- console.log(`New WebSocket client connected: ${clientId}`)
640
-
641
- ws.on?.("error", ((...args: unknown[]) => {
642
- const error = args[0] as Error
643
- console.log("WebSocket error:", error)
644
- connectionManager.remove(ws)
645
- }) as (...args: unknown[]) => void)
833
+ if (options.userLimit) {
834
+ loadUserStorage(() => loadUserLimits(() => logStorageSummary()))
835
+ setInterval(loadUserLimits, 60000).unref()
836
+ }
646
837
 
647
- ws.on?.("close", (() => {
648
- console.log(`WebSocket client disconnected: ${clientId}`)
649
- connectionManager.remove(ws)
650
- }) as (...args: unknown[]) => void)
838
+ ;(
839
+ wss as unknown as {
840
+ on: (event: string, cb: (ws: UnifiedWebSocket) => void) => void
841
+ }
842
+ ).on("connection", (ws: UnifiedWebSocket) => {
843
+ if (!connectionManager.add(ws)) {
844
+ console.log("Connection limit reached, rejecting connection")
845
+ ws.close(1013, "Connection limit reached - try again later")
846
+ return
847
+ }
651
848
 
652
- ws.on?.("message", ((...args: unknown[]) => {
653
- const data = args[0] as Buffer
654
- const isBinary = args[1] as boolean
655
- const validation = validateMessage(data, options.maxMessageSize)
656
- if (!validation.valid) {
657
- console.warn(`Invalid message: ${validation.error}`)
658
- ws.send(JSON.stringify({ error: validation.error }))
659
- return
660
- }
849
+ const clientId = utils.text.random(9)
850
+ console.log(`New WebSocket client connected: ${clientId}`)
851
+
852
+ ws.on?.("error", ((...args: unknown[]) => {
853
+ const error = args[0] as Error
854
+ console.log("WebSocket error:", error)
855
+ connectionManager.remove(ws)
856
+ }) as (...args: unknown[]) => void)
857
+
858
+ ws.on?.("close", (() => {
859
+ console.log(`WebSocket client disconnected: ${clientId}`)
860
+ connectionManager.remove(ws)
861
+ }) as (...args: unknown[]) => void)
862
+
863
+ ws.on?.("message", ((...args: unknown[]) => {
864
+ const data = args[0] as Buffer
865
+ const isBinary = args[1] as boolean
866
+ const validation = validateMessage(data, options.maxMessageSize)
867
+ if (!validation.valid) {
868
+ console.warn(`Invalid message: ${validation.error}`)
869
+ ws.send(JSON.stringify({error: validation.error}))
870
+ return
871
+ }
661
872
 
662
- const parseResult = safeJSONParse(data, options.maxMessageSize)
663
- if (!parseResult.success) {
664
- console.warn(`JSON parse error: ${parseResult.error}`)
665
- ws.send(JSON.stringify({ error: "Invalid JSON" }))
666
- return
667
- }
873
+ const parseResult = safeJSONParse(data, options.maxMessageSize)
874
+ if (!parseResult.success) {
875
+ console.warn(`JSON parse error: ${parseResult.error}`)
876
+ ws.send(JSON.stringify({error: "Invalid JSON"}))
877
+ return
878
+ }
668
879
 
669
- const msg = parseResult.data!
670
- if (!msg["#"]) return // Incoming messages must have '#'
671
- if (dup.check(msg["#"])) return
880
+ const msg = parseResult.data!
881
+ if (!msg["#"]) return // Incoming messages must have '#'
882
+ if (dup.check(msg["#"])) return
672
883
 
673
- const delay = rateLimiter.getDelay(clientId)
674
- if (delay > 0) {
675
- const throttleCount = rateLimiter.getThrottleCount(clientId)
884
+ const delay = rateLimiter.getDelay(clientId)
885
+ if (delay > 0) {
886
+ const throttleCount = rateLimiter.getThrottleCount(clientId)
887
+ console.log(
888
+ `Client ${clientId}: rate limit exceeded, delay would be ${delay}ms, dropping message (throttle count: ${throttleCount})`,
889
+ )
890
+ if (rateLimiter.shouldDisconnect(clientId)) {
676
891
  console.log(
677
- `Client ${clientId}: rate limit exceeded, delay would be ${delay}ms, dropping message (throttle count: ${throttleCount})`
678
- )
679
- if (rateLimiter.shouldDisconnect(clientId)) {
680
- console.log(
681
- `Client ${clientId}: Disconnecting after ${throttleCount} throttle violations`
682
- )
683
- ws.close(1008, "Rate limit violations")
684
- return
685
- }
686
-
687
- ws.send(
688
- JSON.stringify({
689
- "#": dup.track(utils.text.random(9)),
690
- "@": msg["#"],
691
- err: `Rate limit exceeded. Slow down requests. Wait ${Math.ceil(delay / 1000)}s`,
692
- throttle: delay,
693
- })
892
+ `Client ${clientId}: Disconnecting after ${throttleCount} throttle violations`,
694
893
  )
894
+ ws.close(1008, "Rate limit violations")
695
895
  return
696
896
  }
697
897
 
698
- const processMessage = async (): Promise<void> => {
699
- dup.track(msg["#"]!)
898
+ ws.send(
899
+ JSON.stringify({
900
+ "#": dup.track(utils.text.random(9)),
901
+ "@": msg["#"],
902
+ err: `Rate limit exceeded. Slow down requests. Wait ${Math.ceil(delay / 1000)}s`,
903
+ throttle: delay,
904
+ }),
905
+ )
906
+ return
907
+ }
700
908
 
701
- if (msg.get) get(msg as never, send)
702
- if (msg.put) await put(msg as never, send)
703
- send(data.toString(), isBinary)
909
+ const processMessage = async (): Promise<void> => {
910
+ dup.track(msg["#"]!)
704
911
 
705
- const id = msg["@"]
706
- const cb = queue[id!]
707
- if (cb) {
708
- delete (msg as { "#"?: string })["#"]
709
- delete (msg as { "@"?: string })["@"]
710
- cb(msg)
711
- delete queue[id!]
712
- }
713
- }
912
+ if (msg.get) get(msg as never, send)
913
+ if (msg.put) await put(msg as never, send)
914
+ send(data.toString(), isBinary)
714
915
 
715
- if (delay > 0) {
716
- setTimeout(processMessage, delay)
717
- } else {
718
- processMessage()
916
+ const id = msg["@"]
917
+ const cb = queue[id!]
918
+ if (cb) {
919
+ delete (msg as {"#"?: string})["#"]
920
+ delete (msg as {"@"?: string})["@"]
921
+ cb(msg)
922
+ delete queue[id!]
719
923
  }
720
- }) as (...args: unknown[]) => void)
721
- }
722
- )
924
+ }
925
+
926
+ if (delay > 0) {
927
+ setTimeout(processMessage, delay)
928
+ } else {
929
+ processMessage()
930
+ }
931
+ }) as (...args: unknown[]) => void)
932
+ })
723
933
  return api(send)
724
934
  }
725
935
 
@@ -732,18 +942,22 @@ const Wire = (opt: HolsterOptions): WireAPI => {
732
942
  let queueProcessor: NodeJS.Timeout | null = null
733
943
  const maxQueueLength = options.maxQueueLength || 10000
734
944
 
735
- const startResponseTimeout = (trackId: string, lex: Lex, delay = isTestEnv ? 100 : 10000): ReturnType<typeof setTimeout> => {
945
+ const startResponseTimeout = (
946
+ trackId: string,
947
+ lex: Lex,
948
+ delay = isTestEnv ? 100 : 10000,
949
+ ): ReturnType<typeof setTimeout> => {
736
950
  return setTimeout(() => {
737
951
  offlineTimeouts.delete(trackId)
738
952
  pendingTimeouts.delete(trackId)
739
953
  const cb = queue[trackId]
740
954
  if (cb) {
741
955
  const id = lex["#"]
742
- const ack: Graph = { [id]: null as never }
956
+ const ack: Graph = {[id]: null as never}
743
957
  if (typeof lex["."] === "string") {
744
- ack[id] = { [lex["."]]: null } as never
958
+ ack[id] = {[lex["."]]: null} as never
745
959
  }
746
- cb({ put: ack })
960
+ cb({put: ack})
747
961
  delete queue[trackId]
748
962
  }
749
963
  }, delay)
@@ -757,7 +971,10 @@ const Wire = (opt: HolsterOptions): WireAPI => {
757
971
 
758
972
  const now = Date.now()
759
973
  if (clientThrottled && now < throttleUntil) {
760
- queueProcessor = setTimeout(processQueue, Math.min(1000, throttleUntil - now))
974
+ queueProcessor = setTimeout(
975
+ processQueue,
976
+ Math.min(1000, throttleUntil - now),
977
+ )
761
978
  return
762
979
  }
763
980
 
@@ -775,7 +992,8 @@ const Wire = (opt: HolsterOptions): WireAPI => {
775
992
 
776
993
  // Drop GET messages with no pending callback — these were already served
777
994
  // from local store so no caller is waiting for the wire response.
778
- const isStaleCheckMessage = !sent && !!msg.get && !pendingTimeouts.has(trackId!)
995
+ const isStaleCheckMessage =
996
+ !sent && !!msg.get && !pendingTimeouts.has(trackId!)
779
997
 
780
998
  if (sent || isStaleCheckMessage) {
781
999
  messageQueue.shift()
@@ -833,7 +1051,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
833
1051
  return sentToAtLeastOne
834
1052
  }
835
1053
 
836
- const send = (data: string): { err?: string } | void => {
1054
+ const send = (data: string): {err?: string} | void => {
837
1055
  if (messageQueue.length >= maxQueueLength) {
838
1056
  return {
839
1057
  err: `Message queue exceeded maximum length (${maxQueueLength}). Update query logic to request less data.`,
@@ -866,7 +1084,8 @@ const Wire = (opt: HolsterOptions): WireAPI => {
866
1084
  const delay = isTestEnv ? 50 : retryHandler.getDelay()
867
1085
  retryHandler.increment()
868
1086
  const t = setTimeout(start, delay)
869
- if ((t as unknown as { unref?: () => void }).unref) (t as unknown as { unref: () => void }).unref()
1087
+ if ((t as unknown as {unref?: () => void}).unref)
1088
+ (t as unknown as {unref: () => void}).unref()
870
1089
  }
871
1090
  }
872
1091
 
@@ -910,7 +1129,12 @@ const Wire = (opt: HolsterOptions): WireAPI => {
910
1129
  for (const [soul, node] of Object.entries(msg.put)) {
911
1130
  const known = await hasSoul(soul)
912
1131
  hasSoulMap.set(soul, known)
913
- if ((!known && !pendingReferences.has(soul)) || !node || typeof node !== "object") continue
1132
+ if (
1133
+ (!known && !pendingReferences.has(soul)) ||
1134
+ !node ||
1135
+ typeof node !== "object"
1136
+ )
1137
+ continue
914
1138
  for (const [, value] of Object.entries(node)) {
915
1139
  const soulId = utils.rel.is(value as GraphValue)
916
1140
  if (soulId) pendingReferences.add(soulId)
@@ -939,7 +1163,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
939
1163
  // Store filtered data if any
940
1164
  if (Object.keys(filteredPut).length > 0) {
941
1165
  // Create filtered message for Ham.mix with original message ID
942
- const filteredMsg = { put: filteredPut, "#": msg["#"]! }
1166
+ const filteredMsg = {put: filteredPut, "#": msg["#"]!}
943
1167
  await put(filteredMsg, send as never)
944
1168
  }
945
1169
 
@@ -952,8 +1176,8 @@ const Wire = (opt: HolsterOptions): WireAPI => {
952
1176
  const id = msg["@"]
953
1177
  const cb = queue[id!]
954
1178
  if (cb) {
955
- delete (msg as { "#"?: string })["#"]
956
- delete (msg as { "@"?: string })["@"]
1179
+ delete (msg as {"#"?: string})["#"]
1180
+ delete (msg as {"@"?: string})["@"]
957
1181
  cb(msg)
958
1182
  delete queue[id!]
959
1183
  }
@@ -966,4 +1190,3 @@ const Wire = (opt: HolsterOptions): WireAPI => {
966
1190
  }
967
1191
 
968
1192
  export default Wire
969
-