@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.
- package/build/holster.js +3 -3
- package/build/holster.js.map +3 -3
- package/dist/holster.js +4 -0
- package/dist/holster.js.map +7 -0
- package/package.json +22 -5
- package/src/dup.js +4 -5
- package/src/get.js +10 -19
- package/src/ham.js +22 -49
- package/src/holster.js +172 -58
- package/src/index.js +1 -1
- package/src/radisk.js +89 -86
- package/src/sea-utils.js +1 -1
- package/src/sea.js +0 -52
- package/src/store.js +8 -8
- package/src/user.js +0 -4
- package/src/wire.js +209 -79
- package/ts/src/array.ts +78 -0
- package/ts/src/buffer.ts +441 -0
- package/ts/src/dup.ts +38 -0
- package/ts/src/get.ts +83 -0
- package/ts/src/ham.ts +258 -0
- package/ts/src/holster.ts +968 -0
- package/ts/src/index.ts +35 -0
- package/ts/src/radisk.ts +591 -0
- package/ts/src/radix.ts +157 -0
- package/ts/src/schemas.ts +515 -0
- package/ts/src/sea-utils.ts +103 -0
- package/ts/src/sea.ts +388 -0
- package/ts/src/store.ts +321 -0
- package/ts/src/user.ts +377 -0
- package/ts/src/utils.ts +232 -0
- package/ts/src/wire.ts +961 -0
- package/.dockerignore +0 -9
- package/.github/workflows/node.js.yml +0 -31
- package/.github/workflows/npm-publish.yml +0 -33
- package/Dockerfile +0 -20
- package/examples/index.html +0 -33
- package/examples/index.js +0 -49
- package/examples/user.html +0 -31
- package/examples/user.js +0 -93
- package/examples/wire.html +0 -27
- package/examples/wire.js +0 -55
- package/test/holster.get.test.js +0 -94
- package/test/holster.lex.test.js +0 -196
- package/test/holster.off.test.js +0 -53
- package/test/holster.on.test.js +0 -264
- package/test/holster.put.test.js +0 -358
- package/test/holster.secure.test.js +0 -202
- package/test/holster.user.get.test.js +0 -114
- package/test/holster.user.lex.test.js +0 -149
- package/test/holster.user.off.test.js +0 -73
- package/test/holster.user.on.test.js +0 -300
- package/test/holster.user.put.test.js +0 -463
- package/test/kitty/! +0 -9
- package/test/names.js +0 -225
- package/test/radisk.cache.test.js +0 -213
- package/test/radisk.test.js +0 -331
- package/test/radix.test.js +0 -117
- package/test/sea.test.js +0 -284
- package/test/split.test.js +0 -317
- package/test/store.test.js +0 -316
- package/test/system/basic.test.js +0 -29
- package/test/system/chained-on.test.js +0 -41
- package/test/system/concurrent-listeners.test.js +0 -125
- package/test/system/immediate-read.test.js +0 -30
- package/test/system/listener-before-data.test.js +0 -51
- package/test/system/promise-wrapper.test.js +0 -47
- package/test/system/user-chained-on.test.js +0 -60
- package/test/system/user-concurrent-listeners.test.js +0 -142
- package/test/system/user-listener-before-data.test.js +0 -66
- package/test/user.test.js +0 -173
- 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
|
+
|