hivecode-mcp 0.12.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/HIVE_RULES.md +11 -40
  2. package/LICENSE +21 -21
  3. package/README.md +421 -167
  4. package/core.js +91 -91
  5. package/docs/ALWAYS_ON.md +310 -0
  6. package/docs/GITHUB_WORKFLOW.md +334 -0
  7. package/docs/HIVE_RULES.md +41 -41
  8. package/docs/MCP.md +157 -118
  9. package/docs/QUICKSTART.md +266 -0
  10. package/docs/SELF_HOSTING.md +256 -256
  11. package/docs/SLACK_SETUP.md +450 -0
  12. package/docs/TROUBLESHOOTING.md +87 -0
  13. package/hive-activity.js +336 -310
  14. package/hive-await.js +207 -0
  15. package/hive-branch.js +686 -0
  16. package/hive-busy.js +88 -88
  17. package/hive-coord.js +110 -110
  18. package/hive-gate.js +135 -135
  19. package/hive-github.js +1904 -0
  20. package/hive-hook-install.js +179 -103
  21. package/hive-instructions.js +74 -66
  22. package/hive-listen.js +81 -0
  23. package/hive-mailbox.js +520 -386
  24. package/hive-mcp-http.js +580 -0
  25. package/hive-mcp.js +579 -137
  26. package/hive-mux-proto.js +58 -58
  27. package/hive-mux.js +350 -350
  28. package/hive-notify.js +233 -0
  29. package/hive-obligations.js +247 -247
  30. package/hive-pr-staleness.js +189 -189
  31. package/hive-pr.js +350 -0
  32. package/hive-privacy.js +327 -288
  33. package/hive-receipts.js +233 -233
  34. package/hive-relay-health.js +230 -225
  35. package/hive-room-key.js +209 -0
  36. package/hive-secrets.js +89 -0
  37. package/hive-session.js +114 -0
  38. package/hive-setup.js +558 -445
  39. package/hive-slack-agent.js +396 -0
  40. package/hive-slack.js +1809 -0
  41. package/hive-stdio.js +172 -168
  42. package/hive-stop-hook.js +293 -241
  43. package/hive-throttle.js +118 -118
  44. package/hive-time.js +71 -0
  45. package/hive-update.js +58 -58
  46. package/hive-version.js +110 -59
  47. package/hive-webhook.js +54 -0
  48. package/icr-merge.js +58 -58
  49. package/icr.js +851 -745
  50. package/lang-json.js +211 -211
  51. package/lang-ruby.js +230 -230
  52. package/lang-toml.js +136 -136
  53. package/lang-yaml.js +183 -183
  54. package/limits.js +413 -394
  55. package/package.json +189 -164
  56. package/reconcile.cjs +614 -477
  57. package/slack-app-manifest-agent.json +61 -0
  58. package/slack-app-manifest-inbound.json +36 -0
  59. package/slack-app-manifest.json +27 -0
  60. package/substrate.js +252 -252
  61. package/sync.js +1956 -177
  62. package/grm-link.cjs +0 -141
package/hive-mux.js CHANGED
@@ -1,350 +1,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
-
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
+
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
+ }