decentstack
Version:
Decentralized application framework
406 lines (352 loc) • 13.9 kB
JavaScript
const assert = require('assert')
const Protocol = require('hypercore-protocol')
const eos = require('end-of-stream')
const pump = require('pump')
const debugFactory = require('debug')
const { defer } = require('deferinfer')
const { assertCore, fastHash } = require('./util')
const substream = require('hypercore-protocol-substream')
const ph = require('pretty-hash')
const codecs = require('codecs')
const CoreExchangeExtension = require('./exchange')
const {
STATE_INIT,
STATE_ACTIVE,
STATE_DEAD,
PROTOCOL_VERSION,
EXCHANGE_TIMEOUT
} = require('./constants')
class PeerConnection {
constructor (initiator, exchangeKey, opts = {}) {
assert(typeof initiator === 'boolean', 'First argument `initiator` must be a boolean!')
assert(typeof exchangeKey === 'string' || Buffer.isBuffer(exchangeKey), 'Second arg `exchangeKey` must be a string or buffer')
this._id = Buffer.from(Array.from(new Array(6)).map(() => Math.floor(256 * Math.random())))
this.initiator = initiator
this.state = STATE_INIT
this.opts = opts || {}
this.useVirtual = !!opts.useVirtual && false // Disabled for now.
this.activeVirtual = []
this._extensions = {}
// Lift off handlers from options.
this.handlers = {
onmanifest: opts.onmanifest,
onrequest: opts.onrequest,
onstatechange: opts.onstatechange,
onreplicating: opts.onreplicating,
onopen: opts.onopen,
onclose: opts.onclose,
onextension: opts.onextension
}
delete opts.onmanifest
delete opts.onrequest
delete opts.onstatechange
delete opts.onreplicating
// Initialize stats
this.stats = {
snapshotsSent: 0,
snapshotsRecv: 0,
requestsSent: 0,
requestsRecv: 0,
channelesOpened: 0,
channelesClosed: 0
}
// Normalize exchange key
if (typeof exchangeKey === 'string') exchangeKey = Buffer.from(exchangeKey, 'hex')
this.exchangeKey = exchangeKey
// Initialize individual debug handle
this.debug = debugFactory(`decentstack/repl/${this.shortid}`)
// Pre-bind our listeners before registering them, to be able to safely remove them
this.kill = this.kill.bind(this)
// Create our stream
this.stream = new Protocol(initiator, {
onhandshake: this._onHandshake.bind(this),
ondiscoverykey: this._onChannelOpened.bind(this),
onchannelclose: this._onChannelClosed.bind(this)
})
/*
const emit = this.stream.emit
this.stream.emit = (...args) => {
this.debug('STREAM EVENT:', ...args)
emit.apply(this.stream, args)
}
*/
// Register the core-exchange protocol
this.exchangeExt = this.registerExtension(new CoreExchangeExtension({
onmanifest: (snapshot, peer) => {
this.stats.snapshotsRecv++
this.debug(`Received manifest #${snapshot.id}`)
// Forward upwards
if (typeof this.handlers.onmanifest === 'function') this.handlers.onmanifest(snapshot, peer)
},
onrequest: (req, peer) => {
peer.debug(`Received replication request for ${req.keys.length} feeds`)
// Forward upwards
if (typeof this.handlers.onrequest === 'function') this.handlers.onrequest(req, peer)
}
}))
// Clean up theese two as well
let resolveRemoteVersion = null
const remoteVersionPromise = defer(d => { resolveRemoteVersion = d })
// Create the channel that we're going to use for exchange signaling.
this.exchangeChannel = this.stream.open(this.exchangeKey, {
onextension: this._onExtension.bind(this),
onoptions: (opts) => {
// Hack, remote version is packed into first element of extensions array
resolveRemoteVersion(null, opts.extensions.shift())
},
onopen: async () => {
this.debug('Exchange Channel opened', ph(this.exchangeKey))
if (!this.stream.remoteVerified(this.exchangeKey)) throw new Error('open and unverified')
this.exchangeChannel.options({
extensions: [PROTOCOL_VERSION]
})
// TODO: clean up, it's not pretty.
const [lNameVer] = PROTOCOL_VERSION.split('.')
const remoteVersion = await remoteVersionPromise
const [rNameVer] = remoteVersion.split('.')
if (lNameVer !== rNameVer) {
this.kill(new Error(`Version mismatch! local: ${PROTOCOL_VERSION}, remote: ${remoteVersion}`))
} else this._transition(STATE_ACTIVE)
},
onclose: () => {
this.debug('Exchange Channel closed')
}
})
this.debug(`Initializing new PeerConnection(${this.initiator}) extensions:`, Object.values(this._extensions).map(i => i.name))
// Register end-of-stream handler
eos(this.stream, err => {
this.kill(err, true)
})
}
get id () {
return this._id
}
get shortid () {
return this._id.hexSlice(0, 4)
}
// Forward extension to virtual feed.
extension (name, ...args) {
const id = fastHash(name)
// Register new extensions dynamically
if (!this._extensions[id]) this._extensions[id] = name
this.exchangeChannel.extension(id, ...args)
}
_onHandshake () {
this.debug('Handshake received')
}
_transition (newState, err = null) {
// We only have 3 states, and none of the transitions
// loop back on themselves
if (newState === this.state) return console.warn('Connection already in state', newState, err)
const prevState = this.state
this.state = newState
if (typeof this.handlers.onstatechange === 'function') {
this.handlers.onstatechange(newState, prevState, err, this)
} else if (err) {
// Log error if no 'state-change' listeners available to handle it.
console.error('PeerConnection received error during `state-change` event, but no there are no registered handlers for it!\n', err)
}
switch (this.state) {
case STATE_DEAD:
if (typeof this.handlers.onclose === 'function') {
this.handlers.onclose(err, this)
}
break
case STATE_ACTIVE:
// wedge the stream. released in kill()#cleanup()
// our exchangeChannel already works as a wedge.
// if (this.opts.live) this.stream.prefinalize.wait()
// if (this.opts.live) this.stream.prefinalize.continue() // this dosen't make sense. stream is already marked for death.
if (typeof this.handlers.onopen === 'function') {
this.handlers.onopen(this)
}
break
}
}
/*
* this is our errorHandler + peer-cleanup
* calling it without an error implies a natural disconnection.
*/
kill (err = null, eosDetected = false) {
this.debug(`kill invoked, by eos: ${eosDetected}:`, err)
// Report other post-mortem on console as the manager
// will already have removed it's listeners from this connection,
// logging is better than silent failure
if (this.state === STATE_DEAD) {
if (err) console.warning('Warning kill() invoked on dead peer-connection\n', this.shortid, err)
return
}
const cleanup = () => {
this.debug('cleanup:', err)
this.exchangeChannel.close()
// Save error for post mortem debugging
this.lastError = err
// Notify the manager that this PeerConnection died.
this._transition(STATE_DEAD, err)
}
// If stream is alive, destroy it and wait for eos to invoke kill(err) again
// this prevents some post-mortem error reports.
if (!this.stream.destroyed && !eosDetected) {
if (err) {
// Destroy with error
this.stream.destroy(err)
} else {
this.end()
}
} else cleanup() // Perform cleanup now.
}
end () {
// wait for stream to flush before cleanup
this.debug('invoke end & schedule cleanup')
for (const chan of this.activeChannels) {
chan.close()
}
}
registerExtension (name, impl) {
if (!impl && typeof name.name === 'string') return this.registerExtension(name.name, name)
Object.assign(impl, {
_id: fastHash(name),
_codec: codecs(impl.encoding)
})
if (impl.name !== name) {
impl.name = name
}
impl.send = message => {
const buff = impl.encoding ? impl._codec.encode(message) : message
this.exchangeChannel.extension(impl._id, buff)
}
impl.broadcast = impl.send
impl.destroy = () => {
this.debug('Unregister extension', name, impl._id.toString(16))
delete this._extensions[impl._id]
}
this._extensions[impl._id] = impl
this.debug('Register extension', name, impl._id.toString(16))
return impl
}
_onChannelOpened (discoveryKey) {
// console.log('_onChannelOpened', arguments)
this.debug('remote replicates discKey', ph(discoveryKey))
if (this.state === STATE_INIT) {
const verified = this.stream.remoteVerified(discoveryKey)
if (!verified) return this.kill(new Error('Remote uses a different exchangeKey'))
}
}
_onChannelClosed (dk, pk) {
this.stats.channelesClosed++
this._c = this._c || 0
this.debug('closing channel', ++this._c, ph(dk), pk && ph(pk))
// If live is not set, then we do only one single exchange roundtrip.
if (!this.opts.live) {
const localExchanged = this.stats.snapshotsRecv && this.stats.requestsSent
const remoteExchanged = this.stats.snapshotsSent && this.stats.requestsRecv
const isLast = !this.activeChannels.filter(c => {
return c && !dk.equals(c.discoveryKey) && !this.exchangeKey.equals(c.key)
}).length
if (localExchanged && remoteExchanged) this.exchangeChannel.close()
else if (isLast) {
// If we're last, setup a timed trigger to close the connection anyway.
// there's no guarantee that the remote going to offer us anything.
setTimeout(() => {
// TODO: avoid closing exchange channel in mid-communication.
// Close the exchange channel
this.debug('post-close timeout triggered')
this.exchangeChannel.close()
}, EXCHANGE_TIMEOUT) // maybe use a different timeout here.
}
// this.exchangeChannel.close() // <-- politely let nanoguard do it's job
// this.stream.finalize() // <-- force everything to close.
}
}
sendManifest (namespace, manifest, cb) {
const mid = this.exchangeExt.sendManifest(namespace, manifest, cb)
this.stats.snapshotsSent++
this.debug(`manifest#${mid} sent with ${manifest.keys.length} keys`)
}
sendRequest (namespace, keys, manifestId) {
this.exchangeExt.sendRequest(namespace, keys, manifestId)
this.debug(`Replication request sent for mid#${manifestId} with ${keys.length} keys`)
this.stats.requestsSent++
}
_onExtension (id, message) {
const ext = this._extensions[id]
// bubble the message if it's not registered on this peer connection.
if (typeof ext === 'undefined') {
if (typeof this.handlers.onextension === 'function') {
this.handlers.onextension(id, message, this)
}
return // Do not process further
}
if (ext._codec) message = ext._codec.decode(message)
ext.onmessage(message, this)
}
get activeChannels () {
return [...this.activeVirtual, ...this.stream.channelizer.local.filter(c => !!c)]
}
get activeKeys () {
return this.activeChannels.map(f => f.key.hexSlice())
}
isActive (key) {
if (typeof key === 'string') key = Buffer.from(key, 'hex')
assert(Buffer.isBuffer(key), 'Key must be a string or a buffer')
return !!this.activeChannels.find(f => f.key.equals(key))
}
// Expects feeds to be 'ready' when invoked
joinFeed (feed, cb) {
assertCore(feed)
if (this.isActive(feed.key)) {
return cb(new Error('Feed is already being replicated'))
}
this.stats.channelesOpened++
// Substream dosen't support proto v7 yet
if (this.useVirtual) {
// Immediately register feed as active
this.activeVirtual.push(feed)
const cleanUp = () => {
this.activeVirtual.splice(this.activeVirtual.indexOf(feed), 1) // remove from virtual active
this._onChannelClosed(feed.discoveryKey, feed.key)
}
// initialize substream
substream(this.exchangeChannel, feed.key, (err, virtualStream) => {
if (err) {
cleanUp()
return cb(err)
}
const coreStream = feed.replicate(Object.assign({}, {
live: this.opts.live
// encrypt: this.stream.encrypted
// TODO: forward more opts.
}))
this.debug('replicating feed:', this.stream.expectedFeeds, ph(feed.discoveryKey), ph(feed.key))
// connect the streams and attach error/finalization handler.
pump(coreStream, virtualStream, coreStream, err => {
// if (err) this.kill(err) // Kills the peer if a substream fails
if (err) console.error(err)
this.debug('feed finished', this.stream.expectedFeeds, ph(feed.discoveryKey), ph(feed.key))
cleanUp()
})
// notify above about new feed being replicated
if (typeof this.handlers.onreplicating === 'function') {
this.handlers.onreplicating(feed.key, this)
}
return cb(null, virtualStream)
})
} else {
const stream = this.stream
feed.replicate(this.initiator, Object.assign({}, {
live: this.opts.live,
encrypt: this.stream.encrypted,
stream
}))
this.debug('replicating feed:', ph(feed.discoveryKey), ph(feed.key))
// notify manager that a new feed is replicated here,
// The manager will forward this event to other connections that not
// yet have this feed listed in their knownFeeds
if (typeof this.handlers.onreplicating === 'function') {
this.handlers.onreplicating(feed.key, this)
}
return cb(null, stream)
}
}
}
module.exports = PeerConnection