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>
363 lines
14 KiB
JavaScript
363 lines
14 KiB
JavaScript
'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)
|
|
})
|
|
}
|