@mblaney/holster 1.0.35 → 2.0.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.
Files changed (72) hide show
  1. package/build/holster.js +3 -3
  2. package/build/holster.js.map +3 -3
  3. package/dist/holster.js +4 -0
  4. package/dist/holster.js.map +7 -0
  5. package/package.json +22 -5
  6. package/src/dup.js +4 -5
  7. package/src/get.js +10 -19
  8. package/src/ham.js +22 -49
  9. package/src/holster.js +172 -58
  10. package/src/index.js +1 -1
  11. package/src/radisk.js +89 -86
  12. package/src/sea-utils.js +1 -1
  13. package/src/sea.js +0 -52
  14. package/src/store.js +8 -8
  15. package/src/user.js +0 -4
  16. package/src/wire.js +209 -79
  17. package/ts/src/array.ts +78 -0
  18. package/ts/src/buffer.ts +441 -0
  19. package/ts/src/dup.ts +38 -0
  20. package/ts/src/get.ts +83 -0
  21. package/ts/src/ham.ts +258 -0
  22. package/ts/src/holster.ts +968 -0
  23. package/ts/src/index.ts +35 -0
  24. package/ts/src/radisk.ts +591 -0
  25. package/ts/src/radix.ts +157 -0
  26. package/ts/src/schemas.ts +515 -0
  27. package/ts/src/sea-utils.ts +103 -0
  28. package/ts/src/sea.ts +388 -0
  29. package/ts/src/store.ts +321 -0
  30. package/ts/src/user.ts +377 -0
  31. package/ts/src/utils.ts +232 -0
  32. package/ts/src/wire.ts +961 -0
  33. package/.dockerignore +0 -9
  34. package/.github/workflows/node.js.yml +0 -31
  35. package/.github/workflows/npm-publish.yml +0 -33
  36. package/Dockerfile +0 -20
  37. package/examples/index.html +0 -33
  38. package/examples/index.js +0 -49
  39. package/examples/user.html +0 -31
  40. package/examples/user.js +0 -93
  41. package/examples/wire.html +0 -27
  42. package/examples/wire.js +0 -55
  43. package/test/holster.get.test.js +0 -94
  44. package/test/holster.lex.test.js +0 -196
  45. package/test/holster.off.test.js +0 -53
  46. package/test/holster.on.test.js +0 -264
  47. package/test/holster.put.test.js +0 -358
  48. package/test/holster.secure.test.js +0 -202
  49. package/test/holster.user.get.test.js +0 -114
  50. package/test/holster.user.lex.test.js +0 -149
  51. package/test/holster.user.off.test.js +0 -73
  52. package/test/holster.user.on.test.js +0 -300
  53. package/test/holster.user.put.test.js +0 -463
  54. package/test/kitty/! +0 -9
  55. package/test/names.js +0 -225
  56. package/test/radisk.cache.test.js +0 -213
  57. package/test/radisk.test.js +0 -331
  58. package/test/radix.test.js +0 -117
  59. package/test/sea.test.js +0 -284
  60. package/test/split.test.js +0 -317
  61. package/test/store.test.js +0 -316
  62. package/test/system/basic.test.js +0 -29
  63. package/test/system/chained-on.test.js +0 -41
  64. package/test/system/concurrent-listeners.test.js +0 -125
  65. package/test/system/immediate-read.test.js +0 -30
  66. package/test/system/listener-before-data.test.js +0 -51
  67. package/test/system/promise-wrapper.test.js +0 -47
  68. package/test/system/user-chained-on.test.js +0 -60
  69. package/test/system/user-concurrent-listeners.test.js +0 -142
  70. package/test/system/user-listener-before-data.test.js +0 -66
  71. package/test/user.test.js +0 -173
  72. package/test/wire.test.js +0 -158
package/ts/src/wire.ts ADDED
@@ -0,0 +1,961 @@
1
+ /**
2
+ * Wire - WebSocket-based network protocol
3
+ * Handles message routing, rate limiting, and graph synchronization
4
+ */
5
+
6
+ import Dup from "./dup.ts"
7
+ import Get from "./get.ts"
8
+ import Ham from "./ham.ts"
9
+ import Store from "./store.ts"
10
+ import * as utils from "./utils.ts"
11
+ import type {
12
+ Graph,
13
+ GraphNode,
14
+ WireMessage,
15
+ WireOptions,
16
+ Lex,
17
+ ListenMap,
18
+ HolsterOptions,
19
+ GraphValue,
20
+ } from "./schemas.ts"
21
+
22
+ // Unified WebSocket type that works for both Node.js ws and browser WebSocket
23
+ type UnifiedWebSocket = WebSocket & {
24
+ _retryHandler?: ReturnType<typeof createRetryHandler>
25
+ on?: (event: string, cb: (...args: unknown[]) => void) => void
26
+ send: (data: string | Buffer | ArrayBuffer | Uint8Array, options?: unknown, cb?: (err?: Error) => void) => void
27
+ }
28
+
29
+ const isNode = typeof process !== "undefined" && process.versions?.node != null
30
+
31
+ // Dynamic import for Node.js ws module - won't execute in browser/service worker
32
+ const wsModule = isNode ? await import("ws") : undefined
33
+
34
+ if (typeof globalThis.WebSocket === "undefined" && wsModule) {
35
+ globalThis.WebSocket = wsModule.WebSocket as unknown as typeof WebSocket
36
+ }
37
+
38
+ // ============================================================================
39
+ // Rate Limiting
40
+ // ============================================================================
41
+
42
+ interface RateLimiterClient {
43
+ requests: number[]
44
+ lastCleanup: number
45
+ throttleCount: number
46
+ }
47
+
48
+ const createRateLimiter = (isTestEnv: boolean) => {
49
+ const clients = new Map<string, RateLimiterClient>()
50
+ const maxRequests = 1500
51
+ const windowMs = 60000
52
+ const disconnectThreshold = 10
53
+ let cleanupInterval: NodeJS.Timeout | null = null
54
+
55
+ const cleanup = (): void => {
56
+ const now = Date.now()
57
+ for (const [_clientId, data] of Array.from(clients.entries())) {
58
+ if (now - data.lastCleanup > windowMs) {
59
+ data.requests = []
60
+ data.lastCleanup = now
61
+ }
62
+ data.requests = data.requests.filter(time => now - time < windowMs)
63
+ if (now - data.lastCleanup > windowMs * 10) {
64
+ data.throttleCount = 0
65
+ }
66
+ }
67
+ }
68
+
69
+ if (!isTestEnv) {
70
+ cleanupInterval = setInterval(cleanup, windowMs / 4)
71
+ }
72
+
73
+ return {
74
+ getDelay: (clientId: string): number => {
75
+ const now = Date.now()
76
+ const client = clients.get(clientId) || {
77
+ requests: [],
78
+ lastCleanup: now,
79
+ throttleCount: 0,
80
+ }
81
+
82
+ client.requests = client.requests.filter(time => now - time < windowMs)
83
+
84
+ if (client.requests.length >= maxRequests) {
85
+ const oldestRequest = Math.min(...client.requests)
86
+ const delay = windowMs - (now - oldestRequest)
87
+ client.throttleCount = (client.throttleCount || 0) + 1
88
+ clients.set(clientId, client)
89
+ return Math.max(0, delay)
90
+ }
91
+
92
+ client.requests.push(now)
93
+ clients.set(clientId, client)
94
+ return 0
95
+ },
96
+
97
+ getRemainingRequests: (clientId: string): number => {
98
+ const client = clients.get(clientId)
99
+ if (!client) return maxRequests
100
+
101
+ const now = Date.now()
102
+ const validRequests = client.requests.filter(time => now - time < windowMs)
103
+ return Math.max(0, maxRequests - validRequests.length)
104
+ },
105
+
106
+ getThrottleCount: (clientId: string): number => {
107
+ const client = clients.get(clientId)
108
+ return client ? client.throttleCount || 0 : 0
109
+ },
110
+
111
+ shouldDisconnect: (clientId: string): boolean => {
112
+ const client = clients.get(clientId)
113
+ if (!client) return false
114
+ return client.throttleCount >= disconnectThreshold
115
+ },
116
+
117
+ destroy: (): void => {
118
+ if (cleanupInterval) {
119
+ clearInterval(cleanupInterval)
120
+ cleanupInterval = null
121
+ }
122
+ clients.clear()
123
+ },
124
+ }
125
+ }
126
+
127
+ // ============================================================================
128
+ // Message Validation
129
+ // ============================================================================
130
+
131
+ const safeJSONParse = (
132
+ data: string | Buffer,
133
+ maxSize = 1024 * 1024
134
+ ): { success: boolean; data?: WireMessage; error?: string } => {
135
+ try {
136
+ if (typeof data === "string" && data.length > maxSize) {
137
+ throw new Error("Message too large")
138
+ }
139
+ return { success: true, data: JSON.parse(data.toString()) }
140
+ } catch (error) {
141
+ return { success: false, error: (error as Error).message }
142
+ }
143
+ }
144
+
145
+ const validateMessage = (
146
+ data: string | Buffer,
147
+ maxSize = 1024 * 1024
148
+ ): { valid: boolean; error?: string } => {
149
+ if (typeof data === "string" && data.length > maxSize) {
150
+ return { valid: false, error: "Message too large" }
151
+ }
152
+ if (Buffer.isBuffer && Buffer.isBuffer(data) && data.length > maxSize) {
153
+ return { valid: false, error: "Message too large" }
154
+ }
155
+ return { valid: true }
156
+ }
157
+
158
+ // ============================================================================
159
+ // Connection Management
160
+ // ============================================================================
161
+
162
+ const createConnectionManager = (maxConnections = 1000) => {
163
+ const connections = new Set<WebSocket>()
164
+
165
+ return {
166
+ add: (ws: WebSocket): boolean => {
167
+ if (connections.size >= maxConnections) {
168
+ return false
169
+ }
170
+ connections.add(ws)
171
+
172
+ const originalClose = ws.close.bind(ws)
173
+ ws.close = ((...args: unknown[]) => {
174
+ connections.delete(ws)
175
+ return originalClose(...(args as [number?, string?]))
176
+ }) as typeof ws.close
177
+
178
+ return true
179
+ },
180
+
181
+ remove: (ws: WebSocket): void => {
182
+ connections.delete(ws)
183
+ },
184
+
185
+ count: (): number => connections.size,
186
+
187
+ isFull: (): boolean => connections.size >= maxConnections,
188
+ }
189
+ }
190
+
191
+ // ============================================================================
192
+ // Retry Handler
193
+ // ============================================================================
194
+
195
+ const createRetryHandler = (maxRetries = 5) => {
196
+ let retryCount = 0
197
+
198
+ return {
199
+ shouldRetry: (): boolean => retryCount < maxRetries,
200
+ getDelay: (): number => Math.min(1000 * Math.pow(2, retryCount), 30000),
201
+ increment: (): void => {
202
+ retryCount++
203
+ },
204
+ reset: (): void => {
205
+ retryCount = 0
206
+ },
207
+ }
208
+ }
209
+
210
+ // ============================================================================
211
+ // Wire Protocol
212
+ // ============================================================================
213
+
214
+ export interface WireAPI {
215
+ get: (lex: Lex, cb: (msg: WireMessage) => void, _opt?: WireOptions) => void
216
+ put: (data: Graph, cb?: (err?: string | null) => void) => Promise<void> | void
217
+ on: (lex: Lex, cb: () => void, _get?: boolean, _opt?: WireOptions) => void
218
+ off: (lex: Lex, cb: () => void) => void
219
+ }
220
+
221
+ const Wire = (opt: HolsterOptions): WireAPI => {
222
+ const options = opt || {}
223
+ const dup = Dup()
224
+ const store = Store(options as never)
225
+ const graph: Graph = {}
226
+ const queue: Record<string, (msg: WireMessage) => void> = {}
227
+ const listen: ListenMap = {}
228
+
229
+ const pendingReferences = new Set<string>()
230
+ const hasNode = new Set<string>()
231
+
232
+ // Debounce client-side wire update checks: skip if the same lex was checked
233
+ // recently. Keyed by "soul" or "soul.property".
234
+ const recentChecks = new Map<string, number>()
235
+ const recentCheckTTL = 10000 // 10 seconds
236
+
237
+ const pendingTimeouts = new Map<string, Lex>()
238
+ const offlineTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
239
+
240
+ const hasSoul = async (soul: string): Promise<boolean> => {
241
+ if (graph[soul]) return true
242
+ return new Promise(resolve => {
243
+ store.get({ "#": soul }, (err, data) => {
244
+ resolve(!err && !!data && !!data[soul])
245
+ })
246
+ })
247
+ }
248
+
249
+ const isTestEnv = options.wss && (options.wss as { constructor?: { name?: string } }).constructor?.name === "Server"
250
+ const rateLimiter = createRateLimiter(isTestEnv)
251
+ const connectionManager = createConnectionManager(options.maxConnections || 1000)
252
+
253
+ const check = async (
254
+ data: Graph,
255
+ send: (msg: string) => { err?: string } | void,
256
+ cb?: (err?: string | null) => void
257
+ ): Promise<boolean> => {
258
+ const key = utils.userPublicKey
259
+
260
+ for (const soul of Object.keys(data)) {
261
+ const node = data[soul]!
262
+ // If the incoming update doesn't include a userPublicKey property at all,
263
+ // allow it - the update is not trying to change the public key.
264
+ if (node[key] === undefined) continue
265
+
266
+ const msg = await new Promise<WireMessage>(res => {
267
+ getWithCallback({ "#": soul, ".": key }, res, send, {put: true})
268
+ })
269
+ if (msg.err) {
270
+ if (cb) cb(msg.err)
271
+ return false
272
+ }
273
+
274
+ // If there is no current node then the data is ok to write without
275
+ // matching public keys, as the provided soul also needs a rel on the
276
+ // parent node which then also requires checking. Otherwise public keys
277
+ // need to match for existing data.
278
+ if (
279
+ !msg.put ||
280
+ !msg.put[soul] ||
281
+ msg.put[soul]![key] === node[key]
282
+ ) {
283
+ continue
284
+ }
285
+
286
+ if (cb) {
287
+ cb(`error in wire check public key does not match for soul: ${soul}`)
288
+ }
289
+ return false
290
+ }
291
+
292
+ return true
293
+ }
294
+
295
+ const get = (msg: { get: Lex; "#": string }, send: (msg: string) => void): void => {
296
+ const ack = Get(msg.get, graph)
297
+ if (ack) {
298
+ send(
299
+ JSON.stringify({
300
+ "#": dup.track(utils.text.random(9)),
301
+ "@": msg["#"],
302
+ put: ack,
303
+ })
304
+ )
305
+ } 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
+ )
320
+ }
321
+ }
322
+
323
+ const put = async (
324
+ msg: { put: Graph; "#": string },
325
+ send: (msg: string) => void
326
+ ): Promise<void> => {
327
+ const update = await Ham.mix(msg.put, graph, hasNode, options.secure || false, listen)
328
+ if (Object.keys(update.now).length === 0) {
329
+ if (Object.keys(update.defer).length !== 0) {
330
+ setTimeout(() => put({ put: update.defer, "#": msg["#"] }, send), update.wait)
331
+ }
332
+ return
333
+ }
334
+
335
+ if (!(await check(update.now, send as never))) return
336
+
337
+ store.put(update.now, err => {
338
+ if (err) console.warn("store.put", err)
339
+ send(
340
+ JSON.stringify({
341
+ "#": dup.track(utils.text.random(9)),
342
+ "@": msg["#"],
343
+ err: err,
344
+ })
345
+ )
346
+ update.listeners.forEach(cb => cb())
347
+ })
348
+
349
+ if (Object.keys(update.defer).length !== 0) {
350
+ setTimeout(() => put({ put: update.defer, "#": msg["#"] }, send), update.wait)
351
+ }
352
+ }
353
+
354
+ const getWithCallback = (
355
+ lex: Lex,
356
+ cb: (msg: WireMessage) => void,
357
+ send: (msg: string) => { err?: string } | void,
358
+ _opt?: WireOptions
359
+ ): void => {
360
+ if (!cb) return
361
+
362
+ const opts = _opt || {}
363
+ const fast = opts.fast || (!lex["."] && hasNode.has(lex["#"]))
364
+ const ack = Get(lex, graph, fast)
365
+ const track = utils.text.random(9)
366
+ const request = JSON.stringify({
367
+ "#": dup.track(track),
368
+ get: lex,
369
+ })
370
+
371
+ if (ack) {
372
+ if (!isNode) {
373
+ // Client: send request on the wire to check for updates,
374
+ // since it may have missed puts while disconnected.
375
+ // Skip if the same lex was checked recently to avoid flooding.
376
+ if (!lex["."] || typeof lex["."] === "string") {
377
+ const checkKey = lex["#"] + (lex["."] ? "." + lex["."] : "")
378
+ const now = Date.now()
379
+ const lastCheck = recentChecks.get(checkKey)
380
+ if (!lastCheck || now - lastCheck > recentCheckTTL) {
381
+ recentChecks.set(checkKey, now)
382
+ const sendResult = send(request)
383
+ if (sendResult && sendResult.err) {
384
+ cb({ err: sendResult.err })
385
+ return
386
+ }
387
+ }
388
+ } else {
389
+ // Don't skip checks for other types of lex queries.
390
+ const sendResult = send(request)
391
+ if (sendResult && sendResult.err) {
392
+ cb({ err: sendResult.err })
393
+ return
394
+ }
395
+ }
396
+ }
397
+ cb({ put: ack })
398
+ return
399
+ }
400
+
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
+ }
428
+ }
429
+ }
430
+ }
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 {
456
+ const sendResult = send(request)
457
+ if (sendResult && sendResult.err) {
458
+ cb({ err: sendResult.err })
459
+ return
460
+ }
461
+ }
462
+ }
463
+ cb(err ? { put: ack, err: err } : { put: ack })
464
+ return
465
+ }
466
+ }
467
+
468
+ if (err) console.log(err)
469
+
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
479
+ }
480
+
481
+ queue[track] = cb
482
+
483
+ pendingTimeouts.set(track, lex)
484
+
485
+ // Ensure the requested soul is stored when the response arrives.
486
+ pendingReferences.add(lex["#"])
487
+
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
+ )
498
+ }
499
+
500
+ const api = (send: (msg: string) => { err?: string } | void): WireAPI => {
501
+ return {
502
+ get: (lex, cb, _opt) => {
503
+ if (lex && typeof lex["."] === "number") {
504
+ lex = { ...lex, ".": String(lex["."]) }
505
+ }
506
+ if (lex && lex["#"]) {
507
+ pendingReferences.add(lex["#"])
508
+ }
509
+ getWithCallback(lex, cb, send, _opt)
510
+ },
511
+ put: async (data, cb) => {
512
+ const update = await Ham.mix(data, graph, hasNode, options.secure || false, listen)
513
+ if (Object.keys(update.now).length === 0) {
514
+ if (cb) cb(null)
515
+ return
516
+ }
517
+
518
+ if (!(await check(update.now, send as never, cb))) {
519
+ return
520
+ }
521
+
522
+ for (const [_soul, node] of Object.entries(update.now)) {
523
+ if (node && typeof node === "object") {
524
+ for (const [_key, value] of Object.entries(node)) {
525
+ const soulId = utils.rel.is(value as GraphValue)
526
+ if (soulId) {
527
+ pendingReferences.add(soulId)
528
+ }
529
+ }
530
+ }
531
+ }
532
+
533
+ store.put(update.now, err => {
534
+ if (cb) cb(err)
535
+ update.listeners.forEach(l => l())
536
+ })
537
+
538
+ const sendResult = send(
539
+ JSON.stringify({
540
+ "#": dup.track(utils.text.random(9)),
541
+ put: data,
542
+ })
543
+ )
544
+ if (sendResult && sendResult.err) {
545
+ if (cb) cb(sendResult.err)
546
+ return
547
+ }
548
+ },
549
+ on: (lex, cb, _get, _opt) => {
550
+ if (lex && typeof lex["."] === "number") {
551
+ lex = { ...lex, ".": String(lex["."]) }
552
+ }
553
+ const soul = lex && lex["#"]
554
+ if (!soul || !cb) return
555
+
556
+ if (listen[soul]) {
557
+ listen[soul]!.push({ ".": lex["."], cb: cb as never })
558
+ } else {
559
+ listen[soul] = [{ ".": lex["."], cb: cb as never }]
560
+ }
561
+ if (_get) getWithCallback(lex, cb as never, send, _opt)
562
+ },
563
+ off: (lex, cb) => {
564
+ const soul = lex && lex["#"]
565
+ if (!soul || !listen[soul]) return
566
+
567
+ if (cb) {
568
+ const found = listen[soul]!.find(l => l.cb === (cb as never))
569
+ if (found) {
570
+ listen[soul]!.splice(listen[soul]!.indexOf(found), 1)
571
+ }
572
+ } else {
573
+ delete listen[soul]
574
+ }
575
+ },
576
+ }
577
+ }
578
+
579
+ if (isNode && (options.wss || options.server || options.port != null)) {
580
+ let wss = options.wss
581
+ let clients = (): WebSocket[] =>
582
+ (wss as { clients?: () => WebSocket[] }).clients?.() || []
583
+ if (!wss) {
584
+ const config = options.server
585
+ ? { server: options.server }
586
+ : { port: options.port }
587
+ wss = new wsModule!.WebSocketServer(config)
588
+ clients = () => Array.from((wss as { clients: Set<WebSocket> }).clients || [])
589
+ }
590
+
591
+ const send = (data: string, isBinary?: boolean): void => {
592
+ const msg = JSON.parse(data) as WireMessage
593
+ const trackId = msg["#"]
594
+ if (trackId && pendingTimeouts.has(trackId)) {
595
+ const lex = pendingTimeouts.get(trackId)!
596
+ pendingTimeouts.delete(trackId)
597
+
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
605
+ }
606
+ cb({ "#": trackId, put: ack })
607
+ delete queue[trackId]
608
+ }
609
+ }, isTestEnv ? 100 : 10000)
610
+ }
611
+
612
+ clients().forEach(client => {
613
+ const ws = client as UnifiedWebSocket
614
+ if (ws && ws.readyState === WebSocket.OPEN) {
615
+ ws.send(data, { binary: isBinary })
616
+ } else if (ws) {
617
+ const retryHandler = ws._retryHandler || createRetryHandler()
618
+ ws._retryHandler = retryHandler
619
+
620
+ if (retryHandler.shouldRetry()) {
621
+ const delay = retryHandler.getDelay()
622
+ retryHandler.increment()
623
+
624
+ setTimeout(() => {
625
+ if (ws && ws.readyState === WebSocket.OPEN) {
626
+ retryHandler.reset()
627
+ ws.send(data, { binary: isBinary })
628
+ }
629
+ }, delay)
630
+ }
631
+ }
632
+ })
633
+ }
634
+
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)
652
+
653
+ ws.on?.("close", (() => {
654
+ console.log(`WebSocket client disconnected: ${clientId}`)
655
+ connectionManager.remove(ws)
656
+ }) as (...args: unknown[]) => void)
657
+
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
+ }
667
+
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
+ }
674
+
675
+ const msg = parseResult.data!
676
+ if (!msg["#"]) return // Incoming messages must have '#'
677
+ if (dup.check(msg["#"])) return
678
+
679
+ const delay = rateLimiter.getDelay(clientId)
680
+ if (delay > 0) {
681
+ const throttleCount = rateLimiter.getThrottleCount(clientId)
682
+ 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
+ })
700
+ )
701
+ return
702
+ }
703
+
704
+ const processMessage = async (): Promise<void> => {
705
+ dup.track(msg["#"]!)
706
+
707
+ if (msg.get) get(msg as never, send)
708
+ if (msg.put) await put(msg as never, send)
709
+ send(data.toString(), isBinary)
710
+
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
+ }
720
+
721
+ if (delay > 0) {
722
+ setTimeout(processMessage, delay)
723
+ } else {
724
+ processMessage()
725
+ }
726
+ }) as (...args: unknown[]) => void)
727
+ }
728
+ )
729
+ return api(send)
730
+ }
731
+
732
+ // Browser logic
733
+ const peers: WebSocket[] = []
734
+ let clientThrottled = false
735
+ let throttleUntil = 0
736
+ let messageQueue: string[] = []
737
+ let queueProcessor: NodeJS.Timeout | null = null
738
+ const maxQueueLength = options.maxQueueLength || 10000
739
+
740
+ const startResponseTimeout = (trackId: string, lex: Lex, delay = isTestEnv ? 100 : 10000): ReturnType<typeof setTimeout> => {
741
+ return setTimeout(() => {
742
+ offlineTimeouts.delete(trackId)
743
+ pendingTimeouts.delete(trackId)
744
+ const cb = queue[trackId]
745
+ if (cb) {
746
+ const id = lex["#"]
747
+ const ack: Graph = { [id]: null as never }
748
+ if (typeof lex["."] === "string") {
749
+ ack[id] = { [lex["."]]: null } as never
750
+ }
751
+ cb({ "#": trackId, put: ack })
752
+ delete queue[trackId]
753
+ }
754
+ }, delay)
755
+ }
756
+
757
+ const processQueue = (): void => {
758
+ if (messageQueue.length === 0) {
759
+ queueProcessor = null
760
+ return
761
+ }
762
+
763
+ const now = Date.now()
764
+ if (clientThrottled && now < throttleUntil) {
765
+ queueProcessor = setTimeout(processQueue, Math.min(1000, throttleUntil - now))
766
+ return
767
+ }
768
+
769
+ if (clientThrottled && now >= throttleUntil) {
770
+ console.log(`Throttle period ended, resuming queue processing`)
771
+ clientThrottled = false
772
+ throttleUntil = 0
773
+ }
774
+
775
+ const data = messageQueue[0]!
776
+ const sent = sendToPeers(data)
777
+
778
+ const msg = JSON.parse(data) as WireMessage
779
+ const trackId = msg["#"]
780
+
781
+ // Drop GET messages with no pending callback — these were already served
782
+ // from local store so no caller is waiting for the wire response.
783
+ const isStaleCheckMessage = !sent && !!msg.get && !pendingTimeouts.has(trackId!)
784
+
785
+ if (sent || isStaleCheckMessage) {
786
+ messageQueue.shift()
787
+ }
788
+
789
+ if (trackId && pendingTimeouts.has(trackId)) {
790
+ const lex = pendingTimeouts.get(trackId)!
791
+ if (sent) {
792
+ if (offlineTimeouts.has(trackId)) {
793
+ clearTimeout(offlineTimeouts.get(trackId)!)
794
+ offlineTimeouts.delete(trackId)
795
+ }
796
+ startResponseTimeout(trackId, lex)
797
+ } else if (!offlineTimeouts.has(trackId)) {
798
+ offlineTimeouts.set(trackId, startResponseTimeout(trackId, lex, 0))
799
+ }
800
+ }
801
+
802
+ if (messageQueue.length > 0) {
803
+ queueProcessor = setTimeout(processQueue, 50)
804
+ } else {
805
+ queueProcessor = null
806
+ }
807
+ }
808
+
809
+ const sendToPeers = (data: string): boolean => {
810
+ let sentToAtLeastOne = false
811
+ peers.forEach(peer => {
812
+ const ws = peer as UnifiedWebSocket
813
+ if (ws && ws.readyState === WebSocket.OPEN) {
814
+ ws.send(data)
815
+ sentToAtLeastOne = true
816
+ } else if (ws) {
817
+ const retryHandler = ws._retryHandler || createRetryHandler()
818
+ ws._retryHandler = retryHandler
819
+
820
+ if (retryHandler.shouldRetry()) {
821
+ const delay = retryHandler.getDelay()
822
+ retryHandler.increment()
823
+
824
+ setTimeout(() => {
825
+ if (peer && peer.readyState === WebSocket.OPEN) {
826
+ retryHandler.reset()
827
+ peer.send(data)
828
+ }
829
+ }, delay)
830
+ }
831
+ }
832
+ })
833
+ return sentToAtLeastOne
834
+ }
835
+
836
+ const send = (data: string): { err?: string } | void => {
837
+ if (messageQueue.length >= maxQueueLength) {
838
+ return {
839
+ err: `Message queue exceeded maximum length (${maxQueueLength}). Update query logic to request less data.`,
840
+ }
841
+ }
842
+
843
+ messageQueue.push(data)
844
+ if (!queueProcessor) {
845
+ queueProcessor = setTimeout(processQueue, 50)
846
+ }
847
+ }
848
+
849
+ if (!(options.peers instanceof Array) || options.peers.length === 0) {
850
+ options.peers = [`ws://localhost:${options.port || 8765}`]
851
+ }
852
+
853
+ options.peers.forEach((peer: string) => {
854
+ const start = (): void => {
855
+ let ws: WebSocket | null = new WebSocket(peer)
856
+ peers.push(ws)
857
+ const retryHandler = createRetryHandler()
858
+
859
+ ws.onclose = () => {
860
+ if (ws && peers.indexOf(ws) !== -1) {
861
+ peers.splice(peers.indexOf(ws), 1)
862
+ }
863
+ ws = null
864
+
865
+ if (retryHandler.shouldRetry()) {
866
+ const delay = retryHandler.getDelay()
867
+ retryHandler.increment()
868
+ setTimeout(start, delay)
869
+ }
870
+ }
871
+
872
+ ws.onopen = () => {
873
+ retryHandler.reset()
874
+ }
875
+
876
+ ws.onerror = (e: Event) => {
877
+ console.log(e)
878
+ }
879
+
880
+ ws.onmessage = async (m: MessageEvent) => {
881
+ const msg = JSON.parse(m.data as string) as WireMessage
882
+ if (!msg["#"]) return // Incoming messages must have '#'
883
+ if (dup.check(msg["#"])) return
884
+
885
+ if (msg.throttle && msg.err) {
886
+ console.log(`Server throttling: ${msg.err}`)
887
+ clientThrottled = true
888
+ throttleUntil = Date.now() + msg.throttle
889
+ return
890
+ }
891
+
892
+ dup.track(msg["#"]!)
893
+ if (msg.get) get(msg as never, send as never)
894
+ if (msg.put) {
895
+ // Pre-pass: expand pendingReferences to include rels of every soul
896
+ // that will be stored in this batch. This handles the case where a
897
+ // server batch contains both a parent soul (e.g. day_soul with item
898
+ // rels) and its child souls (item_souls), in any order — without the
899
+ // pre-pass, a child soul that appears before its parent in the batch
900
+ // would be missed because pendingReferences hasn't been updated yet.
901
+ const hasSoulMap = new Map<string, boolean>()
902
+ for (const [soul, node] of Object.entries(msg.put)) {
903
+ const known = await hasSoul(soul)
904
+ hasSoulMap.set(soul, known)
905
+ if ((!known && !pendingReferences.has(soul)) || !node || typeof node !== "object") continue
906
+ for (const [, value] of Object.entries(node)) {
907
+ const soulId = utils.rel.is(value as GraphValue)
908
+ if (soulId) pendingReferences.add(soulId)
909
+ }
910
+ }
911
+
912
+ // Main pass: filter using cached hasSoul results and the now-complete
913
+ // pendingReferences set.
914
+ const filteredPut: Graph = {}
915
+ const pendingToDelete: string[] = []
916
+ for (const [soul, node] of Object.entries(msg.put)) {
917
+ let shouldStore = hasSoulMap.get(soul)
918
+ if (pendingReferences.has(soul)) {
919
+ shouldStore = true
920
+ // Don't delete from pendingReferences yet — concurrent onmessage
921
+ // handlers for the same soul may have yielded at await hasSoul()
922
+ // before this pass ran. Deleting here would cause those handlers
923
+ // to see shouldStore=false and skip storing the soul's data.
924
+ // Instead, defer the delete until after put() updates the graph,
925
+ // so any concurrent handler can find the soul via hasSoul(graph).
926
+ pendingToDelete.push(soul)
927
+ }
928
+ if (shouldStore) filteredPut[soul] = node
929
+ }
930
+
931
+ // Store filtered data if any
932
+ if (Object.keys(filteredPut).length > 0) {
933
+ // Create filtered message for Ham.mix with original message ID
934
+ const filteredMsg = { put: filteredPut, "#": msg["#"]! }
935
+ await put(filteredMsg, send as never)
936
+ }
937
+
938
+ // Safe to delete now — put() has run Ham.mix which updates graph
939
+ // in-place, so hasSoul() will return true for these souls going
940
+ // forward, making pendingReferences no longer needed for them.
941
+ pendingToDelete.forEach(soul => pendingReferences.delete(soul))
942
+ }
943
+
944
+ const id = msg["@"]
945
+ const cb = queue[id!]
946
+ if (cb) {
947
+ delete (msg as { "#"?: string })["#"]
948
+ delete (msg as { "@"?: string })["@"]
949
+ cb(msg)
950
+ delete queue[id!]
951
+ }
952
+ }
953
+ }
954
+ start()
955
+ })
956
+
957
+ return api(send)
958
+ }
959
+
960
+ export default Wire
961
+