Implement hrpc with streaming and wire into setup

- Add spec/hyperschema and spec/hrpc codegen (scripts/build-hrpc.js)
  with unary, request/response-stream, duplex, and send-only commands
- Run build:hrpc and build:protomux from install.sh so setup is one-step
- Native host: add attachHrpc (Protomux channel + generated HRPC),
  handlers for ping, streamSum, fetchStream, duplex, notify
- Load HRPC lazily so missing spec does not crash host; use bare-stream
  for Duplex under Bare; add bare-stream, compact-encoding deps
This commit is contained in:
Raven Scott
2026-02-12 04:51:03 -05:00
parent a7f86f3af5
commit bb47954b9f
14 changed files with 1531 additions and 11 deletions
+129 -2
View File
@@ -1,7 +1,7 @@
/**
* BridgeSwarm instance manager for the native messaging host.
* Maps swarmId -> Hyperswarm, connId -> { socket, protomux?, forwarding }; routes commands and emits events.
* Supports Protomux and optional Hypercore replication (attachReplication).
* Supports Protomux, optional Hypercore replication (attachReplication), and hrpc (attachHrpc).
*/
const Hyperswarm = require('hyperswarm');
@@ -12,12 +12,25 @@ const Hyperdrive = require('hyperdrive');
const Autobase = require('autobase');
const HyperDB = require('hyperdb');
const path = require('path');
const { Duplex } = require('bare-stream');
const c = require('compact-encoding');
const def = require(path.join(path.dirname(require.resolve('hyperdb')), 'lib', 'definition.js'));
const minimalDefinition = require('./hyperdb-minimal-definition.js');
const b4a = require('b4a');
// Load generated HRPC lazily so a missing/broken spec does not crash the host at startup
let HRPC = null;
let hrpcLoadError = null;
try {
HRPC = require(path.join(__dirname, '..', 'spec', 'hrpc'));
} catch (err) {
hrpcLoadError = err;
}
const HRPC_PROTOCOL = 'bridgeswarm-hrpc';
const swarms = new Map();
/** @type {Map<string, { socket: import('stream').Duplex, protomux?: import('protomux'), forwarding: boolean }>} */
/** @type {Map<string, { socket: import('stream').Duplex, protomux?: import('protomux'), hrpc?: InstanceType<typeof HRPC>, hrpcDuplex?: import('stream').Duplex, forwarding: boolean, _listeners: object }>} */
const connections = new Map();
const connToSwarm = new Map(); // connId -> swarmId
let nextConnId = 0;
@@ -222,6 +235,9 @@ async function handleMessageAsync(send, msg) {
const { connId } = payload;
const entry = connections.get(connId);
if (entry) {
if (entry.hrpcDuplex && !entry.hrpcDuplex.destroyed) {
entry.hrpcDuplex.destroy();
}
entry.socket.destroy();
connections.delete(connId);
connToSwarm.delete(connId);
@@ -267,6 +283,114 @@ async function handleMessageAsync(send, msg) {
break;
}
case 'attachHrpc': {
if (HRPC === null) {
reply({
ok: false,
error: hrpcLoadError ? `hrpc not available: ${hrpcLoadError.message}` : 'hrpc spec not built (run: npm run build:hrpc)'
});
return;
}
const { connId } = payload;
const entry = connections.get(connId);
if (!entry) {
reply({ ok: false, error: 'Connection not found' });
return;
}
if (entry.hrpc) {
reply({ ok: true });
return;
}
try {
entry.forwarding = false;
entry.socket.removeListener('data', entry._listeners.onData);
entry.socket.removeListener('end', entry._listeners.onEnd);
entry.socket.removeListener('error', entry._listeners.onError);
entry.socket.removeListener('close', entry._listeners.onClose);
entry.socket.once('close', () => {
connections.delete(connId);
connToSwarm.delete(connId);
});
const mux = new Protomux(entry.socket);
entry.protomux = mux;
const channel = mux.createChannel({
protocol: HRPC_PROTOCOL,
id: Buffer.alloc(0),
unique: false
});
let hrpcDuplex;
channel.addMessage({
encoding: c.buffer,
onmessage(buf) {
if (hrpcDuplex && !hrpcDuplex.destroyed) hrpcDuplex.push(buf);
}
});
const frameMsg = channel.messages[0];
hrpcDuplex = new Duplex({
read() {},
write(chunk, enc, cb) {
if (frameMsg && frameMsg.send(chunk, channel)) {
cb();
} else {
mux.stream.once('drain', () => cb());
}
}
});
channel.open();
const rpc = new HRPC(hrpcDuplex);
rpc.onPing((data) => {
return { pong: (data && data.value) || 'pong' };
});
rpc.onStreamSum(async (requestStream) => {
let sum = 0;
let count = 0;
await new Promise((resolve, reject) => {
requestStream.on('data', (data) => {
if (data && typeof data.n === 'number') sum += data.n;
count++;
});
requestStream.on('end', resolve);
requestStream.on('error', reject);
});
return { sum, count };
});
rpc.onFetchStream((responseStream) => {
const count = Math.min((responseStream.data && responseStream.data.count) || 0, 100);
for (let i = 0; i < count; i++) {
responseStream.write({ i, data: `chunk-${i}` });
}
responseStream.end();
});
rpc.onDuplex((stream) => {
stream.on('data', (data) => {
if (data && stream.writable) {
stream.write({
result: `got ${data.x} ${data.y || ''}`.trim(),
n: (data.x || 0) + 1
});
}
});
});
rpc.onNotify((data) => {
if (process.stderr && data) {
process.stderr.write(`[bridge-swarm-hrpc] notify: ${data.event} ${data.payload}\n`);
}
});
hrpcDuplex.on('error', () => {});
hrpcDuplex.on('close', () => {
try {
channel.close();
} catch (_) {}
});
entry.hrpc = rpc;
entry.hrpcDuplex = hrpcDuplex;
reply({ ok: true });
} catch (err) {
reply({ ok: false, error: err.message });
}
break;
}
case 'destroy': {
const { swarmId } = payload;
const swarm = swarms.get(swarmId);
@@ -524,6 +648,9 @@ async function handleMessageAsync(send, msg) {
function cleanup() {
for (const entry of connections.values()) {
try {
if (entry.hrpcDuplex && !entry.hrpcDuplex.destroyed) {
entry.hrpcDuplex.destroy();
}
entry.socket.destroy();
} catch (_) {}
}