hivecode-mcp 0.14.1 → 0.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +105 -105
- package/hive-activity.js +336 -336
- package/hive-authors.js +46 -44
- package/hive-await.js +230 -230
- package/hive-branch.js +608 -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 +695 -691
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +520 -520
- package/hive-mcp-http.js +591 -591
- package/hive-mcp.js +33 -6
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +351 -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 +600 -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 +1668 -1668
- package/hive-room-watcher.js +562 -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 +2384 -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 +578 -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 +851 -851
- package/lang-brace.js +305 -305
- package/lang-js.js +651 -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 +1 -1
- 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 +169 -11
- package/token.js +201 -201
package/hive-transport.js
CHANGED
|
@@ -1,578 +1,578 @@
|
|
|
1
|
-
// hive-transport.js -- how a client reaches the relay: a WebSocket where the network allows one, and
|
|
2
|
-
// HTTPS long-poll where it does not (wire format and why: hive-poll-proto.js).
|
|
3
|
-
//
|
|
4
|
-
// HiveSocket is what every provider is given as its WebSocketPolyfill. It behaves like a WebSocket
|
|
5
|
-
// to y-websocket and to the mux client (readyState, binaryType, onopen/onmessage/onerror/onclose,
|
|
6
|
-
// send, close, terminate), so nothing above it knows or cares which pipe carries the bytes.
|
|
7
|
-
//
|
|
8
|
-
// THE DECISION, per relay, made once and remembered for the process:
|
|
9
|
-
// - HIVE_TRANSPORT=ws forces WebSocket; HIVE_TRANSPORT=http forces long-poll.
|
|
10
|
-
// - Otherwise a WebSocket first. Only when it fails BEFORE opening, and the failure says a network
|
|
11
|
-
// is in the way (an HTTP answer instead of the upgrade -- "403 WebSocket upgrades are not
|
|
12
|
-
// allowed" on Antigravity's VMs -- or any failure while an HTTP(S) proxy is configured), is the
|
|
13
|
-
// relay's /health read over plain HTTPS. If it offers poll, this socket and every later one to
|
|
14
|
-
// that relay use long-poll. A relay that does not offer it gets the original error, unchanged.
|
|
15
|
-
// - A relay that refused for its own reasons (a bad token, a full room) refuses the poll session
|
|
16
|
-
// the same way, with the same status: there is no loop and no second opinion.
|
|
17
|
-
//
|
|
18
|
-
// Plain fetch carries the requests. On Node 24 with NODE_USE_ENV_PROXY=1 -- what Antigravity's VMs
|
|
19
|
-
// set -- fetch goes through HTTPS_PROXY and trusts NODE_EXTRA_CA_CERTS by itself.
|
|
20
|
-
//
|
|
21
|
-
// AN EXPLICIT PROXY (Claude-cloud, 8 Nov 2026). Where the only way out is a forward proxy named in
|
|
22
|
-
// HTTPS_PROXY (Claude Code cloud containers, CI runners, company laptops), outside host names do not
|
|
23
|
-
// even resolve. `ws` never reads HTTPS_PROXY, and fetch ignores it unless NODE_USE_ENV_PROXY=1 was
|
|
24
|
-
// set when node started, so the WebSocket died with ENOTFOUND, the long-poll probe died the same
|
|
25
|
-
// way, and hive_join said "the relay refused the connection" about a relay it never reached. Now:
|
|
26
|
-
// - proxyFor(url) picks the proxy: HIVE_PROXY (a URL, or off), else HTTPS_PROXY for wss:/https:
|
|
27
|
-
// and HTTP_PROXY for ws:/http:. NO_PROXY is honoured; loopback never goes through a proxy.
|
|
28
|
-
// - Both pipes ride an HTTP CONNECT tunnel through it: the WebSocket (an agent for `ws`) and the
|
|
29
|
-
// long-poll requests (fetchFor). Through a tunnel to a wss:// relay the proxy sees only
|
|
30
|
-
// "CONNECT host:443"; the path and query -- where the room token rides -- stay inside TLS.
|
|
31
|
-
// - A tunnel that fails says so: unreachedReason() tells "never reached the relay" (DNS, the
|
|
32
|
-
// proxy refused CONNECT, the proxy itself unreachable) apart from a relay that answered and
|
|
33
|
-
// refused. A proxy's 403/407 deliberately carries no `status`: statusFromError would read it as
|
|
34
|
-
// the relay's own 403 and explain a proxy problem as a bad token. Proxy credentials are sent
|
|
35
|
-
// and never printed.
|
|
36
|
-
import http from 'http'
|
|
37
|
-
import https from 'https'
|
|
38
|
-
import net from 'net'
|
|
39
|
-
import tls from 'tls'
|
|
40
|
-
import { WebSocket as WS } from 'ws'
|
|
41
|
-
import { POLL_PREFIX, POLL_VERSION, packFrames, unpackFrames } from './hive-poll-proto.js'
|
|
42
|
-
|
|
43
|
-
const CONNECTING = 0, OPEN = 1, CLOSING = 2, CLOSED = 3
|
|
44
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
45
|
-
const decided = new Map() // http origin -> 'ws' | 'poll'
|
|
46
|
-
const probes = new Map() // http origin -> { at, offers: Promise<boolean> }
|
|
47
|
-
const reported = new Set()
|
|
48
|
-
|
|
49
|
-
export function httpOrigin(wsUrl) {
|
|
50
|
-
const u = new URL(wsUrl)
|
|
51
|
-
return `${u.protocol === 'wss:' || u.protocol === 'https:' ? 'https:' : 'http:'}//${u.host}`
|
|
52
|
-
}
|
|
53
|
-
export function transportMode(env = process.env) {
|
|
54
|
-
const m = String(env.HIVE_TRANSPORT || '').trim().toLowerCase()
|
|
55
|
-
if (m === 'http' || m === 'https' || m === 'poll') return 'poll'
|
|
56
|
-
if (m === 'ws' || m === 'websocket') return 'ws'
|
|
57
|
-
return 'auto'
|
|
58
|
-
}
|
|
59
|
-
/** Which pipe this process uses for a relay: 'ws', 'poll', or 'ws' until something says otherwise. */
|
|
60
|
-
export function transportFor(relay, env = process.env) {
|
|
61
|
-
try {
|
|
62
|
-
const m = transportMode(env)
|
|
63
|
-
if (m !== 'auto') return m
|
|
64
|
-
return decided.get(httpOrigin(relay)) || 'ws'
|
|
65
|
-
} catch { return 'ws' }
|
|
66
|
-
}
|
|
67
|
-
const PROXY_OFF = /^(off|0|false|no|none|direct)$/i
|
|
68
|
-
const proxyConfigured = (env = process.env) => {
|
|
69
|
-
const hive = String(env.HIVE_PROXY || '').trim()
|
|
70
|
-
if (hive) return !PROXY_OFF.test(hive)
|
|
71
|
-
return !!(env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// ---- the proxy, when there is one -----------------------------------------------------------
|
|
75
|
-
|
|
76
|
-
const bareHost = (h) => String(h || '').replace(/^\[|\]$/g, '').toLowerCase()
|
|
77
|
-
const isLoopback = (h) => h === 'localhost' || h.endsWith('.localhost') || /^127\./.test(h) || h === '::1' || h === '0:0:0:0:0:0:0:1'
|
|
78
|
-
|
|
79
|
-
/** curl's NO_PROXY rules: "*", "host", ".suffix" / "*.suffix", "host:port", "[v6]:port"; a bare name also covers its subdomains. */
|
|
80
|
-
function noProxyMatches(host, port, list) {
|
|
81
|
-
for (const raw of String(list).split(/[\s,]+/)) {
|
|
82
|
-
const e = raw.trim().toLowerCase()
|
|
83
|
-
if (!e) continue
|
|
84
|
-
if (e === '*') return true
|
|
85
|
-
let h = e, p = ''
|
|
86
|
-
const v6 = /^\[([^\]]+)\](?::(\d+))?$/.exec(e)
|
|
87
|
-
if (v6) { h = v6[1]; p = v6[2] || '' }
|
|
88
|
-
else if ((e.match(/:/g) || []).length === 1) { [h, p] = e.split(':') }
|
|
89
|
-
if (p && p !== String(port)) continue
|
|
90
|
-
h = h.replace(/^\*\./, '.')
|
|
91
|
-
if (h.startsWith('.') ? (host === h.slice(1) || host.endsWith(h)) : (host === h || host.endsWith('.' + h))) return true
|
|
92
|
-
}
|
|
93
|
-
return false
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** The proxy to reach `target` (a ws:, wss:, http: or https: URL) through, as a URL, or null for direct. */
|
|
97
|
-
export function proxyFor(target, env = process.env) {
|
|
98
|
-
let u
|
|
99
|
-
try { u = new URL(target) } catch { return null }
|
|
100
|
-
const secure = u.protocol === 'wss:' || u.protocol === 'https:'
|
|
101
|
-
const host = bareHost(u.hostname)
|
|
102
|
-
const port = u.port || (secure ? '443' : '80')
|
|
103
|
-
if (!host || isLoopback(host)) return null
|
|
104
|
-
const hive = String(env.HIVE_PROXY || '').trim()
|
|
105
|
-
if (hive && PROXY_OFF.test(hive)) return null
|
|
106
|
-
const raw = hive || (secure ? (env.HTTPS_PROXY || env.https_proxy) : (env.HTTP_PROXY || env.http_proxy))
|
|
107
|
-
if (!raw) return null
|
|
108
|
-
const np = env.NO_PROXY != null ? env.NO_PROXY : env.no_proxy
|
|
109
|
-
if (np && noProxyMatches(host, port, np)) return null
|
|
110
|
-
try {
|
|
111
|
-
const p = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`)
|
|
112
|
-
return p.protocol === 'http:' || p.protocol === 'https:' ? p : null
|
|
113
|
-
} catch { return null }
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** host:port of a proxy, never its user:pass. */
|
|
117
|
-
const proxyLabel = (p) => `${p.hostname}:${p.port || (p.protocol === 'https:' ? 443 : 80)}`
|
|
118
|
-
|
|
119
|
-
function unreachedError(proxy, detail, extra = {}) {
|
|
120
|
-
// No `status` here, on purpose (see the header): this is the proxy talking, not the relay.
|
|
121
|
-
return Object.assign(new Error(`never reached the relay: proxy ${proxyLabel(proxy)} ${detail}`), { hiveUnreached: true, proxy: proxyLabel(proxy) }, extra)
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** An HTTP CONNECT tunnel to host:port through `proxy`: resolves with the raw socket at the far end. */
|
|
125
|
-
function openTunnel(proxy, host, port, timeoutMs = 15000) {
|
|
126
|
-
return new Promise((resolve, reject) => {
|
|
127
|
-
const target = `${net.isIPv6(host) ? `[${host}]` : host}:${port}`
|
|
128
|
-
const headers = { host: target }
|
|
129
|
-
if (proxy.username || proxy.password) {
|
|
130
|
-
const cred = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`
|
|
131
|
-
headers['proxy-authorization'] = 'Basic ' + Buffer.from(cred).toString('base64')
|
|
132
|
-
}
|
|
133
|
-
const secureProxy = proxy.protocol === 'https:'
|
|
134
|
-
const req = (secureProxy ? https : http).request({
|
|
135
|
-
host: bareHost(proxy.hostname), port: proxy.port || (secureProxy ? 443 : 80),
|
|
136
|
-
method: 'CONNECT', path: target, headers, agent: false,
|
|
137
|
-
})
|
|
138
|
-
let settled = false
|
|
139
|
-
const done = (fn, v) => { if (!settled) { settled = true; clearTimeout(timer); fn(v) } }
|
|
140
|
-
const timer = setTimeout(() => {
|
|
141
|
-
req.destroy()
|
|
142
|
-
done(reject, unreachedError(proxy, `did not answer CONNECT ${target} within ${Math.round(timeoutMs / 1000)}s`, { code: 'ETIMEDOUT' }))
|
|
143
|
-
}, timeoutMs)
|
|
144
|
-
req.once('connect', (res, sock, head) => {
|
|
145
|
-
if (res.statusCode !== 200) {
|
|
146
|
-
sock.destroy()
|
|
147
|
-
const hint = res.statusCode === 407 ? ' (it wants credentials: put user:pass in the proxy URL)' : ''
|
|
148
|
-
return done(reject, unreachedError(proxy, `refused CONNECT ${target} with HTTP ${res.statusCode}${hint}`, { proxyStatus: res.statusCode }))
|
|
149
|
-
}
|
|
150
|
-
// Bytes the far end already sent after the proxy's 200 belong to the tunnelled stream.
|
|
151
|
-
if (head && head.length) sock.unshift(head)
|
|
152
|
-
done(resolve, sock)
|
|
153
|
-
})
|
|
154
|
-
req.once('error', (e) => done(reject, unreachedError(proxy, `could not be reached: ${(e && (e.code || e.message)) || e}`, { code: e && e.code })))
|
|
155
|
-
req.end()
|
|
156
|
-
})
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const TLS_KEYS = ['ca', 'cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'ciphers', 'ALPNProtocols', 'minVersion', 'maxVersion', 'secureContext', 'checkServerIdentity', 'session']
|
|
160
|
-
const agents = new Map()
|
|
161
|
-
/**
|
|
162
|
-
* An http(s).Agent whose every connection is a CONNECT tunnel through `proxy` (TLS on top for wss:/https:).
|
|
163
|
-
* kind 'fetch' keeps tunnels alive for the long-poll requests; kind 'ws' never reuses one, so a
|
|
164
|
-
* WebSocket upgrade always gets a tunnel of its own and never rides one a poll request opened.
|
|
165
|
-
*/
|
|
166
|
-
function tunnelAgent(proxy, secure, kind = 'fetch') {
|
|
167
|
-
const key = `${kind}|${secure ? 's' : 'p'}|${proxy.href}`
|
|
168
|
-
let a = agents.get(key)
|
|
169
|
-
if (a) return a
|
|
170
|
-
a = new (secure ? https.Agent : http.Agent)(kind === 'fetch' ? { keepAlive: true, maxFreeSockets: 4 } : { keepAlive: false })
|
|
171
|
-
a.createConnection = (opts, cb) => {
|
|
172
|
-
const host = bareHost(opts.host || opts.hostname)
|
|
173
|
-
const port = Number(opts.port) || (secure ? 443 : 80)
|
|
174
|
-
openTunnel(proxy, host, port).then((sock) => {
|
|
175
|
-
if (!secure) return cb(null, sock)
|
|
176
|
-
const t = { socket: sock, host, servername: opts.servername || (net.isIP(host) ? undefined : host) }
|
|
177
|
-
for (const k of TLS_KEYS) if (opts[k] !== undefined) t[k] = opts[k]
|
|
178
|
-
cb(null, tls.connect(t))
|
|
179
|
-
}, (e) => cb(e))
|
|
180
|
-
}
|
|
181
|
-
agents.set(key, a)
|
|
182
|
-
return a
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** A fetch-shaped call carried over the tunnel agent: just what offersPoll and PollSocket use. */
|
|
186
|
-
function proxyFetch(proxy, url, init = {}) {
|
|
187
|
-
return new Promise((resolve, reject) => {
|
|
188
|
-
const u = new URL(url)
|
|
189
|
-
const secure = u.protocol === 'https:'
|
|
190
|
-
const body = init.body == null ? null : Buffer.from(init.body)
|
|
191
|
-
const headers = { ...(init.headers || {}) }
|
|
192
|
-
if (body) headers['content-length'] = String(body.length)
|
|
193
|
-
const req = (secure ? https : http).request(u, { method: init.method || 'GET', headers, agent: tunnelAgent(proxy, secure) }, (res) => {
|
|
194
|
-
const chunks = []
|
|
195
|
-
res.on('data', (c) => chunks.push(c))
|
|
196
|
-
res.on('error', reject)
|
|
197
|
-
res.on('end', () => {
|
|
198
|
-
const buf = Buffer.concat(chunks)
|
|
199
|
-
resolve({
|
|
200
|
-
status: res.statusCode,
|
|
201
|
-
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
202
|
-
headers: { get: (k) => { const v = res.headers[String(k).toLowerCase()]; return v == null ? null : Array.isArray(v) ? v.join(', ') : String(v) } },
|
|
203
|
-
json: async () => JSON.parse(buf.toString('utf8')),
|
|
204
|
-
text: async () => buf.toString('utf8'),
|
|
205
|
-
arrayBuffer: async () => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
|
|
206
|
-
})
|
|
207
|
-
})
|
|
208
|
-
})
|
|
209
|
-
req.on('error', reject)
|
|
210
|
-
const sig = init.signal
|
|
211
|
-
if (sig) {
|
|
212
|
-
// Like the built-in fetch: reject with the signal's reason (AbortSignal.timeout gives a
|
|
213
|
-
// TimeoutError, which callers such as hive-setup's wakeRelay test for by name).
|
|
214
|
-
const abort = () => req.destroy(sig.reason instanceof Error ? sig.reason : Object.assign(new Error('This operation was aborted'), { name: 'AbortError' }))
|
|
215
|
-
if (sig.aborted) abort(); else sig.addEventListener('abort', abort, { once: true })
|
|
216
|
-
}
|
|
217
|
-
req.end(body || undefined)
|
|
218
|
-
})
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** The fetch to reach `url` with: through the proxy tunnel when one applies, else the built-in one. */
|
|
222
|
-
export function fetchFor(url, env = process.env) {
|
|
223
|
-
const p = proxyFor(url, env)
|
|
224
|
-
return p ? (u, init) => proxyFetch(p, u, init) : globalThis.fetch
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* fetch(url, init) for ANY call to the relay: the route (proxy tunnel, or direct) is chosen per call
|
|
229
|
-
* from the environment at that moment. Every plain HTTP call to the relay goes through this; the
|
|
230
|
-
* built-in fetch ignores HTTPS_PROXY unless NODE_USE_ENV_PROXY=1 was set when node started
|
|
231
|
-
* (Claude-cloud, 8 Nov 2026: hive-mux's /health probe failing behind a proxy held every file ~75 s).
|
|
232
|
-
*/
|
|
233
|
-
export function relayFetch(url, init) {
|
|
234
|
-
const f = fetchFor(String(url))
|
|
235
|
-
if (typeof f !== 'function') return Promise.reject(new Error('no fetch available in this runtime'))
|
|
236
|
-
return f(url, init)
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const UNREACHED_CODES = {
|
|
240
|
-
ENOTFOUND: 'its host name did not resolve (DNS)',
|
|
241
|
-
EAI_AGAIN: 'its host name did not resolve (DNS, temporary failure)',
|
|
242
|
-
ECONNREFUSED: 'nothing accepted the connection',
|
|
243
|
-
ETIMEDOUT: 'the connection timed out',
|
|
244
|
-
EHOSTUNREACH: 'there is no route to its host',
|
|
245
|
-
ENETUNREACH: 'there is no route to its network',
|
|
246
|
-
}
|
|
247
|
-
/**
|
|
248
|
-
* If a connection error means the relay was NEVER REACHED -- DNS, no route, the proxy refused or was
|
|
249
|
-
* itself unreachable -- say so in one line; null when the relay answered (or it cannot be told).
|
|
250
|
-
* Takes an Error or the event a socket's onerror got.
|
|
251
|
-
*/
|
|
252
|
-
export function unreachedReason(x) {
|
|
253
|
-
const e = x && (x.error instanceof Error ? x.error : x)
|
|
254
|
-
if (!e || typeof e !== 'object') return null
|
|
255
|
-
if (e.hiveUnreached) return String(e.message)
|
|
256
|
-
const code = e.code || (e.cause && e.cause.code)
|
|
257
|
-
const why = typeof code === 'string' && UNREACHED_CODES[code]
|
|
258
|
-
if (!why) return null
|
|
259
|
-
const host = e.hostname || (e.cause && e.cause.hostname) || e.address
|
|
260
|
-
let s = `never reached the relay: ${why}${host ? ` (${host})` : ''} [${code}]`
|
|
261
|
-
if ((code === 'ENOTFOUND' || code === 'EAI_AGAIN') && !proxyConfigured()) s += '. If this machine reaches the internet only through a proxy, set HTTPS_PROXY (or HIVE_PROXY) to it.'
|
|
262
|
-
return s
|
|
263
|
-
}
|
|
264
|
-
function statusOf(ev) {
|
|
265
|
-
if (!ev) return null
|
|
266
|
-
if (typeof ev.status === 'number') return ev.status
|
|
267
|
-
const m = /Unexpected server response:\s*(\d{3})/.exec(String((ev && (ev.message || (ev.error && ev.error.message))) || ''))
|
|
268
|
-
return m ? Number(m[1]) : null
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/** Does this relay offer long-poll? Read from /health over plain HTTPS; remembered for a minute. */
|
|
272
|
-
export function offersPoll(origin, fetchImpl = fetchFor(origin)) {
|
|
273
|
-
// Keyed by the route too: an answer read through one proxy (or directly) says nothing about another.
|
|
274
|
-
const via = proxyFor(origin)
|
|
275
|
-
const key = `${via ? via.href : 'direct'}|${origin}`
|
|
276
|
-
const p = probes.get(key)
|
|
277
|
-
if (p && Date.now() - p.at < 60000) return p.offers
|
|
278
|
-
const offers = (async () => {
|
|
279
|
-
try {
|
|
280
|
-
const ac = new AbortController()
|
|
281
|
-
const t = setTimeout(() => ac.abort(), 15000)
|
|
282
|
-
const r = await fetchImpl(origin + '/health', { signal: ac.signal, headers: { 'cache-control': 'no-store' } }).finally(() => clearTimeout(t))
|
|
283
|
-
if (!r.ok) return false
|
|
284
|
-
const j = await r.json()
|
|
285
|
-
return Number(j && j.poll) >= 1
|
|
286
|
-
} catch { return false }
|
|
287
|
-
})()
|
|
288
|
-
probes.set(key, { at: Date.now(), offers })
|
|
289
|
-
return offers
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function fire(target, type, ev) {
|
|
293
|
-
const h = target['on' + type]
|
|
294
|
-
if (typeof h === 'function') { try { h.call(target, ev) } catch (e) { if (process.env.HIVE_DEBUG) console.error('[hive-transport] handler threw:', e) } }
|
|
295
|
-
const ls = target._listeners && target._listeners[type]
|
|
296
|
-
if (ls) for (const fn of [...ls]) { try { fn.call(target, ev) } catch { } }
|
|
297
|
-
}
|
|
298
|
-
class SocketShape {
|
|
299
|
-
constructor() {
|
|
300
|
-
this.CONNECTING = CONNECTING; this.OPEN = OPEN; this.CLOSING = CLOSING; this.CLOSED = CLOSED
|
|
301
|
-
this.onopen = null; this.onmessage = null; this.onerror = null; this.onclose = null
|
|
302
|
-
this._listeners = {}
|
|
303
|
-
}
|
|
304
|
-
addEventListener(type, fn) { (this._listeners[type] = this._listeners[type] || []).push(fn) }
|
|
305
|
-
removeEventListener(type, fn) { const l = this._listeners[type]; if (l) { const i = l.indexOf(fn); if (i >= 0) l.splice(i, 1) } }
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* One connection to the relay over HTTPS requests. A held recv brings the relay's frames the moment
|
|
310
|
-
* they exist; send posts the client's frames in order, numbered so a retry is never applied twice.
|
|
311
|
-
*/
|
|
312
|
-
export class PollSocket extends SocketShape {
|
|
313
|
-
static CONNECTING = CONNECTING
|
|
314
|
-
static OPEN = OPEN
|
|
315
|
-
static CLOSING = CLOSING
|
|
316
|
-
static CLOSED = CLOSED
|
|
317
|
-
constructor(url, _protocols, opts = {}) {
|
|
318
|
-
super()
|
|
319
|
-
this.url = url
|
|
320
|
-
this.readyState = CONNECTING
|
|
321
|
-
this.binaryType = 'nodebuffer'
|
|
322
|
-
this._fetch = opts.fetch || fetchFor(url)
|
|
323
|
-
const u = new URL(url)
|
|
324
|
-
this._base = httpOrigin(url) + POLL_PREFIX
|
|
325
|
-
this._path = u.pathname + u.search
|
|
326
|
-
this._sid = null
|
|
327
|
-
this._ack = 0 // the last relay frame received
|
|
328
|
-
this._outSeq = 1 // the number of the next frame this client sends
|
|
329
|
-
this._outbox = []
|
|
330
|
-
this._sending = false
|
|
331
|
-
this._hold = 20000
|
|
332
|
-
this._troubleSince = 0
|
|
333
|
-
this._flushQueued = false
|
|
334
|
-
setImmediate(() => { this._open() })
|
|
335
|
-
}
|
|
336
|
-
_req(op, body, headers = {}, timeoutMs = 30000) {
|
|
337
|
-
const ac = new AbortController()
|
|
338
|
-
const t = setTimeout(() => ac.abort(), timeoutMs)
|
|
339
|
-
const url = this._base + (op === 'open' ? 'open' : `${this._sid}/${op}`)
|
|
340
|
-
return this._fetch(url, { method: 'POST', headers: { 'cache-control': 'no-store', ...headers }, body, signal: ac.signal }).finally(() => clearTimeout(t))
|
|
341
|
-
}
|
|
342
|
-
async _open() {
|
|
343
|
-
if (this.readyState !== CONNECTING) return
|
|
344
|
-
let r
|
|
345
|
-
try { r = await this._req('open', JSON.stringify({ path: this._path, v: POLL_VERSION }), { 'content-type': 'application/json' }, 30000) }
|
|
346
|
-
catch (e) {
|
|
347
|
-
// The tunnel said why the relay was never reached: keep that, so unreachedReason() still can.
|
|
348
|
-
if (e && e.hiveUnreached) return this._fail(e.message, undefined, { hiveUnreached: true, proxy: e.proxy, proxyStatus: e.proxyStatus, code: e.code })
|
|
349
|
-
return this._fail(`could not reach the relay over HTTPS: ${(e && e.cause && e.cause.code) || (e && e.message) || e}`, undefined, e && e.cause && e.cause.code ? { code: e.cause.code, hostname: e.cause.hostname } : null)
|
|
350
|
-
}
|
|
351
|
-
let j = null
|
|
352
|
-
try { j = await r.json() } catch { }
|
|
353
|
-
if (r.status !== 200 || !j || !j.sid) {
|
|
354
|
-
// Worded like the ws library's handshake error, so statusFromError and every message built on
|
|
355
|
-
// it read a refused session exactly as they read a refused upgrade.
|
|
356
|
-
return this._fail(`Unexpected server response: ${r.status}${j && j.line ? ` (${j.line})` : ''}`, r.status)
|
|
357
|
-
}
|
|
358
|
-
this._sid = j.sid
|
|
359
|
-
this._hold = Math.max(2000, Math.min(Number(j.holdMs) || 20000, 25000))
|
|
360
|
-
if (this.readyState !== CONNECTING) { this._req('close', undefined, {}, 5000).catch(() => { }); return }
|
|
361
|
-
this.readyState = OPEN
|
|
362
|
-
fire(this, 'open', { type: 'open', target: this })
|
|
363
|
-
this._recvLoop()
|
|
364
|
-
this._flush()
|
|
365
|
-
}
|
|
366
|
-
_fail(message, status, extra = null) {
|
|
367
|
-
if (this.readyState === CLOSED) return
|
|
368
|
-
this.readyState = CLOSED
|
|
369
|
-
const error = Object.assign(new Error(message), extra || {}, status ? { status } : {})
|
|
370
|
-
fire(this, 'error', { type: 'error', message, error, status, target: this })
|
|
371
|
-
fire(this, 'close', { type: 'close', code: 1006, reason: message, wasClean: false, target: this })
|
|
372
|
-
}
|
|
373
|
-
_lost(reason, code = 1006) {
|
|
374
|
-
if (this.readyState === CLOSED) return
|
|
375
|
-
this.readyState = CLOSED
|
|
376
|
-
if (this._sid) this._req('close', undefined, {}, 5000).catch(() => { })
|
|
377
|
-
fire(this, 'close', { type: 'close', code, reason, wasClean: false, target: this })
|
|
378
|
-
}
|
|
379
|
-
/** The relay ended the session: say why, when it said (a revoked token, a deleted room). */
|
|
380
|
-
async _gone(r) {
|
|
381
|
-
let j = null
|
|
382
|
-
try { j = await r.json() } catch { }
|
|
383
|
-
return this._lost((j && j.reason) || 'the relay ended this session', (j && Number(j.code)) || 1006)
|
|
384
|
-
}
|
|
385
|
-
/** Bytes not yet accepted by the relay, the in-flight batch included (as a WebSocket counts them). */
|
|
386
|
-
get bufferedAmount() { let n = 0; for (const b of this._outbox) n += b.length; return n }
|
|
387
|
-
/** A request failed. Keep trying for 45 s (the relay keeps the session that long), then give up. */
|
|
388
|
-
async _trouble(e, startedAt) {
|
|
389
|
-
const now = Date.now()
|
|
390
|
-
if (!this._troubleSince) this._troubleSince = now
|
|
391
|
-
if (now - this._troubleSince > 45000) { this._lost(`the relay stopped answering over HTTPS: ${(e && e.message) || e}`); return false }
|
|
392
|
-
// A proxy that cuts long requests shows up as a failure after a long wait: hold shorter.
|
|
393
|
-
if (startedAt && now - startedAt > 5000 && this._hold > 4000) this._hold = Math.max(4000, Math.floor(this._hold / 2))
|
|
394
|
-
await sleep(Math.min(2000, 250 * 2 ** Math.min(3, Math.floor((now - this._troubleSince) / 5000))))
|
|
395
|
-
return this.readyState === OPEN
|
|
396
|
-
}
|
|
397
|
-
_ok() { this._troubleSince = 0 }
|
|
398
|
-
async _recvLoop() {
|
|
399
|
-
while (this.readyState === OPEN) {
|
|
400
|
-
const started = Date.now()
|
|
401
|
-
let r
|
|
402
|
-
try { r = await this._req('recv', undefined, { 'x-hive-ack': String(this._ack), 'x-hive-hold': String(this._hold) }, this._hold + 15000) }
|
|
403
|
-
catch (e) { if (!(await this._trouble(e, started))) return; continue }
|
|
404
|
-
if (this.readyState !== OPEN) return
|
|
405
|
-
if (r.status === 404 || r.status === 410) return this._gone(r)
|
|
406
|
-
if (r.status === 204) { this._ok(); continue }
|
|
407
|
-
if (r.status !== 200) { await r.arrayBuffer().catch(() => { }); if (!(await this._trouble(new Error(`HTTP ${r.status}`), started))) return; continue }
|
|
408
|
-
let frames, first
|
|
409
|
-
try { first = Number(r.headers.get('x-hive-seq')); frames = unpackFrames(Buffer.from(await r.arrayBuffer())) }
|
|
410
|
-
catch (e) { if (!(await this._trouble(e, started))) return; continue } // asked again: unacked frames come back
|
|
411
|
-
this._ok()
|
|
412
|
-
for (let i = 0; i < frames.length; i++) {
|
|
413
|
-
const seq = first + i
|
|
414
|
-
if (seq <= this._ack) continue // already delivered (a resend after a lost answer)
|
|
415
|
-
if (seq !== this._ack + 1) return this._lost(`a gap in the relay's messages (${this._ack}, then ${seq})`)
|
|
416
|
-
this._ack = seq
|
|
417
|
-
const f = frames[i]
|
|
418
|
-
const data = this.binaryType === 'arraybuffer' ? f.buffer.slice(f.byteOffset, f.byteOffset + f.byteLength) : Buffer.from(f)
|
|
419
|
-
fire(this, 'message', { type: 'message', data, target: this })
|
|
420
|
-
if (this.readyState !== OPEN) return
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
send(data) {
|
|
425
|
-
if (this.readyState === CONNECTING) throw new Error('WebSocket is not open: readyState 0 (CONNECTING)')
|
|
426
|
-
if (this.readyState !== OPEN) return
|
|
427
|
-
const b = data instanceof ArrayBuffer ? Buffer.from(new Uint8Array(data)) : Buffer.from(data.buffer ? Buffer.from(data.buffer, data.byteOffset, data.byteLength) : data)
|
|
428
|
-
this._outbox.push(Buffer.from(b)) // owned: the caller may reuse its buffer
|
|
429
|
-
if (!this._flushQueued) { this._flushQueued = true; setImmediate(() => { this._flushQueued = false; this._flush() }) }
|
|
430
|
-
}
|
|
431
|
-
async _flush() {
|
|
432
|
-
if (this._sending || !this._outbox.length || this.readyState !== OPEN) return
|
|
433
|
-
this._sending = true
|
|
434
|
-
try {
|
|
435
|
-
while (this._outbox.length && this.readyState === OPEN) {
|
|
436
|
-
let n = 0, bytes = 0
|
|
437
|
-
while (n < this._outbox.length && n < 512 && (n === 0 || bytes + this._outbox[n].length <= 4 * 1048576)) { bytes += this._outbox[n].length; n++ }
|
|
438
|
-
const batch = this._outbox.slice(0, n)
|
|
439
|
-
const first = this._outSeq
|
|
440
|
-
const body = packFrames(batch)
|
|
441
|
-
for (;;) {
|
|
442
|
-
if (this.readyState !== OPEN) return
|
|
443
|
-
const started = Date.now()
|
|
444
|
-
let r
|
|
445
|
-
try { r = await this._req('send', body, { 'content-type': 'application/octet-stream', 'x-hive-seq': String(first) }, 60000) }
|
|
446
|
-
catch (e) { if (!(await this._trouble(e, 0))) return; continue } // the same numbers again: never applied twice
|
|
447
|
-
if (r.status === 404 || r.status === 410) return this._gone(r)
|
|
448
|
-
await r.arrayBuffer().catch(() => { })
|
|
449
|
-
if (r.status === 204 || r.status === 200) break
|
|
450
|
-
if (r.status === 409) return this._lost("the relay saw a gap in this client's messages")
|
|
451
|
-
if (r.status === 400 || r.status === 413) return this._lost(`the relay refused a message (HTTP ${r.status})`)
|
|
452
|
-
if (!(await this._trouble(new Error(`HTTP ${r.status}`), started))) return
|
|
453
|
-
}
|
|
454
|
-
this._ok()
|
|
455
|
-
this._outbox.splice(0, n)
|
|
456
|
-
this._outSeq = first + n
|
|
457
|
-
}
|
|
458
|
-
} finally { this._sending = false }
|
|
459
|
-
}
|
|
460
|
-
close(code = 1000, reason = '') {
|
|
461
|
-
if (this.readyState === CLOSING || this.readyState === CLOSED) return
|
|
462
|
-
const had = !!this._sid
|
|
463
|
-
this.readyState = CLOSED
|
|
464
|
-
if (had) this._req('close', undefined, {}, 5000).catch(() => { })
|
|
465
|
-
setImmediate(() => fire(this, 'close', { type: 'close', code, reason, wasClean: true, target: this }))
|
|
466
|
-
}
|
|
467
|
-
terminate() { this.close(1006, 'terminated') }
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
/**
|
|
471
|
-
* The socket every provider gets: a WebSocket, or long-poll where WebSockets are refused (above).
|
|
472
|
-
*/
|
|
473
|
-
export class HiveSocket extends SocketShape {
|
|
474
|
-
static CONNECTING = CONNECTING
|
|
475
|
-
static OPEN = OPEN
|
|
476
|
-
static CLOSING = CLOSING
|
|
477
|
-
static CLOSED = CLOSED
|
|
478
|
-
constructor(url, protocols) {
|
|
479
|
-
super()
|
|
480
|
-
this.url = url
|
|
481
|
-
this.protocols = protocols
|
|
482
|
-
this.readyState = CONNECTING
|
|
483
|
-
this._bt = 'nodebuffer'
|
|
484
|
-
this._inner = null
|
|
485
|
-
this._closedByUser = false
|
|
486
|
-
let origin = ''
|
|
487
|
-
try { origin = httpOrigin(url) } catch { }
|
|
488
|
-
this._origin = origin
|
|
489
|
-
const mode = transportMode()
|
|
490
|
-
const pick = mode !== 'auto' ? mode : (decided.get(origin) || 'ws')
|
|
491
|
-
this._attach(pick === 'poll' ? new PollSocket(url, protocols) : this._makeWS(url, protocols), pick)
|
|
492
|
-
}
|
|
493
|
-
/**
|
|
494
|
-
* A WebSocket that remembers whether a refusal came from the RELAY (it marks its own with
|
|
495
|
-
* X-Hive-Relay) or from something in the way. Antigravity's review, 7 Nov: without this, a bad token
|
|
496
|
-
* or a full room printed "this network refused a WebSocket" and opened a poll session to be refused
|
|
497
|
-
* again. The error and close that follow are the ones ws itself would emit.
|
|
498
|
-
*/
|
|
499
|
-
_makeWS(url, protocols) {
|
|
500
|
-
// Behind an explicit proxy the socket rides a CONNECT tunnel (an agent wins over ws's own dialer).
|
|
501
|
-
const proxy = proxyFor(url)
|
|
502
|
-
const ws = proxy ? new WS(url, protocols, { agent: tunnelAgent(proxy, /^wss:/i.test(url), 'ws') }) : new WS(url, protocols)
|
|
503
|
-
ws.on('unexpected-response', (req, res) => {
|
|
504
|
-
this._relayRefused = !!(res.headers && res.headers['x-hive-relay'])
|
|
505
|
-
res.resume()
|
|
506
|
-
req.destroy(new Error(`Unexpected server response: ${res.statusCode}`))
|
|
507
|
-
})
|
|
508
|
-
return ws
|
|
509
|
-
}
|
|
510
|
-
get binaryType() { return this._bt }
|
|
511
|
-
set binaryType(v) { this._bt = v; if (this._inner) this._inner.binaryType = v }
|
|
512
|
-
get transport() { return this._kind }
|
|
513
|
-
get bufferedAmount() { return (this._inner && this._inner.bufferedAmount) || 0 }
|
|
514
|
-
_attach(inner, kind) {
|
|
515
|
-
this._inner = inner
|
|
516
|
-
this._kind = kind
|
|
517
|
-
this._early = null
|
|
518
|
-
inner.binaryType = this._bt
|
|
519
|
-
inner.onopen = (ev) => {
|
|
520
|
-
if (this._inner !== inner) return
|
|
521
|
-
this.readyState = OPEN
|
|
522
|
-
if (kind === 'poll' && this._origin && transportMode() === 'auto') decided.set(this._origin, 'poll')
|
|
523
|
-
fire(this, 'open', ev)
|
|
524
|
-
}
|
|
525
|
-
inner.onmessage = (ev) => { if (this._inner === inner) fire(this, 'message', ev) }
|
|
526
|
-
inner.onerror = (ev) => {
|
|
527
|
-
if (this._inner !== inner) return
|
|
528
|
-
if (this.readyState === CONNECTING && kind === 'ws' && transportMode() === 'auto') { this._early = ev; return }
|
|
529
|
-
fire(this, 'error', ev)
|
|
530
|
-
}
|
|
531
|
-
inner.onclose = (ev) => {
|
|
532
|
-
if (this._inner !== inner) return
|
|
533
|
-
if (this.readyState === CONNECTING && kind === 'ws' && transportMode() === 'auto') { this._fallBackOrFail(ev); return }
|
|
534
|
-
this.readyState = CLOSED
|
|
535
|
-
fire(this, 'close', ev)
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
async _fallBackOrFail(closeEv) {
|
|
539
|
-
const early = this._early
|
|
540
|
-
const status = statusOf(early)
|
|
541
|
-
const networkInTheWay = !this._relayRefused && (status != null || proxyConfigured())
|
|
542
|
-
let offers = false
|
|
543
|
-
if (networkInTheWay && this._origin && !this._closedByUser) {
|
|
544
|
-
this._probing = true
|
|
545
|
-
try { offers = await offersPoll(this._origin) } finally { this._probing = false }
|
|
546
|
-
}
|
|
547
|
-
if (this._closedByUser) return // close() already settled it (below)
|
|
548
|
-
if (offers) {
|
|
549
|
-
if (!reported.has(this._origin)) {
|
|
550
|
-
reported.add(this._origin)
|
|
551
|
-
console.error(`[hivecode] this network refused a WebSocket to ${this._origin}${status ? ` (HTTP ${status})` : ''}; using HTTPS long-poll instead. Everything works the same, a little slower.`)
|
|
552
|
-
}
|
|
553
|
-
this._attach(new PollSocket(this.url, this.protocols), 'poll')
|
|
554
|
-
return
|
|
555
|
-
}
|
|
556
|
-
this.readyState = CLOSED
|
|
557
|
-
if (early) fire(this, 'error', early)
|
|
558
|
-
fire(this, 'close', closeEv)
|
|
559
|
-
}
|
|
560
|
-
send(data) { return this._inner.send(data) }
|
|
561
|
-
close(code = 1000, reason = '') {
|
|
562
|
-
this._closedByUser = true
|
|
563
|
-
if (this._probing || (this.readyState === CONNECTING && !this._inner)) {
|
|
564
|
-
// Mid-switch the WebSocket is already gone and no poll socket exists yet: settle it here,
|
|
565
|
-
// once, as a WebSocket closed while connecting would.
|
|
566
|
-
if (this.readyState === CLOSED) return
|
|
567
|
-
this.readyState = CLOSED
|
|
568
|
-
setImmediate(() => fire(this, 'close', { type: 'close', code, reason, wasClean: true, target: this }))
|
|
569
|
-
return
|
|
570
|
-
}
|
|
571
|
-
try { this._inner.close(code, reason) } catch { }
|
|
572
|
-
}
|
|
573
|
-
terminate() {
|
|
574
|
-
if (this._probing) return this.close(1006, 'terminated')
|
|
575
|
-
this._closedByUser = true
|
|
576
|
-
try { this._inner.terminate ? this._inner.terminate() : this._inner.close() } catch { }
|
|
577
|
-
}
|
|
578
|
-
}
|
|
1
|
+
// hive-transport.js -- how a client reaches the relay: a WebSocket where the network allows one, and
|
|
2
|
+
// HTTPS long-poll where it does not (wire format and why: hive-poll-proto.js).
|
|
3
|
+
//
|
|
4
|
+
// HiveSocket is what every provider is given as its WebSocketPolyfill. It behaves like a WebSocket
|
|
5
|
+
// to y-websocket and to the mux client (readyState, binaryType, onopen/onmessage/onerror/onclose,
|
|
6
|
+
// send, close, terminate), so nothing above it knows or cares which pipe carries the bytes.
|
|
7
|
+
//
|
|
8
|
+
// THE DECISION, per relay, made once and remembered for the process:
|
|
9
|
+
// - HIVE_TRANSPORT=ws forces WebSocket; HIVE_TRANSPORT=http forces long-poll.
|
|
10
|
+
// - Otherwise a WebSocket first. Only when it fails BEFORE opening, and the failure says a network
|
|
11
|
+
// is in the way (an HTTP answer instead of the upgrade -- "403 WebSocket upgrades are not
|
|
12
|
+
// allowed" on Antigravity's VMs -- or any failure while an HTTP(S) proxy is configured), is the
|
|
13
|
+
// relay's /health read over plain HTTPS. If it offers poll, this socket and every later one to
|
|
14
|
+
// that relay use long-poll. A relay that does not offer it gets the original error, unchanged.
|
|
15
|
+
// - A relay that refused for its own reasons (a bad token, a full room) refuses the poll session
|
|
16
|
+
// the same way, with the same status: there is no loop and no second opinion.
|
|
17
|
+
//
|
|
18
|
+
// Plain fetch carries the requests. On Node 24 with NODE_USE_ENV_PROXY=1 -- what Antigravity's VMs
|
|
19
|
+
// set -- fetch goes through HTTPS_PROXY and trusts NODE_EXTRA_CA_CERTS by itself.
|
|
20
|
+
//
|
|
21
|
+
// AN EXPLICIT PROXY (Claude-cloud, 8 Nov 2026). Where the only way out is a forward proxy named in
|
|
22
|
+
// HTTPS_PROXY (Claude Code cloud containers, CI runners, company laptops), outside host names do not
|
|
23
|
+
// even resolve. `ws` never reads HTTPS_PROXY, and fetch ignores it unless NODE_USE_ENV_PROXY=1 was
|
|
24
|
+
// set when node started, so the WebSocket died with ENOTFOUND, the long-poll probe died the same
|
|
25
|
+
// way, and hive_join said "the relay refused the connection" about a relay it never reached. Now:
|
|
26
|
+
// - proxyFor(url) picks the proxy: HIVE_PROXY (a URL, or off), else HTTPS_PROXY for wss:/https:
|
|
27
|
+
// and HTTP_PROXY for ws:/http:. NO_PROXY is honoured; loopback never goes through a proxy.
|
|
28
|
+
// - Both pipes ride an HTTP CONNECT tunnel through it: the WebSocket (an agent for `ws`) and the
|
|
29
|
+
// long-poll requests (fetchFor). Through a tunnel to a wss:// relay the proxy sees only
|
|
30
|
+
// "CONNECT host:443"; the path and query -- where the room token rides -- stay inside TLS.
|
|
31
|
+
// - A tunnel that fails says so: unreachedReason() tells "never reached the relay" (DNS, the
|
|
32
|
+
// proxy refused CONNECT, the proxy itself unreachable) apart from a relay that answered and
|
|
33
|
+
// refused. A proxy's 403/407 deliberately carries no `status`: statusFromError would read it as
|
|
34
|
+
// the relay's own 403 and explain a proxy problem as a bad token. Proxy credentials are sent
|
|
35
|
+
// and never printed.
|
|
36
|
+
import http from 'http'
|
|
37
|
+
import https from 'https'
|
|
38
|
+
import net from 'net'
|
|
39
|
+
import tls from 'tls'
|
|
40
|
+
import { WebSocket as WS } from 'ws'
|
|
41
|
+
import { POLL_PREFIX, POLL_VERSION, packFrames, unpackFrames } from './hive-poll-proto.js'
|
|
42
|
+
|
|
43
|
+
const CONNECTING = 0, OPEN = 1, CLOSING = 2, CLOSED = 3
|
|
44
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
45
|
+
const decided = new Map() // http origin -> 'ws' | 'poll'
|
|
46
|
+
const probes = new Map() // http origin -> { at, offers: Promise<boolean> }
|
|
47
|
+
const reported = new Set()
|
|
48
|
+
|
|
49
|
+
export function httpOrigin(wsUrl) {
|
|
50
|
+
const u = new URL(wsUrl)
|
|
51
|
+
return `${u.protocol === 'wss:' || u.protocol === 'https:' ? 'https:' : 'http:'}//${u.host}`
|
|
52
|
+
}
|
|
53
|
+
export function transportMode(env = process.env) {
|
|
54
|
+
const m = String(env.HIVE_TRANSPORT || '').trim().toLowerCase()
|
|
55
|
+
if (m === 'http' || m === 'https' || m === 'poll') return 'poll'
|
|
56
|
+
if (m === 'ws' || m === 'websocket') return 'ws'
|
|
57
|
+
return 'auto'
|
|
58
|
+
}
|
|
59
|
+
/** Which pipe this process uses for a relay: 'ws', 'poll', or 'ws' until something says otherwise. */
|
|
60
|
+
export function transportFor(relay, env = process.env) {
|
|
61
|
+
try {
|
|
62
|
+
const m = transportMode(env)
|
|
63
|
+
if (m !== 'auto') return m
|
|
64
|
+
return decided.get(httpOrigin(relay)) || 'ws'
|
|
65
|
+
} catch { return 'ws' }
|
|
66
|
+
}
|
|
67
|
+
const PROXY_OFF = /^(off|0|false|no|none|direct)$/i
|
|
68
|
+
const proxyConfigured = (env = process.env) => {
|
|
69
|
+
const hive = String(env.HIVE_PROXY || '').trim()
|
|
70
|
+
if (hive) return !PROXY_OFF.test(hive)
|
|
71
|
+
return !!(env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---- the proxy, when there is one -----------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
const bareHost = (h) => String(h || '').replace(/^\[|\]$/g, '').toLowerCase()
|
|
77
|
+
const isLoopback = (h) => h === 'localhost' || h.endsWith('.localhost') || /^127\./.test(h) || h === '::1' || h === '0:0:0:0:0:0:0:1'
|
|
78
|
+
|
|
79
|
+
/** curl's NO_PROXY rules: "*", "host", ".suffix" / "*.suffix", "host:port", "[v6]:port"; a bare name also covers its subdomains. */
|
|
80
|
+
function noProxyMatches(host, port, list) {
|
|
81
|
+
for (const raw of String(list).split(/[\s,]+/)) {
|
|
82
|
+
const e = raw.trim().toLowerCase()
|
|
83
|
+
if (!e) continue
|
|
84
|
+
if (e === '*') return true
|
|
85
|
+
let h = e, p = ''
|
|
86
|
+
const v6 = /^\[([^\]]+)\](?::(\d+))?$/.exec(e)
|
|
87
|
+
if (v6) { h = v6[1]; p = v6[2] || '' }
|
|
88
|
+
else if ((e.match(/:/g) || []).length === 1) { [h, p] = e.split(':') }
|
|
89
|
+
if (p && p !== String(port)) continue
|
|
90
|
+
h = h.replace(/^\*\./, '.')
|
|
91
|
+
if (h.startsWith('.') ? (host === h.slice(1) || host.endsWith(h)) : (host === h || host.endsWith('.' + h))) return true
|
|
92
|
+
}
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The proxy to reach `target` (a ws:, wss:, http: or https: URL) through, as a URL, or null for direct. */
|
|
97
|
+
export function proxyFor(target, env = process.env) {
|
|
98
|
+
let u
|
|
99
|
+
try { u = new URL(target) } catch { return null }
|
|
100
|
+
const secure = u.protocol === 'wss:' || u.protocol === 'https:'
|
|
101
|
+
const host = bareHost(u.hostname)
|
|
102
|
+
const port = u.port || (secure ? '443' : '80')
|
|
103
|
+
if (!host || isLoopback(host)) return null
|
|
104
|
+
const hive = String(env.HIVE_PROXY || '').trim()
|
|
105
|
+
if (hive && PROXY_OFF.test(hive)) return null
|
|
106
|
+
const raw = hive || (secure ? (env.HTTPS_PROXY || env.https_proxy) : (env.HTTP_PROXY || env.http_proxy))
|
|
107
|
+
if (!raw) return null
|
|
108
|
+
const np = env.NO_PROXY != null ? env.NO_PROXY : env.no_proxy
|
|
109
|
+
if (np && noProxyMatches(host, port, np)) return null
|
|
110
|
+
try {
|
|
111
|
+
const p = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`)
|
|
112
|
+
return p.protocol === 'http:' || p.protocol === 'https:' ? p : null
|
|
113
|
+
} catch { return null }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** host:port of a proxy, never its user:pass. */
|
|
117
|
+
const proxyLabel = (p) => `${p.hostname}:${p.port || (p.protocol === 'https:' ? 443 : 80)}`
|
|
118
|
+
|
|
119
|
+
function unreachedError(proxy, detail, extra = {}) {
|
|
120
|
+
// No `status` here, on purpose (see the header): this is the proxy talking, not the relay.
|
|
121
|
+
return Object.assign(new Error(`never reached the relay: proxy ${proxyLabel(proxy)} ${detail}`), { hiveUnreached: true, proxy: proxyLabel(proxy) }, extra)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** An HTTP CONNECT tunnel to host:port through `proxy`: resolves with the raw socket at the far end. */
|
|
125
|
+
function openTunnel(proxy, host, port, timeoutMs = 15000) {
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
const target = `${net.isIPv6(host) ? `[${host}]` : host}:${port}`
|
|
128
|
+
const headers = { host: target }
|
|
129
|
+
if (proxy.username || proxy.password) {
|
|
130
|
+
const cred = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`
|
|
131
|
+
headers['proxy-authorization'] = 'Basic ' + Buffer.from(cred).toString('base64')
|
|
132
|
+
}
|
|
133
|
+
const secureProxy = proxy.protocol === 'https:'
|
|
134
|
+
const req = (secureProxy ? https : http).request({
|
|
135
|
+
host: bareHost(proxy.hostname), port: proxy.port || (secureProxy ? 443 : 80),
|
|
136
|
+
method: 'CONNECT', path: target, headers, agent: false,
|
|
137
|
+
})
|
|
138
|
+
let settled = false
|
|
139
|
+
const done = (fn, v) => { if (!settled) { settled = true; clearTimeout(timer); fn(v) } }
|
|
140
|
+
const timer = setTimeout(() => {
|
|
141
|
+
req.destroy()
|
|
142
|
+
done(reject, unreachedError(proxy, `did not answer CONNECT ${target} within ${Math.round(timeoutMs / 1000)}s`, { code: 'ETIMEDOUT' }))
|
|
143
|
+
}, timeoutMs)
|
|
144
|
+
req.once('connect', (res, sock, head) => {
|
|
145
|
+
if (res.statusCode !== 200) {
|
|
146
|
+
sock.destroy()
|
|
147
|
+
const hint = res.statusCode === 407 ? ' (it wants credentials: put user:pass in the proxy URL)' : ''
|
|
148
|
+
return done(reject, unreachedError(proxy, `refused CONNECT ${target} with HTTP ${res.statusCode}${hint}`, { proxyStatus: res.statusCode }))
|
|
149
|
+
}
|
|
150
|
+
// Bytes the far end already sent after the proxy's 200 belong to the tunnelled stream.
|
|
151
|
+
if (head && head.length) sock.unshift(head)
|
|
152
|
+
done(resolve, sock)
|
|
153
|
+
})
|
|
154
|
+
req.once('error', (e) => done(reject, unreachedError(proxy, `could not be reached: ${(e && (e.code || e.message)) || e}`, { code: e && e.code })))
|
|
155
|
+
req.end()
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const TLS_KEYS = ['ca', 'cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'ciphers', 'ALPNProtocols', 'minVersion', 'maxVersion', 'secureContext', 'checkServerIdentity', 'session']
|
|
160
|
+
const agents = new Map()
|
|
161
|
+
/**
|
|
162
|
+
* An http(s).Agent whose every connection is a CONNECT tunnel through `proxy` (TLS on top for wss:/https:).
|
|
163
|
+
* kind 'fetch' keeps tunnels alive for the long-poll requests; kind 'ws' never reuses one, so a
|
|
164
|
+
* WebSocket upgrade always gets a tunnel of its own and never rides one a poll request opened.
|
|
165
|
+
*/
|
|
166
|
+
function tunnelAgent(proxy, secure, kind = 'fetch') {
|
|
167
|
+
const key = `${kind}|${secure ? 's' : 'p'}|${proxy.href}`
|
|
168
|
+
let a = agents.get(key)
|
|
169
|
+
if (a) return a
|
|
170
|
+
a = new (secure ? https.Agent : http.Agent)(kind === 'fetch' ? { keepAlive: true, maxFreeSockets: 4 } : { keepAlive: false })
|
|
171
|
+
a.createConnection = (opts, cb) => {
|
|
172
|
+
const host = bareHost(opts.host || opts.hostname)
|
|
173
|
+
const port = Number(opts.port) || (secure ? 443 : 80)
|
|
174
|
+
openTunnel(proxy, host, port).then((sock) => {
|
|
175
|
+
if (!secure) return cb(null, sock)
|
|
176
|
+
const t = { socket: sock, host, servername: opts.servername || (net.isIP(host) ? undefined : host) }
|
|
177
|
+
for (const k of TLS_KEYS) if (opts[k] !== undefined) t[k] = opts[k]
|
|
178
|
+
cb(null, tls.connect(t))
|
|
179
|
+
}, (e) => cb(e))
|
|
180
|
+
}
|
|
181
|
+
agents.set(key, a)
|
|
182
|
+
return a
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A fetch-shaped call carried over the tunnel agent: just what offersPoll and PollSocket use. */
|
|
186
|
+
function proxyFetch(proxy, url, init = {}) {
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
const u = new URL(url)
|
|
189
|
+
const secure = u.protocol === 'https:'
|
|
190
|
+
const body = init.body == null ? null : Buffer.from(init.body)
|
|
191
|
+
const headers = { ...(init.headers || {}) }
|
|
192
|
+
if (body) headers['content-length'] = String(body.length)
|
|
193
|
+
const req = (secure ? https : http).request(u, { method: init.method || 'GET', headers, agent: tunnelAgent(proxy, secure) }, (res) => {
|
|
194
|
+
const chunks = []
|
|
195
|
+
res.on('data', (c) => chunks.push(c))
|
|
196
|
+
res.on('error', reject)
|
|
197
|
+
res.on('end', () => {
|
|
198
|
+
const buf = Buffer.concat(chunks)
|
|
199
|
+
resolve({
|
|
200
|
+
status: res.statusCode,
|
|
201
|
+
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
202
|
+
headers: { get: (k) => { const v = res.headers[String(k).toLowerCase()]; return v == null ? null : Array.isArray(v) ? v.join(', ') : String(v) } },
|
|
203
|
+
json: async () => JSON.parse(buf.toString('utf8')),
|
|
204
|
+
text: async () => buf.toString('utf8'),
|
|
205
|
+
arrayBuffer: async () => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
req.on('error', reject)
|
|
210
|
+
const sig = init.signal
|
|
211
|
+
if (sig) {
|
|
212
|
+
// Like the built-in fetch: reject with the signal's reason (AbortSignal.timeout gives a
|
|
213
|
+
// TimeoutError, which callers such as hive-setup's wakeRelay test for by name).
|
|
214
|
+
const abort = () => req.destroy(sig.reason instanceof Error ? sig.reason : Object.assign(new Error('This operation was aborted'), { name: 'AbortError' }))
|
|
215
|
+
if (sig.aborted) abort(); else sig.addEventListener('abort', abort, { once: true })
|
|
216
|
+
}
|
|
217
|
+
req.end(body || undefined)
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The fetch to reach `url` with: through the proxy tunnel when one applies, else the built-in one. */
|
|
222
|
+
export function fetchFor(url, env = process.env) {
|
|
223
|
+
const p = proxyFor(url, env)
|
|
224
|
+
return p ? (u, init) => proxyFetch(p, u, init) : globalThis.fetch
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* fetch(url, init) for ANY call to the relay: the route (proxy tunnel, or direct) is chosen per call
|
|
229
|
+
* from the environment at that moment. Every plain HTTP call to the relay goes through this; the
|
|
230
|
+
* built-in fetch ignores HTTPS_PROXY unless NODE_USE_ENV_PROXY=1 was set when node started
|
|
231
|
+
* (Claude-cloud, 8 Nov 2026: hive-mux's /health probe failing behind a proxy held every file ~75 s).
|
|
232
|
+
*/
|
|
233
|
+
export function relayFetch(url, init) {
|
|
234
|
+
const f = fetchFor(String(url))
|
|
235
|
+
if (typeof f !== 'function') return Promise.reject(new Error('no fetch available in this runtime'))
|
|
236
|
+
return f(url, init)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const UNREACHED_CODES = {
|
|
240
|
+
ENOTFOUND: 'its host name did not resolve (DNS)',
|
|
241
|
+
EAI_AGAIN: 'its host name did not resolve (DNS, temporary failure)',
|
|
242
|
+
ECONNREFUSED: 'nothing accepted the connection',
|
|
243
|
+
ETIMEDOUT: 'the connection timed out',
|
|
244
|
+
EHOSTUNREACH: 'there is no route to its host',
|
|
245
|
+
ENETUNREACH: 'there is no route to its network',
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* If a connection error means the relay was NEVER REACHED -- DNS, no route, the proxy refused or was
|
|
249
|
+
* itself unreachable -- say so in one line; null when the relay answered (or it cannot be told).
|
|
250
|
+
* Takes an Error or the event a socket's onerror got.
|
|
251
|
+
*/
|
|
252
|
+
export function unreachedReason(x) {
|
|
253
|
+
const e = x && (x.error instanceof Error ? x.error : x)
|
|
254
|
+
if (!e || typeof e !== 'object') return null
|
|
255
|
+
if (e.hiveUnreached) return String(e.message)
|
|
256
|
+
const code = e.code || (e.cause && e.cause.code)
|
|
257
|
+
const why = typeof code === 'string' && UNREACHED_CODES[code]
|
|
258
|
+
if (!why) return null
|
|
259
|
+
const host = e.hostname || (e.cause && e.cause.hostname) || e.address
|
|
260
|
+
let s = `never reached the relay: ${why}${host ? ` (${host})` : ''} [${code}]`
|
|
261
|
+
if ((code === 'ENOTFOUND' || code === 'EAI_AGAIN') && !proxyConfigured()) s += '. If this machine reaches the internet only through a proxy, set HTTPS_PROXY (or HIVE_PROXY) to it.'
|
|
262
|
+
return s
|
|
263
|
+
}
|
|
264
|
+
function statusOf(ev) {
|
|
265
|
+
if (!ev) return null
|
|
266
|
+
if (typeof ev.status === 'number') return ev.status
|
|
267
|
+
const m = /Unexpected server response:\s*(\d{3})/.exec(String((ev && (ev.message || (ev.error && ev.error.message))) || ''))
|
|
268
|
+
return m ? Number(m[1]) : null
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Does this relay offer long-poll? Read from /health over plain HTTPS; remembered for a minute. */
|
|
272
|
+
export function offersPoll(origin, fetchImpl = fetchFor(origin)) {
|
|
273
|
+
// Keyed by the route too: an answer read through one proxy (or directly) says nothing about another.
|
|
274
|
+
const via = proxyFor(origin)
|
|
275
|
+
const key = `${via ? via.href : 'direct'}|${origin}`
|
|
276
|
+
const p = probes.get(key)
|
|
277
|
+
if (p && Date.now() - p.at < 60000) return p.offers
|
|
278
|
+
const offers = (async () => {
|
|
279
|
+
try {
|
|
280
|
+
const ac = new AbortController()
|
|
281
|
+
const t = setTimeout(() => ac.abort(), 15000)
|
|
282
|
+
const r = await fetchImpl(origin + '/health', { signal: ac.signal, headers: { 'cache-control': 'no-store' } }).finally(() => clearTimeout(t))
|
|
283
|
+
if (!r.ok) return false
|
|
284
|
+
const j = await r.json()
|
|
285
|
+
return Number(j && j.poll) >= 1
|
|
286
|
+
} catch { return false }
|
|
287
|
+
})()
|
|
288
|
+
probes.set(key, { at: Date.now(), offers })
|
|
289
|
+
return offers
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function fire(target, type, ev) {
|
|
293
|
+
const h = target['on' + type]
|
|
294
|
+
if (typeof h === 'function') { try { h.call(target, ev) } catch (e) { if (process.env.HIVE_DEBUG) console.error('[hive-transport] handler threw:', e) } }
|
|
295
|
+
const ls = target._listeners && target._listeners[type]
|
|
296
|
+
if (ls) for (const fn of [...ls]) { try { fn.call(target, ev) } catch { } }
|
|
297
|
+
}
|
|
298
|
+
class SocketShape {
|
|
299
|
+
constructor() {
|
|
300
|
+
this.CONNECTING = CONNECTING; this.OPEN = OPEN; this.CLOSING = CLOSING; this.CLOSED = CLOSED
|
|
301
|
+
this.onopen = null; this.onmessage = null; this.onerror = null; this.onclose = null
|
|
302
|
+
this._listeners = {}
|
|
303
|
+
}
|
|
304
|
+
addEventListener(type, fn) { (this._listeners[type] = this._listeners[type] || []).push(fn) }
|
|
305
|
+
removeEventListener(type, fn) { const l = this._listeners[type]; if (l) { const i = l.indexOf(fn); if (i >= 0) l.splice(i, 1) } }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* One connection to the relay over HTTPS requests. A held recv brings the relay's frames the moment
|
|
310
|
+
* they exist; send posts the client's frames in order, numbered so a retry is never applied twice.
|
|
311
|
+
*/
|
|
312
|
+
export class PollSocket extends SocketShape {
|
|
313
|
+
static CONNECTING = CONNECTING
|
|
314
|
+
static OPEN = OPEN
|
|
315
|
+
static CLOSING = CLOSING
|
|
316
|
+
static CLOSED = CLOSED
|
|
317
|
+
constructor(url, _protocols, opts = {}) {
|
|
318
|
+
super()
|
|
319
|
+
this.url = url
|
|
320
|
+
this.readyState = CONNECTING
|
|
321
|
+
this.binaryType = 'nodebuffer'
|
|
322
|
+
this._fetch = opts.fetch || fetchFor(url)
|
|
323
|
+
const u = new URL(url)
|
|
324
|
+
this._base = httpOrigin(url) + POLL_PREFIX
|
|
325
|
+
this._path = u.pathname + u.search
|
|
326
|
+
this._sid = null
|
|
327
|
+
this._ack = 0 // the last relay frame received
|
|
328
|
+
this._outSeq = 1 // the number of the next frame this client sends
|
|
329
|
+
this._outbox = []
|
|
330
|
+
this._sending = false
|
|
331
|
+
this._hold = 20000
|
|
332
|
+
this._troubleSince = 0
|
|
333
|
+
this._flushQueued = false
|
|
334
|
+
setImmediate(() => { this._open() })
|
|
335
|
+
}
|
|
336
|
+
_req(op, body, headers = {}, timeoutMs = 30000) {
|
|
337
|
+
const ac = new AbortController()
|
|
338
|
+
const t = setTimeout(() => ac.abort(), timeoutMs)
|
|
339
|
+
const url = this._base + (op === 'open' ? 'open' : `${this._sid}/${op}`)
|
|
340
|
+
return this._fetch(url, { method: 'POST', headers: { 'cache-control': 'no-store', ...headers }, body, signal: ac.signal }).finally(() => clearTimeout(t))
|
|
341
|
+
}
|
|
342
|
+
async _open() {
|
|
343
|
+
if (this.readyState !== CONNECTING) return
|
|
344
|
+
let r
|
|
345
|
+
try { r = await this._req('open', JSON.stringify({ path: this._path, v: POLL_VERSION }), { 'content-type': 'application/json' }, 30000) }
|
|
346
|
+
catch (e) {
|
|
347
|
+
// The tunnel said why the relay was never reached: keep that, so unreachedReason() still can.
|
|
348
|
+
if (e && e.hiveUnreached) return this._fail(e.message, undefined, { hiveUnreached: true, proxy: e.proxy, proxyStatus: e.proxyStatus, code: e.code })
|
|
349
|
+
return this._fail(`could not reach the relay over HTTPS: ${(e && e.cause && e.cause.code) || (e && e.message) || e}`, undefined, e && e.cause && e.cause.code ? { code: e.cause.code, hostname: e.cause.hostname } : null)
|
|
350
|
+
}
|
|
351
|
+
let j = null
|
|
352
|
+
try { j = await r.json() } catch { }
|
|
353
|
+
if (r.status !== 200 || !j || !j.sid) {
|
|
354
|
+
// Worded like the ws library's handshake error, so statusFromError and every message built on
|
|
355
|
+
// it read a refused session exactly as they read a refused upgrade.
|
|
356
|
+
return this._fail(`Unexpected server response: ${r.status}${j && j.line ? ` (${j.line})` : ''}`, r.status)
|
|
357
|
+
}
|
|
358
|
+
this._sid = j.sid
|
|
359
|
+
this._hold = Math.max(2000, Math.min(Number(j.holdMs) || 20000, 25000))
|
|
360
|
+
if (this.readyState !== CONNECTING) { this._req('close', undefined, {}, 5000).catch(() => { }); return }
|
|
361
|
+
this.readyState = OPEN
|
|
362
|
+
fire(this, 'open', { type: 'open', target: this })
|
|
363
|
+
this._recvLoop()
|
|
364
|
+
this._flush()
|
|
365
|
+
}
|
|
366
|
+
_fail(message, status, extra = null) {
|
|
367
|
+
if (this.readyState === CLOSED) return
|
|
368
|
+
this.readyState = CLOSED
|
|
369
|
+
const error = Object.assign(new Error(message), extra || {}, status ? { status } : {})
|
|
370
|
+
fire(this, 'error', { type: 'error', message, error, status, target: this })
|
|
371
|
+
fire(this, 'close', { type: 'close', code: 1006, reason: message, wasClean: false, target: this })
|
|
372
|
+
}
|
|
373
|
+
_lost(reason, code = 1006) {
|
|
374
|
+
if (this.readyState === CLOSED) return
|
|
375
|
+
this.readyState = CLOSED
|
|
376
|
+
if (this._sid) this._req('close', undefined, {}, 5000).catch(() => { })
|
|
377
|
+
fire(this, 'close', { type: 'close', code, reason, wasClean: false, target: this })
|
|
378
|
+
}
|
|
379
|
+
/** The relay ended the session: say why, when it said (a revoked token, a deleted room). */
|
|
380
|
+
async _gone(r) {
|
|
381
|
+
let j = null
|
|
382
|
+
try { j = await r.json() } catch { }
|
|
383
|
+
return this._lost((j && j.reason) || 'the relay ended this session', (j && Number(j.code)) || 1006)
|
|
384
|
+
}
|
|
385
|
+
/** Bytes not yet accepted by the relay, the in-flight batch included (as a WebSocket counts them). */
|
|
386
|
+
get bufferedAmount() { let n = 0; for (const b of this._outbox) n += b.length; return n }
|
|
387
|
+
/** A request failed. Keep trying for 45 s (the relay keeps the session that long), then give up. */
|
|
388
|
+
async _trouble(e, startedAt) {
|
|
389
|
+
const now = Date.now()
|
|
390
|
+
if (!this._troubleSince) this._troubleSince = now
|
|
391
|
+
if (now - this._troubleSince > 45000) { this._lost(`the relay stopped answering over HTTPS: ${(e && e.message) || e}`); return false }
|
|
392
|
+
// A proxy that cuts long requests shows up as a failure after a long wait: hold shorter.
|
|
393
|
+
if (startedAt && now - startedAt > 5000 && this._hold > 4000) this._hold = Math.max(4000, Math.floor(this._hold / 2))
|
|
394
|
+
await sleep(Math.min(2000, 250 * 2 ** Math.min(3, Math.floor((now - this._troubleSince) / 5000))))
|
|
395
|
+
return this.readyState === OPEN
|
|
396
|
+
}
|
|
397
|
+
_ok() { this._troubleSince = 0 }
|
|
398
|
+
async _recvLoop() {
|
|
399
|
+
while (this.readyState === OPEN) {
|
|
400
|
+
const started = Date.now()
|
|
401
|
+
let r
|
|
402
|
+
try { r = await this._req('recv', undefined, { 'x-hive-ack': String(this._ack), 'x-hive-hold': String(this._hold) }, this._hold + 15000) }
|
|
403
|
+
catch (e) { if (!(await this._trouble(e, started))) return; continue }
|
|
404
|
+
if (this.readyState !== OPEN) return
|
|
405
|
+
if (r.status === 404 || r.status === 410) return this._gone(r)
|
|
406
|
+
if (r.status === 204) { this._ok(); continue }
|
|
407
|
+
if (r.status !== 200) { await r.arrayBuffer().catch(() => { }); if (!(await this._trouble(new Error(`HTTP ${r.status}`), started))) return; continue }
|
|
408
|
+
let frames, first
|
|
409
|
+
try { first = Number(r.headers.get('x-hive-seq')); frames = unpackFrames(Buffer.from(await r.arrayBuffer())) }
|
|
410
|
+
catch (e) { if (!(await this._trouble(e, started))) return; continue } // asked again: unacked frames come back
|
|
411
|
+
this._ok()
|
|
412
|
+
for (let i = 0; i < frames.length; i++) {
|
|
413
|
+
const seq = first + i
|
|
414
|
+
if (seq <= this._ack) continue // already delivered (a resend after a lost answer)
|
|
415
|
+
if (seq !== this._ack + 1) return this._lost(`a gap in the relay's messages (${this._ack}, then ${seq})`)
|
|
416
|
+
this._ack = seq
|
|
417
|
+
const f = frames[i]
|
|
418
|
+
const data = this.binaryType === 'arraybuffer' ? f.buffer.slice(f.byteOffset, f.byteOffset + f.byteLength) : Buffer.from(f)
|
|
419
|
+
fire(this, 'message', { type: 'message', data, target: this })
|
|
420
|
+
if (this.readyState !== OPEN) return
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
send(data) {
|
|
425
|
+
if (this.readyState === CONNECTING) throw new Error('WebSocket is not open: readyState 0 (CONNECTING)')
|
|
426
|
+
if (this.readyState !== OPEN) return
|
|
427
|
+
const b = data instanceof ArrayBuffer ? Buffer.from(new Uint8Array(data)) : Buffer.from(data.buffer ? Buffer.from(data.buffer, data.byteOffset, data.byteLength) : data)
|
|
428
|
+
this._outbox.push(Buffer.from(b)) // owned: the caller may reuse its buffer
|
|
429
|
+
if (!this._flushQueued) { this._flushQueued = true; setImmediate(() => { this._flushQueued = false; this._flush() }) }
|
|
430
|
+
}
|
|
431
|
+
async _flush() {
|
|
432
|
+
if (this._sending || !this._outbox.length || this.readyState !== OPEN) return
|
|
433
|
+
this._sending = true
|
|
434
|
+
try {
|
|
435
|
+
while (this._outbox.length && this.readyState === OPEN) {
|
|
436
|
+
let n = 0, bytes = 0
|
|
437
|
+
while (n < this._outbox.length && n < 512 && (n === 0 || bytes + this._outbox[n].length <= 4 * 1048576)) { bytes += this._outbox[n].length; n++ }
|
|
438
|
+
const batch = this._outbox.slice(0, n)
|
|
439
|
+
const first = this._outSeq
|
|
440
|
+
const body = packFrames(batch)
|
|
441
|
+
for (;;) {
|
|
442
|
+
if (this.readyState !== OPEN) return
|
|
443
|
+
const started = Date.now()
|
|
444
|
+
let r
|
|
445
|
+
try { r = await this._req('send', body, { 'content-type': 'application/octet-stream', 'x-hive-seq': String(first) }, 60000) }
|
|
446
|
+
catch (e) { if (!(await this._trouble(e, 0))) return; continue } // the same numbers again: never applied twice
|
|
447
|
+
if (r.status === 404 || r.status === 410) return this._gone(r)
|
|
448
|
+
await r.arrayBuffer().catch(() => { })
|
|
449
|
+
if (r.status === 204 || r.status === 200) break
|
|
450
|
+
if (r.status === 409) return this._lost("the relay saw a gap in this client's messages")
|
|
451
|
+
if (r.status === 400 || r.status === 413) return this._lost(`the relay refused a message (HTTP ${r.status})`)
|
|
452
|
+
if (!(await this._trouble(new Error(`HTTP ${r.status}`), started))) return
|
|
453
|
+
}
|
|
454
|
+
this._ok()
|
|
455
|
+
this._outbox.splice(0, n)
|
|
456
|
+
this._outSeq = first + n
|
|
457
|
+
}
|
|
458
|
+
} finally { this._sending = false }
|
|
459
|
+
}
|
|
460
|
+
close(code = 1000, reason = '') {
|
|
461
|
+
if (this.readyState === CLOSING || this.readyState === CLOSED) return
|
|
462
|
+
const had = !!this._sid
|
|
463
|
+
this.readyState = CLOSED
|
|
464
|
+
if (had) this._req('close', undefined, {}, 5000).catch(() => { })
|
|
465
|
+
setImmediate(() => fire(this, 'close', { type: 'close', code, reason, wasClean: true, target: this }))
|
|
466
|
+
}
|
|
467
|
+
terminate() { this.close(1006, 'terminated') }
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* The socket every provider gets: a WebSocket, or long-poll where WebSockets are refused (above).
|
|
472
|
+
*/
|
|
473
|
+
export class HiveSocket extends SocketShape {
|
|
474
|
+
static CONNECTING = CONNECTING
|
|
475
|
+
static OPEN = OPEN
|
|
476
|
+
static CLOSING = CLOSING
|
|
477
|
+
static CLOSED = CLOSED
|
|
478
|
+
constructor(url, protocols) {
|
|
479
|
+
super()
|
|
480
|
+
this.url = url
|
|
481
|
+
this.protocols = protocols
|
|
482
|
+
this.readyState = CONNECTING
|
|
483
|
+
this._bt = 'nodebuffer'
|
|
484
|
+
this._inner = null
|
|
485
|
+
this._closedByUser = false
|
|
486
|
+
let origin = ''
|
|
487
|
+
try { origin = httpOrigin(url) } catch { }
|
|
488
|
+
this._origin = origin
|
|
489
|
+
const mode = transportMode()
|
|
490
|
+
const pick = mode !== 'auto' ? mode : (decided.get(origin) || 'ws')
|
|
491
|
+
this._attach(pick === 'poll' ? new PollSocket(url, protocols) : this._makeWS(url, protocols), pick)
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* A WebSocket that remembers whether a refusal came from the RELAY (it marks its own with
|
|
495
|
+
* X-Hive-Relay) or from something in the way. Antigravity's review, 7 Nov: without this, a bad token
|
|
496
|
+
* or a full room printed "this network refused a WebSocket" and opened a poll session to be refused
|
|
497
|
+
* again. The error and close that follow are the ones ws itself would emit.
|
|
498
|
+
*/
|
|
499
|
+
_makeWS(url, protocols) {
|
|
500
|
+
// Behind an explicit proxy the socket rides a CONNECT tunnel (an agent wins over ws's own dialer).
|
|
501
|
+
const proxy = proxyFor(url)
|
|
502
|
+
const ws = proxy ? new WS(url, protocols, { agent: tunnelAgent(proxy, /^wss:/i.test(url), 'ws') }) : new WS(url, protocols)
|
|
503
|
+
ws.on('unexpected-response', (req, res) => {
|
|
504
|
+
this._relayRefused = !!(res.headers && res.headers['x-hive-relay'])
|
|
505
|
+
res.resume()
|
|
506
|
+
req.destroy(new Error(`Unexpected server response: ${res.statusCode}`))
|
|
507
|
+
})
|
|
508
|
+
return ws
|
|
509
|
+
}
|
|
510
|
+
get binaryType() { return this._bt }
|
|
511
|
+
set binaryType(v) { this._bt = v; if (this._inner) this._inner.binaryType = v }
|
|
512
|
+
get transport() { return this._kind }
|
|
513
|
+
get bufferedAmount() { return (this._inner && this._inner.bufferedAmount) || 0 }
|
|
514
|
+
_attach(inner, kind) {
|
|
515
|
+
this._inner = inner
|
|
516
|
+
this._kind = kind
|
|
517
|
+
this._early = null
|
|
518
|
+
inner.binaryType = this._bt
|
|
519
|
+
inner.onopen = (ev) => {
|
|
520
|
+
if (this._inner !== inner) return
|
|
521
|
+
this.readyState = OPEN
|
|
522
|
+
if (kind === 'poll' && this._origin && transportMode() === 'auto') decided.set(this._origin, 'poll')
|
|
523
|
+
fire(this, 'open', ev)
|
|
524
|
+
}
|
|
525
|
+
inner.onmessage = (ev) => { if (this._inner === inner) fire(this, 'message', ev) }
|
|
526
|
+
inner.onerror = (ev) => {
|
|
527
|
+
if (this._inner !== inner) return
|
|
528
|
+
if (this.readyState === CONNECTING && kind === 'ws' && transportMode() === 'auto') { this._early = ev; return }
|
|
529
|
+
fire(this, 'error', ev)
|
|
530
|
+
}
|
|
531
|
+
inner.onclose = (ev) => {
|
|
532
|
+
if (this._inner !== inner) return
|
|
533
|
+
if (this.readyState === CONNECTING && kind === 'ws' && transportMode() === 'auto') { this._fallBackOrFail(ev); return }
|
|
534
|
+
this.readyState = CLOSED
|
|
535
|
+
fire(this, 'close', ev)
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
async _fallBackOrFail(closeEv) {
|
|
539
|
+
const early = this._early
|
|
540
|
+
const status = statusOf(early)
|
|
541
|
+
const networkInTheWay = !this._relayRefused && (status != null || proxyConfigured())
|
|
542
|
+
let offers = false
|
|
543
|
+
if (networkInTheWay && this._origin && !this._closedByUser) {
|
|
544
|
+
this._probing = true
|
|
545
|
+
try { offers = await offersPoll(this._origin) } finally { this._probing = false }
|
|
546
|
+
}
|
|
547
|
+
if (this._closedByUser) return // close() already settled it (below)
|
|
548
|
+
if (offers) {
|
|
549
|
+
if (!reported.has(this._origin)) {
|
|
550
|
+
reported.add(this._origin)
|
|
551
|
+
console.error(`[hivecode] this network refused a WebSocket to ${this._origin}${status ? ` (HTTP ${status})` : ''}; using HTTPS long-poll instead. Everything works the same, a little slower.`)
|
|
552
|
+
}
|
|
553
|
+
this._attach(new PollSocket(this.url, this.protocols), 'poll')
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
this.readyState = CLOSED
|
|
557
|
+
if (early) fire(this, 'error', early)
|
|
558
|
+
fire(this, 'close', closeEv)
|
|
559
|
+
}
|
|
560
|
+
send(data) { return this._inner.send(data) }
|
|
561
|
+
close(code = 1000, reason = '') {
|
|
562
|
+
this._closedByUser = true
|
|
563
|
+
if (this._probing || (this.readyState === CONNECTING && !this._inner)) {
|
|
564
|
+
// Mid-switch the WebSocket is already gone and no poll socket exists yet: settle it here,
|
|
565
|
+
// once, as a WebSocket closed while connecting would.
|
|
566
|
+
if (this.readyState === CLOSED) return
|
|
567
|
+
this.readyState = CLOSED
|
|
568
|
+
setImmediate(() => fire(this, 'close', { type: 'close', code, reason, wasClean: true, target: this }))
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
try { this._inner.close(code, reason) } catch { }
|
|
572
|
+
}
|
|
573
|
+
terminate() {
|
|
574
|
+
if (this._probing) return this.close(1006, 'terminated')
|
|
575
|
+
this._closedByUser = true
|
|
576
|
+
try { this._inner.terminate ? this._inner.terminate() : this._inner.close() } catch { }
|
|
577
|
+
}
|
|
578
|
+
}
|