ddat interop with dat js
cable interop with cabal js
ddat's frontend = dat-thang
cable fe = cable-thang
dillo support based on cel's dillo-dat
bootstrap node
xplatform support

Signed-off-by: divo <418@cryspace.space>
This commit is contained in:
divorce committed 2026-10-19 00:17:02 -04:00
1 parent f0f91540be
commit b735900f2e
323 files changed
+134347 -343

No files matched your search

@@ -0,0 +1,150 @@
'use strict'
// Cabal's JS implementation is the reference side. Cable is exercised via
// its CLI against the exact room key and admin URI generated by that side.
// This deliberately does not call a Cable feed API: Cabal feed wire support
// has not been connected to Cable yet.
const assert = require('assert')
const fs = require('fs')
const os = require('os')
const path = require('path')
const { spawnSync } = require('child_process')
const Cabal = require(process.env.CABAL_JS_MODULE || 'cabal-core')
function callback (fn, ...args) {
return new Promise((resolve, reject) => fn(...args, (err, value) => err ? reject(err) : resolve(value)))
}
function close (cabal) {
return new Promise((resolve, reject) => cabal.close(err => err ? reject(err) : resolve()))
}
function cable (binary, home, ...args) {
let command = binary
let commandArgs = args
let env = { ...process.env, HOME: home }
if (process.env.CABLE_RUNNER === 'wsl') {
// Node and cabal-core may run on Windows while Cable was built in WSL.
// Convert both the executable and per-test HOME to paths visible in WSL.
const wslPath = value => {
const match = /^([A-Za-z]):[\\/](.*)$/.exec(value)
if (!match) return value.replace(/\\/g, '/')
return `/mnt/${match[1].toLowerCase()}/${match[2].replace(/\\/g, '/')}`
}
command = process.env.WSL_BIN || 'wsl.exe'
commandArgs = ['--exec', 'env', `HOME=${wslPath(home)}`, wslPath(binary), ...args]
env = { ...process.env }
}
const result = spawnSync(command, commandArgs, { encoding: 'utf8', env })
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`cable ${args.join(' ')} failed: ${result.stderr}`)
return result.stdout.trim()
}
function waitForMessage (cabal, expected, timeoutMs) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cabal.messages.events.removeListener('message', onMessage)
reject(new Error('Cabal JS replica did not receive chat/text record'))
}, timeoutMs)
function onMessage (message) {
if (!message || !message.value || !message.value.content ||
message.value.content.text !== expected) return
clearTimeout(timeout)
cabal.messages.events.removeListener('message', onMessage)
resolve(message)
}
cabal.messages.events.on('message', onMessage)
})
}
async function main () {
const root = fs.mkdtempSync(path.join(process.env.CABAL_TEST_DIR || os.tmpdir(), 'cabal-cable-'))
const home = path.join(root, 'cable-home')
const binary = path.resolve(process.env.CABLE_BIN || 'build/linux/cable')
fs.mkdirSync(home)
let source, replica, otherRoom, sourceStream, replicaStream
try {
const sourceStorage = path.join(root, 'js-source')
source = Cabal(sourceStorage)
await callback(source.ready.bind(source))
const roomKey = String(source.key).replace(/^cabal:\/\//, '').toLowerCase()
assert.match(roomKey, /^[0-9a-f]{64}$/, 'Cabal JS must create a 32-byte room key')
replica = Cabal(path.join(root, 'js-replica'), roomKey)
await callback(replica.ready.bind(replica))
const jsUserKey = await callback(source.getLocalKey.bind(source))
assert.match(jsUserKey, /^[0-9a-f]{64}$/, 'Cabal JS local writer key must be 32 bytes')
const expectedText = 'hello from Cabal JS to its replicated room'
const received = waitForMessage(replica, expectedText, 15000)
sourceStream = source.replicate(true)
replicaStream = replica.replicate(false)
sourceStream.on('error', () => {})
replicaStream.on('error', () => {})
sourceStream.pipe(replicaStream).pipe(sourceStream)
await callback(source.publishNick.bind(source), 'cable-side-by-side-user')
await callback(source.publish.bind(source), {
type: 'chat/text',
content: { channel: 'cable-interop', text: expectedText }
})
const replicated = await received
assert.strictEqual(replicated.value.type, 'chat/text')
assert.strictEqual(replicated.value.content.channel, 'cable-interop')
const nickRecord = await callback(source.getMessage.bind(source), { key: jsUserKey, seq: 0 })
// cabal-core releases have returned either the Hypercore node wrapper or
// its decoded value from getMessage; accept both documented representations.
const nickValue = nickRecord.value || nickRecord
assert.strictEqual(nickValue.type, 'about')
assert.strictEqual(nickValue.content.name, 'cable-side-by-side-user')
const replicatedNick = await callback(replica.getMessage.bind(replica), { key: jsUserKey, seq: 0 })
const replicatedNickValue = replicatedNick.value || replicatedNick
assert.strictEqual(replicatedNickValue.type, 'about')
assert.strictEqual(replicatedNickValue.content.name, 'cable-side-by-side-user')
sourceStream.destroy()
replicaStream.destroy()
sourceStream = null
replicaStream = null
await close(source)
source = Cabal(sourceStorage, roomKey)
await callback(source.ready.bind(source))
assert.strictEqual(await callback(source.getLocalKey.bind(source)), jsUserKey,
'Cabal JS local user/feed address must persist for the same room')
const adminKey = 'a'.repeat(64)
const roomAddress = `cabal://${roomKey}?admin=${adminKey}`
assert.strictEqual(cable(binary, home, 'address', roomAddress),
`cable://${roomKey}?admin=${adminKey}`)
const cableUser = cable(binary, home, 'whoami', roomAddress)
assert.match(cableUser, /^cable:\/\/[0-9a-f]{64}$/)
assert.notStrictEqual(cableUser.slice(8), roomKey, 'room key is not a user address')
assert.notStrictEqual(cableUser.slice(8), adminKey, 'room admin key is not a user address')
assert.notStrictEqual(cableUser.slice(8), jsUserKey,
'Cabal JS writer and Cable CLI represent separate local users')
assert.strictEqual(cable(binary, home, 'whoami', `cable://${roomKey}`), cableUser,
'Cable user address must persist for the same room, regardless of URI scheme/admin option')
otherRoom = Cabal(path.join(root, 'js-other-room'))
await callback(otherRoom.ready.bind(otherRoom))
const otherRoomKey = String(otherRoom.key).replace(/^cabal:\/\//, '').toLowerCase()
const otherJsUserKey = await callback(otherRoom.getLocalKey.bind(otherRoom))
assert.notStrictEqual(otherJsUserKey, jsUserKey,
'Cabal JS local writer keys must be independent across rooms')
const otherCableUser = cable(binary, home, 'whoami', `cabal://${otherRoomKey}`)
assert.notStrictEqual(otherCableUser, cableUser,
'Cable user address must be room-scoped like Cabal local writer feeds')
process.stdout.write(`Cabal JS nick/feed replication + Cable room/user identity: PASS room=cabal://${roomKey} js-user=${jsUserKey} cable-user=${cableUser}\n`)
} finally {
for (const stream of [sourceStream, replicaStream]) if (stream) stream.destroy()
for (const cabal of [source, replica, otherRoom]) if (cabal) await close(cabal)
if (process.env.CABAL_TEST_KEEP === '1') process.stdout.write(`kept Cabal test state at ${root}\n`)
else fs.rmSync(root, { recursive: true, force: true })
}
}
main().catch(err => { console.error(err); process.exitCode = 1 })
+162
View File
@@ -0,0 +1,162 @@
'use strict'
// Exercise the APIs that cabal-cli 15.x uses, with two independent local
// Cabal clients connected by the same live Hypercore replication streams.
const assert = require('assert')
const fs = require('fs')
const os = require('os')
const path = require('path')
const EventEmitter = require('events')
const Module = require('module')
const Client = require(process.env.CABAL_CLIENT_MODULE || 'cabal-client')
function once (emitter, event, predicate = () => true, timeoutMs = 12000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
emitter.removeListener(event, listener)
reject(new Error(`timed out waiting for Cabal ${event}`))
}, timeoutMs)
function listener (value) {
if (!predicate(value)) return
clearTimeout(timer)
emitter.removeListener(event, listener)
resolve(value)
}
emitter.on(event, listener)
})
}
function close (client, details) {
return new Promise((resolve, reject) => client.removeCabal(details.key, err => err ? reject(err) : resolve()))
}
function waitForUserName (details, name, timeoutMs = 12000) {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeoutMs
const poll = () => {
const user = Object.values(details.getUsers()).find(candidate => candidate.name === name)
if (user) return resolve(user)
if (Date.now() >= deadline) return reject(new Error(`${name} nickname did not replicate`))
setTimeout(poll, 25)
}
poll()
})
}
function createCliScreen (client) {
const clientPath = require.resolve(process.env.CABAL_CLIENT_MODULE || 'cabal-client')
const cliDirectory = path.resolve(path.dirname(clientPath), '..', '..')
const screenPath = path.join(cliDirectory, 'neat-screen.js')
const originalLoad = Module._load
function fakeNeatLog () {
const input = new EventEmitter()
input.cursor = 0
input.rawLine = () => ''
input.set = () => {}
return {
input,
render: () => {},
clear: () => {},
use: plugin => {
const state = {}
plugin(state, new EventEmitter())
}
}
}
Module._load = function (request, parent, isMain) {
if (request === 'neat-log') return fakeNeatLog
return originalLoad.call(this, request, parent, isMain)
}
try {
const NeatScreen = require(screenPath)
return new NeatScreen({
client,
frontendConfig: { messageTimeformat: '%H:%M:%S', messageIndent: 'nick' }
})
} finally {
Module._load = originalLoad
}
}
async function main () {
const root = fs.mkdtempSync(path.join(process.env.CABAL_TEST_DIR || os.tmpdir(), 'cabal-cli-features-'))
const sourceClient = new Client({ config: { dbdir: path.join(root, 'alice'), preferredPort: 0 } })
const peerClient = new Client({ config: { dbdir: path.join(root, 'bob'), preferredPort: 0 } })
let alice, bob, aliceStream, bobStream, screen
try {
alice = await sourceClient.createCabal()
const roomKey = alice.key
assert.match(roomKey, /^[0-9a-f]{64}$/i, 'create room returns a Cabal room key')
bob = await peerClient.addCabal(`cabal://${roomKey}`)
aliceStream = alice.core.replicate(true)
bobStream = bob.core.replicate(false)
aliceStream.on('error', () => {})
bobStream.on('error', () => {})
aliceStream.pipe(bobStream).pipe(aliceStream)
const aliceNick = once(alice, 'publish-nick', update => update.name === 'alice')
alice.processLine('/nick alice')
await aliceNick
const aliceChannel = once(alice, 'channel-join', update => update.channel === 'lobby')
alice.processLine('/join lobby')
await aliceChannel
const secondChannel = once(alice, 'channel-join', update => update.channel === 'garden')
alice.processLine('/join garden')
await secondChannel
assert(alice.getJoinedChannels().includes('lobby'))
assert(alice.getJoinedChannels().includes('garden'))
screen = createCliScreen(sourceClient)
const cliChannels = alice.getChannels({ includePM: true, onlyJoined: true })
alice.focusChannel(cliChannels[0])
screen.neat.input.emit('ctrl-n')
assert.strictEqual(alice.getCurrentChannel(), cliChannels[1], 'the cabal-cli Ctrl+N handler advances channels')
screen.neat.input.emit('ctrl-n')
assert.strictEqual(alice.getCurrentChannel(), cliChannels[2], 'the cabal-cli Ctrl+N handler continues the cycle')
alice.focusChannel('lobby')
const bobJoined = once(alice, 'channel-join', update => update.channel === 'lobby' && !update.isLocal)
const bobChannel = once(bob, 'channel-join', update => update.channel === 'lobby')
bob.processLine('/join lobby')
await bobChannel
await bobJoined
const bobNick = once(bob, 'publish-nick', update => update.name === 'bob')
bob.processLine('/nick bob')
await bobNick
await waitForUserName(alice, 'bob')
const fromAlice = 'hello from Alice in the lobby'
const gotAlice = once(bob, 'new-message', update =>
update.channel === 'lobby' && update.message.value.content.text === fromAlice)
alice.processLine(fromAlice)
await gotAlice
// Verify the actual room stream carries the published nickname record to Bob.
await waitForUserName(bob, 'alice')
const fromBob = 'hello back from Bob'
const gotBob = once(alice, 'new-message', update =>
update.channel === 'lobby' && update.message.value.content.text === fromBob)
bob.processLine(fromBob)
await gotBob
aliceStream.destroy()
bobStream.destroy()
aliceStream = bobStream = null
process.stdout.write(`cabal-cli 15.x room, /join, /nick, Ctrl+N channel cycle, and two-peer chat: PASS room=cabal://${roomKey}\n`)
} finally {
if (aliceStream) aliceStream.destroy()
if (bobStream) bobStream.destroy()
if (screen) {
if (screen.updateTimer) clearTimeout(screen.updateTimer)
if (alice && screen._updateHandler && screen._updateHandler[alice.key])
alice.removeListener('update', screen._updateHandler[alice.key])
}
if (alice) await close(sourceClient, alice)
if (bob) await close(peerClient, bob)
fs.rmSync(root, { recursive: true, force: true })
}
}
main().catch(err => { console.error(err); process.exitCode = 1 })
@@ -0,0 +1,118 @@
'use strict'
// Direct Cabal Core live discovery/chat check. The cross-process SDL/C to JS
// integration is covered separately by cable_js_c_live.js.
const crypto = require('crypto')
const fs = require('fs')
const os = require('os')
const path = require('path')
const dgram = require('dgram')
const { startHyperswarm, loadCabalCore } = require('./cable_hyperswarm')
function freeUdpPort () {
return new Promise((resolve, reject) => {
const socket = dgram.createSocket('udp4')
socket.once('error', reject)
socket.bind(0, '127.0.0.1', () => {
const port = socket.address().port
socket.close(err => err ? reject(err) : resolve(port))
})
})
}
const { Cabal, requireCabal } = loadCabalCore()
const DHT = requireCabal('hyperdht')
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cabal-core-hyperswarm-'))
const roomKey = crypto.randomBytes(32).toString('hex')
const clients = []
let bootstrapper = null
function ready (cabal) { return new Promise((resolve, reject) => cabal.ready(err => err ? reject(err) : resolve())) }
function localKey (cabal) { return new Promise((resolve, reject) => cabal.getLocalKey((err, key) => err ? reject(err) : resolve(key))) }
function swarm (cabal) { return new Promise((resolve, reject) => cabal.swarm({ preferredPort: 0 }, err => err ? reject(err) : resolve())) }
function publish (cabal, value) { return new Promise((resolve, reject) => cabal.publish(value, err => err ? reject(err) : resolve())) }
function nick (cabal, value) { return new Promise((resolve, reject) => cabal.publishNick(value, err => err ? reject(err) : resolve())) }
function waitFor (predicate, description, timeout = 90000) {
const started = Date.now()
return new Promise((resolve, reject) => {
const timer = setInterval(() => {
if (Date.now() - started > timeout) { clearInterval(timer); reject(new Error('timeout: ' + description)); return }
try { const result = predicate(); if (result) { clearInterval(timer); resolve(result) } } catch (err) { clearInterval(timer); reject(err) }
}, 100)
})
}
async function main () {
const port = await freeUdpPort()
bootstrapper = DHT.bootstrapper(port, '127.0.0.1', { host: '127.0.0.1' })
await bootstrapper.ready()
let bootstrapRequests = 0
bootstrapper.on('request', request => {
bootstrapRequests++
if (process.env.CABLE_TRACE_DHT) console.log('local DHT request command=' + request.command)
})
console.log('local Hyperswarm DHT bootstrap node listening at ' + bootstrapper.address().host + ':' + bootstrapper.address().port)
process.env.CABLE_BOOTSTRAP = `127.0.0.1:${port}`
process.env.CABLE_DHT_HOST = '127.0.0.1'
const alice = Cabal(path.join(tempRoot, 'alice'), roomKey)
const bob = Cabal(path.join(tempRoot, 'bob'), roomKey)
clients.push(alice, bob)
await Promise.all([ready(alice), ready(bob)])
const [aliceKey, bobKey] = await Promise.all([localKey(alice), localKey(bob)])
const [alicePort, bobPort] = await Promise.all([freeUdpPort(), freeUdpPort()])
if (aliceKey === bobKey) throw new Error('Cabal Core reused a room feed identity across clients')
const events = { alice: [], bob: [] }
alice.on('peer-added', key => events.alice.push({ kind: 'peer', key }))
bob.on('peer-added', key => events.bob.push({ kind: 'peer', key }))
alice.kcore.api.messages.events.on('message', message => events.alice.push({ kind: 'message', message }))
bob.kcore.api.messages.events.on('message', message => events.bob.push({ kind: 'message', message }))
await startHyperswarm(alice, { host: '127.0.0.1', port: alicePort })
await startHyperswarm(bob, { host: '127.0.0.1', port: bobPort })
console.log('both Cabal Core clients joined the shared discovery topic')
await Promise.all([alice._swarm.dht.fullyBootstrapped(), bob._swarm.dht.fullyBootstrapped()])
console.log('both Cabal Core DHTs bootstrapped')
await Promise.all([alice._swarm.flush(), bob._swarm.flush()])
await Promise.all([alice._swarm.__cabalDiscovery.flushed(), bob._swarm.__cabalDiscovery.flushed()])
console.log(`Hyperswarm connections queued: ${alice._swarm.peers.size} / ${bob._swarm.peers.size}`)
await Promise.all([nick(alice, 'Alice'), nick(bob, 'Bob')])
await Promise.all([
publish(alice, { type: 'channel/join', content: { channel: 'default' } }),
publish(bob, { type: 'channel/join', content: { channel: 'default' } })
])
setTimeout(() => console.log('DHT state', JSON.stringify({
bootstrap: bootstrapper.toArray(),
bootstrapRequests,
bootstrapStats: bootstrapper.stats.requests,
configuredBootstraps: [alice._swarm.dht.bootstrapNodes, bob._swarm.dht.bootstrapNodes],
alice: alice._swarm.dht.toArray(), bob: bob._swarm.dht.toArray(),
queries: [alice._swarm.dht.stats.queries, bob._swarm.dht.stats.queries],
requests: [alice._swarm.dht.stats.requests, bob._swarm.dht.stats.requests],
commands: [alice._swarm.dht.stats.commands, bob._swarm.dht.stats.commands],
announcedPeers: [Array.from(alice._swarm.__cabalDiscovery.discovery._discovered),
Array.from(bob._swarm.__cabalDiscovery.discovery._discovered)],
aliceConnections: alice._swarm.peers.size, bobConnections: bob._swarm.peers.size,
aliceConnecting: alice._swarm.connecting, bobConnecting: bob._swarm.connecting
})), 10000).unref()
await Promise.all([
waitFor(() => events.alice.find(event => event.kind === 'peer' && event.key === bobKey), 'Alice discovers Bob over Hyperswarm', 20000),
waitFor(() => events.bob.find(event => event.kind === 'peer' && event.key === aliceKey), 'Bob discovers Alice over Hyperswarm', 20000)
])
await publish(alice, { type: 'chat/text', content: { channel: 'default', text: 'JS-to-Cable bridge path A' } })
await waitFor(() => events.bob.find(event => event.kind === 'message' && event.message.value.content.text === 'JS-to-Cable bridge path A'), 'Alice-to-Bob Cabal feed replication')
await publish(bob, { type: 'chat/text', content: { channel: 'default', text: 'JS-to-Cable bridge path B' } })
await waitFor(() => events.alice.find(event => event.kind === 'message' && event.message.value.content.text === 'JS-to-Cable bridge path B'), 'Bob-to-Alice Cabal feed replication')
console.log('Cabal Core Hyperswarm discovery and bidirectional Hypercore chat: PASS')
}
main().catch(err => { console.error(err.stack || err); process.exitCode = 1 }).finally(async () => {
for (const cabal of clients) if (cabal._swarm && cabal._swarm.__markShutdown) cabal._swarm.__markShutdown()
for (const cabal of clients) {
if (cabal._swarm && cabal._swarm.__shutdown) { try { await cabal._swarm.__shutdown() } catch (_) {} }
try { await cabal.close() } catch (_) {}
}
if (bootstrapper) { try { await bootstrapper.destroy() } catch (_) {} }
const resolved = path.resolve(tempRoot)
if (resolved.startsWith(path.resolve(os.tmpdir()) + path.sep) && path.basename(resolved).startsWith('cabal-core-hyperswarm-'))
fs.rmSync(resolved, { recursive: true, force: true })
})
+33
View File
@@ -0,0 +1,33 @@
'use strict'
// Run a public Cabal-compatible HyperDHT bootstrap node in the foreground.
// Put this under a service manager and advertise the reachable IPv4 host/UDP port.
const { loadCabalCore } = require('./cable_hyperswarm')
async function main () {
const host = process.argv[2]
const port = Number(process.argv[3])
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('usage: cable bootstrap serve <public-ipv4-host> <udp-port>')
}
const { requireCabal } = loadCabalCore()
const DHT = requireCabal('hyperdht')
const node = DHT.bootstrapper(port, host)
await node.ready()
const address = node.address()
process.stdout.write('Cabal Hyperswarm bootstrap serving ' + host + ':' + address.port +
' (bound ' + address.host + ':' + address.port + ')\n')
const close = async () => {
try { await node.destroy() } finally { process.exit(0) }
}
process.once('SIGINT', close)
process.once('SIGTERM', close)
}
if (require.main === module) main().catch(err => {
process.stderr.write((err && err.stack) || String(err))
process.stderr.write('\n')
process.exitCode = 1
})
module.exports = { main }
@@ -0,0 +1,78 @@
'use strict'
// Live Cabal Core/Hyperswarm smoke test for the same S-expression IPC used by
// cable-thang. Run with `node tests/interop/cabal/cable_cabal_hyperswarm_live.js`.
const { spawn } = require('child_process')
const crypto = require('crypto')
const fs = require('fs')
const os = require('os')
const path = require('path')
const readline = require('readline')
const root = path.resolve(__dirname, '../..')
const bridgePath = path.join(__dirname, 'cable_hyperswarm.js')
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dat-gpt-cabal-live-'))
const room = crypto.randomBytes(32).toString('hex')
const peers = []
function startPeer (name) {
const child = spawn(process.execPath, [bridgePath], { cwd: root, stdio: ['pipe', 'pipe', 'inherit'] })
const peer = { name, child, lines: [], error: null, rl: readline.createInterface({ input: child.stdout }) }
peer.rl.on('line', line => { peer.lines.push(line); process.stdout.write(name + ' ' + line + '\n') })
child.on('error', err => { peer.error = err })
child.on('exit', (code, signal) => { if (code && code !== 0) peer.error = new Error(`${name} exited ${code} (${signal || 'no signal'})`) })
peers.push(peer)
return peer
}
function send (peer, expression) {
if (!peer.child.stdin.write(expression + '\n')) throw new Error(`${peer.name} bridge stdin closed`)
}
async function waitFor (peer, predicate, description, timeout = 90000) {
const start = Date.now()
while (Date.now() - start < timeout) {
if (peer.error) throw peer.error
const found = peer.lines.find(predicate)
if (found) return found
await new Promise(resolve => setTimeout(resolve, 100))
}
throw new Error(`timeout waiting for ${description}; ${peer.name} last output: ${peer.lines.slice(-5).join(' | ')}`)
}
function startExpression (peer, nick) {
const storage = path.join(tempRoot, peer.name)
return `(start ${JSON.stringify(room)} ${JSON.stringify(storage)} ${JSON.stringify(nick)} "default")`
}
async function main () {
const alice = startPeer('alice')
const bob = startPeer('bob')
send(alice, startExpression(alice, 'Alice'))
send(bob, startExpression(bob, 'Bob'))
await Promise.all([waitFor(alice, line => line.startsWith('(event ready '), 'Alice Cabal feed'),
waitFor(bob, line => line.startsWith('(event ready '), 'Bob Cabal feed')])
await Promise.all([waitFor(alice, line => line.startsWith('(event peer-up '), 'Alice Hyperswarm peer discovery'),
waitFor(bob, line => line.startsWith('(event peer-up '), 'Bob Hyperswarm peer discovery')])
send(alice, `(send "default" "Cable bridge says hello")`)
await waitFor(bob, line => line.includes('Cable bridge says hello'), 'Alice-to-Bob Cabal message')
send(bob, `(send "default" "Cabal bridge replies")`)
await waitFor(alice, line => line.includes('Cabal bridge replies'), 'Bob-to-Alice Cabal message')
console.log('Cabal Core Hyperswarm discovery and bidirectional chat: PASS')
}
main().catch(err => { console.error(err.stack || err); process.exitCode = 1 }).finally(async () => {
for (const peer of peers) {
try { send(peer, '(close)') } catch (_) {}
peer.child.stdin.end()
}
await Promise.all(peers.map(peer => new Promise(resolve => {
if (peer.child.exitCode !== null) return resolve()
const timer = setTimeout(() => { peer.child.kill('SIGTERM'); resolve() }, 5000)
peer.child.once('exit', () => { clearTimeout(timer); resolve() })
})))
const resolvedRoot = path.resolve(tempRoot)
if (resolvedRoot.startsWith(path.resolve(os.tmpdir()) + path.sep) && path.basename(resolvedRoot).startsWith('dat-gpt-cabal-live-'))
fs.rmSync(resolvedRoot, { recursive: true, force: true })
})
+226
View File
@@ -0,0 +1,226 @@
#ifdef _WIN32
#include "cable_hyperswarm_win.inc"
#else
#define _POSIX_C_SOURCE 200809L
#include "cable_hyperswarm.h"
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
#include <sodium.h>
#include "cable_media.h"
static const char *find_bridge_script(char *path, size_t capacity)
{
const char *configured = getenv("CABLE_HYPERSWARM_BRIDGE");
static const char *candidates[] = {
"src/cable_hyperswarm.js", "./cable_hyperswarm.js",
"/usr/local/share/datgpt/cable_hyperswarm.js",
"/usr/share/datgpt/cable_hyperswarm.js"
};
size_t i;
if (configured && *configured) {
if (strlen(configured) >= capacity) { errno = ENAMETOOLONG; return NULL; }
strcpy(path, configured);
return access(path, R_OK) == 0 ? path : NULL;
}
for (i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++)
if (access(candidates[i], R_OK) == 0) {
if (strlen(candidates[i]) >= capacity) { errno = ENAMETOOLONG; return NULL; }
strcpy(path, candidates[i]);
return path;
}
errno = ENOENT;
return NULL;
}
static int send_form(cable_hyperswarm_t *bridge, const char *verb,
const char *a, const char *b, const char *c, const char *d)
{
s7_scheme *sc;
s7_pointer port, form;
const char *serialized;
size_t length, offset = 0;
const char *values[4] = {a, b, c, d};
s7_pointer args[5];
int i;
if (!bridge || !bridge->scheme.scheme || bridge->input_fd < 0 || !verb) { errno = EINVAL; return -1; }
sc = bridge->scheme.scheme;
args[0] = s7_make_symbol(sc, verb);
length = 1;
for (i = 0; i < 4 && values[i]; i++) args[length++] = s7_make_string(sc, values[i]);
form = s7_nil(sc);
while (length) form = s7_cons(sc, args[--length], form);
port = s7_open_output_string(sc);
if (!port) { errno = ENOMEM; return -1; }
(void)s7_write(sc, form, port);
serialized = s7_get_output_string(sc, port);
if (!serialized) { errno = EIO; return -1; }
length = strlen(serialized);
while (offset < length) {
ssize_t written = write(bridge->input_fd, serialized + offset, length - offset);
if (written < 0 && errno == EINTR) continue;
if (written <= 0) return -1;
offset += (size_t)written;
}
return write(bridge->input_fd, "\n", 1) == 1 ? 0 : -1;
}
static int parse_event_line(cable_hyperswarm_t *bridge, const char *line,
size_t length, cable_hyperswarm_event_fn callback,
void *context)
{
s7_scheme *sc = bridge->scheme.scheme;
s7_pointer parser, event;
if (length > 8192) { errno = EMSGSIZE; return -1; }
{
char *terminated = malloc(length + 1);
if (!terminated) return -1;
memcpy(terminated, line, length); terminated[length] = '\0';
parser = s7_name_to_value(sc, "cable:parse-bridge-event");
event = s7_call(sc, parser, s7_list(sc, 1, s7_make_string(sc, terminated)));
free(terminated);
}
if (!event || s7_is_eq(event, s7_f(sc))) { errno = EPROTO; return -1; }
callback(context, event);
return 0;
}
int cable_hyperswarm_start(cable_hyperswarm_t *bridge, const char *room_key,
const char *storage, const char *nick,
const char *channel)
{
int child_in[2] = {-1, -1}, child_out[2] = {-1, -1};
char script[2048];
const char *node = getenv("CABLE_NODE");
pid_t pid;
if (!bridge || !room_key || !storage || !nick || !channel) { errno = EINVAL; return -1; }
memset(bridge, 0, sizeof(*bridge)); bridge->input_fd = -1; bridge->output_fd = -1;
if (!find_bridge_script(script, sizeof(script))) return -1;
if (!node || !*node) node = "node";
if (dat_scheme_runtime_init(&bridge->scheme)) { errno = EINVAL; return -1; }
{
char scheme_file[2048];
const char *dir = getenv("DAT_SCHEME_DIR");
int n;
if (!dir || !*dir) dir = access("scheme/cable-room.scm", R_OK) == 0 ? "scheme" : "/usr/local/share/datgpt/scheme";
n = snprintf(scheme_file, sizeof(scheme_file), "%s/cable-room.scm", dir);
if (n < 0 || (size_t)n >= sizeof(scheme_file) || !s7_load(bridge->scheme.scheme, scheme_file)) {
dat_scheme_runtime_close(&bridge->scheme); errno = ENOENT; return -1;
}
}
if (pipe(child_in) || pipe(child_out)) goto fail;
pid = fork();
if (pid < 0) goto fail;
if (pid == 0) {
(void)signal(SIGPIPE, SIG_DFL);
(void)dup2(child_in[0], STDIN_FILENO);
(void)dup2(child_out[1], STDOUT_FILENO);
close(child_in[0]); close(child_in[1]); close(child_out[0]); close(child_out[1]);
execlp(node, node, script, (char *)NULL);
_exit(127);
}
/* A sidecar shutdown must become EPIPE here, not terminate SDL. */
(void)signal(SIGPIPE, SIG_IGN);
close(child_in[0]); child_in[0] = -1;
close(child_out[1]); child_out[1] = -1;
bridge->pid = pid; bridge->input_fd = child_in[1]; bridge->output_fd = child_out[0];
if (fcntl(bridge->output_fd, F_SETFL, fcntl(bridge->output_fd, F_GETFL) | O_NONBLOCK) < 0 ||
send_form(bridge, "start", room_key, storage, nick, channel)) goto fail;
return 0;
fail:
{
int saved = errno;
if (child_in[0] >= 0) close(child_in[0]);
if (child_in[1] >= 0) close(child_in[1]);
if (child_out[0] >= 0) close(child_out[0]);
if (child_out[1] >= 0) close(child_out[1]);
if (bridge->pid > 0) { kill(bridge->pid, SIGTERM); (void)waitpid(bridge->pid, NULL, 0); }
bridge->pid = 0; bridge->input_fd = -1; bridge->output_fd = -1;
dat_scheme_runtime_close(&bridge->scheme); errno = saved; return -1;
}
}
int cable_hyperswarm_send(cable_hyperswarm_t *bridge, const char *channel, const char *text)
{ return send_form(bridge, "send", channel, text, NULL, NULL); }
int cable_hyperswarm_nick(cable_hyperswarm_t *bridge, const char *nick)
{ return send_form(bridge, "nick", nick, NULL, NULL, NULL); }
int cable_hyperswarm_join(cable_hyperswarm_t *bridge, const char *channel)
{ return send_form(bridge, "join", channel, NULL, NULL, NULL); }
int cable_hyperswarm_live(cable_hyperswarm_t *bridge,
const uint8_t *packet, size_t packet_length)
{
size_t capacity;
char *encoded;
int result;
if (!packet || !packet_length || packet_length > CABLE_MEDIA_MAX_PACKET_BYTES) {
errno = EMSGSIZE; return -1;
}
capacity = sodium_base64_encoded_len(packet_length, sodium_base64_VARIANT_ORIGINAL);
encoded = (char *)malloc(capacity);
if (!encoded) return -1;
if (!sodium_bin2base64(encoded, capacity, packet, packet_length,
sodium_base64_VARIANT_ORIGINAL)) {
free(encoded); errno = EINVAL; return -1;
}
result = send_form(bridge, "live", encoded, NULL, NULL, NULL);
free(encoded);
return result;
}
int cable_hyperswarm_poll(cable_hyperswarm_t *bridge,
cable_hyperswarm_event_fn callback, void *context)
{
char incoming[4096];
ssize_t got;
if (!bridge || bridge->output_fd < 0 || !callback) { errno = EINVAL; return -1; }
while ((got = read(bridge->output_fd, incoming, sizeof(incoming))) > 0) {
size_t i;
for (i = 0; i < (size_t)got; i++) {
if (incoming[i] == '\n') {
if (parse_event_line(bridge, bridge->pending, bridge->pending_length, callback, context)) return -1;
bridge->pending_length = 0;
} else {
if (bridge->pending_length + 1 >= sizeof(bridge->pending)) { errno = EMSGSIZE; return -1; }
bridge->pending[bridge->pending_length++] = incoming[i];
}
}
}
if (got < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) return -1;
if (bridge->pid > 0) {
int status;
pid_t ended = waitpid(bridge->pid, &status, WNOHANG);
if (ended == bridge->pid) { bridge->pid = 0; errno = EPIPE; return -1; }
}
return 0;
}
void cable_hyperswarm_stop(cable_hyperswarm_t *bridge)
{
if (!bridge) return;
if (bridge->input_fd >= 0) { (void)send_form(bridge, "close", NULL, NULL, NULL, NULL); close(bridge->input_fd); }
if (bridge->output_fd >= 0) close(bridge->output_fd);
if (bridge->pid > 0) {
int status;
unsigned tries;
for (tries = 0; tries < 20; tries++) {
pid_t ended = waitpid(bridge->pid, &status, WNOHANG);
if (ended == bridge->pid || (ended < 0 && errno == ECHILD)) break;
{
struct timespec delay = {0, 10000000L};
(void)nanosleep(&delay, NULL);
}
}
if (tries == 20) { kill(bridge->pid, SIGTERM); (void)waitpid(bridge->pid, &status, 0); }
}
dat_scheme_runtime_close(&bridge->scheme);
memset(bridge, 0, sizeof(*bridge)); bridge->input_fd = -1; bridge->output_fd = -1;
}
#endif
+42
View File
@@ -0,0 +1,42 @@
#ifndef CABLE_HYPERSWARM_H
#define CABLE_HYPERSWARM_H
#include "dat_s7.h"
#include "s7.h"
#include <stddef.h>
#ifdef _WIN32
#include <windows.h>
#else
#include <sys/types.h>
#endif
typedef struct cable_hyperswarm {
#ifdef _WIN32
HANDLE process_handle;
#else
pid_t pid;
#endif
int input_fd;
int output_fd;
char pending[16384];
size_t pending_length;
dat_scheme_runtime_t scheme;
} cable_hyperswarm_t;
typedef void (*cable_hyperswarm_event_fn)(void *context, s7_pointer event);
int cable_hyperswarm_start(cable_hyperswarm_t *bridge, const char *room_key,
const char *storage, const char *nick,
const char *channel);
int cable_hyperswarm_send(cable_hyperswarm_t *bridge, const char *channel,
const char *text);
int cable_hyperswarm_nick(cable_hyperswarm_t *bridge, const char *nick);
int cable_hyperswarm_join(cable_hyperswarm_t *bridge, const char *channel);
/* Carry one bounded, binary-safe live media packet over Cable's room peers. */
int cable_hyperswarm_live(cable_hyperswarm_t *bridge,
const uint8_t *packet, size_t packet_length);
int cable_hyperswarm_poll(cable_hyperswarm_t *bridge,
cable_hyperswarm_event_fn callback, void *context);
void cable_hyperswarm_stop(cable_hyperswarm_t *bridge);
#endif
+362
View File
@@ -0,0 +1,362 @@
'use strict'
// Cable's SDL-facing bridge speaks S-expressions on stdin/stdout. Cabal Core
// owns the network boundary and continues to exchange its canonical JSON
// Hypercore blocks over Hyperswarm.
const fs = require('fs')
const { createRequire } = require('module')
const os = require('os')
const path = require('path')
const readline = require('readline')
function loadCabalCore () {
const configured = process.env.CABLE_CABAL_CORE || process.env.CABAL_JS_MODULE
if (configured) {
const entry = require.resolve(configured)
const requireCabal = createRequire(entry)
return { Cabal: requireCabal(entry), requireCabal }
}
try {
const entry = require.resolve('cabal-core')
const requireCabal = createRequire(entry)
return { Cabal: requireCabal(entry), requireCabal }
} catch (_) {}
const home = os.homedir()
const candidates = [
path.join(home, 'AppData', 'Roaming', 'npm', 'node_modules', 'cabal-cli', 'node_modules', 'cabal-core'),
path.join(home, '.npm-global', 'lib', 'node_modules', 'cabal-cli', 'node_modules', 'cabal-core'),
'/usr/local/lib/node_modules/cabal-cli/node_modules/cabal-core',
'/usr/lib/node_modules/cabal-cli/node_modules/cabal-core'
]
for (const candidate of candidates) {
if (fs.existsSync(path.join(candidate, 'index.js'))) {
const entry = path.join(candidate, 'index.js')
const requireCabal = createRequire(entry)
return { Cabal: requireCabal(entry), requireCabal }
}
}
throw new Error('Cabal Core not found; install cabal-cli or set CABLE_CABAL_CORE')
}
function parseBootstrap (requireCabal) {
const setting = process.env.CABLE_BOOTSTRAP || ''
const configPath = path.join(os.homedir(), '.config', 'dat-gpt', 'cable-bootstrap')
let saved = ''
try { saved = fs.readFileSync(configPath, 'utf8') } catch (err) {
if (err.code !== 'ENOENT') throw err
}
const endpoints = (setting + '\n' + saved).split(/[;,\s]+/).filter(Boolean)
if (!endpoints.length) return requireCabal('./bootstrap_nodes')
return Array.from(new Set(endpoints))
}
// Mirrors Cabal Core's swarm.js replication and peer-id extension, while
// allowing the same DHT bootstrap list to be selected by the local client.
async function startHyperswarm (cabal, networkOptions = {}) {
const loaded = loadCabalCore()
const DHT = loaded.requireCabal('hyperdht')
const Swarm = loaded.requireCabal('hyperswarm')
const crypto = loaded.requireCabal('hypercore-crypto')
const pump = loaded.requireCabal('pump')
const localKey = await new Promise((resolve, reject) =>
cabal.getLocalKey((err, key) => err ? reject(err) : resolve(key)))
const dhtOptions = { bootstrap: parseBootstrap(loaded.requireCabal) }
const host = networkOptions.host || process.env.CABLE_DHT_HOST
const port = networkOptions.port || process.env.CABLE_DHT_PORT
if (host) dhtOptions.host = host
if (port) {
const numericPort = Number(port)
if (!Number.isInteger(numericPort) || numericPort < 1 || numericPort > 65535)
throw new Error('CABLE_DHT_PORT must be an integer from 1 to 65535')
dhtOptions.port = numericPort
}
const advertiseHost = networkOptions.advertiseHost || process.env.CABLE_DHT_ADVERTISE_HOST
if (advertiseHost) dhtOptions.firewalled = false
const dht = new DHT(dhtOptions)
if (advertiseHost && Number.isInteger(dhtOptions.port) && dht._nat && typeof dht._nat.add === 'function')
dht._nat.add(advertiseHost, dhtOptions.port)
const swarm = new Swarm({ dht })
mediaExtensions.clear()
mediaSeen.clear()
swarm.on('error', err => {
if (process.env.CABLE_DEBUG) process.stderr.write('Cable Hyperswarm error: ' + err.message + '\n')
})
const discoveryKey = crypto.discoveryKey(Buffer.from(cabal.key, 'hex'))
if (process.env.CABLE_DEBUG) process.stderr.write('Cable swarm pid=' + process.pid + ' room=' + cabal.key +
' discovery=' + discoveryKey.toString('hex') + '\n')
const discovery = swarm.join(discoveryKey, { server: true, client: true })
let shuttingDown = false
swarm.__cabalDiscovery = discovery
swarm.__markShutdown = () => { shuttingDown = true }
swarm.__shutdown = async function () {
swarm.__markShutdown()
await discovery.destroy()
await swarm.destroy()
}
swarm.on('connection', (socket, info) => {
if (process.env.CABLE_DEBUG) process.stderr.write('Cable Hyperswarm connection opened pid=' + process.pid +
' client=' + Boolean(info.client) + ' peer=' + (info.publicKey ? info.publicKey.toString('hex') : 'unknown') + '\n')
socket.on('error', err => {
if (process.env.CABLE_DEBUG) process.stderr.write('Cable Hyperswarm socket error: ' + err.message + '\n')
})
let remoteKey
const replication = cabal.replicate(info.client)
if (process.env.CABLE_DEBUG) replication.on('error', err => process.stderr.write('Cable Hypercore replication error: ' + err.message + '\n'))
pump(socket, replication, socket, err => {
if (err && !shuttingDown && err.message !== 'Readable stream closed' && err.code !== 'ECONNRESET')
process.stderr.write('Cable replication: ' + err.message + '\n')
if (remoteKey) cabal._removeConnection(remoteKey)
})
const extension = replication.registerExtension('peer-id', {
encoding: 'json',
onmessage (message) {
if (remoteKey || !message || !message.id) return
const key = Buffer.from(message.id, 'hex')
if (key.length !== 32) return
remoteKey = message.id
cabal._addConnection(remoteKey)
if (process.env.CABLE_DEBUG) process.stderr.write('Cable Cabal peer id ' + remoteKey + '\n')
}
})
const liveExtension = replication.registerExtension('cable-live', {
onmessage (message) {
const packet = Buffer.isBuffer(message) ? message : Buffer.from(message)
if (packet.length > 4096) return
relayMediaPacket(packet, liveExtension)
if (packet.length >= 42 && packet.toString('ascii', 0, 4) === 'CBLM')
emit('live', remoteKey || '', packet.toString('base64'))
}
})
mediaExtensions.add(liveExtension)
socket.once('close', () => mediaExtensions.delete(liveExtension))
extension.send({ id: localKey })
if (process.env.CABLE_DEBUG) process.stderr.write('Cable local peer id sent: ' + localKey + '\n')
})
cabal._swarm = swarm
if (networkOptions.waitForBootstrap || process.env.CABLE_WAIT_DHT === '1') {
await dht.fullyBootstrapped()
await swarm.flush()
}
if (!(await discovery.flushed())) {
await swarm.__shutdown()
throw new Error('Hyperswarm topic discovery did not complete')
}
return swarm
}
function quote (value) {
const text = String(value == null ? '' : value)
let quoted = '"'
for (const character of text) {
switch (character) {
case '"': quoted += '\\"'; break
case '\\': quoted += '\\\\'; break
case '\b': quoted += '\\b'; break
case '\f': quoted += '\\f'; break
case '\n': quoted += '\\n'; break
case '\r': quoted += '\\r'; break
case '\t': quoted += '\\t'; break
default: quoted += character
}
}
return quoted + '"'
}
function emit (kind, ...fields) {
process.stdout.write('(event ' + kind + (fields.length ? ' ' + fields.map(value =>
typeof value === 'number' ? String(value) : quote(value)).join(' ') : '') + ')\n')
}
function parse (source) {
let at = 0
function white () { while (/\s/.test(source[at] || '')) at++ }
function value () {
white()
if (source[at] === '(') {
at++
const list = []
while (true) {
white()
if (source[at] === ')') { at++; return list }
if (at >= source.length) throw new Error('unterminated S-expression')
list.push(value())
}
}
if (source[at] === '"') {
at++
let text = ''
while (at < source.length) {
const c = source[at++]
if (c === '"') return text
if (c !== '\\') { text += c; continue }
if (at >= source.length) throw new Error('incomplete S-expression escape')
const escape = source[at++]
switch (escape) {
case '"': text += '"'; break
case '\\': text += '\\'; break
case 'b': text += '\b'; break
case 'f': text += '\f'; break
case 'n': text += '\n'; break
case 'r': text += '\r'; break
case 't': text += '\t'; break
default: throw new Error('unsupported S-expression escape: \\' + escape)
}
}
throw new Error('unterminated S-expression string')
}
const begin = at
while (at < source.length && !/[\s()]/.test(source[at])) at++
if (begin === at) throw new Error('expected S-expression atom')
const atom = source.slice(begin, at)
if (/^-?[0-9]+$/.test(atom)) return Number(atom)
return atom
}
const result = value()
white()
if (at !== source.length) throw new Error('trailing S-expression data')
return result
}
let cabal = null
let localKey = null
let starting = false
const names = new Map()
const pending = []
const mediaExtensions = new Set()
const mediaSeen = new Map()
function markMediaPacket (packet) {
if (!Buffer.isBuffer(packet) || packet.length < 42 || packet.length > 4096 ||
packet.toString('ascii', 0, 4) !== 'CBLM' || packet[4] !== 1 ||
packet[5] < 1 || packet[5] > 6 || packet.readUInt16BE(40) !== packet.length - 42) return false
const id = packet.subarray(8, 32).toString('hex')
if (mediaSeen.has(id)) return false
mediaSeen.set(id, true)
if (mediaSeen.size > 2048) mediaSeen.delete(mediaSeen.keys().next().value)
return true
}
function relayMediaPacket (packet, source) {
if (!markMediaPacket(packet)) return
for (const extension of mediaExtensions) {
if (extension === source) continue
try { extension.send(packet) } catch (_) {}
}
}
function channelName (msg) {
return msg && msg.value && msg.value.content && msg.value.content.channel
}
async function start (roomKey, storage, nick, channel) {
if (starting || cabal) throw new Error('Cabal room already started')
starting = true
try {
if (process.env.CABLE_DEBUG) process.stderr.write('Cable Core storage: ' + storage + '\n')
const { Cabal } = loadCabalCore()
fs.mkdirSync(storage, { recursive: true })
cabal = Cabal(storage, roomKey)
await new Promise((resolve, reject) => cabal.ready(err => err ? reject(err) : resolve()))
localKey = await new Promise((resolve, reject) => cabal.getLocalKey((err, key) => err ? reject(err) : resolve(key)))
cabal.on('peer-added', key => emit('peer-up', key))
cabal.on('peer-dropped', key => emit('peer-down', key))
cabal.kcore.api.users.events.on('update', (key, msg) => {
if (key === localKey) return
const name = msg && msg.value && msg.value.content && msg.value.content.name
if (typeof name === 'string') names.set(key, name)
emit('nick', key, names.get(key) || key.slice(0, 8))
})
cabal.kcore.api.messages.events.on('message', msg => {
const val = msg && msg.value
const content = val && val.content
const key = msg && msg.key
if (!val || !content || key === localKey || !key) return
if (val.type === 'chat/text' && typeof content.channel === 'string' && typeof content.text === 'string') {
emit('message', key, names.get(key) || key.slice(0, 8), content.channel,
content.text, val.timestamp || 0)
} else if (val.type === 'channel/join' && typeof content.channel === 'string') {
emit('joined', key, names.get(key) || key.slice(0, 8), content.channel)
}
})
await startHyperswarm(cabal)
emit('ready', localKey)
await new Promise((resolve, reject) => cabal.publishNick(nick, err => err ? reject(err) : resolve()))
await publish({ type: 'channel/join', content: { channel } })
emit('joined-local', channel)
for (const command of pending.splice(0)) await handle(command)
} finally {
starting = false
}
}
function publish (message) {
return new Promise((resolve, reject) => cabal.publish(message, err => err ? reject(err) : resolve()))
}
async function handle (command) {
if (!Array.isArray(command) || !command.length) throw new Error('expected a command list')
const [verb, ...args] = command
if (verb === 'start') return start(...args)
if (verb === 'close') {
if (cabal && cabal._swarm && cabal._swarm.__shutdown) await cabal._swarm.__shutdown()
if (cabal) await cabal.close()
cabal = null
localKey = null
emit('closed')
return
}
if (!cabal) {
if (starting) { pending.push(command); return }
throw new Error('Cabal room is not started')
}
if (verb === 'send') {
const [channel, text] = args
if (typeof channel !== 'string' || typeof text !== 'string') throw new Error('send expects channel and text strings')
return publish({ type: 'chat/text', content: { channel, text } })
}
if (verb === 'nick') {
const [nick] = args
if (typeof nick !== 'string') throw new Error('nick expects a string')
return new Promise((resolve, reject) => cabal.publishNick(nick, err => err ? reject(err) : resolve()))
}
if (verb === 'join') {
const [channel] = args
if (typeof channel !== 'string') throw new Error('join expects a channel string')
return publish({ type: 'channel/join', content: { channel } })
}
if (verb === 'live') {
const [encoded] = args
if (typeof encoded !== 'string' || encoded.length > 5500)
throw new Error('live expects one bounded base64 media packet')
const packet = Buffer.from(encoded, 'base64')
if (!markMediaPacket(packet)) throw new Error('invalid or repeated Cable live packet')
for (const extension of mediaExtensions) {
try { extension.send(packet) } catch (_) {}
}
return
}
throw new Error('unknown Cable bridge command: ' + verb)
}
module.exports = { startHyperswarm, loadCabalCore, quote, parse }
if (require.main === module) {
process.stdout.on('error', err => {
if (err && err.code === 'EPIPE') process.exit(0)
throw err
})
const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity })
input.on('line', line => {
if (!line.trim()) return
let result
try { result = handle(parse(line)) } catch (err) { emit('error', err.message); return }
Promise.resolve(result).catch(err => emit('error', err && err.message ? err.message : String(err)))
})
input.on('close', async () => {
if (cabal && cabal._swarm && cabal._swarm.__shutdown) { try { await cabal._swarm.__shutdown() } catch (_) {} }
if (cabal) { try { await cabal.close() } catch (_) {} }
process.exit(0)
})
}
@@ -0,0 +1,226 @@
#define _POSIX_C_SOURCE 200809L
#include "cable_hyperswarm.h"
#include <errno.h>
#include <fcntl.h>
#include <io.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sodium.h>
#include "cable_media.h"
static const char *find_bridge_script(char *path, size_t capacity)
{
const char *configured = getenv("CABLE_HYPERSWARM_BRIDGE");
static const char *candidates[] = {"src/cable_hyperswarm.js", ".\\cable_hyperswarm.js", "cable_hyperswarm.js"};
char module[MAX_PATH * 4], sibling[MAX_PATH * 4];
size_t i;
if (configured && *configured) {
if (strlen(configured) >= capacity) { errno = ENAMETOOLONG; return NULL; }
strcpy(path, configured);
return _access(path, 4) == 0 ? path : NULL;
}
for (i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++)
if (_access(candidates[i], 4) == 0) {
if (strlen(candidates[i]) >= capacity) { errno = ENAMETOOLONG; return NULL; }
strcpy(path, candidates[i]);
return path;
}
{
DWORD length = GetModuleFileNameA(NULL, module, (DWORD)sizeof(module));
char *separator;
int n;
if (length && length < sizeof(module) && (separator = strrchr(module, '\\')) != NULL) {
*separator = '\0';
n = snprintf(sibling, sizeof(sibling), "%s\\cable_hyperswarm.js", module);
if (n >= 0 && (size_t)n < sizeof(sibling) && _access(sibling, 4) == 0 &&
strlen(sibling) < capacity) { strcpy(path, sibling); return path; }
}
}
errno = ENOENT;
return NULL;
}
static int send_form(cable_hyperswarm_t *bridge, const char *verb,
const char *a, const char *b, const char *c, const char *d)
{
s7_scheme *sc;
s7_pointer port, form, args[5];
const char *serialized;
const char *values[4] = {a,b,c,d};
size_t length, offset = 0;
int count = 1, i;
if (!bridge || !bridge->scheme.scheme || bridge->input_fd < 0 || !verb) { errno = EINVAL; return -1; }
sc = bridge->scheme.scheme;
args[0] = s7_make_symbol(sc, verb);
for (i = 0; i < 4 && values[i]; i++) args[count++] = s7_make_string(sc, values[i]);
form = s7_nil(sc);
while (count) form = s7_cons(sc, args[--count], form);
port = s7_open_output_string(sc);
if (!port) { errno = ENOMEM; return -1; }
(void)s7_write(sc, form, port);
serialized = s7_get_output_string(sc, port);
if (!serialized) { errno = EIO; return -1; }
length = strlen(serialized);
while (offset < length) {
unsigned int chunk = length - offset > 0x7fffffffu ? 0x7fffffffu : (unsigned int)(length - offset);
int written = _write(bridge->input_fd, serialized + offset, chunk);
if (written <= 0) { errno = EPIPE; return -1; }
offset += (size_t)written;
}
return _write(bridge->input_fd, "\n", 1) == 1 ? 0 : -1;
}
static int parse_event_line(cable_hyperswarm_t *bridge, const char *line,
size_t length, cable_hyperswarm_event_fn callback,
void *context)
{
s7_scheme *sc = bridge->scheme.scheme;
s7_pointer parser, event;
char *terminated;
if (length > 8192) { errno = EMSGSIZE; return -1; }
terminated = (char *)malloc(length + 1);
if (!terminated) return -1;
memcpy(terminated, line, length); terminated[length] = '\0';
parser = s7_name_to_value(sc, "cable:parse-bridge-event");
event = s7_call(sc, parser, s7_list(sc, 1, s7_make_string(sc, terminated)));
free(terminated);
if (!event || s7_is_eq(event, s7_f(sc))) { errno = EPROTO; return -1; }
callback(context, event);
return 0;
}
int cable_hyperswarm_start(cable_hyperswarm_t *bridge, const char *room_key,
const char *storage, const char *nick, const char *channel)
{
SECURITY_ATTRIBUTES security;
HANDLE child_in = NULL, parent_in = NULL, parent_out = NULL, child_out = NULL;
STARTUPINFOA startup;
PROCESS_INFORMATION process;
char script[2048], command[4096];
const char *node = getenv("CABLE_NODE");
const char *dir = getenv("DAT_SCHEME_DIR");
char scheme_file[2048];
char module[MAX_PATH * 4];
int n;
if (!bridge || !room_key || !storage || !nick || !channel) { errno = EINVAL; return -1; }
memset(bridge, 0, sizeof(*bridge)); bridge->input_fd = -1; bridge->output_fd = -1;
if (!find_bridge_script(script, sizeof(script))) return -1;
if (!node || !*node) node = "node";
if (dat_scheme_runtime_init(&bridge->scheme)) { errno = EINVAL; return -1; }
if (dir && *dir) n = snprintf(scheme_file, sizeof(scheme_file), "%s\\cable-room.scm", dir);
else if (_access("scheme/cable-room.scm", 4) == 0) n = snprintf(scheme_file, sizeof(scheme_file), "scheme\\cable-room.scm");
else {
DWORD length = GetModuleFileNameA(NULL, module, (DWORD)sizeof(module));
char *separator = length && length < sizeof(module) ? strrchr(module, '\\') : NULL;
if (separator) { *separator = '\0'; n = snprintf(scheme_file, sizeof(scheme_file), "%s\\scheme\\cable-room.scm", module); }
else n = snprintf(scheme_file, sizeof(scheme_file), "share\\datgpt\\scheme\\cable-room.scm");
}
if (n < 0 || (size_t)n >= sizeof(scheme_file) || !s7_load(bridge->scheme.scheme, scheme_file)) {
dat_scheme_runtime_close(&bridge->scheme); errno = ENOENT; return -1;
}
security.nLength = sizeof(security); security.lpSecurityDescriptor = NULL; security.bInheritHandle = TRUE;
if (!CreatePipe(&child_in, &parent_in, &security, 0) || !CreatePipe(&parent_out, &child_out, &security, 0)) goto fail;
SetHandleInformation(parent_in, HANDLE_FLAG_INHERIT, 0);
SetHandleInformation(parent_out, HANDLE_FLAG_INHERIT, 0);
memset(&startup, 0, sizeof(startup)); startup.cb = sizeof(startup);
startup.dwFlags = STARTF_USESTDHANDLES;
startup.hStdInput = child_in; startup.hStdOutput = child_out; startup.hStdError = GetStdHandle(STD_ERROR_HANDLE);
memset(&process, 0, sizeof(process));
n = snprintf(command, sizeof(command), "\"%s\" \"%s\"", node, script);
if (n < 0 || (size_t)n >= sizeof(command) ||
!CreateProcessA(NULL, command, NULL, NULL, TRUE, CREATE_NO_WINDOW, NULL, NULL, &startup, &process)) {
errno = ENOENT; goto fail;
}
CloseHandle(child_in); child_in = NULL;
CloseHandle(child_out); child_out = NULL;
CloseHandle(process.hThread);
bridge->process_handle = process.hProcess;
bridge->input_fd = _open_osfhandle((intptr_t)parent_in, _O_BINARY | _O_WRONLY); parent_in = NULL;
bridge->output_fd = _open_osfhandle((intptr_t)parent_out, _O_BINARY | _O_RDONLY); parent_out = NULL;
if (bridge->input_fd < 0 || bridge->output_fd < 0) { errno = EIO; goto fail; }
if (send_form(bridge, "start", room_key, storage, nick, channel)) goto fail;
return 0;
fail:
if (child_in) CloseHandle(child_in);
if (parent_in) CloseHandle(parent_in);
if (parent_out) CloseHandle(parent_out);
if (child_out) CloseHandle(child_out);
if (bridge->input_fd >= 0) _close(bridge->input_fd);
if (bridge->output_fd >= 0) _close(bridge->output_fd);
if (bridge->process_handle) { TerminateProcess(bridge->process_handle, 1); WaitForSingleObject(bridge->process_handle, INFINITE); CloseHandle(bridge->process_handle); }
bridge->input_fd = bridge->output_fd = -1; bridge->process_handle = NULL;
dat_scheme_runtime_close(&bridge->scheme);
return -1;
}
int cable_hyperswarm_send(cable_hyperswarm_t *bridge, const char *channel, const char *text)
{ return send_form(bridge, "send", channel, text, NULL, NULL); }
int cable_hyperswarm_nick(cable_hyperswarm_t *bridge, const char *nick)
{ return send_form(bridge, "nick", nick, NULL, NULL, NULL); }
int cable_hyperswarm_join(cable_hyperswarm_t *bridge, const char *channel)
{ return send_form(bridge, "join", channel, NULL, NULL, NULL); }
int cable_hyperswarm_live(cable_hyperswarm_t *bridge,
const uint8_t *packet, size_t packet_length)
{
size_t capacity;
char *encoded;
int result;
if (!packet || !packet_length || packet_length > CABLE_MEDIA_MAX_PACKET_BYTES) {
errno = EMSGSIZE; return -1;
}
capacity = sodium_base64_encoded_len(packet_length, sodium_base64_VARIANT_ORIGINAL);
encoded = (char *)malloc(capacity);
if (!encoded) return -1;
if (!sodium_bin2base64(encoded, capacity, packet, packet_length,
sodium_base64_VARIANT_ORIGINAL)) {
free(encoded); errno = EINVAL; return -1;
}
result = send_form(bridge, "live", encoded, NULL, NULL, NULL);
free(encoded);
return result;
}
int cable_hyperswarm_poll(cable_hyperswarm_t *bridge,
cable_hyperswarm_event_fn callback, void *context)
{
char incoming[4096];
HANDLE pipe;
if (!bridge || bridge->output_fd < 0 || !callback) { errno = EINVAL; return -1; }
pipe = (HANDLE)_get_osfhandle(bridge->output_fd);
for (;;) {
DWORD available = 0, received = 0;
size_t i;
if (!PeekNamedPipe(pipe, NULL, 0, NULL, &available, NULL)) { errno = EPIPE; return -1; }
if (!available) break;
if (!ReadFile(pipe, incoming, available < sizeof(incoming) ? available : (DWORD)sizeof(incoming), &received, NULL)) { errno = EPIPE; return -1; }
for (i = 0; i < (size_t)received; i++) {
if (incoming[i] == '\n') {
if (parse_event_line(bridge, bridge->pending, bridge->pending_length, callback, context)) return -1;
bridge->pending_length = 0;
} else {
if (bridge->pending_length + 1 >= sizeof(bridge->pending)) { errno = EMSGSIZE; return -1; }
bridge->pending[bridge->pending_length++] = incoming[i];
}
}
}
if (bridge->process_handle && WaitForSingleObject(bridge->process_handle, 0) == WAIT_OBJECT_0) { errno = EPIPE; return -1; }
return 0;
}
void cable_hyperswarm_stop(cable_hyperswarm_t *bridge)
{
if (!bridge) return;
if (bridge->input_fd >= 0) { (void)send_form(bridge, "close", NULL, NULL, NULL, NULL); _close(bridge->input_fd); }
if (bridge->output_fd >= 0) _close(bridge->output_fd);
if (bridge->process_handle) {
if (WaitForSingleObject(bridge->process_handle, 200) == WAIT_TIMEOUT) {
TerminateProcess(bridge->process_handle, 1);
WaitForSingleObject(bridge->process_handle, INFINITE);
}
CloseHandle(bridge->process_handle);
}
dat_scheme_runtime_close(&bridge->scheme);
memset(bridge, 0, sizeof(*bridge)); bridge->input_fd = -1; bridge->output_fd = -1;
}
+184
View File
@@ -0,0 +1,184 @@
'use strict'
// One SDL/C Cable client and one Cabal Core JavaScript client share a local
// Hyperswarm bootstrap node, discover one another, and exchange chat records.
const assert = require('assert')
const { spawn } = require('child_process')
const crypto = require('crypto')
const dgram = require('dgram')
const fs = require('fs')
const os = require('os')
const path = require('path')
const readline = require('readline')
const { startHyperswarm, loadCabalCore } = require('./cable_hyperswarm')
const binary = path.resolve(process.argv[2] || 'build/linux/test_cable_thang')
const { Cabal, requireCabal } = loadCabalCore()
const DHT = requireCabal('hyperdht')
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cable-js-c-live-'))
const roomKey = crypto.randomBytes(32).toString('hex')
const address = 'cabal://' + roomKey
const cHome = path.join(root, 'c-client')
const jsHome = path.join(root, 'js-client')
const cLines = []
const cWaiters = []
let cClient = null
let cabal = null
let bootstrapper = null
let relay = null
function freeUdpPort () {
return new Promise((resolve, reject) => {
const socket = dgram.createSocket('udp4')
socket.once('error', reject)
socket.bind(0, '127.0.0.1', () => {
const port = socket.address().port
socket.close(err => err ? reject(err) : resolve(port))
})
})
}
function ready (instance) {
return new Promise((resolve, reject) => instance.ready(err => err ? reject(err) : resolve()))
}
function waitFor (predicate, description, timeoutMs = 90000) {
const started = Date.now()
return new Promise((resolve, reject) => {
const timer = setInterval(() => {
if (Date.now() - started > timeoutMs) {
clearInterval(timer)
reject(new Error('timeout waiting for ' + description))
return
}
try {
const value = predicate()
if (value) { clearInterval(timer); resolve(value) }
} catch (err) { clearInterval(timer); reject(err) }
}, 50)
})
}
function cLine (line) {
cLines.push(line)
process.stdout.write('C> ' + line + '\n')
for (let i = cWaiters.length - 1; i >= 0; i--) {
if (cWaiters[i].predicate(line)) {
const waiter = cWaiters.splice(i, 1)[0]
clearTimeout(waiter.timer)
waiter.resolve(line)
}
}
}
function waitC (predicate, description, timeoutMs = 90000) {
const existing = cLines.find(predicate)
if (existing) return Promise.resolve(existing)
return new Promise((resolve, reject) => {
const waiter = { predicate, resolve, reject, timer: null }
waiter.timer = setTimeout(() => {
const index = cWaiters.indexOf(waiter)
if (index >= 0) cWaiters.splice(index, 1)
reject(new Error('timeout waiting for C client ' + description))
}, timeoutMs)
cWaiters.push(waiter)
})
}
async function main () {
fs.mkdirSync(cHome, { recursive: true })
fs.mkdirSync(jsHome, { recursive: true })
const port = await freeUdpPort()
const relayPort = await freeUdpPort()
const jsPort = await freeUdpPort()
const cPort = await freeUdpPort()
bootstrapper = DHT.bootstrapper(port, '127.0.0.1', { host: '127.0.0.1' })
await bootstrapper.ready()
relay = new DHT({ bootstrap: ['127.0.0.1:' + port], host: '127.0.0.1', port: relayPort,
ephemeral: false, firewalled: false })
await relay.ready()
const endpoint = '127.0.0.1:' + relayPort
process.env.CABLE_BOOTSTRAP = endpoint
process.env.CABLE_DHT_HOST = '127.0.0.1'
process.env.CABLE_DHT_ADVERTISE_HOST = '127.0.0.1'
process.env.CABLE_WAIT_DHT = '1'
console.log('Local Hyperswarm bootstrap listening at ' + endpoint)
cabal = Cabal(jsHome, roomKey)
await ready(cabal)
if (process.env.CABLE_DEBUG) {
const key = await new Promise((resolve, reject) => cabal.getLocalKey((err, value) => err ? reject(err) : resolve(value)))
console.log('JS test storage: ' + jsHome)
console.log('JS Cabal user key: ' + key)
}
const jsPeers = new Set()
const jsMessages = []
cabal.on('peer-added', key => { jsPeers.add(key); console.log('JS peer up: ' + key) })
cabal.on('peer-dropped', key => jsPeers.delete(key))
cabal.kcore.api.messages.events.on('message', message => {
const value = message && message.value
const content = value && value.content
if (value && value.type === 'chat/text' && content && typeof content.text === 'string') {
jsMessages.push(content.text)
console.log('JS chat: ' + content.text)
}
})
cClient = spawn(binary, [address], {
cwd: process.cwd(),
env: {
...process.env,
HOME: cHome,
SDL_VIDEODRIVER: 'dummy',
SDL_AUDIODRIVER: 'dummy',
CABLE_BOOTSTRAP: endpoint,
CABLE_DHT_HOST: '127.0.0.1',
CABLE_DHT_PORT: String(cPort)
},
stdio: ['pipe', 'pipe', 'inherit']
})
cClient.stdin.on('error', () => {})
cClient.once('error', err => {
for (const waiter of cWaiters) { clearTimeout(waiter.timer); waiter.reject(err) }
cWaiters.length = 0
})
readline.createInterface({ input: cClient.stdout }).on('line', cLine)
const cReady = await waitC(line => line.startsWith('C_READY '), 'S7-backed Cabal room startup')
const cFeedKey = cReady.slice('C_READY '.length)
await startHyperswarm(cabal, { host: '127.0.0.1', port: jsPort,
advertiseHost: '127.0.0.1', waitForBootstrap: true })
await Promise.all([
waitFor(() => jsPeers.size > 0, 'C client discovered by JS Cabal Core'),
waitC(line => line === 'PEER_UP', 'JS client discovered by C Cable')
])
const jsKey = await new Promise((resolve, reject) =>
cabal.getLocalKey((err, key) => err ? reject(err) : resolve(key)))
assert.notStrictEqual(jsKey, cFeedKey, 'independent Cabal users must have distinct feed keys')
assert(jsPeers.has(cFeedKey), 'JS Cabal Core must identify the C client feed')
await new Promise((resolve, reject) => cabal.publishNick('js-peer', err => err ? reject(err) : resolve()))
await new Promise((resolve, reject) => cabal.publish({
type: 'chat/text', content: { channel: 'default', text: 'message from cabal js' }
}, err => err ? reject(err) : resolve()))
await waitC(line => line.includes('message from cabal js'), 'JS-to-C message delivery')
cClient.stdin.write('SEND message from sdl c\n')
await waitFor(() => jsMessages.includes('message from sdl c'), 'C-to-JS Hypercore feed replication')
console.log('C SDL / Cabal JS Hyperswarm discovery and bidirectional chat: PASS')
}
main().catch(err => { console.error(err.stack || err); process.exitCode = 1 }).finally(async () => {
if (cClient && cClient.exitCode === null) {
try { cClient.stdin.write('QUIT\n') } catch (_) {}
await Promise.race([new Promise(resolve => cClient.once('exit', resolve)), new Promise(resolve => setTimeout(resolve, 2000))])
if (cClient.exitCode === null) cClient.kill('SIGTERM')
}
if (cabal) {
if (cabal._swarm && cabal._swarm.__shutdown) { try { await cabal._swarm.__shutdown() } catch (_) {} }
try { await cabal.close() } catch (_) {}
}
if (relay) { try { await relay.destroy() } catch (_) {} }
if (bootstrapper) { try { await bootstrapper.destroy() } catch (_) {} }
const resolved = path.resolve(root)
if (resolved.startsWith(path.resolve(os.tmpdir()) + path.sep) && path.basename(resolved).startsWith('cable-js-c-live-'))
fs.rmSync(resolved, { recursive: true, force: true })
})
@@ -0,0 +1,15 @@
'use strict'
const assert = require('assert')
const { quote, parse } = require('./cable_hyperswarm')
for (const value of [
'', 'plain text', 'quote " slash \\', 'line one\nline two',
'tabs\tand\bbackspace\f formfeed\r', 'Unicode: 媒腻㐤┖ꈳ埳 🐬'
]) {
assert.deepStrictEqual(parse('(send "general" ' + quote(value) + ')'), ['send', 'general', value])
}
assert.throws(() => parse('(send "unterminated)'), /unterminated/)
assert.throws(() => parse('(send "bad\\q")'), /unsupported S-expression escape/)
console.log('Cable S-expression IPC: PASS')
@@ -0,0 +1,99 @@
'use strict'
// Narrow diagnostic: validates the installed Hyperswarm/HyperDHT package and
// the local bootstrap node independently of Cabal's Hypercore replication.
const assert = require('assert')
const crypto = require('crypto')
const dgram = require('dgram')
const { loadCabalCore } = require('./cable_hyperswarm')
const { requireCabal } = loadCabalCore()
const DHT = requireCabal('hyperdht')
const Swarm = requireCabal('hyperswarm')
function freePort () {
return new Promise((resolve, reject) => {
const socket = dgram.createSocket('udp4')
socket.once('error', reject)
socket.bind(0, '127.0.0.1', () => {
const port = socket.address().port
socket.close(err => err ? reject(err) : resolve(port))
})
})
}
async function main () {
const portA = await freePort()
const portB = await freePort()
const relayPort = await freePort()
const useBuiltin = process.env.HYPERSWARM_TEST_BUILTIN === '1'
const bootstrapPort = useBuiltin ? 0 : await freePort()
const bootstrap = useBuiltin ? null : DHT.bootstrapper(bootstrapPort, '127.0.0.1', { host: '127.0.0.1' })
const relay = useBuiltin ? null : new DHT({
bootstrap: ['127.0.0.1:' + bootstrapPort], host: '127.0.0.1', port: relayPort,
ephemeral: false, firewalled: false
})
const swarms = []
try {
if (bootstrap) await bootstrap.ready()
if (relay) await relay.ready()
if (bootstrap) console.log('bootstrap state', { id: Boolean(bootstrap.id), ephemeral: bootstrap.ephemeral, storage: Boolean(bootstrap._persistent) })
if (relay) console.log('relay state', { id: Boolean(relay.id), ephemeral: relay.ephemeral, storage: Boolean(relay._persistent) })
const endpoint = useBuiltin ? undefined : '127.0.0.1:' + relayPort
for (const port of [portA, portB]) {
const dht = new DHT({ ...(useBuiltin ? {} : { bootstrap: [endpoint] }), host: '127.0.0.1', port,
ephemeral: false, firewalled: false })
dht._nat.add('127.0.0.1', port)
const swarm = new Swarm({ dht })
dht.on('error', err => console.error('DHT error:', err.message))
swarm.on('error', err => console.error('swarm error:', err.message))
swarms.push(swarm)
}
const [a, b] = swarms
const topic = crypto.randomBytes(32)
let connected = 0
const onConnection = socket => { connected++; socket.on('error', () => {}) }
a.on('connection', onConnection)
b.on('connection', onConnection)
const onDiscoveryError = (label) => err => console.error(label + ' discovery error:', err && (err.stack || err))
const discoveryA = a.join(topic, { server: true, client: false, onerror: onDiscoveryError('A') })
const aFlushed = await discoveryA.flushed()
const discoveryB = b.join(topic, { server: false, client: true, onerror: onDiscoveryError('B') })
const bFlushed = await discoveryB.flushed()
console.log('Hyperswarm discovery flush:', aFlushed + '/' + bFlushed)
console.log('DHT node tables:', {
a: a.dht.toArray(), b: b.dht.toArray(),
relay: relay && relay.table.toArray().map(node => ({ host: node.host, port: node.port }))
})
if (relay) console.log('relay stored topic peers:', relay._persistent.records.get(topic.toString('hex'), 20).length)
const lookup = b.dht.lookup(topic)
const lookupResults = []
lookup.on('data', result => lookupResults.push(result.peers.length))
await lookup.finished()
console.log('explicit topic lookup peer counts:', lookupResults)
const deadline = Date.now() + 20000
while (connected < 2 && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100))
const discovered = connected >= 2
console.log('Hyperswarm state:', { connections: [a.connections.size, b.connections.size],
queued: [a.connecting, b.connecting], peerEntries: [a.peers.size, b.peers.size] })
if (!discovered) {
console.log('DHT discovery did not connect both local nodes; trying direct Hyperswarm peers to isolate transport')
a.joinPeer(b.keyPair.publicKey)
b.joinPeer(a.keyPair.publicKey)
const directDeadline = Date.now() + 10000
while (connected < 2 && Date.now() < directDeadline) await new Promise(resolve => setTimeout(resolve, 100))
}
assert(connected >= 2, 'local Hyperswarm peers failed direct transport too')
console.log('local Hyperswarm transport: PASS')
if (!discovered) {
console.error('local DHT discovery: FAIL (the bootstrap node did not yield peer addresses)')
process.exitCode = 1
} else console.log('local Hyperswarm DHT discovery: PASS')
} finally {
for (const swarm of swarms) { try { await swarm.destroy() } catch (_) {} }
if (relay) { try { await relay.destroy() } catch (_) {} }
if (bootstrap) { try { await bootstrap.destroy() } catch (_) {} }
}
}
main().catch(err => { console.error(err.stack || err); process.exitCode = 1 })