hivecode-mcp 0.12.2 → 0.12.4
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/LICENSE +21 -21
- package/README.md +187 -8
- package/core.js +91 -91
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +5 -5
- package/docs/SELF_HOSTING.md +256 -256
- package/hive-activity.js +336 -310
- package/hive-await.js +187 -0
- package/hive-branch.js +656 -0
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-hook-install.js +179 -103
- package/hive-instructions.js +74 -66
- package/hive-listen.js +81 -0
- package/hive-mailbox.js +517 -386
- package/hive-mcp.js +483 -126
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +350 -350
- package/hive-obligations.js +247 -247
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +337 -0
- package/hive-privacy.js +327 -288
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +7 -2
- package/hive-secrets.js +89 -0
- package/hive-setup.js +548 -445
- package/hive-stdio.js +172 -168
- package/hive-stop-hook.js +290 -241
- package/hive-throttle.js +118 -118
- package/hive-update.js +58 -58
- package/hive-version.js +110 -59
- package/icr-merge.js +58 -58
- package/icr.js +121 -15
- package/lang-json.js +211 -211
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -394
- package/package.json +19 -11
- package/reconcile.cjs +614 -477
- package/substrate.js +252 -252
- package/sync.js +4691 -3011
- package/grm-link.cjs +0 -141
package/limits.js
CHANGED
|
@@ -1,394 +1,413 @@
|
|
|
1
|
-
// USAGE LIMITS — what stops one misbehaving client from degrading a room for everyone
|
|
2
|
-
// who paid for it.
|
|
3
|
-
//
|
|
4
|
-
// Before this, the only bound anywhere in the relay was `MAX_NAME = 48`. A single agent in a
|
|
5
|
-
// loop could push unbounded messages, grow a document without limit, or open connections
|
|
6
|
-
// until the process died — and the blast radius is not that client, it is every other room
|
|
7
|
-
// on the same relay. That is the difference between "works on my machine" and something you
|
|
8
|
-
// can charge for.
|
|
9
|
-
//
|
|
10
|
-
// Four limits, each chosen because an agent can actually hit it by accident:
|
|
11
|
-
//
|
|
12
|
-
// MESSAGES an agent stuck in a write loop. Token bucket, so normal bursty sync (a big
|
|
13
|
-
// paste, a first-join catch-up) passes and a sustained flood does not.
|
|
14
|
-
// BYTES the same loop with big payloads. Rate-limiting messages alone lets 10 msg/s
|
|
15
|
-
// of 5 MB through, which is the same outage with fewer packets.
|
|
16
|
-
// DOC SIZE a file (or a room) that grows without bound. A CRDT never shrinks: every
|
|
17
|
-
// insert is retained history, so "it will be garbage collected" is not true
|
|
18
|
-
// here. Past the cap we refuse WRITES and keep serving reads, because dropping
|
|
19
|
-
// the client would take their unsaved work with it.
|
|
20
|
-
// CONNS connection floods, per room and per relay. Separate from the committer/seat
|
|
21
|
-
// limit: seats are about entitlement, this is about survival.
|
|
22
|
-
//
|
|
23
|
-
// EVERY limit is a REFUSAL WITH A REASON, never a silent drop. An agent that is told
|
|
24
|
-
// `Q4xx over the message rate` can back off; one whose writes vanish will retry forever and
|
|
25
|
-
// look like a broken product. Nothing here throttles reads.
|
|
26
|
-
//
|
|
27
|
-
// Defaults are deliberately generous — roughly 100x what the run2 four-agent session used at
|
|
28
|
-
// peak — so a real team never meets them and a runaway loop meets them in about a second.
|
|
29
|
-
// All of them are env-tunable so a self-hoster can raise them without a code change.
|
|
30
|
-
//
|
|
31
|
-
// ASCII only. Q-codes on refusals.
|
|
32
|
-
|
|
33
|
-
const num = (name, dflt) => {
|
|
34
|
-
const v = Number(process.env[name])
|
|
35
|
-
return Number.isFinite(v) && v > 0 ? v : dflt
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// ── TIERS ───────────────────────────────────────────────────────────────────────────────
|
|
39
|
-
// The first version of this file applied ONE set of limits to everybody, which is wrong for
|
|
40
|
-
// a product that charges: a free room got the same 64 MB document ceiling and the same
|
|
41
|
-
// message rate as a room someone is paying $29 a seat for. That is both a giveaway and a
|
|
42
|
-
// support problem -- the paying customer is the one who notices the shared ceiling.
|
|
43
|
-
//
|
|
44
|
-
// So limits scale with the tier the licence actually reports. `plan` comes from the licence
|
|
45
|
-
// record (see license-check.js, which had been dropping it), never from the key string --
|
|
46
|
-
// the whole reason the record is authoritative is that a key told the relay whatever it
|
|
47
|
-
// liked in an earlier version and granted 9,999 seats.
|
|
48
|
-
//
|
|
49
|
-
// The multiplier is per SEAT, deliberately. A 20-seat room legitimately does 10x the traffic
|
|
50
|
-
// of a 2-seat one, and a flat per-room ceiling would punish exactly the customers worth
|
|
51
|
-
// keeping. Free stays generous enough that nobody hits it while evaluating -- the tier is
|
|
52
|
-
// there to bound cost and abuse, not to nag someone into upgrading.
|
|
53
|
-
const TIERS = {
|
|
54
|
-
free: {
|
|
55
|
-
// PLANS.free caps at 2 seats (shared/license.js), so this is sized for two agents doing
|
|
56
|
-
// real work, with headroom. A single evaluator will never see it.
|
|
57
|
-
msgsPerSec: num('HIVE_FREE_MSGS_PER_SEC', 60),
|
|
58
|
-
msgBurst: num('HIVE_FREE_MSG_BURST', 600),
|
|
59
|
-
bytesPerSec: num('HIVE_FREE_BYTES_PER_SEC', 2 * 1024 * 1024),
|
|
60
|
-
byteBurst: num('HIVE_FREE_BYTE_BURST', 8 * 1024 * 1024),
|
|
61
|
-
maxDocBytes: num('HIVE_FREE_MAX_DOC_BYTES', 8 * 1024 * 1024),
|
|
62
|
-
maxConnsPerRoom: num('HIVE_FREE_MAX_CONNS_PER_ROOM', 8),
|
|
63
|
-
},
|
|
64
|
-
pro: {
|
|
65
|
-
// Per seat. A 1-seat Pro room already gets more than the whole free tier.
|
|
66
|
-
msgsPerSecPerSeat: num('HIVE_PRO_MSGS_PER_SEC_PER_SEAT', 100),
|
|
67
|
-
msgBurstPerSeat: num('HIVE_PRO_MSG_BURST_PER_SEAT', 1000),
|
|
68
|
-
bytesPerSecPerSeat: num('HIVE_PRO_BYTES_PER_SEC_PER_SEAT', 4 * 1024 * 1024),
|
|
69
|
-
byteBurstPerSeat: num('HIVE_PRO_BYTE_BURST_PER_SEAT', 16 * 1024 * 1024),
|
|
70
|
-
maxDocBytesPerSeat: num('HIVE_PRO_MAX_DOC_BYTES_PER_SEAT', 32 * 1024 * 1024),
|
|
71
|
-
// Connections, not committers. An agent may hold several file-rooms at once, and a
|
|
72
|
-
// reader costs a socket without costing a seat.
|
|
73
|
-
connsPerSeat: num('HIVE_PRO_CONNS_PER_SEAT', 8),
|
|
74
|
-
// Ceilings so one licence cannot consume the relay no matter how many seats it buys.
|
|
75
|
-
maxDocBytesCeiling: num('HIVE_PRO_MAX_DOC_BYTES_CEILING', 512 * 1024 * 1024),
|
|
76
|
-
maxConnsCeiling: num('HIVE_PRO_MAX_CONNS_CEILING', 256),
|
|
77
|
-
},
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* The limits that apply to one connection, from the licence verdict.
|
|
82
|
-
*
|
|
83
|
-
* Called with the verdict verifyLicense() returned at the handshake. An invalid or absent
|
|
84
|
-
* licence is the free tier — NOT a refusal: whether an unlicensed client may connect at all
|
|
85
|
-
* is HIVE_REQUIRE_LICENSE's decision, made before this, and conflating the two would mean a
|
|
86
|
-
* relay running without licensing enforced silently applied nobody's limits.
|
|
87
|
-
*/
|
|
88
|
-
export function limitsFor(verdict) {
|
|
89
|
-
const plan = verdict && verdict.valid ? (verdict.plan || 'pro') : 'free'
|
|
90
|
-
if (plan !== 'pro') {
|
|
91
|
-
const f = TIERS.free
|
|
92
|
-
return {
|
|
93
|
-
tier: 'free', seats: 1,
|
|
94
|
-
msgsPerSec: f.msgsPerSec, msgBurst: f.msgBurst,
|
|
95
|
-
bytesPerSec: f.bytesPerSec, byteBurst: f.byteBurst,
|
|
96
|
-
maxFrameBytes: LIMITS.maxFrameBytes,
|
|
97
|
-
maxDocBytes: f.maxDocBytes,
|
|
98
|
-
maxConnsPerRoom: f.maxConnsPerRoom,
|
|
99
|
-
cooloffMs: LIMITS.cooloffMs,
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
const seats = Math.max(1, Number(verdict.seats) || 1)
|
|
103
|
-
const p = TIERS.pro
|
|
104
|
-
return {
|
|
105
|
-
tier: 'pro', seats,
|
|
106
|
-
msgsPerSec: p.msgsPerSecPerSeat * seats,
|
|
107
|
-
msgBurst: p.msgBurstPerSeat * seats,
|
|
108
|
-
bytesPerSec: p.bytesPerSecPerSeat * seats,
|
|
109
|
-
byteBurst: p.byteBurstPerSeat * seats,
|
|
110
|
-
maxFrameBytes: LIMITS.maxFrameBytes,
|
|
111
|
-
maxDocBytes: Math.min(p.maxDocBytesPerSeat * seats, p.maxDocBytesCeiling),
|
|
112
|
-
maxConnsPerRoom: Math.min(p.connsPerSeat * seats, p.maxConnsCeiling),
|
|
113
|
-
cooloffMs: LIMITS.cooloffMs,
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export { TIERS }
|
|
118
|
-
|
|
119
|
-
export const LIMITS = {
|
|
120
|
-
// Token bucket: sustained rate, with a burst allowance for legitimate spikes.
|
|
121
|
-
msgsPerSec: num('HIVE_MAX_MSGS_PER_SEC', 200),
|
|
122
|
-
msgBurst: num('HIVE_MAX_MSG_BURST', 2000),
|
|
123
|
-
bytesPerSec: num('HIVE_MAX_BYTES_PER_SEC', 8 * 1024 * 1024),
|
|
124
|
-
byteBurst: num('HIVE_MAX_BYTE_BURST', 32 * 1024 * 1024),
|
|
125
|
-
// A single frame. y-websocket's first sync step can be large, so this is not tight.
|
|
126
|
-
maxFrameBytes: num('HIVE_MAX_FRAME_BYTES', 16 * 1024 * 1024),
|
|
127
|
-
// Retained CRDT size for one doc. A doc is one file in this design.
|
|
128
|
-
maxDocBytes: num('HIVE_MAX_DOC_BYTES', 64 * 1024 * 1024),
|
|
129
|
-
maxConnsPerRoom: num('HIVE_MAX_CONNS_PER_ROOM', 64),
|
|
130
|
-
// The only cap that spans a whole project: a client holds one socket per open file (up to
|
|
131
|
-
// HIVE_MAX_OPEN_FILES=128) plus its base room, and each file is its own room as far as the
|
|
132
|
-
// per-room ceiling is concerned -- so a real team is bounded by THIS number and nothing else.
|
|
133
|
-
// It was 512: three participants on a 150-file room measured 409 connections, so a fourth
|
|
134
|
-
// was refused, on a relay already carrying other rooms. Measured cost 25 Sep 2026: ~56 KB of
|
|
135
|
-
// relay memory per connection (400 extra sockets to the same 400 docs: +22 MB), so 2048 is
|
|
136
|
-
// ~115 MB -- comfortable on a 512 MB free instance.
|
|
137
|
-
maxConnsTotal: num('HIVE_MAX_CONNS_TOTAL', 2048),
|
|
138
|
-
// ONE SOCKET PER PARTICIPANT (hive-mux-proto.js). A mux socket counts ONCE against
|
|
139
|
-
// maxConnsTotal above; each file it opens is a CHANNEL, counted against the file's per-room
|
|
140
|
-
// ceiling exactly as a per-file socket is, and against these two caps instead of the socket
|
|
141
|
-
// total -- which is the whole point: a participant stops costing 129 sockets.
|
|
142
|
-
//
|
|
143
|
-
// Sized by MEMORY, measured 25 Sep 2026: ~25.8 KB per channel whose doc no one else holds (the
|
|
144
|
-
// doc is most of it), ~2.4 KB for each further participant on the same doc. The socket cap
|
|
145
|
-
// used to bound open docs as a side effect; this is what bounds them now. 8192 lone small docs
|
|
146
|
-
// is ~211 MB on the 512 MB hosted instance -- raise it with the instance, not before. The per-
|
|
147
|
-
// participant cap matches the client's own bound in one-socket mode, so no single participant
|
|
148
|
-
// can take more than half the relay.
|
|
149
|
-
maxChannelsTotal: num('HIVE_MAX_CHANNELS_TOTAL', 8192),
|
|
150
|
-
maxChannelsPerSocket: num('HIVE_MUX_MAX_DOCS', 4096),
|
|
151
|
-
// How
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
this.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
export function
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
1
|
+
// USAGE LIMITS — what stops one misbehaving client from degrading a room for everyone
|
|
2
|
+
// who paid for it.
|
|
3
|
+
//
|
|
4
|
+
// Before this, the only bound anywhere in the relay was `MAX_NAME = 48`. A single agent in a
|
|
5
|
+
// loop could push unbounded messages, grow a document without limit, or open connections
|
|
6
|
+
// until the process died — and the blast radius is not that client, it is every other room
|
|
7
|
+
// on the same relay. That is the difference between "works on my machine" and something you
|
|
8
|
+
// can charge for.
|
|
9
|
+
//
|
|
10
|
+
// Four limits, each chosen because an agent can actually hit it by accident:
|
|
11
|
+
//
|
|
12
|
+
// MESSAGES an agent stuck in a write loop. Token bucket, so normal bursty sync (a big
|
|
13
|
+
// paste, a first-join catch-up) passes and a sustained flood does not.
|
|
14
|
+
// BYTES the same loop with big payloads. Rate-limiting messages alone lets 10 msg/s
|
|
15
|
+
// of 5 MB through, which is the same outage with fewer packets.
|
|
16
|
+
// DOC SIZE a file (or a room) that grows without bound. A CRDT never shrinks: every
|
|
17
|
+
// insert is retained history, so "it will be garbage collected" is not true
|
|
18
|
+
// here. Past the cap we refuse WRITES and keep serving reads, because dropping
|
|
19
|
+
// the client would take their unsaved work with it.
|
|
20
|
+
// CONNS connection floods, per room and per relay. Separate from the committer/seat
|
|
21
|
+
// limit: seats are about entitlement, this is about survival.
|
|
22
|
+
//
|
|
23
|
+
// EVERY limit is a REFUSAL WITH A REASON, never a silent drop. An agent that is told
|
|
24
|
+
// `Q4xx over the message rate` can back off; one whose writes vanish will retry forever and
|
|
25
|
+
// look like a broken product. Nothing here throttles reads.
|
|
26
|
+
//
|
|
27
|
+
// Defaults are deliberately generous — roughly 100x what the run2 four-agent session used at
|
|
28
|
+
// peak — so a real team never meets them and a runaway loop meets them in about a second.
|
|
29
|
+
// All of them are env-tunable so a self-hoster can raise them without a code change.
|
|
30
|
+
//
|
|
31
|
+
// ASCII only. Q-codes on refusals.
|
|
32
|
+
|
|
33
|
+
const num = (name, dflt) => {
|
|
34
|
+
const v = Number(process.env[name])
|
|
35
|
+
return Number.isFinite(v) && v > 0 ? v : dflt
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── TIERS ───────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
// The first version of this file applied ONE set of limits to everybody, which is wrong for
|
|
40
|
+
// a product that charges: a free room got the same 64 MB document ceiling and the same
|
|
41
|
+
// message rate as a room someone is paying $29 a seat for. That is both a giveaway and a
|
|
42
|
+
// support problem -- the paying customer is the one who notices the shared ceiling.
|
|
43
|
+
//
|
|
44
|
+
// So limits scale with the tier the licence actually reports. `plan` comes from the licence
|
|
45
|
+
// record (see license-check.js, which had been dropping it), never from the key string --
|
|
46
|
+
// the whole reason the record is authoritative is that a key told the relay whatever it
|
|
47
|
+
// liked in an earlier version and granted 9,999 seats.
|
|
48
|
+
//
|
|
49
|
+
// The multiplier is per SEAT, deliberately. A 20-seat room legitimately does 10x the traffic
|
|
50
|
+
// of a 2-seat one, and a flat per-room ceiling would punish exactly the customers worth
|
|
51
|
+
// keeping. Free stays generous enough that nobody hits it while evaluating -- the tier is
|
|
52
|
+
// there to bound cost and abuse, not to nag someone into upgrading.
|
|
53
|
+
const TIERS = {
|
|
54
|
+
free: {
|
|
55
|
+
// PLANS.free caps at 2 seats (shared/license.js), so this is sized for two agents doing
|
|
56
|
+
// real work, with headroom. A single evaluator will never see it.
|
|
57
|
+
msgsPerSec: num('HIVE_FREE_MSGS_PER_SEC', 60),
|
|
58
|
+
msgBurst: num('HIVE_FREE_MSG_BURST', 600),
|
|
59
|
+
bytesPerSec: num('HIVE_FREE_BYTES_PER_SEC', 2 * 1024 * 1024),
|
|
60
|
+
byteBurst: num('HIVE_FREE_BYTE_BURST', 8 * 1024 * 1024),
|
|
61
|
+
maxDocBytes: num('HIVE_FREE_MAX_DOC_BYTES', 8 * 1024 * 1024),
|
|
62
|
+
maxConnsPerRoom: num('HIVE_FREE_MAX_CONNS_PER_ROOM', 8),
|
|
63
|
+
},
|
|
64
|
+
pro: {
|
|
65
|
+
// Per seat. A 1-seat Pro room already gets more than the whole free tier.
|
|
66
|
+
msgsPerSecPerSeat: num('HIVE_PRO_MSGS_PER_SEC_PER_SEAT', 100),
|
|
67
|
+
msgBurstPerSeat: num('HIVE_PRO_MSG_BURST_PER_SEAT', 1000),
|
|
68
|
+
bytesPerSecPerSeat: num('HIVE_PRO_BYTES_PER_SEC_PER_SEAT', 4 * 1024 * 1024),
|
|
69
|
+
byteBurstPerSeat: num('HIVE_PRO_BYTE_BURST_PER_SEAT', 16 * 1024 * 1024),
|
|
70
|
+
maxDocBytesPerSeat: num('HIVE_PRO_MAX_DOC_BYTES_PER_SEAT', 32 * 1024 * 1024),
|
|
71
|
+
// Connections, not committers. An agent may hold several file-rooms at once, and a
|
|
72
|
+
// reader costs a socket without costing a seat.
|
|
73
|
+
connsPerSeat: num('HIVE_PRO_CONNS_PER_SEAT', 8),
|
|
74
|
+
// Ceilings so one licence cannot consume the relay no matter how many seats it buys.
|
|
75
|
+
maxDocBytesCeiling: num('HIVE_PRO_MAX_DOC_BYTES_CEILING', 512 * 1024 * 1024),
|
|
76
|
+
maxConnsCeiling: num('HIVE_PRO_MAX_CONNS_CEILING', 256),
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The limits that apply to one connection, from the licence verdict.
|
|
82
|
+
*
|
|
83
|
+
* Called with the verdict verifyLicense() returned at the handshake. An invalid or absent
|
|
84
|
+
* licence is the free tier — NOT a refusal: whether an unlicensed client may connect at all
|
|
85
|
+
* is HIVE_REQUIRE_LICENSE's decision, made before this, and conflating the two would mean a
|
|
86
|
+
* relay running without licensing enforced silently applied nobody's limits.
|
|
87
|
+
*/
|
|
88
|
+
export function limitsFor(verdict) {
|
|
89
|
+
const plan = verdict && verdict.valid ? (verdict.plan || 'pro') : 'free'
|
|
90
|
+
if (plan !== 'pro') {
|
|
91
|
+
const f = TIERS.free
|
|
92
|
+
return {
|
|
93
|
+
tier: 'free', seats: 1,
|
|
94
|
+
msgsPerSec: f.msgsPerSec, msgBurst: f.msgBurst,
|
|
95
|
+
bytesPerSec: f.bytesPerSec, byteBurst: f.byteBurst,
|
|
96
|
+
maxFrameBytes: LIMITS.maxFrameBytes,
|
|
97
|
+
maxDocBytes: f.maxDocBytes,
|
|
98
|
+
maxConnsPerRoom: f.maxConnsPerRoom,
|
|
99
|
+
cooloffMs: LIMITS.cooloffMs,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const seats = Math.max(1, Number(verdict.seats) || 1)
|
|
103
|
+
const p = TIERS.pro
|
|
104
|
+
return {
|
|
105
|
+
tier: 'pro', seats,
|
|
106
|
+
msgsPerSec: p.msgsPerSecPerSeat * seats,
|
|
107
|
+
msgBurst: p.msgBurstPerSeat * seats,
|
|
108
|
+
bytesPerSec: p.bytesPerSecPerSeat * seats,
|
|
109
|
+
byteBurst: p.byteBurstPerSeat * seats,
|
|
110
|
+
maxFrameBytes: LIMITS.maxFrameBytes,
|
|
111
|
+
maxDocBytes: Math.min(p.maxDocBytesPerSeat * seats, p.maxDocBytesCeiling),
|
|
112
|
+
maxConnsPerRoom: Math.min(p.connsPerSeat * seats, p.maxConnsCeiling),
|
|
113
|
+
cooloffMs: LIMITS.cooloffMs,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export { TIERS }
|
|
118
|
+
|
|
119
|
+
export const LIMITS = {
|
|
120
|
+
// Token bucket: sustained rate, with a burst allowance for legitimate spikes.
|
|
121
|
+
msgsPerSec: num('HIVE_MAX_MSGS_PER_SEC', 200),
|
|
122
|
+
msgBurst: num('HIVE_MAX_MSG_BURST', 2000),
|
|
123
|
+
bytesPerSec: num('HIVE_MAX_BYTES_PER_SEC', 8 * 1024 * 1024),
|
|
124
|
+
byteBurst: num('HIVE_MAX_BYTE_BURST', 32 * 1024 * 1024),
|
|
125
|
+
// A single frame. y-websocket's first sync step can be large, so this is not tight.
|
|
126
|
+
maxFrameBytes: num('HIVE_MAX_FRAME_BYTES', 16 * 1024 * 1024),
|
|
127
|
+
// Retained CRDT size for one doc. A doc is one file in this design.
|
|
128
|
+
maxDocBytes: num('HIVE_MAX_DOC_BYTES', 64 * 1024 * 1024),
|
|
129
|
+
maxConnsPerRoom: num('HIVE_MAX_CONNS_PER_ROOM', 64),
|
|
130
|
+
// The only cap that spans a whole project: a client holds one socket per open file (up to
|
|
131
|
+
// HIVE_MAX_OPEN_FILES=128) plus its base room, and each file is its own room as far as the
|
|
132
|
+
// per-room ceiling is concerned -- so a real team is bounded by THIS number and nothing else.
|
|
133
|
+
// It was 512: three participants on a 150-file room measured 409 connections, so a fourth
|
|
134
|
+
// was refused, on a relay already carrying other rooms. Measured cost 25 Sep 2026: ~56 KB of
|
|
135
|
+
// relay memory per connection (400 extra sockets to the same 400 docs: +22 MB), so 2048 is
|
|
136
|
+
// ~115 MB -- comfortable on a 512 MB free instance.
|
|
137
|
+
maxConnsTotal: num('HIVE_MAX_CONNS_TOTAL', 2048),
|
|
138
|
+
// ONE SOCKET PER PARTICIPANT (hive-mux-proto.js). A mux socket counts ONCE against
|
|
139
|
+
// maxConnsTotal above; each file it opens is a CHANNEL, counted against the file's per-room
|
|
140
|
+
// ceiling exactly as a per-file socket is, and against these two caps instead of the socket
|
|
141
|
+
// total -- which is the whole point: a participant stops costing 129 sockets.
|
|
142
|
+
//
|
|
143
|
+
// Sized by MEMORY, measured 25 Sep 2026: ~25.8 KB per channel whose doc no one else holds (the
|
|
144
|
+
// doc is most of it), ~2.4 KB for each further participant on the same doc. The socket cap
|
|
145
|
+
// used to bound open docs as a side effect; this is what bounds them now. 8192 lone small docs
|
|
146
|
+
// is ~211 MB on the 512 MB hosted instance -- raise it with the instance, not before. The per-
|
|
147
|
+
// participant cap matches the client's own bound in one-socket mode, so no single participant
|
|
148
|
+
// can take more than half the relay.
|
|
149
|
+
maxChannelsTotal: num('HIVE_MAX_CHANNELS_TOTAL', 8192),
|
|
150
|
+
maxChannelsPerSocket: num('HIVE_MUX_MAX_DOCS', 4096),
|
|
151
|
+
// How many channel OPENs one participant's socket gets per event-loop turn (relay-mux.js).
|
|
152
|
+
opensPerTurn: num('HIVE_MUX_OPENS_PER_TURN', 64),
|
|
153
|
+
// UNUSED since 27 Sep 2026, kept so a relay configured with it still starts. It was how long
|
|
154
|
+
// a client stayed refused after tripping a rate limit; a frame over the rate now waits and
|
|
155
|
+
// drains at the tier's rate instead (attachRateLimit), so there is nothing to cool off from.
|
|
156
|
+
cooloffMs: num('HIVE_RATE_COOLOFF_MS', 5000),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── token bucket ────────────────────────────────────────────────────────────────────────
|
|
160
|
+
// Chosen over a fixed window because a fixed window has an edge: 2x the limit can pass
|
|
161
|
+
// across a boundary, which is exactly when a sync storm happens.
|
|
162
|
+
class Bucket {
|
|
163
|
+
constructor(ratePerSec, capacity) {
|
|
164
|
+
this.rate = ratePerSec
|
|
165
|
+
this.cap = capacity
|
|
166
|
+
this.tokens = capacity
|
|
167
|
+
this.last = Date.now()
|
|
168
|
+
}
|
|
169
|
+
refill(now) {
|
|
170
|
+
const elapsed = (now - this.last) / 1000
|
|
171
|
+
if (elapsed > 0) {
|
|
172
|
+
this.tokens = Math.min(this.cap, this.tokens + elapsed * this.rate)
|
|
173
|
+
this.last = now
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
take(n, now = Date.now()) {
|
|
177
|
+
this.refill(now)
|
|
178
|
+
// A request bigger than the whole burst could never be covered, so it passes when the
|
|
179
|
+
// bucket is FULL and leaves a debt the rate repays. Needed now that an over-budget frame
|
|
180
|
+
// waits instead of being dropped: a frame between the byte burst and the frame cap would
|
|
181
|
+
// otherwise wait forever.
|
|
182
|
+
if (this.tokens >= n || (n > this.cap && this.tokens >= this.cap)) { this.tokens -= n; return true }
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
give(n) { this.tokens = Math.min(this.cap, this.tokens + n) }
|
|
186
|
+
/** Milliseconds until take(n) could succeed. */
|
|
187
|
+
msUntil(n, now = Date.now()) {
|
|
188
|
+
this.refill(now)
|
|
189
|
+
const need = Math.min(n, this.cap) - this.tokens
|
|
190
|
+
return need <= 0 ? 0 : Math.ceil((need / this.rate) * 1000)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── per-relay and per-room connection counts ────────────────────────────────────────────
|
|
195
|
+
let totalConns = 0
|
|
196
|
+
const roomConns = new Map() // room -> count
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Called at the HTTP upgrade, BEFORE the websocket handshake completes, so a rejected
|
|
200
|
+
* connection costs one socket and no doc state. Returns null to admit, or {code, reason}.
|
|
201
|
+
*/
|
|
202
|
+
export function admitConnection(room, tier = null) {
|
|
203
|
+
if (totalConns >= LIMITS.maxConnsTotal) {
|
|
204
|
+
return { code: 503, reason: `Q430 relay at capacity (${LIMITS.maxConnsTotal} connections)` }
|
|
205
|
+
}
|
|
206
|
+
// The per-room ceiling is the TIER's, so a free room cannot hold as many sockets as a
|
|
207
|
+
// 20-seat Pro room. Falls back to the flat value when no tier is supplied, which is what
|
|
208
|
+
// happens on a relay running with licensing disabled.
|
|
209
|
+
const cap = (tier && tier.maxConnsPerRoom) || LIMITS.maxConnsPerRoom
|
|
210
|
+
const n = roomConns.get(room) || 0
|
|
211
|
+
if (n >= cap) {
|
|
212
|
+
const which = tier ? `${tier.tier} tier` : 'this relay'
|
|
213
|
+
return { code: 429, reason: `Q431 room at capacity (${cap} connections on ${which})` }
|
|
214
|
+
}
|
|
215
|
+
return null
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function noteConnectionOpened(room) {
|
|
219
|
+
totalConns++
|
|
220
|
+
roomConns.set(room, (roomConns.get(room) || 0) + 1)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Channels share roomConns with sockets on purpose: the per-room ceiling means "how many
|
|
224
|
+
// participants hold this file", and it must mean that whichever transport each one uses.
|
|
225
|
+
let totalChannels = 0
|
|
226
|
+
export function admitChannel(room, tier = null) {
|
|
227
|
+
if (totalChannels >= LIMITS.maxChannelsTotal) {
|
|
228
|
+
return { code: 503, reason: `Q438 relay at capacity (${LIMITS.maxChannelsTotal} open files across all participants)` }
|
|
229
|
+
}
|
|
230
|
+
const cap = (tier && tier.maxConnsPerRoom) || LIMITS.maxConnsPerRoom
|
|
231
|
+
const n = roomConns.get(room) || 0
|
|
232
|
+
if (n >= cap) {
|
|
233
|
+
const which = tier ? `${tier.tier} tier` : 'this relay'
|
|
234
|
+
return { code: 429, reason: `Q431 room at capacity (${cap} connections on ${which})` }
|
|
235
|
+
}
|
|
236
|
+
return null
|
|
237
|
+
}
|
|
238
|
+
export function noteChannelOpened(room) {
|
|
239
|
+
totalChannels++
|
|
240
|
+
roomConns.set(room, (roomConns.get(room) || 0) + 1)
|
|
241
|
+
}
|
|
242
|
+
export function noteChannelClosed(room) {
|
|
243
|
+
totalChannels = Math.max(0, totalChannels - 1)
|
|
244
|
+
const n = (roomConns.get(room) || 1) - 1
|
|
245
|
+
if (n <= 0) roomConns.delete(room)
|
|
246
|
+
else roomConns.set(room, n)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function noteConnectionClosed(room) {
|
|
250
|
+
totalConns = Math.max(0, totalConns - 1)
|
|
251
|
+
const n = (roomConns.get(room) || 1) - 1
|
|
252
|
+
if (n <= 0) roomConns.delete(room) // never leak a zero entry per room
|
|
253
|
+
else roomConns.set(room, n)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── document size ───────────────────────────────────────────────────────────────────────
|
|
257
|
+
// Measured from the encoded update size we have already seen, accumulated per doc. Asking
|
|
258
|
+
// Yjs to encode the whole state on every message would itself be the performance problem.
|
|
259
|
+
const docBytes = new Map() // docName -> approximate retained bytes
|
|
260
|
+
|
|
261
|
+
export function noteDocBytes(docName, n) {
|
|
262
|
+
docBytes.set(docName, (docBytes.get(docName) || 0) + n)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function docOverCap(docName, tier = null) {
|
|
266
|
+
const cap = (tier && tier.maxDocBytes) || LIMITS.maxDocBytes
|
|
267
|
+
return (docBytes.get(docName) || 0) >= cap
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function docSize(docName) { return docBytes.get(docName) || 0 }
|
|
271
|
+
|
|
272
|
+
/** Seed a doc's counter from its real encoded size, e.g. after loading a snapshot. */
|
|
273
|
+
export function setDocBytes(docName, n) { docBytes.set(docName, Math.max(0, Number(n) || 0)) }
|
|
274
|
+
|
|
275
|
+
export function forgetDoc(docName) { docBytes.delete(docName) }
|
|
276
|
+
|
|
277
|
+
// ── per-connection rate limiting ────────────────────────────────────────────────────────
|
|
278
|
+
/**
|
|
279
|
+
* Attach buckets to one connection. Returns a `check(byteLength)` the message handler calls
|
|
280
|
+
* for every inbound frame: null to allow (the budget is charged), {reason, hold:true, retryMs}
|
|
281
|
+
* when the frame is over the rate and must WAIT (nothing is charged, so the retry is charged
|
|
282
|
+
* once), or {reason} to refuse it outright -- only an oversized frame, which no wait can fix.
|
|
283
|
+
*
|
|
284
|
+
* Over the rate means wait, never drop: a dropped Yjs update is a gap that every later update
|
|
285
|
+
* from the same client builds on, so the document freezes for that client (server.js,
|
|
286
|
+
* installUsageLimits). There is no cooloff any more; a held frame drains at the tier's rate.
|
|
287
|
+
*
|
|
288
|
+
* State lives on the connection object, so it is freed with the socket and there is no map
|
|
289
|
+
* to leak or sweep.
|
|
290
|
+
*/
|
|
291
|
+
export function attachRateLimit(conn, tier = null) {
|
|
292
|
+
const T = tier || LIMITS
|
|
293
|
+
const msgs = new Bucket(T.msgsPerSec, T.msgBurst)
|
|
294
|
+
const bytes = new Bucket(T.bytesPerSec, T.byteBurst)
|
|
295
|
+
const frameCap = T.maxFrameBytes || LIMITS.maxFrameBytes
|
|
296
|
+
let refusedCount = 0, throttledCount = 0
|
|
297
|
+
// Usage, per connection. This is the raw material for the per-agent visibility below,
|
|
298
|
+
// and it is counted here because this is the only place every inbound frame passes.
|
|
299
|
+
let msgCount = 0, byteCount = 0, writeCount = 0, writeBytes = 0
|
|
300
|
+
const openedAt = Date.now()
|
|
301
|
+
|
|
302
|
+
conn._hiveLimit = {
|
|
303
|
+
tier: T.tier || 'flat',
|
|
304
|
+
seats: T.seats || null,
|
|
305
|
+
check(byteLength) {
|
|
306
|
+
const now = Date.now()
|
|
307
|
+
if (byteLength > frameCap) {
|
|
308
|
+
// Not a rate problem: one oversized frame is a bug in the client.
|
|
309
|
+
refusedCount++
|
|
310
|
+
return { reason: `Q433 frame of ${byteLength} bytes exceeds the ${frameCap}-byte limit` }
|
|
311
|
+
}
|
|
312
|
+
if (!msgs.take(1, now)) {
|
|
313
|
+
return { hold: true, retryMs: msgs.msUntil(1, now), reason: `Q432 over the message rate (${T.msgsPerSec}/s sustained, ${T.msgBurst} burst${T.tier ? `, ${T.tier} tier` : ''})` }
|
|
314
|
+
}
|
|
315
|
+
if (!bytes.take(byteLength, now)) {
|
|
316
|
+
msgs.give(1)
|
|
317
|
+
return { hold: true, retryMs: bytes.msUntil(byteLength, now), reason: `Q434 over the byte rate (${Math.round(T.bytesPerSec / 1024)} KB/s sustained${T.tier ? `, ${T.tier} tier` : ''})` }
|
|
318
|
+
}
|
|
319
|
+
msgCount++; byteCount += byteLength
|
|
320
|
+
return null
|
|
321
|
+
},
|
|
322
|
+
/** Called by the relay once it knows a passing frame was a write, not a read. */
|
|
323
|
+
noteWrite(byteLength) { writeCount++; writeBytes += byteLength },
|
|
324
|
+
/** Called by the relay once per frame it made wait, however many retries that took. */
|
|
325
|
+
noteThrottled() { throttledCount++ },
|
|
326
|
+
get refused() { return refusedCount },
|
|
327
|
+
get throttled() { return throttledCount },
|
|
328
|
+
usage() {
|
|
329
|
+
return {
|
|
330
|
+
msgs: msgCount, bytes: byteCount,
|
|
331
|
+
writes: writeCount, writeBytes,
|
|
332
|
+
refused: refusedCount,
|
|
333
|
+
throttled: throttledCount,
|
|
334
|
+
heldMs: Date.now() - openedAt,
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
}
|
|
338
|
+
return conn._hiveLimit
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── PER-AGENT USAGE ─────────────────────────────────────────────────────────────────────
|
|
342
|
+
// "Cost visibility (per-agent token spend)" is a stated buyer requirement, and it needs one
|
|
343
|
+
// honest correction: THE RELAY CANNOT SEE TOKENS. An agent's LLM calls go to Anthropic or
|
|
344
|
+
// OpenAI directly and never touch this process, so any token number here would be invented.
|
|
345
|
+
//
|
|
346
|
+
// What the relay genuinely knows is ACTIVITY, and it is the more useful half for the problem
|
|
347
|
+
// people actually have: which agent is writing, how much, to which files, and who is sitting
|
|
348
|
+
// on a claim. That answers "why is this room slow", "which agent is hammering routes.js" and
|
|
349
|
+
// "what did each agent actually do" -- none of which a token count answers.
|
|
350
|
+
//
|
|
351
|
+
// Keyed by room+identity and kept in memory only. It is operational telemetry, not billing:
|
|
352
|
+
// billing is seats, which the licence record already decides.
|
|
353
|
+
const usage = new Map() // `${room}${identity}` -> {msgs, bytes, writes, writeBytes, refused, files:Set, sessions, lastAt}
|
|
354
|
+
const UKEY = (room, who) => `${room}${who}`
|
|
355
|
+
|
|
356
|
+
export function recordUsage(room, who, u, file = null) {
|
|
357
|
+
if (!room || !who || !u) return
|
|
358
|
+
const k = UKEY(room, who)
|
|
359
|
+
let e = usage.get(k)
|
|
360
|
+
if (!e) { e = { msgs: 0, bytes: 0, writes: 0, writeBytes: 0, refused: 0, throttled: 0, files: new Set(), sessions: 0, heldMs: 0, lastAt: 0 }; usage.set(k, e) }
|
|
361
|
+
e.msgs += u.msgs || 0
|
|
362
|
+
e.bytes += u.bytes || 0
|
|
363
|
+
e.writes += u.writes || 0
|
|
364
|
+
e.writeBytes += u.writeBytes || 0
|
|
365
|
+
e.refused += u.refused || 0
|
|
366
|
+
e.throttled += u.throttled || 0
|
|
367
|
+
e.heldMs += u.heldMs || 0
|
|
368
|
+
e.sessions += 1
|
|
369
|
+
if (file) e.files.add(file)
|
|
370
|
+
e.lastAt = Date.now()
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Per-agent activity for one room, or every room when `room` is omitted. */
|
|
374
|
+
export function usageFor(room = null) {
|
|
375
|
+
const out = []
|
|
376
|
+
for (const [k, e] of usage) {
|
|
377
|
+
const [r, who] = k.split('')
|
|
378
|
+
if (room && r !== room) continue
|
|
379
|
+
out.push({
|
|
380
|
+
room: r, agent: who,
|
|
381
|
+
msgs: e.msgs, bytes: e.bytes,
|
|
382
|
+
writes: e.writes, writeBytes: e.writeBytes,
|
|
383
|
+
refused: e.refused,
|
|
384
|
+
throttled: e.throttled,
|
|
385
|
+
files: e.files.size,
|
|
386
|
+
sessions: e.sessions,
|
|
387
|
+
connectedMs: e.heldMs,
|
|
388
|
+
lastAt: e.lastAt,
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
// Busiest writer first — the question this data exists to answer.
|
|
392
|
+
return out.sort((a, b) => b.writeBytes - a.writeBytes || b.writes - a.writes)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function stats() {
|
|
396
|
+
return {
|
|
397
|
+
connections: totalConns,
|
|
398
|
+
channels: totalChannels,
|
|
399
|
+
rooms: roomConns.size,
|
|
400
|
+
largestDocBytes: docBytes.size ? Math.max(...docBytes.values()) : 0,
|
|
401
|
+
docsTracked: docBytes.size,
|
|
402
|
+
limits: LIMITS,
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Test-only: reset module state between cases. */
|
|
407
|
+
export function _reset() {
|
|
408
|
+
totalConns = 0
|
|
409
|
+
totalChannels = 0
|
|
410
|
+
roomConns.clear()
|
|
411
|
+
docBytes.clear()
|
|
412
|
+
usage.clear()
|
|
413
|
+
}
|