hivecode-mcp 0.12.0 → 0.12.1
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/README.md +93 -280
- package/docs/MCP.md +18 -10
- package/docs/SELF_HOSTING.md +10 -7
- package/hive-mcp.js +108 -12
- package/hive-mux-proto.js +58 -0
- package/hive-mux.js +350 -0
- package/hive-privacy.js +25 -0
- package/hive-relay-health.js +24 -4
- package/hive-setup.js +445 -0
- package/hive-stdio.js +168 -0
- package/hive-update.js +58 -0
- package/limits.js +49 -1
- package/package.json +16 -7
- package/sync.js +106 -25
package/hive-mcp.js
CHANGED
|
@@ -20,12 +20,14 @@
|
|
|
20
20
|
// it returns (read chat + board before editing, announce intent, prefer patches).
|
|
21
21
|
|
|
22
22
|
import fs from 'fs'
|
|
23
|
+
import os from 'os'
|
|
23
24
|
import path from 'path'
|
|
24
25
|
import crypto from 'crypto'
|
|
25
26
|
import { fileURLToPath } from 'url'
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
import { ListToolsRequestSchema, CallToolRequestSchema } from '
|
|
27
|
+
// The stdio server is our own, not @modelcontextprotocol/sdk's: the SDK drags express, hono, zod
|
|
28
|
+
// and ~85 more packages into every install for a slice this small. See hive-stdio.js.
|
|
29
|
+
import { Server, StdioServerTransport, ListToolsRequestSchema, CallToolRequestSchema } from './hive-stdio.js'
|
|
30
|
+
import { startUpdateCheck, updateNotice } from './hive-update.js'
|
|
29
31
|
// Substrate defaults for MCP agents: signed provenance + the silent-fork gate are ON
|
|
30
32
|
// unless explicitly disabled. Agents register this server with arbitrary (often empty)
|
|
31
33
|
// env, so relying on per-registration env vars left rooms in mixed signed/unsigned mode —
|
|
@@ -34,7 +36,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprot
|
|
|
34
36
|
if (!process.env.HIVE_PROVENANCE) process.env.HIVE_PROVENANCE = 'on'
|
|
35
37
|
if (!process.env.HIVE_FORK_GATE) process.env.HIVE_FORK_GATE = 'on'
|
|
36
38
|
import { startSync, parseLink } from './sync.js'
|
|
37
|
-
import { checkRelay, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
|
|
39
|
+
import { checkRelay, currentRelayFor, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
|
|
38
40
|
import { createMailbox, formatMessage, mentionsName, mentionedNames, mentionsAll, unansweredFor, priorityOf, ambientDigest, validateMemberName } from './hive-mailbox.js'
|
|
39
41
|
import { INSTRUCTIONS } from './hive-instructions.js'
|
|
40
42
|
import { makeBusy, isBusy, describeRecipient } from './hive-busy.js'
|
|
@@ -63,7 +65,16 @@ const text = (s) => {
|
|
|
63
65
|
}
|
|
64
66
|
const err = (s) => ({ content: [{ type: 'text', text: s }], isError: true })
|
|
65
67
|
|
|
68
|
+
// Every route to a relay passes through here, so a retired public relay is followed to its
|
|
69
|
+
// replacement in one place -- whether it arrived in a pasted link, a .hive.json written before
|
|
70
|
+
// the move, or a HIVE_RELAY our own docs once told people to set. See currentRelayFor().
|
|
66
71
|
function resolveRoom(link, dir) {
|
|
72
|
+
const r = resolveRoomRaw(link, dir)
|
|
73
|
+
const { relay, migratedFrom } = currentRelayFor(r.relay)
|
|
74
|
+
return { ...r, relay, migratedFrom }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveRoomRaw(link, dir) {
|
|
67
78
|
if (link) { const { relay, room, token } = parseLink(link); return { relay: relay || DEFAULT_RELAY, room, token, mode: 'joined (link)' } }
|
|
68
79
|
const cfgPath = path.join(path.resolve(dir), '.hive.json')
|
|
69
80
|
if (fs.existsSync(cfgPath)) {
|
|
@@ -105,7 +116,49 @@ async function tryResumeSession() {
|
|
|
105
116
|
} catch { /* resume is best-effort; the agent can always hive_join explicitly */ }
|
|
106
117
|
}
|
|
107
118
|
|
|
108
|
-
|
|
119
|
+
// WHERE AN AGENT'S FILES GO WHEN NOBODY SAID. This was './workspace', resolved against the
|
|
120
|
+
// directory the agent app happened to start this server in: the user's project for Claude Code,
|
|
121
|
+
// something unwritable or unguessable for others -- and the SAME folder for two agents started
|
|
122
|
+
// alike, which is the one thing a room must never have (one folder per participant). Now it is
|
|
123
|
+
// ~/hivecode/<room>/<name>: absolute, writable, and different for every name.
|
|
124
|
+
//
|
|
125
|
+
// One exception keeps returning users whole: an agent that joined the old way and now calls
|
|
126
|
+
// hive_join with nothing, expecting its room back from ./workspace/.hive.json, still gets it.
|
|
127
|
+
// Sending it to a new folder instead would host a brand-new room and report success -- the agent
|
|
128
|
+
// would be alone, told it was joined.
|
|
129
|
+
const HIVE_HOME = process.env.HIVE_HOME || path.join(os.homedir(), 'hivecode')
|
|
130
|
+
const LEGACY_DIR = './workspace'
|
|
131
|
+
function safeSegment(s) {
|
|
132
|
+
const t = String(s || '').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[.]+/, '_').slice(0, 64) || '_'
|
|
133
|
+
return /^(con|prn|aux|nul|com\d|lpt\d)$/i.test(t) ? '_' + t : t
|
|
134
|
+
}
|
|
135
|
+
function roomIn(dir) {
|
|
136
|
+
try { return JSON.parse(fs.readFileSync(path.join(path.resolve(dir), '.hive.json'), 'utf8')).room || null } catch { return null }
|
|
137
|
+
}
|
|
138
|
+
function defaultJoinTarget(link, name) {
|
|
139
|
+
// The folders this agent already has a room in: the one its last session used (hive_leave
|
|
140
|
+
// deletes that record, so it only survives a disconnect or a restart), then the old default.
|
|
141
|
+
const prev = readSessionFile()
|
|
142
|
+
const prevDir = prev && prev.dir && roomIn(prev.dir) ? prev.dir : null
|
|
143
|
+
const legacy = roomIn(LEGACY_DIR) ? LEGACY_DIR : null
|
|
144
|
+
if (!link) {
|
|
145
|
+
// "No link, no folder" has always meant "my room, if I have one". Hosting a new one instead
|
|
146
|
+
// would leave an agent that meant to rejoin alone in an empty room, told it had joined.
|
|
147
|
+
const back = prevDir || legacy
|
|
148
|
+
if (back) return { dir: back, link, hostedHere: false }
|
|
149
|
+
const room = 'room-' + crypto.randomBytes(13).toString('base64url')
|
|
150
|
+
return { dir: path.join(HIVE_HOME, room, safeSegment(name)), link: `${DEFAULT_RELAY}|${room}`, hostedHere: true }
|
|
151
|
+
}
|
|
152
|
+
const room = parseLink(link).room
|
|
153
|
+
if (!room) return { dir: LEGACY_DIR, link, hostedHere: false }
|
|
154
|
+
// Rejoining a room this agent was already in lands in the folder it used, files and all. The
|
|
155
|
+
// session record must be under THIS name: two runtimes started in one directory share it.
|
|
156
|
+
if (prevDir && prev.name === name && roomIn(prevDir) === room) return { dir: prevDir, link, hostedHere: false }
|
|
157
|
+
if (legacy && roomIn(legacy) === room) return { dir: legacy, link, hostedHere: false }
|
|
158
|
+
return { dir: path.join(HIVE_HOME, safeSegment(room), safeSegment(name)), link, hostedHere: false }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function joinRoom({ link = '', dir = '', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false }) {
|
|
109
162
|
// REFUSED BEFORE THE OLD SESSION IS TORN DOWN. joinRoom's first act is to stop whatever session
|
|
110
163
|
// exists, so validating after that point would leave an agent with a bad name holding nothing at
|
|
111
164
|
// all -- it would lose a working room to a refusal it could have been given for free.
|
|
@@ -118,9 +171,12 @@ Nothing was joined and your current session is untouched. Re-run hive_join with
|
|
|
118
171
|
This is refused rather than fixed for you because renaming silently is worse: if another member is already called "${nameCheck.suggested}", you would be merged into them -- one awareness entry, one read cursor, one mailbox -- and neither of you would be told. Your own prompt would also still say your name is "${name}", so you would stop recognising messages about yourself.`
|
|
119
172
|
}
|
|
120
173
|
if (session) { try { session.hive.stop() } catch {} ; session = null }
|
|
174
|
+
let hostedHere = false
|
|
175
|
+
if (!dir) ({ dir, link, hostedHere } = defaultJoinTarget(link, name))
|
|
121
176
|
const ROOT = path.resolve(dir)
|
|
122
177
|
fs.mkdirSync(ROOT, { recursive: true })
|
|
123
|
-
const { relay, room, token: linkToken, mode } = resolveRoom(link, dir)
|
|
178
|
+
const { relay, room, token: linkToken, mode: resolvedMode, migratedFrom } = resolveRoom(link, dir)
|
|
179
|
+
const mode = hostedHere ? 'HOSTED (new room)' : resolvedMode
|
|
124
180
|
// THE PRIVACY GATE, and note where it sits: after the relay is resolved, BEFORE the
|
|
125
181
|
// .hive.json write below and before startSync opens a socket. A refused join must leave
|
|
126
182
|
// nothing behind -- no config pointing at the wrong relay for the next process to find,
|
|
@@ -143,7 +199,13 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
143
199
|
const useToken = token || linkToken || process.env.HIVE_TOKEN || ''
|
|
144
200
|
// persist the token too, so a re-join from .hive.json keeps the grant (the
|
|
145
201
|
// invitee pastes the link once; subsequent joins just read the folder config).
|
|
146
|
-
|
|
202
|
+
// pinnedRelay SURVIVES the rewrite. This line used to write only relay/room/token, so a pin
|
|
203
|
+
// kept in .hive.json -- one of the two places readPin() looks -- was erased by the very first
|
|
204
|
+
// join that honoured it. Only the pin is carried over: an old token must not follow the
|
|
205
|
+
// folder into a different room.
|
|
206
|
+
let keep = {}
|
|
207
|
+
try { const old = JSON.parse(fs.readFileSync(path.join(ROOT, '.hive.json'), 'utf8')); if (old && old.pinnedRelay) keep = { pinnedRelay: old.pinnedRelay } } catch { /* none yet */ }
|
|
208
|
+
fs.writeFileSync(path.join(ROOT, '.hive.json'), JSON.stringify({ relay, room, ...(useToken ? { token: useToken } : {}), ...keep }, null, 2))
|
|
147
209
|
// The ACCOUNT licence is passed through but NOT persisted to .hive.json above, and that
|
|
148
210
|
// asymmetry is deliberate. `.hive.json` sits in the project folder, so it gets committed
|
|
149
211
|
// and it gets shared -- fine for a room invite, which is what it is for, and wrong for a
|
|
@@ -331,7 +393,13 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
331
393
|
// bracketed verdict is what makes "did my source just leave the building?" answerable
|
|
332
394
|
// without asking, and the warning line below is what makes it actionable.
|
|
333
395
|
const privNote = priv.warn ? `\n\n!! ${priv.warn}` : ''
|
|
334
|
-
|
|
396
|
+
// Said out loud, because changing where someone's files go without telling them is the thing
|
|
397
|
+
// this whole module exists to prevent -- even when the move is to where they meant to be.
|
|
398
|
+
const movedNote = migratedFrom
|
|
399
|
+
? `\n\nMOVED: this folder pointed at ${migratedFrom}, a Hivecode relay that has been retired. ` +
|
|
400
|
+
`It now uses ${relay}, and .hive.json has been updated, so this happens once.`
|
|
401
|
+
: ''
|
|
402
|
+
return `${mode}\nroom: ${room}\nrelay: ${relay} [${priv.headline}]\nfolder: ${ROOT}\nhivecode: ${VERSION}\nothers here: ${others.join(', ') || 'none yet'}\ninvite link: ${inviteLink}${movedNote}${privNote}\n\nANNOUNCE YOUR VERSION: say "joined on hivecode ${VERSION}" in chat. Mixed builds in one room cause data loss, and this is the only way anyone finds out.${updateNotice() ? '\n' + updateNotice() : ''}\n\n--- FOLLOW THESE RULES ---\n${rulesText}`
|
|
335
403
|
}
|
|
336
404
|
|
|
337
405
|
function requireSession() { if (!session) throw new Error('not in a room — call hive_join first'); return session }
|
|
@@ -473,7 +541,7 @@ function waitForWork(timeoutMs, opts = {}) {
|
|
|
473
541
|
|
|
474
542
|
const TOOLS = [
|
|
475
543
|
{ name: 'hive_join', description: 'Join (or host) a Hivecode room for a project folder. Joins automatically as an AI participant. If a join link is given, uses it; else reads <dir>/.hive.json; else hosts a new room. Returns the room info and the HIVE_RULES you must follow.',
|
|
476
|
-
inputSchema: { type: 'object', properties: { link: { type: 'string', description: 'optional join link "wss://relay|room"' }, dir: { type: 'string', description: '
|
|
544
|
+
inputSchema: { type: 'object', properties: { link: { type: 'string', description: 'optional join link "wss://relay|room"' }, dir: { type: 'string', description: 'folder to sync. Leave it out and Hivecode uses ~/hivecode/<room>/<your name>, a folder of your own. Never share a folder with another participant.' }, name: { type: 'string', description: 'your display name in the room' }, owner: { type: 'string', description: 'the human responsible for you — only they may approve tasks directed at you' }, token: { type: 'string', description: 'access token for a secured room (else uses $HIVE_TOKEN). Required only if the relay runs in auth-required mode.' }, kind: { type: 'string', description: 'participant kind (ai or human)' }, passive: { type: 'boolean', description: 'true = this participant is a passive listener or bot daemon and does not take turns or answer questions' } } } },
|
|
477
545
|
{ name: 'hive_assign', description: 'Direct a task at another participant by name (e.g. ask another agent to work on a file). If the target is an AI with an owner, it stays PENDING until that owner approves.',
|
|
478
546
|
inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name to assign to' }, text: { type: 'string', description: 'what to do' } }, required: ['to', 'text'] } },
|
|
479
547
|
{ name: 'hive_read_tasks', description: 'Read tasks. Shows tasks directed at YOU (act only on accepted ones) and all pending tasks awaiting approval.',
|
|
@@ -1318,7 +1386,7 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
|
|
|
1318
1386
|
// answer to the second -- the public relay and a company's own are the same shape.
|
|
1319
1387
|
const pv = checkRelay({ relay: s.relay, env: process.env, dir: s.dir, fs })
|
|
1320
1388
|
return text({
|
|
1321
|
-
room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION,
|
|
1389
|
+
room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION, ...(updateNotice() ? { update: updateNotice() } : {}),
|
|
1322
1390
|
privacy: pv.headline,
|
|
1323
1391
|
...(pv.warn ? { privacyWarning: pv.warn } : {}),
|
|
1324
1392
|
...(pv.pin && pv.pin.pinned ? { relayPin: pv.pin.origins.join(', ') } : {}),
|
|
@@ -1470,10 +1538,37 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
1470
1538
|
|
|
1471
1539
|
// Only claim stdio when this file IS the program. Importing it (a test, a tool) must not
|
|
1472
1540
|
// hijack the JSON-RPC channel — that is what made the wait/mailbox logic untestable before.
|
|
1473
|
-
|
|
1474
|
-
|
|
1541
|
+
//
|
|
1542
|
+
// COMPARED AS REAL PATHS. On macOS and Linux, npx starts a package's bin through the symlink in
|
|
1543
|
+
// node_modules/.bin. Node resolves that symlink for import.meta.url and NOT for argv[1], so the
|
|
1544
|
+
// plain comparison this used to make was false there: the server never started, the process
|
|
1545
|
+
// exited 0 with no output, and the agent app showed nothing but "failed to start". Reproduced on
|
|
1546
|
+
// 25 Sep 2026 through a Windows junction, which Node treats the same way.
|
|
1547
|
+
function isMainModule() {
|
|
1548
|
+
if (!process.argv[1]) return false
|
|
1549
|
+
const me = fileURLToPath(import.meta.url)
|
|
1550
|
+
try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(me) } catch { return path.resolve(process.argv[1]) === path.resolve(me) }
|
|
1551
|
+
}
|
|
1552
|
+
const isEntryPoint = isMainModule()
|
|
1553
|
+
if (isEntryPoint && process.argv[2] === 'setup') {
|
|
1554
|
+
// `npx -y hivecode-mcp@latest setup` -- configures every agent app on this machine and checks
|
|
1555
|
+
// each one starts. It never opens stdio: nobody is speaking JSON-RPC to a setup run.
|
|
1556
|
+
const { main } = await import('./hive-setup.js')
|
|
1557
|
+
process.exit(await main(process.argv.slice(3)))
|
|
1558
|
+
} else if (isEntryPoint && (process.argv[2] === '--version' || process.argv[2] === '-v')) {
|
|
1559
|
+
console.log(VERSION)
|
|
1560
|
+
process.exit(0)
|
|
1561
|
+
} else if (isEntryPoint) {
|
|
1475
1562
|
const transport = new StdioServerTransport()
|
|
1563
|
+
// STDIN CLOSING IS THE AGENT APP SAYING GOODBYE -- the MCP spec's stdio shutdown. Leave the
|
|
1564
|
+
// room and exit. The SDK kept this process alive on its open sockets instead, holding a seat
|
|
1565
|
+
// and a presence entry for a member that was gone, until something killed it.
|
|
1566
|
+
server.onclose = () => {
|
|
1567
|
+
try { if (session) session.hive.stop() } catch { /* leaving is best-effort; exiting is not */ }
|
|
1568
|
+
setTimeout(() => process.exit(0), 500)
|
|
1569
|
+
}
|
|
1476
1570
|
await server.connect(transport)
|
|
1571
|
+
startUpdateCheck() // background, short timeout; hive_join and hive_status show what it finds
|
|
1477
1572
|
// stderr is fine for logs; stdout is the JSON-RPC channel and must stay clean.
|
|
1478
1573
|
console.error('[hive-mcp] Hivecode MCP server ready (stdio). Default relay:', DEFAULT_RELAY)
|
|
1479
1574
|
}
|
|
@@ -1481,6 +1576,7 @@ if (isEntryPoint) {
|
|
|
1481
1576
|
/** Test seam: drive the real wait/delivery logic against a doc, with no relay and no stdio. */
|
|
1482
1577
|
export const _test = {
|
|
1483
1578
|
waitForWork,
|
|
1579
|
+
defaultJoinTarget,
|
|
1484
1580
|
mailbox,
|
|
1485
1581
|
// Exposed so the throttle's saving is asserted against the counter itself rather than inferred
|
|
1486
1582
|
// from what happened not to appear in a tool result. Counting absences is how you prove a thing
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// hive-mux-proto.js -- the wire format for ONE socket per participant, shared by relay and client.
|
|
2
|
+
//
|
|
3
|
+
// WHY: Hivecode opened one WebSocket per FILE. A participant held 1 + min(files, 128) sockets,
|
|
4
|
+
// so an agent in a 1000-file repository saw only 128 files on disk, and a relay's connection cap
|
|
5
|
+
// divided by 129 per person. The mux carries every file doc of one room over one socket.
|
|
6
|
+
//
|
|
7
|
+
// The design rule that makes it safe: the relay does not grow a second enforcement path. Each
|
|
8
|
+
// OPEN runs the SAME per-doc admission a per-file upgrade runs (token scope for that path, the
|
|
9
|
+
// per-room ceiling, the committer limit), and the accepted channel is wrapped in a VirtualConn
|
|
10
|
+
// that looks exactly like a ws connection to installUsageLimits, makeReadOnly, installWriteGate
|
|
11
|
+
// and setupWSConnection. The bytes inside a MSG frame are the same y-protocols message a
|
|
12
|
+
// per-file socket would carry.
|
|
13
|
+
//
|
|
14
|
+
// Frames are binary, lib0-encoded:
|
|
15
|
+
// client -> relay OPEN [0, id, docName] open this file doc as channel `id`
|
|
16
|
+
// CLOSE [1, id] the client is done with it
|
|
17
|
+
// MSG [2, id, ...payload] a y-protocols message for that doc
|
|
18
|
+
// relay -> client MSG [2, id, ...payload]
|
|
19
|
+
// OPENED [3, id] admitted; the relay's sync step 1 follows
|
|
20
|
+
// REFUSED [4, id, code, reason] not admitted -- the HTTP status a per-file
|
|
21
|
+
// upgrade would have answered, and why
|
|
22
|
+
// CLOSED [5, id, reason] the relay closed that channel
|
|
23
|
+
// BEAT [6, 0] liveness, so a dead socket is noticed
|
|
24
|
+
// Channel ids are chosen by the client and are only meaningful on the socket that opened them.
|
|
25
|
+
import * as encoding from 'lib0/encoding'
|
|
26
|
+
import * as decoding from 'lib0/decoding'
|
|
27
|
+
|
|
28
|
+
export const MUX_PREFIX = '_hive_mux/'
|
|
29
|
+
export const MUX_VERSION = 1
|
|
30
|
+
export const F = Object.freeze({ OPEN: 0, CLOSE: 1, MSG: 2, OPENED: 3, REFUSED: 4, CLOSED: 5, BEAT: 6 })
|
|
31
|
+
|
|
32
|
+
export function encodeFrame(kind, id, a, b) {
|
|
33
|
+
const e = encoding.createEncoder()
|
|
34
|
+
encoding.writeVarUint(e, kind)
|
|
35
|
+
encoding.writeVarUint(e, id >>> 0)
|
|
36
|
+
if (kind === F.OPEN) encoding.writeVarString(e, String(a))
|
|
37
|
+
else if (kind === F.MSG) encoding.writeUint8Array(e, a instanceof Uint8Array ? a : new Uint8Array(a))
|
|
38
|
+
else if (kind === F.REFUSED) { encoding.writeVarUint(e, a >>> 0); encoding.writeVarString(e, String(b || '')) }
|
|
39
|
+
else if (kind === F.CLOSED) encoding.writeVarString(e, String(a || ''))
|
|
40
|
+
return encoding.toUint8Array(e)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Decode one frame. Throws on garbage, which the caller treats as a protocol violation. */
|
|
44
|
+
export function decodeFrame(buf) {
|
|
45
|
+
const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf)
|
|
46
|
+
const d = decoding.createDecoder(u8)
|
|
47
|
+
const kind = decoding.readVarUint(d)
|
|
48
|
+
const id = decoding.readVarUint(d)
|
|
49
|
+
if (kind === F.OPEN) return { kind, id, docName: decoding.readVarString(d) }
|
|
50
|
+
if (kind === F.MSG) return { kind, id, payload: u8.subarray(d.pos) }
|
|
51
|
+
if (kind === F.REFUSED) return { kind, id, code: decoding.readVarUint(d), reason: decoding.readVarString(d) }
|
|
52
|
+
if (kind === F.CLOSED) return { kind, id, reason: decoding.readVarString(d) }
|
|
53
|
+
if (kind === F.CLOSE || kind === F.OPENED || kind === F.BEAT) return { kind, id }
|
|
54
|
+
throw new Error(`unknown mux frame kind ${kind}`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The mux upgrade path for a base room, as the relay parses it (pathname minus the slash). */
|
|
58
|
+
export function muxPath(room) { return MUX_PREFIX + encodeURIComponent(room) }
|
package/hive-mux.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// hive-mux.js -- the client half of ONE socket per participant (wire format: hive-mux-proto.js).
|
|
2
|
+
//
|
|
3
|
+
// sync.js asks this module for a provider per file doc and gets back an object with the surface
|
|
4
|
+
// it already used from y-websocket's WebsocketProvider: `synced`, the 'sync' / 'status' /
|
|
5
|
+
// 'connection-error' / 'connection-close' events, and destroy(). Behind it is EITHER a channel
|
|
6
|
+
// on the participant's one mux socket, or -- when the relay does not advertise mux in /health,
|
|
7
|
+
// or HIVE_MUX=0 -- a plain per-file WebsocketProvider exactly as before. An old relay therefore
|
|
8
|
+
// keeps working with a new client, and the choice is made once per session, before any file
|
|
9
|
+
// socket exists, so a room never ends up half on one transport by accident.
|
|
10
|
+
//
|
|
11
|
+
// Inside a channel the y-protocols exchange is the one y-websocket performs: on admission send
|
|
12
|
+
// sync step 1, answer the relay's step 1, count the session synced when step 2 arrives, forward
|
|
13
|
+
// local updates, never echo an update this provider applied. A refused OPEN is reported as a
|
|
14
|
+
// 'connection-error' carrying the same HTTP status a refused per-file upgrade carried, so every
|
|
15
|
+
// piece of refusal reporting in sync.js (Q430/Q431 counts, notSyncing, explain()) works unchanged.
|
|
16
|
+
import * as syncProtocol from 'y-protocols/sync'
|
|
17
|
+
import * as encoding from 'lib0/encoding'
|
|
18
|
+
import * as decoding from 'lib0/decoding'
|
|
19
|
+
import { ObservableV2 } from 'lib0/observable'
|
|
20
|
+
import { WebsocketProvider } from 'y-websocket'
|
|
21
|
+
import { F, encodeFrame, decodeFrame, muxPath, MUX_VERSION } from './hive-mux-proto.js'
|
|
22
|
+
|
|
23
|
+
const messageSync = 0
|
|
24
|
+
|
|
25
|
+
export const httpBase = (relay) => String(relay).replace(/^wss:/, 'https:').replace(/^ws:/, 'http:').replace(/\/+$/, '')
|
|
26
|
+
|
|
27
|
+
/** The provider sync.js holds per file. Delegates to a mux channel or a legacy provider. */
|
|
28
|
+
class FileProvider extends ObservableV2 {
|
|
29
|
+
constructor(docName, doc) {
|
|
30
|
+
super()
|
|
31
|
+
this.docName = docName
|
|
32
|
+
this.doc = doc
|
|
33
|
+
this.destroyed = false
|
|
34
|
+
this._impl = null
|
|
35
|
+
}
|
|
36
|
+
get synced() { return !!(this._impl && this._impl.synced) }
|
|
37
|
+
get wsconnected() { return !!(this._impl && this._impl.wsconnected) }
|
|
38
|
+
get transport() { return this._impl ? this._impl.transport : 'pending' }
|
|
39
|
+
_bindLegacy(relay, wsOpts) {
|
|
40
|
+
const p = new WebsocketProvider(relay, this.docName, this.doc, wsOpts)
|
|
41
|
+
for (const ev of ['sync', 'status', 'connection-error', 'connection-close']) p.on(ev, (...args) => this.emit(ev, args))
|
|
42
|
+
this._impl = { transport: 'socket', get synced() { return p.synced }, get wsconnected() { return p.wsconnected }, destroy: () => p.destroy() }
|
|
43
|
+
}
|
|
44
|
+
destroy() {
|
|
45
|
+
if (this.destroyed) return
|
|
46
|
+
this.destroyed = true
|
|
47
|
+
try { if (this._impl) this._impl.destroy() } catch { }
|
|
48
|
+
super.destroy()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class Channel {
|
|
53
|
+
constructor(mux, id, docName, doc, facade) {
|
|
54
|
+
this.mux = mux
|
|
55
|
+
this.id = id
|
|
56
|
+
this.docName = docName
|
|
57
|
+
this.doc = doc
|
|
58
|
+
this.facade = facade
|
|
59
|
+
this.transport = 'mux'
|
|
60
|
+
this.opened = false
|
|
61
|
+
this.synced = false
|
|
62
|
+
this.retries = 0
|
|
63
|
+
this.retryTimer = null
|
|
64
|
+
this.destroyed = false
|
|
65
|
+
this._onUpdate = (update, origin) => {
|
|
66
|
+
if (origin === this.facade || !this.opened) return // gaps are closed by the next step1/step2
|
|
67
|
+
const e = encoding.createEncoder()
|
|
68
|
+
encoding.writeVarUint(e, messageSync)
|
|
69
|
+
syncProtocol.writeUpdate(e, update)
|
|
70
|
+
this.mux.send(encodeFrame(F.MSG, this.id, encoding.toUint8Array(e)))
|
|
71
|
+
}
|
|
72
|
+
doc.on('update', this._onUpdate)
|
|
73
|
+
}
|
|
74
|
+
get wsconnected() { return this.opened && this.mux.connected }
|
|
75
|
+
sendOpen() {
|
|
76
|
+
if (this.destroyed || !this.mux.connected) return
|
|
77
|
+
clearTimeout(this.retryTimer); this.retryTimer = null
|
|
78
|
+
this.mux.send(encodeFrame(F.OPEN, this.id, this.docName))
|
|
79
|
+
}
|
|
80
|
+
retryLater() {
|
|
81
|
+
if (this.destroyed || this.retryTimer) return
|
|
82
|
+
// Refused or closed by the relay: back off, like the per-file provider did, but further --
|
|
83
|
+
// an OPEN is cheap to send and a relay at capacity should not be asked 40 times a second.
|
|
84
|
+
const ms = Math.min(15000, 500 * 2 ** Math.min(this.retries++, 5))
|
|
85
|
+
this.retryTimer = setTimeout(() => { this.retryTimer = null; this.sendOpen() }, ms)
|
|
86
|
+
if (this.retryTimer.unref) this.retryTimer.unref()
|
|
87
|
+
}
|
|
88
|
+
onFrame(f) {
|
|
89
|
+
if (this.destroyed) return
|
|
90
|
+
if (f.kind === F.OPENED) {
|
|
91
|
+
this.opened = true
|
|
92
|
+
this.retries = 0
|
|
93
|
+
const e = encoding.createEncoder()
|
|
94
|
+
encoding.writeVarUint(e, messageSync)
|
|
95
|
+
syncProtocol.writeSyncStep1(e, this.doc)
|
|
96
|
+
this.mux.send(encodeFrame(F.MSG, this.id, encoding.toUint8Array(e)))
|
|
97
|
+
this.facade.emit('status', [{ status: 'connected' }])
|
|
98
|
+
} else if (f.kind === F.MSG) {
|
|
99
|
+
try { this.onMessage(f.payload) } catch (e) {
|
|
100
|
+
// A message this doc could not apply. Skipping it would leave a gap that every later
|
|
101
|
+
// update builds on, silently; reopening makes the relay run the sync exchange again,
|
|
102
|
+
// and that carries whatever was missed.
|
|
103
|
+
this.mux.log(`[hive] could not apply a relay message for ${JSON.stringify(this.docName)} (${e && e.message}); resyncing it`)
|
|
104
|
+
this.reopen()
|
|
105
|
+
}
|
|
106
|
+
} else if (f.kind === F.REFUSED) {
|
|
107
|
+
this.opened = false
|
|
108
|
+
this.facade.emit('connection-error', [{ code: f.code, status: f.code, reason: f.reason, message: `Unexpected server response: ${f.code} ${f.reason}` }])
|
|
109
|
+
this.retryLater()
|
|
110
|
+
} else if (f.kind === F.CLOSED) {
|
|
111
|
+
this.lost({ code: 4000, reason: f.reason })
|
|
112
|
+
this.retryLater()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
onMessage(payload) {
|
|
116
|
+
let d
|
|
117
|
+
try { d = decoding.createDecoder(payload) } catch { return }
|
|
118
|
+
const type = decoding.readVarUint(d)
|
|
119
|
+
if (type !== messageSync) return // awareness/auth/query: file docs carry no presence
|
|
120
|
+
const e = encoding.createEncoder()
|
|
121
|
+
encoding.writeVarUint(e, messageSync)
|
|
122
|
+
const t = syncProtocol.readSyncMessage(d, e, this.doc, this.facade)
|
|
123
|
+
if (t === syncProtocol.messageYjsSyncStep2 && !this.synced) {
|
|
124
|
+
this.synced = true
|
|
125
|
+
this.facade.emit('sync', [true])
|
|
126
|
+
}
|
|
127
|
+
if (encoding.length(e) > 1) this.mux.send(encodeFrame(F.MSG, this.id, encoding.toUint8Array(e)))
|
|
128
|
+
}
|
|
129
|
+
reopen() {
|
|
130
|
+
if (this.destroyed) return
|
|
131
|
+
if (this.mux.connected) this.mux.send(encodeFrame(F.CLOSE, this.id))
|
|
132
|
+
this.lost({ code: 4001, reason: 'resync' })
|
|
133
|
+
this.retryLater()
|
|
134
|
+
}
|
|
135
|
+
/** The channel is gone (socket dropped, or the relay closed it); a reconnect will reopen it. */
|
|
136
|
+
lost(ev = null) {
|
|
137
|
+
const wasOpen = this.opened
|
|
138
|
+
this.opened = false
|
|
139
|
+
if (this.synced) { this.synced = false; this.facade.emit('sync', [false]) }
|
|
140
|
+
if (wasOpen) {
|
|
141
|
+
this.facade.emit('connection-close', [ev])
|
|
142
|
+
this.facade.emit('status', [{ status: 'disconnected' }])
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
destroy() {
|
|
146
|
+
if (this.destroyed) return
|
|
147
|
+
this.destroyed = true
|
|
148
|
+
clearTimeout(this.retryTimer)
|
|
149
|
+
try { this.doc.off('update', this._onUpdate) } catch { }
|
|
150
|
+
if (this.mux.connected) this.mux.send(encodeFrame(F.CLOSE, this.id))
|
|
151
|
+
this.mux.channels.delete(this.id)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The participant's one physical socket, carrying a channel per open file doc. */
|
|
156
|
+
class MuxConnection {
|
|
157
|
+
constructor({ relay, room, params, WebSocketPolyfill, log, onRefused }) {
|
|
158
|
+
const q = new URLSearchParams({ ...(params || {}), mux: String(MUX_VERSION) })
|
|
159
|
+
this.url = `${String(relay).replace(/\/+$/, '')}/${muxPath(room)}?${q}`
|
|
160
|
+
this.WS = WebSocketPolyfill
|
|
161
|
+
this.log = log || (() => { })
|
|
162
|
+
this.onRefused = onRefused || (() => { })
|
|
163
|
+
this.channels = new Map()
|
|
164
|
+
this.nextId = 1
|
|
165
|
+
this.ws = null
|
|
166
|
+
this.connected = false
|
|
167
|
+
this.connecting = false
|
|
168
|
+
this.destroyed = false
|
|
169
|
+
this.failures = 0
|
|
170
|
+
this.lastHeard = 0
|
|
171
|
+
this.reconnectTimer = null
|
|
172
|
+
// The relay sends a BEAT every 20s; 45s of silence means the socket is dead even if the OS
|
|
173
|
+
// has not said so, which is exactly the half-open state a laptop lid produces.
|
|
174
|
+
this.liveness = setInterval(() => {
|
|
175
|
+
// terminate, not close: a close handshake cannot complete on a dead link, and ws waits 30s more for it
|
|
176
|
+
if (this.connected && Date.now() - this.lastHeard > 45000) { try { this.ws.terminate ? this.ws.terminate() : this.ws.close() } catch { } }
|
|
177
|
+
}, 5000)
|
|
178
|
+
if (this.liveness.unref) this.liveness.unref()
|
|
179
|
+
}
|
|
180
|
+
channel(docName, doc, facade) {
|
|
181
|
+
const ch = new Channel(this, this.nextId++, docName, doc, facade)
|
|
182
|
+
this.channels.set(ch.id, ch)
|
|
183
|
+
if (this.connected) ch.sendOpen()
|
|
184
|
+
else this.connect()
|
|
185
|
+
return ch
|
|
186
|
+
}
|
|
187
|
+
send(bytes) {
|
|
188
|
+
if (!this.connected || !this.ws) return
|
|
189
|
+
try { this.ws.send(bytes) } catch { /* the close handler takes it from here */ }
|
|
190
|
+
}
|
|
191
|
+
connect() {
|
|
192
|
+
if (this.destroyed || this.connected || this.connecting) return
|
|
193
|
+
this.connecting = true
|
|
194
|
+
let ws
|
|
195
|
+
try { ws = new this.WS(this.url) } catch (e) { this.connecting = false; this.scheduleReconnect(); return }
|
|
196
|
+
ws.binaryType = 'arraybuffer'
|
|
197
|
+
this.ws = ws
|
|
198
|
+
ws.onopen = () => {
|
|
199
|
+
if (this.ws !== ws) return
|
|
200
|
+
this.connecting = false
|
|
201
|
+
this.connected = true
|
|
202
|
+
this.failures = 0
|
|
203
|
+
this.lastHeard = Date.now()
|
|
204
|
+
for (const ch of this.channels.values()) ch.sendOpen()
|
|
205
|
+
}
|
|
206
|
+
ws.onmessage = (ev) => {
|
|
207
|
+
if (this.ws !== ws) return
|
|
208
|
+
this.lastHeard = Date.now()
|
|
209
|
+
let f
|
|
210
|
+
try { f = decodeFrame(new Uint8Array(ev.data)) } catch { return }
|
|
211
|
+
if (f.kind === F.BEAT) return
|
|
212
|
+
const ch = this.channels.get(f.id)
|
|
213
|
+
if (ch) ch.onFrame(f)
|
|
214
|
+
}
|
|
215
|
+
ws.onerror = (ev) => {
|
|
216
|
+
if (this.ws !== ws) return
|
|
217
|
+
// A refused UPGRADE of the mux socket itself (401/402/403/429/503) refuses every file on it.
|
|
218
|
+
// Hand each unsynced channel the same event a refused per-file socket produced.
|
|
219
|
+
const m = /Unexpected server response:\s*(\d{3})/.exec((ev && (ev.message || (ev.error && ev.error.message))) || '')
|
|
220
|
+
const code = m ? Number(m[1]) : null
|
|
221
|
+
for (const ch of this.channels.values()) {
|
|
222
|
+
if (!ch.synced) ch.facade.emit('connection-error', [{ code, status: code, message: (ev && ev.message) || 'mux socket error' }])
|
|
223
|
+
}
|
|
224
|
+
if (code) this.onRefused(code)
|
|
225
|
+
}
|
|
226
|
+
ws.onclose = (ev) => {
|
|
227
|
+
if (this.ws !== ws) return
|
|
228
|
+
this.ws = null
|
|
229
|
+
this.connected = false
|
|
230
|
+
this.connecting = false
|
|
231
|
+
for (const ch of this.channels.values()) ch.lost(ev)
|
|
232
|
+
this.scheduleReconnect()
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
scheduleReconnect() {
|
|
236
|
+
if (this.destroyed || this.reconnectTimer || !this.channels.size) return
|
|
237
|
+
const ms = Math.min(5000, 100 * 2 ** Math.min(this.failures++, 6))
|
|
238
|
+
this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect() }, ms)
|
|
239
|
+
if (this.reconnectTimer.unref) this.reconnectTimer.unref()
|
|
240
|
+
}
|
|
241
|
+
destroy() {
|
|
242
|
+
this.destroyed = true
|
|
243
|
+
clearInterval(this.liveness)
|
|
244
|
+
clearTimeout(this.reconnectTimer)
|
|
245
|
+
const ws = this.ws
|
|
246
|
+
this.ws = null
|
|
247
|
+
// Disconnected FIRST: closing the socket ends every channel on the relay at once, so no
|
|
248
|
+
// channel sends a CLOSE frame of its own down a socket that is about to go.
|
|
249
|
+
this.connected = false
|
|
250
|
+
for (const ch of [...this.channels.values()]) { try { ch.destroy() } catch { } }
|
|
251
|
+
try { if (ws) ws.close() } catch { }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* One per startSync. provide(docName, doc) returns the provider for a file doc; the transport is
|
|
257
|
+
* decided once -- HIVE_MUX=1 forces mux, HIVE_MUX=0 forces a socket per file, anything else asks
|
|
258
|
+
* the relay's /health -- and file providers created before the answer are bound when it comes.
|
|
259
|
+
*/
|
|
260
|
+
export function createFileTransport({ relay, room, wsOpts = {}, log = () => { }, env = process.env, fetchImpl = globalThis.fetch, onFallback = () => { }, retryMs = 1000, detectForMs = 90000 }) {
|
|
261
|
+
const forced = env.HIVE_MUX === '0' || env.HIVE_MUX === '1'
|
|
262
|
+
let mode = env.HIVE_MUX === '0' ? 'socket' : env.HIVE_MUX === '1' ? 'mux' : null
|
|
263
|
+
let mux = null
|
|
264
|
+
let stopped = false
|
|
265
|
+
const pending = new Set()
|
|
266
|
+
const health = () => Promise.resolve()
|
|
267
|
+
.then(() => fetchImpl(`${httpBase(relay)}/health`, { signal: AbortSignal.timeout(20000) }))
|
|
268
|
+
.then((r) => (r && r.ok ? r.json() : null))
|
|
269
|
+
const offersMux = (j) => !!j && Number(j.mux) >= MUX_VERSION
|
|
270
|
+
|
|
271
|
+
// THE KILL SWITCH HAS TO REACH CLIENTS THAT ARE ALREADY CONNECTED. A relay that stops serving
|
|
272
|
+
// mux -- an operator setting HIVE_RELAY_MUX=off, or a rollback to a build that predates it --
|
|
273
|
+
// refuses the mux socket from then on, and without this every file would stay refused until
|
|
274
|
+
// the agent restarted. A 404 says so outright. Any other refusal is checked against /health, at
|
|
275
|
+
// most once a minute, because a relay that predates mux reads the mux path as a room name and
|
|
276
|
+
// refuses the token -- a 401 that looks exactly like a real one.
|
|
277
|
+
let lastCheck = 0
|
|
278
|
+
const onRefused = (code) => {
|
|
279
|
+
if (forced || stopped || mode !== 'mux') return
|
|
280
|
+
if (code === 404) return fallBack('the relay no longer serves one-socket mode (404)')
|
|
281
|
+
const now = Date.now()
|
|
282
|
+
if (now - lastCheck < 60000) return
|
|
283
|
+
lastCheck = now
|
|
284
|
+
health().then((j) => { if (j && !offersMux(j)) fallBack('the relay stopped offering one-socket mode') }, () => { })
|
|
285
|
+
}
|
|
286
|
+
const fallBack = (why) => {
|
|
287
|
+
if (stopped || mode !== 'mux') return
|
|
288
|
+
mode = 'socket'
|
|
289
|
+
log(`[hive] ${why}: falling back to a socket per file`)
|
|
290
|
+
// sync.js lets go of every file beyond the per-file bound FIRST. Without that, a client holding
|
|
291
|
+
// 900 files on one socket would open 900 sockets at once -- the storm the bound exists to stop.
|
|
292
|
+
try { onFallback() } catch { }
|
|
293
|
+
const moving = mux ? [...mux.channels.values()].map((ch) => ch.facade) : []
|
|
294
|
+
try { if (mux) mux.destroy() } catch { }
|
|
295
|
+
mux = null
|
|
296
|
+
for (const fp of moving) { if (!fp.destroyed) fp._bindLegacy(relay, wsOpts) }
|
|
297
|
+
}
|
|
298
|
+
const bind = (fp) => {
|
|
299
|
+
if (fp.destroyed) return
|
|
300
|
+
if (mode === 'mux') {
|
|
301
|
+
if (!mux) mux = new MuxConnection({ relay, room, params: wsOpts.params, WebSocketPolyfill: wsOpts.WebSocketPolyfill, log, onRefused })
|
|
302
|
+
fp._impl = mux.channel(fp.docName, fp.doc, fp)
|
|
303
|
+
} else {
|
|
304
|
+
fp._bindLegacy(relay, wsOpts)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
let resolveReady
|
|
308
|
+
const ready = new Promise((r) => { resolveReady = r })
|
|
309
|
+
const decide = (m) => {
|
|
310
|
+
if (!mode) mode = m
|
|
311
|
+
for (const fp of pending) bind(fp)
|
|
312
|
+
pending.clear()
|
|
313
|
+
resolveReady(mode)
|
|
314
|
+
}
|
|
315
|
+
// ONLY AN ANSWER DECIDES. A free Render instance takes 30-60s to wake and says nothing at all
|
|
316
|
+
// meanwhile; reading that silence as "no mux" pinned the whole session to a socket per file.
|
|
317
|
+
// So silence (a timeout, a refused connection, a 5xx from a proxy fronting a booting service) is
|
|
318
|
+
// asked again for up to ~90s. Any real answer decides at once -- including an old relay's
|
|
319
|
+
// plain-text /health, which is a relay that has never heard of mux. Out of time: a socket per
|
|
320
|
+
// file, the transport every relay has always spoken.
|
|
321
|
+
const detect = async () => {
|
|
322
|
+
const deadline = Date.now() + detectForMs
|
|
323
|
+
for (let wait = retryMs; ; wait = Math.min(wait * 2, 15000)) {
|
|
324
|
+
try {
|
|
325
|
+
const r = await fetchImpl(`${httpBase(relay)}/health`, { signal: AbortSignal.timeout(20000) })
|
|
326
|
+
if (r && r.status < 500) {
|
|
327
|
+
let j = null
|
|
328
|
+
try { j = JSON.parse(await r.text()) } catch { /* not JSON: an old relay, or not a relay */ }
|
|
329
|
+
return r.ok && offersMux(j) ? 'mux' : 'socket'
|
|
330
|
+
}
|
|
331
|
+
} catch { /* no answer yet */ }
|
|
332
|
+
if (stopped || Date.now() + wait > deadline) return 'socket'
|
|
333
|
+
await new Promise((res) => { const t = setTimeout(res, wait); if (t.unref) t.unref() })
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (mode) resolveReady(mode)
|
|
337
|
+
else detect().then(decide, () => decide('socket'))
|
|
338
|
+
return {
|
|
339
|
+
ready,
|
|
340
|
+
get mode() { return mode || 'detecting' },
|
|
341
|
+
provide(docName, doc) {
|
|
342
|
+
const fp = new FileProvider(docName, doc)
|
|
343
|
+
if (mode) bind(fp)
|
|
344
|
+
else pending.add(fp)
|
|
345
|
+
return fp
|
|
346
|
+
},
|
|
347
|
+
stats() { return { mode: mode || 'detecting', channels: mux ? mux.channels.size : 0, muxConnected: !!(mux && mux.connected) } },
|
|
348
|
+
stop() { stopped = true; try { if (mux) mux.destroy() } catch { } },
|
|
349
|
+
}
|
|
350
|
+
}
|
package/hive-privacy.js
CHANGED
|
@@ -42,6 +42,31 @@ export const PUBLIC_DEFAULT_RELAY = 'wss://hivecode-relay.onrender.com'
|
|
|
42
42
|
// outside this list is called.
|
|
43
43
|
export const RETIRED_PUBLIC_RELAYS = ['wss://livecode-xoss.onrender.com']
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The relay a client should actually dial for a URL it was handed.
|
|
47
|
+
*
|
|
48
|
+
* A RETIRED PUBLIC RELAY IS FOLLOWED TO ITS REPLACEMENT, and nothing else is ever rewritten.
|
|
49
|
+
* Measured 23 Sep 2026 on 0.12.1 -- a build that already shipped the new default: a project
|
|
50
|
+
* folder whose .hive.json was written before the move still named the retired host, the client
|
|
51
|
+
* obeyed it, and a returning user got "NOT JOINED ... 503" exactly as if nothing had been fixed.
|
|
52
|
+
* The same held for HIVE_RELAY set to the old host, which docs/MCP.md told people to do.
|
|
53
|
+
*
|
|
54
|
+
* That address was never the user's choice -- it was this project's default, copied into their
|
|
55
|
+
* config by the client and by our own docs -- so following it to the current default honours
|
|
56
|
+
* what they actually chose (the public relay), and moves nobody from anyone to anyone else.
|
|
57
|
+
* A relay the user runs is never in RETIRED_PUBLIC_RELAYS and is returned untouched.
|
|
58
|
+
*
|
|
59
|
+
* Returns { relay, migratedFrom } -- migratedFrom is the retired URL when a move happened,
|
|
60
|
+
* so the caller can say so on screen instead of changing someone's destination silently.
|
|
61
|
+
*/
|
|
62
|
+
export function currentRelayFor(url) {
|
|
63
|
+
const origin = relayOrigin(url)
|
|
64
|
+
if (origin && RETIRED_PUBLIC_RELAYS.some((r) => relayOrigin(r) === origin)) {
|
|
65
|
+
return { relay: PUBLIC_DEFAULT_RELAY, migratedFrom: String(url).trim() }
|
|
66
|
+
}
|
|
67
|
+
return { relay: url, migratedFrom: null }
|
|
68
|
+
}
|
|
69
|
+
|
|
45
70
|
const DEFAULT_PORT = { 'ws:': '80', 'wss:': '443', 'http:': '80', 'https:': '443' }
|
|
46
71
|
|
|
47
72
|
/**
|