hivecode-mcp 0.14.1 → 0.14.2

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/HIVE_RULES.md +98 -98
  2. package/LICENSE +21 -21
  3. package/README.md +421 -421
  4. package/core.js +91 -91
  5. package/docs/ALWAYS_ON.md +310 -310
  6. package/docs/GITHUB_WORKFLOW.md +394 -394
  7. package/docs/HIVE_RULES.md +41 -41
  8. package/docs/MCP.md +157 -157
  9. package/docs/QUICKSTART.md +266 -266
  10. package/docs/SELF_HOSTING.md +256 -256
  11. package/docs/SLACK_SETUP.md +474 -474
  12. package/docs/TROUBLESHOOTING.md +105 -105
  13. package/hive-activity.js +336 -336
  14. package/hive-authors.js +46 -44
  15. package/hive-await.js +230 -230
  16. package/hive-branch.js +608 -607
  17. package/hive-busy.js +88 -88
  18. package/hive-coord.js +110 -110
  19. package/hive-gate.js +135 -135
  20. package/hive-github.js +3 -1
  21. package/hive-hook-install.js +179 -179
  22. package/hive-instructions.js +74 -74
  23. package/hive-land.js +695 -691
  24. package/hive-listen.js +81 -81
  25. package/hive-mailbox.js +520 -520
  26. package/hive-mcp-http.js +591 -591
  27. package/hive-mcp.js +33 -6
  28. package/hive-mux-proto.js +58 -58
  29. package/hive-mux.js +351 -351
  30. package/hive-notify.js +234 -233
  31. package/hive-obligations.js +247 -247
  32. package/hive-poll-proto.js +50 -50
  33. package/hive-pr-staleness.js +189 -189
  34. package/hive-pr.js +600 -512
  35. package/hive-privacy.js +327 -327
  36. package/hive-receipts.js +233 -233
  37. package/hive-relay-health.js +234 -234
  38. package/hive-repos.js +430 -430
  39. package/hive-room-key.js +210 -210
  40. package/hive-room-mode.js +1668 -1668
  41. package/hive-room-watcher.js +562 -561
  42. package/hive-secrets.js +89 -89
  43. package/hive-session.js +114 -114
  44. package/hive-setup.js +560 -560
  45. package/hive-slack-agent.js +408 -408
  46. package/hive-slack.js +2384 -2367
  47. package/hive-stdio.js +172 -172
  48. package/hive-stop-hook.js +293 -293
  49. package/hive-throttle.js +118 -118
  50. package/hive-time.js +71 -71
  51. package/hive-transport.js +578 -578
  52. package/hive-update.js +58 -58
  53. package/hive-version.js +110 -110
  54. package/hive-webhook.js +55 -54
  55. package/icr-merge.js +58 -58
  56. package/icr.js +851 -851
  57. package/lang-brace.js +305 -305
  58. package/lang-js.js +651 -651
  59. package/lang-json.js +211 -211
  60. package/lang-python.js +588 -588
  61. package/lang-ruby.js +230 -230
  62. package/lang-toml.js +136 -136
  63. package/lang-yaml.js +183 -183
  64. package/limits.js +413 -413
  65. package/package.json +1 -1
  66. package/reconcile.cjs +621 -621
  67. package/slack-app-manifest-agent.json +61 -61
  68. package/slack-app-manifest-inbound.json +36 -36
  69. package/slack-app-manifest.json +27 -27
  70. package/substrate.js +252 -252
  71. package/sync.js +169 -11
  72. package/token.js +201 -201
package/hive-mux.js CHANGED
@@ -1,351 +1,351 @@
1
- // hive-mux.js -- the client half of ONE socket per participant (wire format: hive-mux-proto.js).
2
- //
3
- // sync.js asks this module for a provider per file doc and gets back an object with the surface
4
- // it already used from y-websocket's WebsocketProvider: `synced`, the 'sync' / 'status' /
5
- // 'connection-error' / 'connection-close' events, and destroy(). Behind it is EITHER a channel
6
- // on the participant's one mux socket, or -- when the relay does not advertise mux in /health,
7
- // or HIVE_MUX=0 -- a plain per-file WebsocketProvider exactly as before. An old relay therefore
8
- // keeps working with a new client, and the choice is made once per session, before any file
9
- // socket exists, so a room never ends up half on one transport by accident.
10
- //
11
- // Inside a channel the y-protocols exchange is the one y-websocket performs: on admission send
12
- // sync step 1, answer the relay's step 1, count the session synced when step 2 arrives, forward
13
- // local updates, never echo an update this provider applied. A refused OPEN is reported as a
14
- // 'connection-error' carrying the same HTTP status a refused per-file upgrade carried, so every
15
- // piece of refusal reporting in sync.js (Q430/Q431 counts, notSyncing, explain()) works unchanged.
16
- import * as syncProtocol from 'y-protocols/sync'
17
- import * as encoding from 'lib0/encoding'
18
- import * as decoding from 'lib0/decoding'
19
- import { ObservableV2 } from 'lib0/observable'
20
- import { WebsocketProvider } from 'y-websocket'
21
- import { F, encodeFrame, decodeFrame, muxPath, MUX_VERSION } from './hive-mux-proto.js'
22
- import { relayFetch } from './hive-transport.js' // /health through the proxy, as the sockets go
23
-
24
- const messageSync = 0
25
-
26
- export const httpBase = (relay) => String(relay).replace(/^wss:/, 'https:').replace(/^ws:/, 'http:').replace(/\/+$/, '')
27
-
28
- /** The provider sync.js holds per file. Delegates to a mux channel or a legacy provider. */
29
- class FileProvider extends ObservableV2 {
30
- constructor(docName, doc) {
31
- super()
32
- this.docName = docName
33
- this.doc = doc
34
- this.destroyed = false
35
- this._impl = null
36
- }
37
- get synced() { return !!(this._impl && this._impl.synced) }
38
- get wsconnected() { return !!(this._impl && this._impl.wsconnected) }
39
- get transport() { return this._impl ? this._impl.transport : 'pending' }
40
- _bindLegacy(relay, wsOpts) {
41
- const p = new WebsocketProvider(relay, this.docName, this.doc, wsOpts)
42
- for (const ev of ['sync', 'status', 'connection-error', 'connection-close']) p.on(ev, (...args) => this.emit(ev, args))
43
- this._impl = { transport: 'socket', get synced() { return p.synced }, get wsconnected() { return p.wsconnected }, destroy: () => p.destroy() }
44
- }
45
- destroy() {
46
- if (this.destroyed) return
47
- this.destroyed = true
48
- try { if (this._impl) this._impl.destroy() } catch { }
49
- super.destroy()
50
- }
51
- }
52
-
53
- class Channel {
54
- constructor(mux, id, docName, doc, facade) {
55
- this.mux = mux
56
- this.id = id
57
- this.docName = docName
58
- this.doc = doc
59
- this.facade = facade
60
- this.transport = 'mux'
61
- this.opened = false
62
- this.synced = false
63
- this.retries = 0
64
- this.retryTimer = null
65
- this.destroyed = false
66
- this._onUpdate = (update, origin) => {
67
- if (origin === this.facade || !this.opened) return // gaps are closed by the next step1/step2
68
- const e = encoding.createEncoder()
69
- encoding.writeVarUint(e, messageSync)
70
- syncProtocol.writeUpdate(e, update)
71
- this.mux.send(encodeFrame(F.MSG, this.id, encoding.toUint8Array(e)))
72
- }
73
- doc.on('update', this._onUpdate)
74
- }
75
- get wsconnected() { return this.opened && this.mux.connected }
76
- sendOpen() {
77
- if (this.destroyed || !this.mux.connected) return
78
- clearTimeout(this.retryTimer); this.retryTimer = null
79
- this.mux.send(encodeFrame(F.OPEN, this.id, this.docName))
80
- }
81
- retryLater() {
82
- if (this.destroyed || this.retryTimer) return
83
- // Refused or closed by the relay: back off, like the per-file provider did, but further --
84
- // an OPEN is cheap to send and a relay at capacity should not be asked 40 times a second.
85
- const ms = Math.min(15000, 500 * 2 ** Math.min(this.retries++, 5))
86
- this.retryTimer = setTimeout(() => { this.retryTimer = null; this.sendOpen() }, ms)
87
- if (this.retryTimer.unref) this.retryTimer.unref()
88
- }
89
- onFrame(f) {
90
- if (this.destroyed) return
91
- if (f.kind === F.OPENED) {
92
- this.opened = true
93
- this.retries = 0
94
- const e = encoding.createEncoder()
95
- encoding.writeVarUint(e, messageSync)
96
- syncProtocol.writeSyncStep1(e, this.doc)
97
- this.mux.send(encodeFrame(F.MSG, this.id, encoding.toUint8Array(e)))
98
- this.facade.emit('status', [{ status: 'connected' }])
99
- } else if (f.kind === F.MSG) {
100
- try { this.onMessage(f.payload) } catch (e) {
101
- // A message this doc could not apply. Skipping it would leave a gap that every later
102
- // update builds on, silently; reopening makes the relay run the sync exchange again,
103
- // and that carries whatever was missed.
104
- this.mux.log(`[hive] could not apply a relay message for ${JSON.stringify(this.docName)} (${e && e.message}); resyncing it`)
105
- this.reopen()
106
- }
107
- } else if (f.kind === F.REFUSED) {
108
- this.opened = false
109
- this.facade.emit('connection-error', [{ code: f.code, status: f.code, reason: f.reason, message: `Unexpected server response: ${f.code} ${f.reason}` }])
110
- this.retryLater()
111
- } else if (f.kind === F.CLOSED) {
112
- this.lost({ code: 4000, reason: f.reason })
113
- this.retryLater()
114
- }
115
- }
116
- onMessage(payload) {
117
- let d
118
- try { d = decoding.createDecoder(payload) } catch { return }
119
- const type = decoding.readVarUint(d)
120
- if (type !== messageSync) return // awareness/auth/query: file docs carry no presence
121
- const e = encoding.createEncoder()
122
- encoding.writeVarUint(e, messageSync)
123
- const t = syncProtocol.readSyncMessage(d, e, this.doc, this.facade)
124
- if (t === syncProtocol.messageYjsSyncStep2 && !this.synced) {
125
- this.synced = true
126
- this.facade.emit('sync', [true])
127
- }
128
- if (encoding.length(e) > 1) this.mux.send(encodeFrame(F.MSG, this.id, encoding.toUint8Array(e)))
129
- }
130
- reopen() {
131
- if (this.destroyed) return
132
- if (this.mux.connected) this.mux.send(encodeFrame(F.CLOSE, this.id))
133
- this.lost({ code: 4001, reason: 'resync' })
134
- this.retryLater()
135
- }
136
- /** The channel is gone (socket dropped, or the relay closed it); a reconnect will reopen it. */
137
- lost(ev = null) {
138
- const wasOpen = this.opened
139
- this.opened = false
140
- if (this.synced) { this.synced = false; this.facade.emit('sync', [false]) }
141
- if (wasOpen) {
142
- this.facade.emit('connection-close', [ev])
143
- this.facade.emit('status', [{ status: 'disconnected' }])
144
- }
145
- }
146
- destroy() {
147
- if (this.destroyed) return
148
- this.destroyed = true
149
- clearTimeout(this.retryTimer)
150
- try { this.doc.off('update', this._onUpdate) } catch { }
151
- if (this.mux.connected) this.mux.send(encodeFrame(F.CLOSE, this.id))
152
- this.mux.channels.delete(this.id)
153
- }
154
- }
155
-
156
- /** The participant's one physical socket, carrying a channel per open file doc. */
157
- class MuxConnection {
158
- constructor({ relay, room, params, WebSocketPolyfill, log, onRefused }) {
159
- const q = new URLSearchParams({ ...(params || {}), mux: String(MUX_VERSION) })
160
- this.url = `${String(relay).replace(/\/+$/, '')}/${muxPath(room)}?${q}`
161
- this.WS = WebSocketPolyfill
162
- this.log = log || (() => { })
163
- this.onRefused = onRefused || (() => { })
164
- this.channels = new Map()
165
- this.nextId = 1
166
- this.ws = null
167
- this.connected = false
168
- this.connecting = false
169
- this.destroyed = false
170
- this.failures = 0
171
- this.lastHeard = 0
172
- this.reconnectTimer = null
173
- // The relay sends a BEAT every 20s; 45s of silence means the socket is dead even if the OS
174
- // has not said so, which is exactly the half-open state a laptop lid produces.
175
- this.liveness = setInterval(() => {
176
- // terminate, not close: a close handshake cannot complete on a dead link, and ws waits 30s more for it
177
- if (this.connected && Date.now() - this.lastHeard > 45000) { try { this.ws.terminate ? this.ws.terminate() : this.ws.close() } catch { } }
178
- }, 5000)
179
- if (this.liveness.unref) this.liveness.unref()
180
- }
181
- channel(docName, doc, facade) {
182
- const ch = new Channel(this, this.nextId++, docName, doc, facade)
183
- this.channels.set(ch.id, ch)
184
- if (this.connected) ch.sendOpen()
185
- else this.connect()
186
- return ch
187
- }
188
- send(bytes) {
189
- if (!this.connected || !this.ws) return
190
- try { this.ws.send(bytes) } catch { /* the close handler takes it from here */ }
191
- }
192
- connect() {
193
- if (this.destroyed || this.connected || this.connecting) return
194
- this.connecting = true
195
- let ws
196
- try { ws = new this.WS(this.url) } catch (e) { this.connecting = false; this.scheduleReconnect(); return }
197
- ws.binaryType = 'arraybuffer'
198
- this.ws = ws
199
- ws.onopen = () => {
200
- if (this.ws !== ws) return
201
- this.connecting = false
202
- this.connected = true
203
- this.failures = 0
204
- this.lastHeard = Date.now()
205
- for (const ch of this.channels.values()) ch.sendOpen()
206
- }
207
- ws.onmessage = (ev) => {
208
- if (this.ws !== ws) return
209
- this.lastHeard = Date.now()
210
- let f
211
- try { f = decodeFrame(new Uint8Array(ev.data)) } catch { return }
212
- if (f.kind === F.BEAT) return
213
- const ch = this.channels.get(f.id)
214
- if (ch) ch.onFrame(f)
215
- }
216
- ws.onerror = (ev) => {
217
- if (this.ws !== ws) return
218
- // A refused UPGRADE of the mux socket itself (401/402/403/429/503) refuses every file on it.
219
- // Hand each unsynced channel the same event a refused per-file socket produced.
220
- const m = /Unexpected server response:\s*(\d{3})/.exec((ev && (ev.message || (ev.error && ev.error.message))) || '')
221
- const code = m ? Number(m[1]) : null
222
- for (const ch of this.channels.values()) {
223
- if (!ch.synced) ch.facade.emit('connection-error', [{ code, status: code, message: (ev && ev.message) || 'mux socket error' }])
224
- }
225
- if (code) this.onRefused(code)
226
- }
227
- ws.onclose = (ev) => {
228
- if (this.ws !== ws) return
229
- this.ws = null
230
- this.connected = false
231
- this.connecting = false
232
- for (const ch of this.channels.values()) ch.lost(ev)
233
- this.scheduleReconnect()
234
- }
235
- }
236
- scheduleReconnect() {
237
- if (this.destroyed || this.reconnectTimer || !this.channels.size) return
238
- const ms = Math.min(5000, 100 * 2 ** Math.min(this.failures++, 6))
239
- this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect() }, ms)
240
- if (this.reconnectTimer.unref) this.reconnectTimer.unref()
241
- }
242
- destroy() {
243
- this.destroyed = true
244
- clearInterval(this.liveness)
245
- clearTimeout(this.reconnectTimer)
246
- const ws = this.ws
247
- this.ws = null
248
- // Disconnected FIRST: closing the socket ends every channel on the relay at once, so no
249
- // channel sends a CLOSE frame of its own down a socket that is about to go.
250
- this.connected = false
251
- for (const ch of [...this.channels.values()]) { try { ch.destroy() } catch { } }
252
- try { if (ws) ws.close() } catch { }
253
- }
254
- }
255
-
256
- /**
257
- * One per startSync. provide(docName, doc) returns the provider for a file doc; the transport is
258
- * decided once -- HIVE_MUX=1 forces mux, HIVE_MUX=0 forces a socket per file, anything else asks
259
- * the relay's /health -- and file providers created before the answer are bound when it comes.
260
- */
261
- export function createFileTransport({ relay, room, wsOpts = {}, log = () => { }, env = process.env, fetchImpl = relayFetch, onFallback = () => { }, retryMs = 1000, detectForMs = 90000 }) {
262
- const forced = env.HIVE_MUX === '0' || env.HIVE_MUX === '1'
263
- let mode = env.HIVE_MUX === '0' ? 'socket' : env.HIVE_MUX === '1' ? 'mux' : null
264
- let mux = null
265
- let stopped = false
266
- const pending = new Set()
267
- const health = () => Promise.resolve()
268
- .then(() => fetchImpl(`${httpBase(relay)}/health`, { signal: AbortSignal.timeout(20000) }))
269
- .then((r) => (r && r.ok ? r.json() : null))
270
- const offersMux = (j) => !!j && Number(j.mux) >= MUX_VERSION
271
-
272
- // THE KILL SWITCH HAS TO REACH CLIENTS THAT ARE ALREADY CONNECTED. A relay that stops serving
273
- // mux -- an operator setting HIVE_RELAY_MUX=off, or a rollback to a build that predates it --
274
- // refuses the mux socket from then on, and without this every file would stay refused until
275
- // the agent restarted. A 404 says so outright. Any other refusal is checked against /health, at
276
- // most once a minute, because a relay that predates mux reads the mux path as a room name and
277
- // refuses the token -- a 401 that looks exactly like a real one.
278
- let lastCheck = 0
279
- const onRefused = (code) => {
280
- if (forced || stopped || mode !== 'mux') return
281
- if (code === 404) return fallBack('the relay no longer serves one-socket mode (404)')
282
- const now = Date.now()
283
- if (now - lastCheck < 60000) return
284
- lastCheck = now
285
- health().then((j) => { if (j && !offersMux(j)) fallBack('the relay stopped offering one-socket mode') }, () => { })
286
- }
287
- const fallBack = (why) => {
288
- if (stopped || mode !== 'mux') return
289
- mode = 'socket'
290
- log(`[hive] ${why}: falling back to a socket per file`)
291
- // sync.js lets go of every file beyond the per-file bound FIRST. Without that, a client holding
292
- // 900 files on one socket would open 900 sockets at once -- the storm the bound exists to stop.
293
- try { onFallback() } catch { }
294
- const moving = mux ? [...mux.channels.values()].map((ch) => ch.facade) : []
295
- try { if (mux) mux.destroy() } catch { }
296
- mux = null
297
- for (const fp of moving) { if (!fp.destroyed) fp._bindLegacy(relay, wsOpts) }
298
- }
299
- const bind = (fp) => {
300
- if (fp.destroyed) return
301
- if (mode === 'mux') {
302
- if (!mux) mux = new MuxConnection({ relay, room, params: wsOpts.params, WebSocketPolyfill: wsOpts.WebSocketPolyfill, log, onRefused })
303
- fp._impl = mux.channel(fp.docName, fp.doc, fp)
304
- } else {
305
- fp._bindLegacy(relay, wsOpts)
306
- }
307
- }
308
- let resolveReady
309
- const ready = new Promise((r) => { resolveReady = r })
310
- const decide = (m) => {
311
- if (!mode) mode = m
312
- for (const fp of pending) bind(fp)
313
- pending.clear()
314
- resolveReady(mode)
315
- }
316
- // ONLY AN ANSWER DECIDES. A free Render instance takes 30-60s to wake and says nothing at all
317
- // meanwhile; reading that silence as "no mux" pinned the whole session to a socket per file.
318
- // So silence (a timeout, a refused connection, a 5xx from a proxy fronting a booting service) is
319
- // asked again for up to ~90s. Any real answer decides at once -- including an old relay's
320
- // plain-text /health, which is a relay that has never heard of mux. Out of time: a socket per
321
- // file, the transport every relay has always spoken.
322
- const detect = async () => {
323
- const deadline = Date.now() + detectForMs
324
- for (let wait = retryMs; ; wait = Math.min(wait * 2, 15000)) {
325
- try {
326
- const r = await fetchImpl(`${httpBase(relay)}/health`, { signal: AbortSignal.timeout(20000) })
327
- if (r && r.status < 500) {
328
- let j = null
329
- try { j = JSON.parse(await r.text()) } catch { /* not JSON: an old relay, or not a relay */ }
330
- return r.ok && offersMux(j) ? 'mux' : 'socket'
331
- }
332
- } catch { /* no answer yet */ }
333
- if (stopped || Date.now() + wait > deadline) return 'socket'
334
- await new Promise((res) => { const t = setTimeout(res, wait); if (t.unref) t.unref() })
335
- }
336
- }
337
- if (mode) resolveReady(mode)
338
- else detect().then(decide, () => decide('socket'))
339
- return {
340
- ready,
341
- get mode() { return mode || 'detecting' },
342
- provide(docName, doc) {
343
- const fp = new FileProvider(docName, doc)
344
- if (mode) bind(fp)
345
- else pending.add(fp)
346
- return fp
347
- },
348
- stats() { return { mode: mode || 'detecting', channels: mux ? mux.channels.size : 0, muxConnected: !!(mux && mux.connected) } },
349
- stop() { stopped = true; try { if (mux) mux.destroy() } catch { } },
350
- }
351
- }
1
+ // hive-mux.js -- the client half of ONE socket per participant (wire format: hive-mux-proto.js).
2
+ //
3
+ // sync.js asks this module for a provider per file doc and gets back an object with the surface
4
+ // it already used from y-websocket's WebsocketProvider: `synced`, the 'sync' / 'status' /
5
+ // 'connection-error' / 'connection-close' events, and destroy(). Behind it is EITHER a channel
6
+ // on the participant's one mux socket, or -- when the relay does not advertise mux in /health,
7
+ // or HIVE_MUX=0 -- a plain per-file WebsocketProvider exactly as before. An old relay therefore
8
+ // keeps working with a new client, and the choice is made once per session, before any file
9
+ // socket exists, so a room never ends up half on one transport by accident.
10
+ //
11
+ // Inside a channel the y-protocols exchange is the one y-websocket performs: on admission send
12
+ // sync step 1, answer the relay's step 1, count the session synced when step 2 arrives, forward
13
+ // local updates, never echo an update this provider applied. A refused OPEN is reported as a
14
+ // 'connection-error' carrying the same HTTP status a refused per-file upgrade carried, so every
15
+ // piece of refusal reporting in sync.js (Q430/Q431 counts, notSyncing, explain()) works unchanged.
16
+ import * as syncProtocol from 'y-protocols/sync'
17
+ import * as encoding from 'lib0/encoding'
18
+ import * as decoding from 'lib0/decoding'
19
+ import { ObservableV2 } from 'lib0/observable'
20
+ import { WebsocketProvider } from 'y-websocket'
21
+ import { F, encodeFrame, decodeFrame, muxPath, MUX_VERSION } from './hive-mux-proto.js'
22
+ import { relayFetch } from './hive-transport.js' // /health through the proxy, as the sockets go
23
+
24
+ const messageSync = 0
25
+
26
+ export const httpBase = (relay) => String(relay).replace(/^wss:/, 'https:').replace(/^ws:/, 'http:').replace(/\/+$/, '')
27
+
28
+ /** The provider sync.js holds per file. Delegates to a mux channel or a legacy provider. */
29
+ class FileProvider extends ObservableV2 {
30
+ constructor(docName, doc) {
31
+ super()
32
+ this.docName = docName
33
+ this.doc = doc
34
+ this.destroyed = false
35
+ this._impl = null
36
+ }
37
+ get synced() { return !!(this._impl && this._impl.synced) }
38
+ get wsconnected() { return !!(this._impl && this._impl.wsconnected) }
39
+ get transport() { return this._impl ? this._impl.transport : 'pending' }
40
+ _bindLegacy(relay, wsOpts) {
41
+ const p = new WebsocketProvider(relay, this.docName, this.doc, wsOpts)
42
+ for (const ev of ['sync', 'status', 'connection-error', 'connection-close']) p.on(ev, (...args) => this.emit(ev, args))
43
+ this._impl = { transport: 'socket', get synced() { return p.synced }, get wsconnected() { return p.wsconnected }, destroy: () => p.destroy() }
44
+ }
45
+ destroy() {
46
+ if (this.destroyed) return
47
+ this.destroyed = true
48
+ try { if (this._impl) this._impl.destroy() } catch { }
49
+ super.destroy()
50
+ }
51
+ }
52
+
53
+ class Channel {
54
+ constructor(mux, id, docName, doc, facade) {
55
+ this.mux = mux
56
+ this.id = id
57
+ this.docName = docName
58
+ this.doc = doc
59
+ this.facade = facade
60
+ this.transport = 'mux'
61
+ this.opened = false
62
+ this.synced = false
63
+ this.retries = 0
64
+ this.retryTimer = null
65
+ this.destroyed = false
66
+ this._onUpdate = (update, origin) => {
67
+ if (origin === this.facade || !this.opened) return // gaps are closed by the next step1/step2
68
+ const e = encoding.createEncoder()
69
+ encoding.writeVarUint(e, messageSync)
70
+ syncProtocol.writeUpdate(e, update)
71
+ this.mux.send(encodeFrame(F.MSG, this.id, encoding.toUint8Array(e)))
72
+ }
73
+ doc.on('update', this._onUpdate)
74
+ }
75
+ get wsconnected() { return this.opened && this.mux.connected }
76
+ sendOpen() {
77
+ if (this.destroyed || !this.mux.connected) return
78
+ clearTimeout(this.retryTimer); this.retryTimer = null
79
+ this.mux.send(encodeFrame(F.OPEN, this.id, this.docName))
80
+ }
81
+ retryLater() {
82
+ if (this.destroyed || this.retryTimer) return
83
+ // Refused or closed by the relay: back off, like the per-file provider did, but further --
84
+ // an OPEN is cheap to send and a relay at capacity should not be asked 40 times a second.
85
+ const ms = Math.min(15000, 500 * 2 ** Math.min(this.retries++, 5))
86
+ this.retryTimer = setTimeout(() => { this.retryTimer = null; this.sendOpen() }, ms)
87
+ if (this.retryTimer.unref) this.retryTimer.unref()
88
+ }
89
+ onFrame(f) {
90
+ if (this.destroyed) return
91
+ if (f.kind === F.OPENED) {
92
+ this.opened = true
93
+ this.retries = 0
94
+ const e = encoding.createEncoder()
95
+ encoding.writeVarUint(e, messageSync)
96
+ syncProtocol.writeSyncStep1(e, this.doc)
97
+ this.mux.send(encodeFrame(F.MSG, this.id, encoding.toUint8Array(e)))
98
+ this.facade.emit('status', [{ status: 'connected' }])
99
+ } else if (f.kind === F.MSG) {
100
+ try { this.onMessage(f.payload) } catch (e) {
101
+ // A message this doc could not apply. Skipping it would leave a gap that every later
102
+ // update builds on, silently; reopening makes the relay run the sync exchange again,
103
+ // and that carries whatever was missed.
104
+ this.mux.log(`[hive] could not apply a relay message for ${JSON.stringify(this.docName)} (${e && e.message}); resyncing it`)
105
+ this.reopen()
106
+ }
107
+ } else if (f.kind === F.REFUSED) {
108
+ this.opened = false
109
+ this.facade.emit('connection-error', [{ code: f.code, status: f.code, reason: f.reason, message: `Unexpected server response: ${f.code} ${f.reason}` }])
110
+ this.retryLater()
111
+ } else if (f.kind === F.CLOSED) {
112
+ this.lost({ code: 4000, reason: f.reason })
113
+ this.retryLater()
114
+ }
115
+ }
116
+ onMessage(payload) {
117
+ let d
118
+ try { d = decoding.createDecoder(payload) } catch { return }
119
+ const type = decoding.readVarUint(d)
120
+ if (type !== messageSync) return // awareness/auth/query: file docs carry no presence
121
+ const e = encoding.createEncoder()
122
+ encoding.writeVarUint(e, messageSync)
123
+ const t = syncProtocol.readSyncMessage(d, e, this.doc, this.facade)
124
+ if (t === syncProtocol.messageYjsSyncStep2 && !this.synced) {
125
+ this.synced = true
126
+ this.facade.emit('sync', [true])
127
+ }
128
+ if (encoding.length(e) > 1) this.mux.send(encodeFrame(F.MSG, this.id, encoding.toUint8Array(e)))
129
+ }
130
+ reopen() {
131
+ if (this.destroyed) return
132
+ if (this.mux.connected) this.mux.send(encodeFrame(F.CLOSE, this.id))
133
+ this.lost({ code: 4001, reason: 'resync' })
134
+ this.retryLater()
135
+ }
136
+ /** The channel is gone (socket dropped, or the relay closed it); a reconnect will reopen it. */
137
+ lost(ev = null) {
138
+ const wasOpen = this.opened
139
+ this.opened = false
140
+ if (this.synced) { this.synced = false; this.facade.emit('sync', [false]) }
141
+ if (wasOpen) {
142
+ this.facade.emit('connection-close', [ev])
143
+ this.facade.emit('status', [{ status: 'disconnected' }])
144
+ }
145
+ }
146
+ destroy() {
147
+ if (this.destroyed) return
148
+ this.destroyed = true
149
+ clearTimeout(this.retryTimer)
150
+ try { this.doc.off('update', this._onUpdate) } catch { }
151
+ if (this.mux.connected) this.mux.send(encodeFrame(F.CLOSE, this.id))
152
+ this.mux.channels.delete(this.id)
153
+ }
154
+ }
155
+
156
+ /** The participant's one physical socket, carrying a channel per open file doc. */
157
+ class MuxConnection {
158
+ constructor({ relay, room, params, WebSocketPolyfill, log, onRefused }) {
159
+ const q = new URLSearchParams({ ...(params || {}), mux: String(MUX_VERSION) })
160
+ this.url = `${String(relay).replace(/\/+$/, '')}/${muxPath(room)}?${q}`
161
+ this.WS = WebSocketPolyfill
162
+ this.log = log || (() => { })
163
+ this.onRefused = onRefused || (() => { })
164
+ this.channels = new Map()
165
+ this.nextId = 1
166
+ this.ws = null
167
+ this.connected = false
168
+ this.connecting = false
169
+ this.destroyed = false
170
+ this.failures = 0
171
+ this.lastHeard = 0
172
+ this.reconnectTimer = null
173
+ // The relay sends a BEAT every 20s; 45s of silence means the socket is dead even if the OS
174
+ // has not said so, which is exactly the half-open state a laptop lid produces.
175
+ this.liveness = setInterval(() => {
176
+ // terminate, not close: a close handshake cannot complete on a dead link, and ws waits 30s more for it
177
+ if (this.connected && Date.now() - this.lastHeard > 45000) { try { this.ws.terminate ? this.ws.terminate() : this.ws.close() } catch { } }
178
+ }, 5000)
179
+ if (this.liveness.unref) this.liveness.unref()
180
+ }
181
+ channel(docName, doc, facade) {
182
+ const ch = new Channel(this, this.nextId++, docName, doc, facade)
183
+ this.channels.set(ch.id, ch)
184
+ if (this.connected) ch.sendOpen()
185
+ else this.connect()
186
+ return ch
187
+ }
188
+ send(bytes) {
189
+ if (!this.connected || !this.ws) return
190
+ try { this.ws.send(bytes) } catch { /* the close handler takes it from here */ }
191
+ }
192
+ connect() {
193
+ if (this.destroyed || this.connected || this.connecting) return
194
+ this.connecting = true
195
+ let ws
196
+ try { ws = new this.WS(this.url) } catch (e) { this.connecting = false; this.scheduleReconnect(); return }
197
+ ws.binaryType = 'arraybuffer'
198
+ this.ws = ws
199
+ ws.onopen = () => {
200
+ if (this.ws !== ws) return
201
+ this.connecting = false
202
+ this.connected = true
203
+ this.failures = 0
204
+ this.lastHeard = Date.now()
205
+ for (const ch of this.channels.values()) ch.sendOpen()
206
+ }
207
+ ws.onmessage = (ev) => {
208
+ if (this.ws !== ws) return
209
+ this.lastHeard = Date.now()
210
+ let f
211
+ try { f = decodeFrame(new Uint8Array(ev.data)) } catch { return }
212
+ if (f.kind === F.BEAT) return
213
+ const ch = this.channels.get(f.id)
214
+ if (ch) ch.onFrame(f)
215
+ }
216
+ ws.onerror = (ev) => {
217
+ if (this.ws !== ws) return
218
+ // A refused UPGRADE of the mux socket itself (401/402/403/429/503) refuses every file on it.
219
+ // Hand each unsynced channel the same event a refused per-file socket produced.
220
+ const m = /Unexpected server response:\s*(\d{3})/.exec((ev && (ev.message || (ev.error && ev.error.message))) || '')
221
+ const code = m ? Number(m[1]) : null
222
+ for (const ch of this.channels.values()) {
223
+ if (!ch.synced) ch.facade.emit('connection-error', [{ code, status: code, message: (ev && ev.message) || 'mux socket error' }])
224
+ }
225
+ if (code) this.onRefused(code)
226
+ }
227
+ ws.onclose = (ev) => {
228
+ if (this.ws !== ws) return
229
+ this.ws = null
230
+ this.connected = false
231
+ this.connecting = false
232
+ for (const ch of this.channels.values()) ch.lost(ev)
233
+ this.scheduleReconnect()
234
+ }
235
+ }
236
+ scheduleReconnect() {
237
+ if (this.destroyed || this.reconnectTimer || !this.channels.size) return
238
+ const ms = Math.min(5000, 100 * 2 ** Math.min(this.failures++, 6))
239
+ this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect() }, ms)
240
+ if (this.reconnectTimer.unref) this.reconnectTimer.unref()
241
+ }
242
+ destroy() {
243
+ this.destroyed = true
244
+ clearInterval(this.liveness)
245
+ clearTimeout(this.reconnectTimer)
246
+ const ws = this.ws
247
+ this.ws = null
248
+ // Disconnected FIRST: closing the socket ends every channel on the relay at once, so no
249
+ // channel sends a CLOSE frame of its own down a socket that is about to go.
250
+ this.connected = false
251
+ for (const ch of [...this.channels.values()]) { try { ch.destroy() } catch { } }
252
+ try { if (ws) ws.close() } catch { }
253
+ }
254
+ }
255
+
256
+ /**
257
+ * One per startSync. provide(docName, doc) returns the provider for a file doc; the transport is
258
+ * decided once -- HIVE_MUX=1 forces mux, HIVE_MUX=0 forces a socket per file, anything else asks
259
+ * the relay's /health -- and file providers created before the answer are bound when it comes.
260
+ */
261
+ export function createFileTransport({ relay, room, wsOpts = {}, log = () => { }, env = process.env, fetchImpl = relayFetch, onFallback = () => { }, retryMs = 1000, detectForMs = 90000 }) {
262
+ const forced = env.HIVE_MUX === '0' || env.HIVE_MUX === '1'
263
+ let mode = env.HIVE_MUX === '0' ? 'socket' : env.HIVE_MUX === '1' ? 'mux' : null
264
+ let mux = null
265
+ let stopped = false
266
+ const pending = new Set()
267
+ const health = () => Promise.resolve()
268
+ .then(() => fetchImpl(`${httpBase(relay)}/health`, { signal: AbortSignal.timeout(20000) }))
269
+ .then((r) => (r && r.ok ? r.json() : null))
270
+ const offersMux = (j) => !!j && Number(j.mux) >= MUX_VERSION
271
+
272
+ // THE KILL SWITCH HAS TO REACH CLIENTS THAT ARE ALREADY CONNECTED. A relay that stops serving
273
+ // mux -- an operator setting HIVE_RELAY_MUX=off, or a rollback to a build that predates it --
274
+ // refuses the mux socket from then on, and without this every file would stay refused until
275
+ // the agent restarted. A 404 says so outright. Any other refusal is checked against /health, at
276
+ // most once a minute, because a relay that predates mux reads the mux path as a room name and
277
+ // refuses the token -- a 401 that looks exactly like a real one.
278
+ let lastCheck = 0
279
+ const onRefused = (code) => {
280
+ if (forced || stopped || mode !== 'mux') return
281
+ if (code === 404) return fallBack('the relay no longer serves one-socket mode (404)')
282
+ const now = Date.now()
283
+ if (now - lastCheck < 60000) return
284
+ lastCheck = now
285
+ health().then((j) => { if (j && !offersMux(j)) fallBack('the relay stopped offering one-socket mode') }, () => { })
286
+ }
287
+ const fallBack = (why) => {
288
+ if (stopped || mode !== 'mux') return
289
+ mode = 'socket'
290
+ log(`[hive] ${why}: falling back to a socket per file`)
291
+ // sync.js lets go of every file beyond the per-file bound FIRST. Without that, a client holding
292
+ // 900 files on one socket would open 900 sockets at once -- the storm the bound exists to stop.
293
+ try { onFallback() } catch { }
294
+ const moving = mux ? [...mux.channels.values()].map((ch) => ch.facade) : []
295
+ try { if (mux) mux.destroy() } catch { }
296
+ mux = null
297
+ for (const fp of moving) { if (!fp.destroyed) fp._bindLegacy(relay, wsOpts) }
298
+ }
299
+ const bind = (fp) => {
300
+ if (fp.destroyed) return
301
+ if (mode === 'mux') {
302
+ if (!mux) mux = new MuxConnection({ relay, room, params: wsOpts.params, WebSocketPolyfill: wsOpts.WebSocketPolyfill, log, onRefused })
303
+ fp._impl = mux.channel(fp.docName, fp.doc, fp)
304
+ } else {
305
+ fp._bindLegacy(relay, wsOpts)
306
+ }
307
+ }
308
+ let resolveReady
309
+ const ready = new Promise((r) => { resolveReady = r })
310
+ const decide = (m) => {
311
+ if (!mode) mode = m
312
+ for (const fp of pending) bind(fp)
313
+ pending.clear()
314
+ resolveReady(mode)
315
+ }
316
+ // ONLY AN ANSWER DECIDES. A free Render instance takes 30-60s to wake and says nothing at all
317
+ // meanwhile; reading that silence as "no mux" pinned the whole session to a socket per file.
318
+ // So silence (a timeout, a refused connection, a 5xx from a proxy fronting a booting service) is
319
+ // asked again for up to ~90s. Any real answer decides at once -- including an old relay's
320
+ // plain-text /health, which is a relay that has never heard of mux. Out of time: a socket per
321
+ // file, the transport every relay has always spoken.
322
+ const detect = async () => {
323
+ const deadline = Date.now() + detectForMs
324
+ for (let wait = retryMs; ; wait = Math.min(wait * 2, 15000)) {
325
+ try {
326
+ const r = await fetchImpl(`${httpBase(relay)}/health`, { signal: AbortSignal.timeout(20000) })
327
+ if (r && r.status < 500) {
328
+ let j = null
329
+ try { j = JSON.parse(await r.text()) } catch { /* not JSON: an old relay, or not a relay */ }
330
+ return r.ok && offersMux(j) ? 'mux' : 'socket'
331
+ }
332
+ } catch { /* no answer yet */ }
333
+ if (stopped || Date.now() + wait > deadline) return 'socket'
334
+ await new Promise((res) => { const t = setTimeout(res, wait); if (t.unref) t.unref() })
335
+ }
336
+ }
337
+ if (mode) resolveReady(mode)
338
+ else detect().then(decide, () => decide('socket'))
339
+ return {
340
+ ready,
341
+ get mode() { return mode || 'detecting' },
342
+ provide(docName, doc) {
343
+ const fp = new FileProvider(docName, doc)
344
+ if (mode) bind(fp)
345
+ else pending.add(fp)
346
+ return fp
347
+ },
348
+ stats() { return { mode: mode || 'detecting', channels: mux ? mux.channels.size : 0, muxConnected: !!(mux && mux.connected) } },
349
+ stop() { stopped = true; try { if (mux) mux.destroy() } catch { } },
350
+ }
351
+ }