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