0.0.002
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:
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 })
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 })
|
||||
@@ -0,0 +1,87 @@
|
||||
'use strict'
|
||||
|
||||
// Opens the native Cable user log with the bundled legacy Hypercore 7.7.1,
|
||||
// appends a JS record, and leaves the C side to read it back in the runner.
|
||||
const assert = require('assert')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const root = path.resolve(__dirname, '../../..')
|
||||
const home = path.resolve(process.argv[2])
|
||||
const roomKey = process.argv[3]
|
||||
assert.match(roomKey, /^[0-9a-f]{64}$/)
|
||||
const roomDir = path.join(home, '.cable', 'rooms', roomKey)
|
||||
const feedDir = path.join(roomDir, '0')
|
||||
const feedKey = fs.readFileSync(path.join(feedDir, 'key'))
|
||||
const secretKey = fs.readFileSync(path.join(roomDir, 'user-feed.key'))
|
||||
assert.strictEqual(feedKey.length, 32)
|
||||
assert.strictEqual(secretKey.length, 64)
|
||||
|
||||
const Hypercore = require(path.join(root, 'extra/dillo-dat/node_modules/hypercore'))
|
||||
const randomAccessFile = require(path.join(root, 'extra/dillo-dat/node_modules/random-access-file'))
|
||||
function memoryStorage () {
|
||||
let bytes = Buffer.alloc(0)
|
||||
return {
|
||||
read (offset, length, callback) {
|
||||
const value = Buffer.alloc(length)
|
||||
bytes.copy(value, 0, offset, offset + length)
|
||||
callback(null, value)
|
||||
},
|
||||
write (offset, value, callback) {
|
||||
if (bytes.length < offset + value.length) {
|
||||
const grown = Buffer.alloc(offset + value.length)
|
||||
bytes.copy(grown)
|
||||
bytes = grown
|
||||
}
|
||||
value.copy(bytes, offset)
|
||||
callback(null)
|
||||
},
|
||||
stat (callback) { callback(null, { size: bytes.length }) },
|
||||
close (callback) { callback(null) }
|
||||
}
|
||||
}
|
||||
const storage = name => name === 'secret_key'
|
||||
? memoryStorage()
|
||||
: randomAccessFile(name, { directory: feedDir })
|
||||
|
||||
function ready (feed) {
|
||||
return new Promise((resolve, reject) => feed.ready(err => err ? reject(err) : resolve()))
|
||||
}
|
||||
|
||||
function get (feed, index) {
|
||||
return new Promise((resolve, reject) => feed.get(index, (err, value) => err ? reject(err) : resolve(value)))
|
||||
}
|
||||
|
||||
function append (feed, value) {
|
||||
return new Promise((resolve, reject) => feed.append(Buffer.from(JSON.stringify(value)), err => err ? reject(err) : resolve()))
|
||||
}
|
||||
|
||||
function close (feed) {
|
||||
return new Promise((resolve, reject) => feed.close(err => err ? reject(err) : resolve()))
|
||||
}
|
||||
|
||||
async function main () {
|
||||
const feed = Hypercore(storage, feedKey, { secretKey })
|
||||
feed.on('error', err => { throw err })
|
||||
try {
|
||||
await ready(feed)
|
||||
assert.strictEqual(feed.length, 2, 'C should have written two Cabal records')
|
||||
const nick = JSON.parse((await get(feed, 0)).toString('utf8'))
|
||||
const message = JSON.parse((await get(feed, 1)).toString('utf8'))
|
||||
assert.strictEqual(nick.type, 'about')
|
||||
assert.strictEqual(nick.content.name, 'cable-c-user')
|
||||
assert.strictEqual(message.type, 'chat/text')
|
||||
assert.strictEqual(message.content.channel, 'interop')
|
||||
assert.strictEqual(message.content.text, 'hello from C Hypercore')
|
||||
await append(feed, {
|
||||
type: 'chat/text',
|
||||
content: { channel: 'interop', text: 'hello from Cabal JavaScript Hypercore' },
|
||||
timestamp: Date.now()
|
||||
})
|
||||
process.stdout.write(`Cabal Hypercore v7.7.1 read C user feed and appended reply: PASS key=${feedKey.toString('hex')}\n`)
|
||||
} finally {
|
||||
await close(feed)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exitCode = 1 })
|
||||
@@ -0,0 +1,44 @@
|
||||
param([switch]$KeepFixture)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path
|
||||
$buildDir = [System.IO.Path]::GetFullPath((Join-Path $root 'build\linux'))
|
||||
$binary = Join-Path $buildDir 'cable'
|
||||
$nodeScript = Join-Path $PSScriptRoot 'cable_hypercore_compat.js'
|
||||
$runRoot = [System.IO.Path]::GetFullPath((Join-Path $root 'local-data\test-runs'))
|
||||
$fixtureHome = Join-Path $runRoot ('cable-hypercore-interop-' + [guid]::NewGuid().ToString('N'))
|
||||
$linuxRoot = '/mnt/' + $root.Substring(0, 1).ToLower() + '/' + $root.Substring(3).Replace('\', '/')
|
||||
$linuxHome = $linuxRoot + '/local-data/test-runs/' + (Split-Path $fixtureHome -Leaf)
|
||||
$room = '42' * 32
|
||||
|
||||
function Invoke-WslCable([string[]]$Arguments) {
|
||||
& wsl.exe --exec env "HOME=$linuxHome" "$linuxRoot/build/linux/cable" @Arguments
|
||||
if ($LASTEXITCODE -ne 0) { throw "C cable command failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $fixtureHome -Force | Out-Null
|
||||
try {
|
||||
& wsl.exe --exec make -C $linuxRoot build/linux/cable
|
||||
if ($LASTEXITCODE -ne 0) { throw "WSL Cable build failed with exit code $LASTEXITCODE" }
|
||||
Invoke-WslCable @('nick', "cabal://$room", 'cable-c-user') | Out-Null
|
||||
Invoke-WslCable @('publish', "cabal://$room", 'interop', 'hello from C Hypercore') | Out-Null
|
||||
|
||||
& node $nodeScript $fixtureHome $room
|
||||
if ($LASTEXITCODE -ne 0) { throw "JavaScript Hypercore check failed with exit code $LASTEXITCODE" }
|
||||
|
||||
$records = Invoke-WslCable @('feed', "cabal://$room")
|
||||
if (($records -join "`n") -notmatch 'hello from Cabal JavaScript Hypercore') {
|
||||
throw 'C did not read the JavaScript-appended block'
|
||||
}
|
||||
Write-Output 'Cabal Core room/0 storage + Hypercore v7.7.1 read/write: PASS'
|
||||
Write-Output 'C read JavaScript-appended Hypercore block: PASS'
|
||||
} finally {
|
||||
if (-not $KeepFixture -and (Test-Path -LiteralPath $fixtureHome)) {
|
||||
$resolvedFixture = [System.IO.Path]::GetFullPath((Resolve-Path -LiteralPath $fixtureHome).Path)
|
||||
$runPrefix = $runRoot.TrimEnd('\') + '\'
|
||||
if (-not $resolvedFixture.StartsWith($runPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Refusing to remove fixture outside local-data/test-runs/: $resolvedFixture"
|
||||
}
|
||||
Remove-Item -LiteralPath $resolvedFixture -Recurse -Force
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
const hyperdrive = require('../../extra/dillo-dat/node_modules/hyperdrive')
|
||||
const hyperdiscovery = require('../../extra/dillo-dat/node_modules/hyperdiscovery')
|
||||
|
||||
function ready (archive) {
|
||||
return new Promise((resolve, reject) => archive.ready(err => err ? reject(err) : resolve()))
|
||||
}
|
||||
|
||||
function writeFile (archive, name, data) {
|
||||
return new Promise((resolve, reject) => archive.writeFile(name, data, err => err ? reject(err) : resolve()))
|
||||
}
|
||||
|
||||
function readFile (archive, name) {
|
||||
return new Promise((resolve, reject) => archive.readFile(name, (err, data) => err ? reject(err) : resolve(data)))
|
||||
}
|
||||
|
||||
function listening (swarm) {
|
||||
if (swarm.address()) return Promise.resolve(swarm.address())
|
||||
return new Promise(resolve => swarm.once('listening', () => resolve(swarm.address())))
|
||||
}
|
||||
|
||||
function sleep (ms) { return new Promise(resolve => setTimeout(resolve, ms)) }
|
||||
|
||||
async function main () {
|
||||
const root = fs.mkdtempSync(path.join(process.env.DDAT_TEST_DIR || os.tmpdir(), 'ddat-js-p2p-'))
|
||||
let source, replica, sourceSwarm, replicaSwarm
|
||||
try {
|
||||
source = hyperdrive(path.join(root, 'source'))
|
||||
await ready(source)
|
||||
const key = source.key.toString('hex')
|
||||
const address = 'dat://' + key
|
||||
await writeFile(source, 'hello.txt', 'hello from old dillo-dat v1')
|
||||
await writeFile(source, 'hello.txt', 'hello from old dillo-dat v2')
|
||||
if (source.key.toString('hex') !== key) throw new Error('JS archive address changed after update')
|
||||
replica = hyperdrive(path.join(root, 'replica'), source.key, {sparse: true})
|
||||
await ready(replica)
|
||||
|
||||
sourceSwarm = hyperdiscovery(source, {port: 0, discovery: false, dht: false, dns: false, utp: false})
|
||||
replicaSwarm = hyperdiscovery(replica, {port: 0, discovery: false, dht: false, dns: false, utp: false})
|
||||
const sourceAddress = await listening(sourceSwarm)
|
||||
const replicaAddress = await listening(replicaSwarm)
|
||||
const topic = source.discoveryKey
|
||||
sourceSwarm.addPeer(topic, {host: '127.0.0.1', port: replicaAddress.port})
|
||||
replicaSwarm.addPeer(topic, {host: '127.0.0.1', port: sourceAddress.port})
|
||||
|
||||
const deadline = Date.now() + 15000
|
||||
let result
|
||||
while (Date.now() < deadline) {
|
||||
try { result = await readFile(replica, 'hello.txt'); break } catch (_) { await sleep(100) }
|
||||
}
|
||||
if (!result || result.toString() !== 'hello from old dillo-dat v2') throw new Error('old JS replication did not deliver the latest file')
|
||||
process.stdout.write('old Dillo Dat JS create/update/address/xfer: PASS ' + address +
|
||||
' metadata=' + source.metadata.length + ' content=' + source.content.length + '\n')
|
||||
} finally {
|
||||
if (sourceSwarm) sourceSwarm.destroy()
|
||||
if (replicaSwarm) replicaSwarm.destroy()
|
||||
if (source) source.close()
|
||||
if (replica) replica.close()
|
||||
if (process.env.DDAT_TEST_KEEP === '1') {
|
||||
process.stdout.write('kept old JS source and replica at ' + root + '\n')
|
||||
} else {
|
||||
fs.rmSync(root, {recursive: true, force: true})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exitCode = 1 })
|
||||
Reference in new issue
Block a user