@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/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 = JSON.stringify(node).length
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,23 @@ 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 = JSON.stringify(node).length
709
+ userStorage.set(pub, Math.max(0, current - oldBytes + newBytes))
710
+ saveUserStorage()
711
+ }
712
+ }
713
+
516
714
  for (const [_soul, node] of Object.entries(update.now)) {
517
715
  if (node && typeof node === "object") {
518
716
  for (const [_key, value] of Object.entries(node)) {
@@ -533,7 +731,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
533
731
  JSON.stringify({
534
732
  "#": dup.track(utils.text.random(9)),
535
733
  put: data,
536
- })
734
+ }),
537
735
  )
538
736
  if (sendResult && sendResult.err) {
539
737
  if (cb) cb(sendResult.err)
@@ -542,15 +740,15 @@ const Wire = (opt: HolsterOptions): WireAPI => {
542
740
  },
543
741
  on: (lex, cb, _get, _opt) => {
544
742
  if (lex && typeof lex["."] === "number") {
545
- lex = { ...lex, ".": String(lex["."]) }
743
+ lex = {...lex, ".": String(lex["."])}
546
744
  }
547
745
  const soul = lex && lex["#"]
548
746
  if (!soul || !cb) return
549
747
 
550
748
  if (listen[soul]) {
551
- listen[soul]!.push({ ".": lex["."], cb: cb as never })
749
+ listen[soul]!.push({".": lex["."], cb: cb as never})
552
750
  } else {
553
- listen[soul] = [{ ".": lex["."], cb: cb as never }]
751
+ listen[soul] = [{".": lex["."], cb: cb as never}]
554
752
  }
555
753
  if (_get) getWithCallback(lex, cb as never, send, _opt)
556
754
  },
@@ -573,13 +771,14 @@ const Wire = (opt: HolsterOptions): WireAPI => {
573
771
  if (isNode && (options.wss || options.server || options.port != null)) {
574
772
  let wss = options.wss
575
773
  let clients = (): WebSocket[] =>
576
- (wss as { clients?: () => WebSocket[] }).clients?.() || []
774
+ (wss as {clients?: () => WebSocket[]}).clients?.() || []
577
775
  if (!wss) {
578
776
  const config = options.server
579
- ? { server: options.server }
580
- : { port: options.port }
777
+ ? {server: options.server}
778
+ : {port: options.port}
581
779
  wss = new wsModule!.WebSocketServer(config)
582
- clients = () => Array.from((wss as { clients: Set<WebSocket> }).clients || [])
780
+ clients = () =>
781
+ Array.from((wss as {clients: Set<WebSocket>}).clients || [])
583
782
  }
584
783
 
585
784
  const send = (data: string, isBinary?: boolean): void => {
@@ -589,24 +788,27 @@ const Wire = (opt: HolsterOptions): WireAPI => {
589
788
  const lex = pendingTimeouts.get(trackId)!
590
789
  pendingTimeouts.delete(trackId)
591
790
 
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
791
+ setTimeout(
792
+ () => {
793
+ const cb = queue[trackId]
794
+ if (cb) {
795
+ const id = lex["#"]
796
+ const ack: Graph = {[id]: null as never}
797
+ if (typeof lex["."] === "string") {
798
+ ack[id] = {[lex["."]]: null} as never
799
+ }
800
+ cb({put: ack})
801
+ delete queue[trackId]
599
802
  }
600
- cb({ put: ack })
601
- delete queue[trackId]
602
- }
603
- }, isTestEnv ? 100 : 10000)
803
+ },
804
+ isTestEnv ? 100 : 10000,
805
+ )
604
806
  }
605
807
 
606
808
  clients().forEach(client => {
607
809
  const ws = client as UnifiedWebSocket
608
810
  if (ws && ws.readyState === WebSocket.OPEN) {
609
- ws.send(data, { binary: isBinary })
811
+ ws.send(data, {binary: isBinary})
610
812
  } else if (ws) {
611
813
  const retryHandler = ws._retryHandler || createRetryHandler()
612
814
  ws._retryHandler = retryHandler
@@ -618,7 +820,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
618
820
  setTimeout(() => {
619
821
  if (ws && ws.readyState === WebSocket.OPEN) {
620
822
  retryHandler.reset()
621
- ws.send(data, { binary: isBinary })
823
+ ws.send(data, {binary: isBinary})
622
824
  }
623
825
  }, delay)
624
826
  }
@@ -626,100 +828,106 @@ const Wire = (opt: HolsterOptions): WireAPI => {
626
828
  })
627
829
  }
628
830
 
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)
831
+ if (options.userLimit) {
832
+ loadUserStorage(() => loadUserLimits(() => logStorageSummary()))
833
+ setInterval(loadUserLimits, 60000).unref()
834
+ }
646
835
 
647
- ws.on?.("close", (() => {
648
- console.log(`WebSocket client disconnected: ${clientId}`)
649
- connectionManager.remove(ws)
650
- }) as (...args: unknown[]) => void)
836
+ ;(
837
+ wss as unknown as {
838
+ on: (event: string, cb: (ws: UnifiedWebSocket) => void) => void
839
+ }
840
+ ).on("connection", (ws: UnifiedWebSocket) => {
841
+ if (!connectionManager.add(ws)) {
842
+ console.log("Connection limit reached, rejecting connection")
843
+ ws.close(1013, "Connection limit reached - try again later")
844
+ return
845
+ }
651
846
 
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
- }
847
+ const clientId = utils.text.random(9)
848
+ console.log(`New WebSocket client connected: ${clientId}`)
849
+
850
+ ws.on?.("error", ((...args: unknown[]) => {
851
+ const error = args[0] as Error
852
+ console.log("WebSocket error:", error)
853
+ connectionManager.remove(ws)
854
+ }) as (...args: unknown[]) => void)
855
+
856
+ ws.on?.("close", (() => {
857
+ console.log(`WebSocket client disconnected: ${clientId}`)
858
+ connectionManager.remove(ws)
859
+ }) as (...args: unknown[]) => void)
860
+
861
+ ws.on?.("message", ((...args: unknown[]) => {
862
+ const data = args[0] as Buffer
863
+ const isBinary = args[1] as boolean
864
+ const validation = validateMessage(data, options.maxMessageSize)
865
+ if (!validation.valid) {
866
+ console.warn(`Invalid message: ${validation.error}`)
867
+ ws.send(JSON.stringify({error: validation.error}))
868
+ return
869
+ }
661
870
 
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
- }
871
+ const parseResult = safeJSONParse(data, options.maxMessageSize)
872
+ if (!parseResult.success) {
873
+ console.warn(`JSON parse error: ${parseResult.error}`)
874
+ ws.send(JSON.stringify({error: "Invalid JSON"}))
875
+ return
876
+ }
668
877
 
669
- const msg = parseResult.data!
670
- if (!msg["#"]) return // Incoming messages must have '#'
671
- if (dup.check(msg["#"])) return
878
+ const msg = parseResult.data!
879
+ if (!msg["#"]) return // Incoming messages must have '#'
880
+ if (dup.check(msg["#"])) return
672
881
 
673
- const delay = rateLimiter.getDelay(clientId)
674
- if (delay > 0) {
675
- const throttleCount = rateLimiter.getThrottleCount(clientId)
882
+ const delay = rateLimiter.getDelay(clientId)
883
+ if (delay > 0) {
884
+ const throttleCount = rateLimiter.getThrottleCount(clientId)
885
+ console.log(
886
+ `Client ${clientId}: rate limit exceeded, delay would be ${delay}ms, dropping message (throttle count: ${throttleCount})`,
887
+ )
888
+ if (rateLimiter.shouldDisconnect(clientId)) {
676
889
  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
- })
890
+ `Client ${clientId}: Disconnecting after ${throttleCount} throttle violations`,
694
891
  )
892
+ ws.close(1008, "Rate limit violations")
695
893
  return
696
894
  }
697
895
 
698
- const processMessage = async (): Promise<void> => {
699
- dup.track(msg["#"]!)
896
+ ws.send(
897
+ JSON.stringify({
898
+ "#": dup.track(utils.text.random(9)),
899
+ "@": msg["#"],
900
+ err: `Rate limit exceeded. Slow down requests. Wait ${Math.ceil(delay / 1000)}s`,
901
+ throttle: delay,
902
+ }),
903
+ )
904
+ return
905
+ }
700
906
 
701
- if (msg.get) get(msg as never, send)
702
- if (msg.put) await put(msg as never, send)
703
- send(data.toString(), isBinary)
907
+ const processMessage = async (): Promise<void> => {
908
+ dup.track(msg["#"]!)
704
909
 
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
- }
910
+ if (msg.get) get(msg as never, send)
911
+ if (msg.put) await put(msg as never, send)
912
+ send(data.toString(), isBinary)
714
913
 
715
- if (delay > 0) {
716
- setTimeout(processMessage, delay)
717
- } else {
718
- processMessage()
914
+ const id = msg["@"]
915
+ const cb = queue[id!]
916
+ if (cb) {
917
+ delete (msg as {"#"?: string})["#"]
918
+ delete (msg as {"@"?: string})["@"]
919
+ cb(msg)
920
+ delete queue[id!]
719
921
  }
720
- }) as (...args: unknown[]) => void)
721
- }
722
- )
922
+ }
923
+
924
+ if (delay > 0) {
925
+ setTimeout(processMessage, delay)
926
+ } else {
927
+ processMessage()
928
+ }
929
+ }) as (...args: unknown[]) => void)
930
+ })
723
931
  return api(send)
724
932
  }
725
933
 
@@ -732,18 +940,22 @@ const Wire = (opt: HolsterOptions): WireAPI => {
732
940
  let queueProcessor: NodeJS.Timeout | null = null
733
941
  const maxQueueLength = options.maxQueueLength || 10000
734
942
 
735
- const startResponseTimeout = (trackId: string, lex: Lex, delay = isTestEnv ? 100 : 10000): ReturnType<typeof setTimeout> => {
943
+ const startResponseTimeout = (
944
+ trackId: string,
945
+ lex: Lex,
946
+ delay = isTestEnv ? 100 : 10000,
947
+ ): ReturnType<typeof setTimeout> => {
736
948
  return setTimeout(() => {
737
949
  offlineTimeouts.delete(trackId)
738
950
  pendingTimeouts.delete(trackId)
739
951
  const cb = queue[trackId]
740
952
  if (cb) {
741
953
  const id = lex["#"]
742
- const ack: Graph = { [id]: null as never }
954
+ const ack: Graph = {[id]: null as never}
743
955
  if (typeof lex["."] === "string") {
744
- ack[id] = { [lex["."]]: null } as never
956
+ ack[id] = {[lex["."]]: null} as never
745
957
  }
746
- cb({ put: ack })
958
+ cb({put: ack})
747
959
  delete queue[trackId]
748
960
  }
749
961
  }, delay)
@@ -757,7 +969,10 @@ const Wire = (opt: HolsterOptions): WireAPI => {
757
969
 
758
970
  const now = Date.now()
759
971
  if (clientThrottled && now < throttleUntil) {
760
- queueProcessor = setTimeout(processQueue, Math.min(1000, throttleUntil - now))
972
+ queueProcessor = setTimeout(
973
+ processQueue,
974
+ Math.min(1000, throttleUntil - now),
975
+ )
761
976
  return
762
977
  }
763
978
 
@@ -775,7 +990,8 @@ const Wire = (opt: HolsterOptions): WireAPI => {
775
990
 
776
991
  // Drop GET messages with no pending callback — these were already served
777
992
  // from local store so no caller is waiting for the wire response.
778
- const isStaleCheckMessage = !sent && !!msg.get && !pendingTimeouts.has(trackId!)
993
+ const isStaleCheckMessage =
994
+ !sent && !!msg.get && !pendingTimeouts.has(trackId!)
779
995
 
780
996
  if (sent || isStaleCheckMessage) {
781
997
  messageQueue.shift()
@@ -833,7 +1049,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
833
1049
  return sentToAtLeastOne
834
1050
  }
835
1051
 
836
- const send = (data: string): { err?: string } | void => {
1052
+ const send = (data: string): {err?: string} | void => {
837
1053
  if (messageQueue.length >= maxQueueLength) {
838
1054
  return {
839
1055
  err: `Message queue exceeded maximum length (${maxQueueLength}). Update query logic to request less data.`,
@@ -866,7 +1082,8 @@ const Wire = (opt: HolsterOptions): WireAPI => {
866
1082
  const delay = isTestEnv ? 50 : retryHandler.getDelay()
867
1083
  retryHandler.increment()
868
1084
  const t = setTimeout(start, delay)
869
- if ((t as unknown as { unref?: () => void }).unref) (t as unknown as { unref: () => void }).unref()
1085
+ if ((t as unknown as {unref?: () => void}).unref)
1086
+ (t as unknown as {unref: () => void}).unref()
870
1087
  }
871
1088
  }
872
1089
 
@@ -910,7 +1127,12 @@ const Wire = (opt: HolsterOptions): WireAPI => {
910
1127
  for (const [soul, node] of Object.entries(msg.put)) {
911
1128
  const known = await hasSoul(soul)
912
1129
  hasSoulMap.set(soul, known)
913
- if ((!known && !pendingReferences.has(soul)) || !node || typeof node !== "object") continue
1130
+ if (
1131
+ (!known && !pendingReferences.has(soul)) ||
1132
+ !node ||
1133
+ typeof node !== "object"
1134
+ )
1135
+ continue
914
1136
  for (const [, value] of Object.entries(node)) {
915
1137
  const soulId = utils.rel.is(value as GraphValue)
916
1138
  if (soulId) pendingReferences.add(soulId)
@@ -939,7 +1161,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
939
1161
  // Store filtered data if any
940
1162
  if (Object.keys(filteredPut).length > 0) {
941
1163
  // Create filtered message for Ham.mix with original message ID
942
- const filteredMsg = { put: filteredPut, "#": msg["#"]! }
1164
+ const filteredMsg = {put: filteredPut, "#": msg["#"]!}
943
1165
  await put(filteredMsg, send as never)
944
1166
  }
945
1167
 
@@ -952,8 +1174,8 @@ const Wire = (opt: HolsterOptions): WireAPI => {
952
1174
  const id = msg["@"]
953
1175
  const cb = queue[id!]
954
1176
  if (cb) {
955
- delete (msg as { "#"?: string })["#"]
956
- delete (msg as { "@"?: string })["@"]
1177
+ delete (msg as {"#"?: string})["#"]
1178
+ delete (msg as {"@"?: string})["@"]
957
1179
  cb(msg)
958
1180
  delete queue[id!]
959
1181
  }
@@ -966,4 +1188,3 @@ const Wire = (opt: HolsterOptions): WireAPI => {
966
1188
  }
967
1189
 
968
1190
  export default Wire
969
-