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