@mblaney/holster 2.0.1 → 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 = options.wss && (options.wss as { constructor?: { name?: string } }).constructor?.name === "Server"
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,62 +417,109 @@ 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
- store.get(
307
- msg.get,
308
- (err, ack) => {
309
- send(
310
- JSON.stringify({
311
- "#": dup.track(utils.text.random(9)),
312
- "@": msg["#"],
313
- put: ack,
314
- err: err,
315
- })
316
- )
317
- },
318
- { secure: true }
319
- )
423
+ store.get(msg.get, (err, ack) => {
424
+ send(
425
+ JSON.stringify({
426
+ "#": dup.track(utils.text.random(9)),
427
+ "@": msg["#"],
428
+ put: ack,
429
+ err: err,
430
+ }),
431
+ )
432
+ })
320
433
  }
321
434
  }
322
435
 
323
436
  const put = async (
324
- msg: { put: Graph; "#": string },
325
- send: (msg: string) => void
437
+ msg: {put: Graph; "#": string},
438
+ send: (msg: string) => void,
326
439
  ): Promise<void> => {
327
- 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
+ )
328
458
  if (Object.keys(update.now).length === 0) {
329
459
  if (Object.keys(update.defer).length !== 0) {
330
- setTimeout(() => put({ put: update.defer, "#": msg["#"] }, send), update.wait)
460
+ setTimeout(
461
+ () => put({put: update.defer, "#": msg["#"]}, send),
462
+ update.wait,
463
+ )
331
464
  }
332
465
  return
333
466
  }
334
467
 
335
468
  if (!(await check(update.now, send as never))) return
336
469
 
337
- store.put(update.now, err => {
470
+ const finish = (err?: string | null): void => {
338
471
  if (err) console.warn("store.put", err)
339
472
  send(
340
473
  JSON.stringify({
341
474
  "#": dup.track(utils.text.random(9)),
342
475
  "@": msg["#"],
343
476
  err: err,
344
- })
477
+ }),
345
478
  )
346
479
  update.listeners.forEach(cb => cb())
347
- })
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
+ }
348
509
 
349
510
  if (Object.keys(update.defer).length !== 0) {
350
- setTimeout(() => put({ put: update.defer, "#": msg["#"] }, send), update.wait)
511
+ setTimeout(
512
+ () => put({put: update.defer, "#": msg["#"]}, send),
513
+ update.wait,
514
+ )
351
515
  }
352
516
  }
353
517
 
354
518
  const getWithCallback = (
355
519
  lex: Lex,
356
520
  cb: (msg: WireMessage) => void,
357
- send: (msg: string) => { err?: string } | void,
358
- _opt?: WireOptions
521
+ send: (msg: string) => {err?: string} | void,
522
+ _opt?: WireOptions,
359
523
  ): void => {
360
524
  if (!cb) return
361
525
 
@@ -381,7 +545,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
381
545
  recentChecks.set(checkKey, now)
382
546
  const sendResult = send(request)
383
547
  if (sendResult && sendResult.err) {
384
- cb({ err: sendResult.err })
548
+ cb({err: sendResult.err})
385
549
  return
386
550
  }
387
551
  }
@@ -389,119 +553,115 @@ const Wire = (opt: HolsterOptions): WireAPI => {
389
553
  // Don't skip checks for other types of lex queries.
390
554
  const sendResult = send(request)
391
555
  if (sendResult && sendResult.err) {
392
- cb({ err: sendResult.err })
556
+ cb({err: sendResult.err})
393
557
  return
394
558
  }
395
559
  }
396
560
  }
397
- cb({ put: ack })
561
+ cb({put: ack})
398
562
  return
399
563
  }
400
564
 
401
- store.get(
402
- lex,
403
- (err, ack) => {
404
- if (ack) {
405
- // Populate in-memory graph from store data before sending the server
406
- // request, so stale server responses with older state are correctly
407
- // rejected by ham.mix.
408
- for (const [soul, node] of Object.entries(ack)) {
409
- if (!node) continue
410
- if (!graph[soul]) {
411
- graph[soul] = {...node} as unknown as Graph[string]
412
- continue
413
- }
414
- for (const [key, value] of Object.entries(node)) {
415
- if (key === "_") continue
416
- const gNode = graph[soul] as unknown as Record<string, unknown>
417
- if (typeof gNode[key] !== "undefined") continue
418
- gNode[key] = value
419
- const meta = (node as unknown as GraphNode)._
420
- if (meta?.[">"] && typeof meta[">"][key] !== "undefined") {
421
- const state = meta[">"][key]
422
- const gMeta = (graph[soul] as unknown as GraphNode)._
423
- gMeta[">"][key] = state
424
- if (meta["s"]?.[state]) {
425
- if (!gMeta["s"]) gMeta["s"] = {}
426
- gMeta["s"]![state] = meta["s"][state]
427
- }
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]
428
589
  }
429
590
  }
430
591
  }
431
- // Full-node fetch: the graph is now complete for this soul.
432
- if (!lex["."] && ack[lex["#"]]) hasNode.add(lex["#"])
433
- // Only serve from store if the requested property is present.
434
- const node = ack[lex["#"]]
435
- const hasProperty =
436
- node &&
437
- Object.keys(node).some(
438
- key => key !== "_" && utils.match(lex["."], key),
439
- )
440
- if (hasProperty) {
441
- // Public keys are immutable once written — never need a wire check.
442
- if (!isNode && lex["."] !== utils.userPublicKey) {
443
- if (!lex["."] || typeof lex["."] === "string") {
444
- const checkKey = lex["#"] + (lex["."] ? "." + lex["."] : "")
445
- const now = Date.now()
446
- const lastCheck = recentChecks.get(checkKey)
447
- if (!lastCheck || now - lastCheck > recentCheckTTL) {
448
- recentChecks.set(checkKey, now)
449
- const sendResult = send(request)
450
- if (sendResult && sendResult.err) {
451
- cb({ err: sendResult.err })
452
- return
453
- }
454
- }
455
- } 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)
456
611
  const sendResult = send(request)
457
612
  if (sendResult && sendResult.err) {
458
- cb({ err: sendResult.err })
613
+ cb({err: sendResult.err})
459
614
  return
460
615
  }
461
616
  }
617
+ } else {
618
+ const sendResult = send(request)
619
+ if (sendResult && sendResult.err) {
620
+ cb({err: sendResult.err})
621
+ return
622
+ }
462
623
  }
463
- cb(err ? { put: ack, err: err } : { put: ack })
464
- return
465
624
  }
625
+ cb(err ? {put: ack, err: err} : {put: ack})
626
+ return
466
627
  }
628
+ }
467
629
 
468
- if (err) console.log(err)
630
+ if (err) console.log(err)
469
631
 
470
- // Put requests only need a local answer — skip the wire round-trip.
471
- if (opts && opts.put) {
472
- const id = lex["#"]
473
- const ack: Record<string, null | Record<string, null>> = {[id]: null}
474
- if (typeof lex["."] === "string") {
475
- ack[id] = {[lex["."]]: null}
476
- }
477
- cb({put: ack as never})
478
- 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}
479
638
  }
639
+ cb({put: ack as never})
640
+ return
641
+ }
480
642
 
481
- queue[track] = cb
643
+ queue[track] = cb
482
644
 
483
- pendingTimeouts.set(track, lex)
645
+ pendingTimeouts.set(track, lex)
484
646
 
485
- // Ensure the requested soul is stored when the response arrives.
486
- pendingReferences.add(lex["#"])
647
+ // Ensure the requested soul is stored when the response arrives.
648
+ pendingReferences.add(lex["#"])
487
649
 
488
- const sendResult = send(request)
489
- if (sendResult && sendResult.err) {
490
- cb({ err: sendResult.err })
491
- delete queue[track]
492
- pendingTimeouts.delete(track)
493
- return
494
- }
495
- },
496
- opts
497
- )
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
+ })
498
658
  }
499
659
 
500
- const api = (send: (msg: string) => { err?: string } | void): WireAPI => {
660
+ const api = (send: (msg: string) => {err?: string} | void): WireAPI => {
501
661
  return {
502
662
  get: (lex, cb, _opt) => {
503
663
  if (lex && typeof lex["."] === "number") {
504
- lex = { ...lex, ".": String(lex["."]) }
664
+ lex = {...lex, ".": String(lex["."])}
505
665
  }
506
666
  if (lex && lex["#"]) {
507
667
  pendingReferences.add(lex["#"])
@@ -509,7 +669,22 @@ const Wire = (opt: HolsterOptions): WireAPI => {
509
669
  getWithCallback(lex, cb, send, _opt)
510
670
  },
511
671
  put: async (data, cb) => {
512
- 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
+ )
513
688
  if (Object.keys(update.now).length === 0) {
514
689
  if (cb) cb(null)
515
690
  return
@@ -519,6 +694,23 @@ const Wire = (opt: HolsterOptions): WireAPI => {
519
694
  return
520
695
  }
521
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
+
522
714
  for (const [_soul, node] of Object.entries(update.now)) {
523
715
  if (node && typeof node === "object") {
524
716
  for (const [_key, value] of Object.entries(node)) {
@@ -539,7 +731,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
539
731
  JSON.stringify({
540
732
  "#": dup.track(utils.text.random(9)),
541
733
  put: data,
542
- })
734
+ }),
543
735
  )
544
736
  if (sendResult && sendResult.err) {
545
737
  if (cb) cb(sendResult.err)
@@ -548,15 +740,15 @@ const Wire = (opt: HolsterOptions): WireAPI => {
548
740
  },
549
741
  on: (lex, cb, _get, _opt) => {
550
742
  if (lex && typeof lex["."] === "number") {
551
- lex = { ...lex, ".": String(lex["."]) }
743
+ lex = {...lex, ".": String(lex["."])}
552
744
  }
553
745
  const soul = lex && lex["#"]
554
746
  if (!soul || !cb) return
555
747
 
556
748
  if (listen[soul]) {
557
- listen[soul]!.push({ ".": lex["."], cb: cb as never })
749
+ listen[soul]!.push({".": lex["."], cb: cb as never})
558
750
  } else {
559
- listen[soul] = [{ ".": lex["."], cb: cb as never }]
751
+ listen[soul] = [{".": lex["."], cb: cb as never}]
560
752
  }
561
753
  if (_get) getWithCallback(lex, cb as never, send, _opt)
562
754
  },
@@ -579,13 +771,14 @@ const Wire = (opt: HolsterOptions): WireAPI => {
579
771
  if (isNode && (options.wss || options.server || options.port != null)) {
580
772
  let wss = options.wss
581
773
  let clients = (): WebSocket[] =>
582
- (wss as { clients?: () => WebSocket[] }).clients?.() || []
774
+ (wss as {clients?: () => WebSocket[]}).clients?.() || []
583
775
  if (!wss) {
584
776
  const config = options.server
585
- ? { server: options.server }
586
- : { port: options.port }
777
+ ? {server: options.server}
778
+ : {port: options.port}
587
779
  wss = new wsModule!.WebSocketServer(config)
588
- clients = () => Array.from((wss as { clients: Set<WebSocket> }).clients || [])
780
+ clients = () =>
781
+ Array.from((wss as {clients: Set<WebSocket>}).clients || [])
589
782
  }
590
783
 
591
784
  const send = (data: string, isBinary?: boolean): void => {
@@ -595,24 +788,27 @@ const Wire = (opt: HolsterOptions): WireAPI => {
595
788
  const lex = pendingTimeouts.get(trackId)!
596
789
  pendingTimeouts.delete(trackId)
597
790
 
598
- setTimeout(() => {
599
- const cb = queue[trackId]
600
- if (cb) {
601
- const id = lex["#"]
602
- const ack: Graph = { [id]: null as never }
603
- if (typeof lex["."] === "string") {
604
- 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]
605
802
  }
606
- cb({ "#": trackId, put: ack })
607
- delete queue[trackId]
608
- }
609
- }, isTestEnv ? 100 : 10000)
803
+ },
804
+ isTestEnv ? 100 : 10000,
805
+ )
610
806
  }
611
807
 
612
808
  clients().forEach(client => {
613
809
  const ws = client as UnifiedWebSocket
614
810
  if (ws && ws.readyState === WebSocket.OPEN) {
615
- ws.send(data, { binary: isBinary })
811
+ ws.send(data, {binary: isBinary})
616
812
  } else if (ws) {
617
813
  const retryHandler = ws._retryHandler || createRetryHandler()
618
814
  ws._retryHandler = retryHandler
@@ -624,7 +820,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
624
820
  setTimeout(() => {
625
821
  if (ws && ws.readyState === WebSocket.OPEN) {
626
822
  retryHandler.reset()
627
- ws.send(data, { binary: isBinary })
823
+ ws.send(data, {binary: isBinary})
628
824
  }
629
825
  }, delay)
630
826
  }
@@ -632,100 +828,106 @@ const Wire = (opt: HolsterOptions): WireAPI => {
632
828
  })
633
829
  }
634
830
 
635
- ;(wss as unknown as { on: (event: string, cb: (ws: UnifiedWebSocket) => void) => void }).on(
636
- "connection",
637
- (ws: UnifiedWebSocket) => {
638
- if (!connectionManager.add(ws)) {
639
- console.log("Connection limit reached, rejecting connection")
640
- ws.close(1013, "Connection limit reached - try again later")
641
- return
642
- }
643
-
644
- const clientId = utils.text.random(9)
645
- console.log(`New WebSocket client connected: ${clientId}`)
646
-
647
- ws.on?.("error", ((...args: unknown[]) => {
648
- const error = args[0] as Error
649
- console.log("WebSocket error:", error)
650
- connectionManager.remove(ws)
651
- }) as (...args: unknown[]) => void)
831
+ if (options.userLimit) {
832
+ loadUserStorage(() => loadUserLimits(() => logStorageSummary()))
833
+ setInterval(loadUserLimits, 60000).unref()
834
+ }
652
835
 
653
- ws.on?.("close", (() => {
654
- console.log(`WebSocket client disconnected: ${clientId}`)
655
- connectionManager.remove(ws)
656
- }) 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
+ }
657
846
 
658
- ws.on?.("message", ((...args: unknown[]) => {
659
- const data = args[0] as Buffer
660
- const isBinary = args[1] as boolean
661
- const validation = validateMessage(data, options.maxMessageSize)
662
- if (!validation.valid) {
663
- console.warn(`Invalid message: ${validation.error}`)
664
- ws.send(JSON.stringify({ error: validation.error }))
665
- return
666
- }
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
+ }
667
870
 
668
- const parseResult = safeJSONParse(data, options.maxMessageSize)
669
- if (!parseResult.success) {
670
- console.warn(`JSON parse error: ${parseResult.error}`)
671
- ws.send(JSON.stringify({ error: "Invalid JSON" }))
672
- return
673
- }
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
+ }
674
877
 
675
- const msg = parseResult.data!
676
- if (!msg["#"]) return // Incoming messages must have '#'
677
- if (dup.check(msg["#"])) return
878
+ const msg = parseResult.data!
879
+ if (!msg["#"]) return // Incoming messages must have '#'
880
+ if (dup.check(msg["#"])) return
678
881
 
679
- const delay = rateLimiter.getDelay(clientId)
680
- if (delay > 0) {
681
- 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)) {
682
889
  console.log(
683
- `Client ${clientId}: rate limit exceeded, delay would be ${delay}ms, dropping message (throttle count: ${throttleCount})`
684
- )
685
- if (rateLimiter.shouldDisconnect(clientId)) {
686
- console.log(
687
- `Client ${clientId}: Disconnecting after ${throttleCount} throttle violations`
688
- )
689
- ws.close(1008, "Rate limit violations")
690
- return
691
- }
692
-
693
- ws.send(
694
- JSON.stringify({
695
- "#": dup.track(utils.text.random(9)),
696
- "@": msg["#"],
697
- err: `Rate limit exceeded. Slow down requests. Wait ${Math.ceil(delay / 1000)}s`,
698
- throttle: delay,
699
- })
890
+ `Client ${clientId}: Disconnecting after ${throttleCount} throttle violations`,
700
891
  )
892
+ ws.close(1008, "Rate limit violations")
701
893
  return
702
894
  }
703
895
 
704
- const processMessage = async (): Promise<void> => {
705
- 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
+ }
706
906
 
707
- if (msg.get) get(msg as never, send)
708
- if (msg.put) await put(msg as never, send)
709
- send(data.toString(), isBinary)
907
+ const processMessage = async (): Promise<void> => {
908
+ dup.track(msg["#"]!)
710
909
 
711
- const id = msg["@"]
712
- const cb = queue[id!]
713
- if (cb) {
714
- delete (msg as { "#"?: string })["#"]
715
- delete (msg as { "@"?: string })["@"]
716
- cb(msg)
717
- delete queue[id!]
718
- }
719
- }
910
+ if (msg.get) get(msg as never, send)
911
+ if (msg.put) await put(msg as never, send)
912
+ send(data.toString(), isBinary)
720
913
 
721
- if (delay > 0) {
722
- setTimeout(processMessage, delay)
723
- } else {
724
- 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!]
725
921
  }
726
- }) as (...args: unknown[]) => void)
727
- }
728
- )
922
+ }
923
+
924
+ if (delay > 0) {
925
+ setTimeout(processMessage, delay)
926
+ } else {
927
+ processMessage()
928
+ }
929
+ }) as (...args: unknown[]) => void)
930
+ })
729
931
  return api(send)
730
932
  }
731
933
 
@@ -734,21 +936,26 @@ const Wire = (opt: HolsterOptions): WireAPI => {
734
936
  let clientThrottled = false
735
937
  let throttleUntil = 0
736
938
  let messageQueue: string[] = []
939
+ let offlinePuts: string[] = []
737
940
  let queueProcessor: NodeJS.Timeout | null = null
738
941
  const maxQueueLength = options.maxQueueLength || 10000
739
942
 
740
- 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> => {
741
948
  return setTimeout(() => {
742
949
  offlineTimeouts.delete(trackId)
743
950
  pendingTimeouts.delete(trackId)
744
951
  const cb = queue[trackId]
745
952
  if (cb) {
746
953
  const id = lex["#"]
747
- const ack: Graph = { [id]: null as never }
954
+ const ack: Graph = {[id]: null as never}
748
955
  if (typeof lex["."] === "string") {
749
- ack[id] = { [lex["."]]: null } as never
956
+ ack[id] = {[lex["."]]: null} as never
750
957
  }
751
- cb({ "#": trackId, put: ack })
958
+ cb({put: ack})
752
959
  delete queue[trackId]
753
960
  }
754
961
  }, delay)
@@ -762,7 +969,10 @@ const Wire = (opt: HolsterOptions): WireAPI => {
762
969
 
763
970
  const now = Date.now()
764
971
  if (clientThrottled && now < throttleUntil) {
765
- queueProcessor = setTimeout(processQueue, Math.min(1000, throttleUntil - now))
972
+ queueProcessor = setTimeout(
973
+ processQueue,
974
+ Math.min(1000, throttleUntil - now),
975
+ )
766
976
  return
767
977
  }
768
978
 
@@ -780,10 +990,16 @@ const Wire = (opt: HolsterOptions): WireAPI => {
780
990
 
781
991
  // Drop GET messages with no pending callback — these were already served
782
992
  // from local store so no caller is waiting for the wire response.
783
- const isStaleCheckMessage = !sent && !!msg.get && !pendingTimeouts.has(trackId!)
993
+ const isStaleCheckMessage =
994
+ !sent && !!msg.get && !pendingTimeouts.has(trackId!)
784
995
 
785
996
  if (sent || isStaleCheckMessage) {
786
997
  messageQueue.shift()
998
+ } else if (!sent && msg.put) {
999
+ // Hold offline PUTs separately so they don't block GET messages behind
1000
+ // them. They'll be flushed back to the front of the queue on reconnect.
1001
+ messageQueue.shift()
1002
+ offlinePuts.push(data)
787
1003
  }
788
1004
 
789
1005
  if (trackId && pendingTimeouts.has(trackId)) {
@@ -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.`,
@@ -863,14 +1079,23 @@ const Wire = (opt: HolsterOptions): WireAPI => {
863
1079
  ws = null
864
1080
 
865
1081
  if (retryHandler.shouldRetry()) {
866
- const delay = retryHandler.getDelay()
1082
+ const delay = isTestEnv ? 50 : retryHandler.getDelay()
867
1083
  retryHandler.increment()
868
- setTimeout(start, delay)
1084
+ const t = setTimeout(start, delay)
1085
+ if ((t as unknown as {unref?: () => void}).unref)
1086
+ (t as unknown as {unref: () => void}).unref()
869
1087
  }
870
1088
  }
871
1089
 
872
1090
  ws.onopen = () => {
873
1091
  retryHandler.reset()
1092
+ if (offlinePuts.length > 0) {
1093
+ messageQueue = offlinePuts.concat(messageQueue)
1094
+ offlinePuts = []
1095
+ if (!queueProcessor) {
1096
+ queueProcessor = setTimeout(processQueue, 50)
1097
+ }
1098
+ }
874
1099
  }
875
1100
 
876
1101
  ws.onerror = (e: Event) => {
@@ -902,7 +1127,12 @@ const Wire = (opt: HolsterOptions): WireAPI => {
902
1127
  for (const [soul, node] of Object.entries(msg.put)) {
903
1128
  const known = await hasSoul(soul)
904
1129
  hasSoulMap.set(soul, known)
905
- 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
906
1136
  for (const [, value] of Object.entries(node)) {
907
1137
  const soulId = utils.rel.is(value as GraphValue)
908
1138
  if (soulId) pendingReferences.add(soulId)
@@ -931,7 +1161,7 @@ const Wire = (opt: HolsterOptions): WireAPI => {
931
1161
  // Store filtered data if any
932
1162
  if (Object.keys(filteredPut).length > 0) {
933
1163
  // Create filtered message for Ham.mix with original message ID
934
- const filteredMsg = { put: filteredPut, "#": msg["#"]! }
1164
+ const filteredMsg = {put: filteredPut, "#": msg["#"]!}
935
1165
  await put(filteredMsg, send as never)
936
1166
  }
937
1167
 
@@ -944,8 +1174,8 @@ const Wire = (opt: HolsterOptions): WireAPI => {
944
1174
  const id = msg["@"]
945
1175
  const cb = queue[id!]
946
1176
  if (cb) {
947
- delete (msg as { "#"?: string })["#"]
948
- delete (msg as { "@"?: string })["@"]
1177
+ delete (msg as {"#"?: string})["#"]
1178
+ delete (msg as {"@"?: string})["@"]
949
1179
  cb(msg)
950
1180
  delete queue[id!]
951
1181
  }
@@ -958,4 +1188,3 @@ const Wire = (opt: HolsterOptions): WireAPI => {
958
1188
  }
959
1189
 
960
1190
  export default Wire
961
-