udx-native
Version:
udx is reliable, multiplexed, and congestion-controlled streams over udp
513 lines (387 loc) • 12.5 kB
JavaScript
const streamx = require('streamx')
const b4a = require('b4a')
const binding = require('../binding')
const ip = require('./ip')
const MAX_PACKET = 2048
const BUFFER_SIZE = 65536 + MAX_PACKET
module.exports = class UDXStream extends streamx.Duplex {
constructor (udx, id, opts = {}) {
super({ mapWritable: toBuffer, eagerOpen: true })
this.udx = udx
this.socket = null
this._handle = b4a.allocUnsafe(binding.sizeof_udx_napi_stream_t)
this._view = new Uint32Array(this._handle.buffer, this._handle.byteOffset, this._handle.byteLength >> 2)
this._view16 = new Uint16Array(this._handle.buffer, this._handle.byteOffset, this._handle.byteLength >> 1)
this._view64 = new BigUint64Array(this._handle.buffer, this._handle.byteOffset, this._handle.byteLength >> 3)
this._wreqs = []
this._wfree = []
this._sreqs = []
this._sfree = []
this._closed = false
this._flushing = 0
this._flushes = []
this._buffer = null
this._reallocData()
this._onwrite = null
this._ondestroy = null
this._firewall = opts.firewall || firewallAll
this._remoteChanging = null
this._previousSocket = null
this.id = id
this.remoteId = 0
this.remoteHost = null
this.remoteFamily = 0
this.remotePort = 0
this.userData = null
binding.udx_napi_stream_init(this.udx._handle, this._handle, id, opts.framed ? 1 : 0, this,
this._ondata,
this._onend,
this._ondrain,
this._onack,
this._onsend,
this._onmessage,
this._onclose,
this._onfirewall,
this._onremotechanged,
this._reallocData,
this._reallocMessage
)
if (opts.seq) binding.udx_napi_stream_set_seq(this._handle, opts.seq)
binding.udx_napi_stream_recv_start(this._handle, this._buffer)
}
get connected () {
return this.socket !== null
}
get mtu () {
return this._view16[binding.offsetof_udx_stream_t_mtu >> 1]
}
get rtt () {
return this._view[binding.offsetof_udx_stream_t_srtt >> 2]
}
get cwnd () {
return this._view[binding.offsetof_udx_stream_t_cwnd >> 2]
}
get rtoCount () {
return this._view16[binding.offsetof_udx_stream_t_rto_count >> 1]
}
get retransmits () {
return this._view16[binding.offsetof_udx_stream_t_retransmit_count >> 1]
}
get fastRecoveries () {
return this._view16[binding.offsetof_udx_stream_t_fast_recovery_count >> 1]
}
get inflight () {
return this._view[binding.offsetof_udx_stream_t_inflight >> 2]
}
get bytesTransmitted () {
return Number(this._view64[binding.offsetof_udx_stream_t_bytes_tx >> 3])
}
get packetsTransmitted () {
return Number(this._view64[binding.offsetof_udx_stream_t_packets_tx >> 3])
}
get bytesReceived () {
return Number(this._view64[binding.offsetof_udx_stream_t_bytes_rx >> 3])
}
get packetsReceived () {
return Number(this._view64[binding.offsetof_udx_stream_t_packets_rx >> 3])
}
get localHost () {
return this.socket ? this.socket.address().host : null
}
get localFamily () {
return this.socket ? this.socket.address().family : 0
}
get localPort () {
return this.socket ? this.socket.address().port : 0
}
setInteractive (bool) {
if (!this._closed) return
binding.udx_napi_stream_set_mode(this._handle, bool ? 0 : 1)
}
connect (socket, remoteId, port, host, opts = {}) {
if (this._closed) return
if (this.connected) throw new Error('Already connected')
if (socket.closing) throw new Error('Socket is closed')
if (typeof host === 'object') {
opts = host
host = null
}
if (!host) host = '127.0.0.1'
const family = ip.isIP(host)
if (!family) throw new Error(`${host} is not a valid IP address`)
if (!(port > 0 && port < 65536)) throw new Error(`${port} is not a valid port`)
if (!socket.bound) socket.bind(0)
this.remoteId = remoteId
this.remotePort = port
this.remoteHost = host
this.remoteFamily = family
this.socket = socket
if (opts.ack) binding.udx_napi_stream_set_ack(this._handle, opts.ack)
binding.udx_napi_stream_connect(this._handle, socket._handle, remoteId, port, host, family)
this.socket._addStream(this)
this.emit('connect')
}
changeRemote (socket, remoteId, port, host) {
if (this._remoteChanging) throw new Error('Remote already changing')
if (!this.connected) throw new Error('Not yet connected')
if (socket.closing) throw new Error('Socket is closed')
if (this.socket.udx !== socket.udx) {
throw new Error('Cannot change to a socket on another UDX instance')
}
if (!host) host = '127.0.0.1'
const family = ip.isIP(host)
if (!family) throw new Error(`${host} is not a valid IP address`)
if (!(port > 0 && port < 65536)) throw new Error(`${port} is not a valid port`)
if (this.socket !== socket) this._previousSocket = this.socket
this.remoteId = remoteId
this.remotePort = port
this.remoteHost = host
this.remoteFamily = family
this.socket = socket
this._remoteChanging = new Promise((resolve, reject) => {
const onchanged = () => {
this.off('close', onclose)
resolve()
}
const onclose = () => {
this.off('remote-changed', onchanged)
reject(new Error('Stream is closed'))
}
this
.once('remote-changed', onchanged)
.once('close', onclose)
})
binding.udx_napi_stream_change_remote(this._handle, socket._handle, remoteId, port, host, family)
this.socket._addStream(this)
return this._remoteChanging
}
relayTo (destination) {
if (this._closed) return
binding.udx_napi_stream_relay_to(this._handle, destination._handle)
}
async send (buffer) {
if (!this.connected || this._closed) return false
const id = this._allocSend()
const req = this._sreqs[id]
req.buffer = buffer
const promise = new Promise((resolve) => {
req.onflush = resolve
})
binding.udx_napi_stream_send(this._handle, req.handle, id, buffer)
return promise
}
trySend (buffer) {
if (!this.connected || this._closed) return
const id = this._allocSend()
const req = this._sreqs[id]
req.buffer = buffer
req.onflush = noop
binding.udx_napi_stream_send(this._handle, req.handle, id, buffer)
}
async flush () {
if ((await streamx.Writable.drained(this)) === false) return false
if (this.destroying) return false
const missing = this._wreqs.length - this._wfree.length
if (missing === 0) return true
return new Promise((resolve) => {
this._flushes.push({ flush: this._flushing++, missing, resolve })
})
}
toJSON () {
return {
id: this.id,
connected: this.connected,
destroying: this.destroying,
destroyed: this.destroyed,
remoteId: this.remoteId,
remoteHost: this.remoteHost,
remoteFamily: this.remoteFamily,
remotePort: this.remotePort,
mtu: this.mtu,
rtt: this.rtt,
cwnd: this.cwnd,
inflight: this.inflight,
socket: this.socket ? this.socket.toJSON() : null
}
}
_read (cb) {
cb(null)
}
_writeContinue (err) {
if (this._onwrite === null) return
const cb = this._onwrite
this._onwrite = null
cb(err)
}
_destroyContinue (err) {
if (this._ondestroy === null) return
const cb = this._ondestroy
this._ondestroy = null
cb(err)
}
_writev (buffers, cb) {
if (!this.connected) throw customError('Writing while not connected not currently supported', 'ERR_ASSERTION')
let drained = true
if (buffers.length === 1) {
const id = this._allocWrite(1)
const req = this._wreqs[id]
req.flush = this._flushing
req.buffer = buffers[0]
drained = binding.udx_napi_stream_write(this._handle, req.handle, id, req.buffer) !== 0
} else {
const id = this._allocWrite(nextBatchSize(buffers.length))
const req = this._wreqs[id]
req.flush = this._flushing
req.buffers = buffers
drained = binding.udx_napi_stream_writev(this._handle, req.handle, id, req.buffers) !== 0
}
if (drained) cb(null)
else this._onwrite = cb
}
_final (cb) {
const id = this._allocWrite(1)
const req = this._wreqs[id]
req.flush = this._flushes
req.buffer = b4a.allocUnsafe(0)
const drained = binding.udx_napi_stream_write_end(this._handle, req.handle, id, req.buffer) !== 0
if (drained) cb(null)
else this._onwrite = cb
}
_predestroy () {
if (!this._closed) binding.udx_napi_stream_destroy(this._handle)
this._closed = true
this._writeContinue(null)
}
_destroy (cb) {
if (this.connected) this._ondestroy = cb
else cb(null)
}
_ondata (read) {
this.push(this._consumeData(read))
return this._buffer
}
_onend (read) {
if (read > 0) this.push(this._consumeData(read))
this.push(null)
}
_ondrain () {
this._writeContinue(null)
}
_flushAck (flush) {
for (let i = this._flushes.length - 1; i >= 0; i--) {
const f = this._flushes[i]
if (f.flush < flush) break
f.missing--
}
while (this._flushes.length > 0 && this._flushes[0].missing === 0) {
this._flushes.shift().resolve(true)
}
}
_onack (id) {
const req = this._wreqs[id]
req.buffers = req.buffer = null
this._wfree.push(id)
if (this._flushes.length > 0) this._flushAck(req.flush)
// gc the free list
if (this._wfree.length >= 64 && this._wfree.length === this._wreqs.length) {
this._wfree = []
this._wreqs = []
}
}
_onsend (id, err) {
const req = this._sreqs[id]
const onflush = req.onflush
req.buffer = null
req.onflush = null
this._sfree.push(id)
onflush(err >= 0)
// gc the free list
if (this._sfree.length >= 16 && this._sfree.length === this._sreqs.length) {
this._sfree = []
this._sreqs = []
}
}
_onmessage (len) {
this.emit('message', this.udx._consumeMessage(len))
return this.udx._buffer
}
_onclose (err) {
this._closed = true
if (this.socket) {
this.socket._removeStream(this)
this.socket = null
}
if (this._previousSocket) {
this._previousSocket._removeStream(this)
this._previousSocket = null
}
// no error, we don't need to do anything
if (!err) return this._destroyContinue(null)
if (this._ondestroy === null) this.destroy(err)
else this._destroyContinue(err)
}
_onfirewall (socket, port, host, family) {
return this._firewall(socket, port, host, family) ? 1 : 0
}
_onremotechanged () {
if (this._previousSocket) {
this._previousSocket._removeStream(this)
this._previousSocket = null
}
this._remoteChanging = null
this.emit('remote-changed')
}
_consumeData (len) {
const next = this._buffer.subarray(0, len)
this._buffer = this._buffer.subarray(len)
if (this._buffer.byteLength < MAX_PACKET) this._reallocData()
return next
}
_reallocData () {
this._buffer = b4a.allocUnsafe(BUFFER_SIZE)
return this._buffer
}
_reallocMessage () {
return this.udx._reallocMessage()
}
_allocWrite (size) {
if (this._wfree.length === 0) {
const handle = b4a.allocUnsafe(binding.udx_napi_stream_write_sizeof(size))
return this._wreqs.push({ handle, size, buffers: null, buffer: null, flush: 0 }) - 1
}
const free = this._wfree.pop()
if (size === 1) return free
const next = this._wreqs[free]
if (next.size < size) {
next.handle = b4a.allocUnsafe(binding.udx_napi_stream_write_sizeof(size))
next.size = size
}
return free
}
_allocSend () {
if (this._sfree.length > 0) return this._sfree.pop()
const handle = b4a.allocUnsafe(binding.sizeof_udx_stream_send_t)
return this._sreqs.push({ handle, buffer: null, resolve: null, reject: null }) - 1
}
}
function noop () {}
function toBuffer (data) {
return typeof data === 'string' ? b4a.from(data) : data
}
function firewallAll (socket, port, host) {
return true
}
function customError (message, code) {
const error = new Error(message)
error.code = code
return error
}
function nextBatchSize (n) { // try to coerce the the writevs into sameish size
if (n === 1) return 1
// group all < 8 to the same size, low mem overhead but save some small allocs
if (n < 8) return 8
if (n < 16) return 16
if (n < 32) return 32
if (n < 64) return 64
return n
}