@fails-components/webtransport
Version:
A component to add webtransport support (server and client) to node.js using libquiche
723 lines (668 loc) • 22.3 kB
JavaScript
import { ReadableStream, WritableStream } from './webstreams.js'
import { HttpWTStream } from './stream.js'
import { WebTransportError } from './error.js'
import { logger } from './utils.js'
import { canByteStream } from './features.js'
const pid = typeof process !== 'undefined' ? process.pid : 0
const log = logger(`webtransport:httpwtsession(${pid})`)
/**
* WebTransport session events
* @typedef {import('./types').WebTransportSessionEventHandler} WebTransportSessionEventHandler
* @typedef {import('./types').SessionReadyEvent} SessionReadyEvent
* @typedef {import('./types').SessionCloseEvent} SessionCloseEvent
* @typedef {import('./types').DatagramReceivedEvent} DatagramReceivedEvent
* @typedef {import('./types').GoawayReceivedEvent} GoawayReceivedEvent
* @typedef {import('./types').DatagramStatsEvent} DatagramStatsEvent
* @typedef {import('./types').SessionStatsEvent} SessionStatsEvent
* @typedef {import('./types').NewStreamEvent} NewStreamEvent
*
* @typedef {import('./dom').WebTransportCloseInfo} WebTransportCloseInfo
* @typedef {import('./dom').WebTransportBidirectionalStream} WebTransportBidirectionalStream
* @typedef {import('./dom').WebTransportSendStream} WebTransportSendStream
* @typedef {import('./dom').WebTransportSendStreamOptions} WebTransportSendStreamOptions
* @typedef {import('./dom').WebTransportReceiveStream} WebTransportReceiveStream
* @typedef {import('./dom').WebTransportDatagramDuplexStream} WebTransportDatagramDuplexStream
* @typedef {import('./dom').WebTransportReliabilityMode} WebTransportReliabilityMode
* @typedef {import('./dom').WebTransportCongestionControl} WebTransportCongestionControl
* @typedef {import('./dom').WebTransportSendGroup} WebTransportSendGroup
* @typedef {import('./dom').WebTransportStats} WebTransportStats
* @typedef {import('./dom').WebTransportDatagramStats} WebTransportDatagramStats
* @typedef {import('./dom').WebTransportSendOptions} WebTransportSendOptions
* @typedef {import('./dom').WebTransportDatagramsWritable} WebTransportDatagramsWritable
* @typedef {import('./dom').DatagramsReadableMode} DatagramsReadableMode
*
* @typedef {import('./types').NativeHttpWTSession} NativeHttpWTSession
*
* Public API
* @typedef {import('./types').WebTransportSessionImpl} WebTransportSession
*
* @typedef {import('./server').HttpServer} HttpServer
* @typedef {import('./client').HttpClient} HttpClient
*
* @typedef {import('stream/web').WritableStreamDefaultController} WritableStreamDefaultController
*/
/**
* @implements {WebTransportSessionEventHandler}
* @implements {WebTransportSession}
*/
export class HttpWTSession {
/**
* @param {object} args
* @param {import('./types').NativeHttpWTSession} [args.object]
* @param {HttpServer | HttpClient} args.parentobj
* @param {Object | undefined} [args.header= undefined]
* @param {Object | undefined} [args.userData= undefined]
* @param {string | undefined} [args.peerAddress= undefined]
* @param {DatagramsReadableMode} [args.datagramsReadableMode]
*/
constructor(args) {
if (args.object) {
this.objint = args.object
this.objint.jsobj = this
if (this.objint.sendInitialParameters) {
this.objint.sendInitialParameters()
}
}
this.parentobj = args.parentobj
/** @type {import('./types').WebTransportSessionState} */
this.state = 'connecting'
/** @type {((value?: any) => void) | null | undefined} */
this.readyResolve = null
/** @type {(() => void) | null | undefined} */
this.closeHook = null
/** @type {(Object | null | undefined)} */
this.header = args.header
/** @type {(Object | null | undefined)} */
this.userData = args.userData
/** @type {(string | undefined)} */
this.peerAddress_ = args.peerAddress
this.datagramsReadableMode_ = args.datagramsReadableMode
/** @type {Promise<void>} */
this.ready = new Promise((resolve, reject) => {
this.readyResolve = resolve
this.readyReject = reject
})
/** @type {WebTransportReliabilityMode} */
this.reliability = 'pending'
/** @type {WebTransportCongestionControl} */
this.congestionControl = 'default'
/** @type {Promise<WebTransportCloseInfo>} */
this.closed = new Promise((resolve, reject) => {
this.closedResolve = resolve
this.closedReject = reject
})
/** @type {Promise<undefined>} */
this.draining = new Promise((resolve, reject) => {
this.drainingResolve = resolve
this.drainingReject = reject
})
/** @type {ReadableStream<WebTransportBidirectionalStream>} */
// @ts-ignore
this.incomingBidirectionalStreams = new ReadableStream({
/** @param {ReadableStreamDefaultController<WebTransportBidirectionalStream>} controller */
start: (controller) => {
this.incomBiDiController = controller
}
})
/** @type {ReadableStream<WebTransportReceiveStream>} */
// @ts-ignore
this.incomingUnidirectionalStreams = new ReadableStream({
/** @param {ReadableStreamDefaultController<WebTransportReceiveStream>} controller */
start: (controller) => {
this.incomUniDiController = controller
}
})
/** @type {import("stream/web").ReadableByteStreamController | undefined} */
this.incomDatagramControllerBytes_ = undefined
/** @type {import("stream/web").ReadableStreamController<Uint8Array> | undefined} */
this.incomDatagramController_ = undefined
const readableopts = {
start: (
/** @type {import("stream/web").ReadableByteStreamController | import("stream/web").ReadableStreamController<Uint8Array>} */ controller
) => {
if (this.datagramsReadableMode_ === 'bytes') {
this.incomDatagramControllerBytes_ =
/** @type {import("stream/web").ReadableByteStreamController} */ (
controller
)
} else {
this.incomDatagramController_ =
/** @type {import("stream/web").ReadableStreamController<Uint8Array>} */ (
controller
)
}
},
/** @type {undefined | 'bytes'} */
type: undefined
}
if (this.datagramsReadableMode_ === 'bytes') {
readableopts.type = 'bytes'
}
if (!canByteStream) {
// @ts-ignore
delete readableopts.type
}
this._lastGetMaxDatagramSize = 0
/** @type {WebTransportDatagramDuplexStream} */
this.datagrams = {
/** @type {ReadableStream<Uint8Array>} */
// @ts-ignore
readable: new ReadableStream(readableopts),
/**
* @param {WebTransportSendOptions|undefined} options
* @return {import('./dom').WebTransportDatagramsWritable}
*/
createWritable: (options) => {
let sendOrder = options?.sendOrder ?? 0n
let sendGroup = options?.sendGroup
/** @type {WebTransportSendStream} */
// @ts-expect-error some props are initially missing
const retWritable = new WritableStream({
start: (controller) => {
this.outgoDatagramController = controller
},
// eslint-disable-next-line no-unused-vars
write: (chunk, controller) => {
if (this.state === 'closed') throw new Error('Session is closed')
if (chunk instanceof Uint8Array) {
/** @type {Promise<void>} */
if (this.objint == null) {
throw new Error('this.objint is not set')
}
const { code, message } = this.objint.writeDatagram(chunk)
if (
code !== 'success' &&
code !== 'blocked' &&
code !== 'tooBig'
) {
throw new WebTransportError(code + ':' + message)
}
} else throw new Error('chunk is not of type Uint8Array')
},
close: () => {
// do nothing
}
})
Object.defineProperties(retWritable, {
sendOrder: {
get: () => {
return sendOrder
},
/**
* @param {bigint} value
*/
set: (value) => {
sendOrder = value
}
},
sendGroup: {
get: () => {
return sendGroup
},
/**
* @param {WebTransportSendGroup} value
*/
set: (value) => {
if (value !== sendGroup) {
sendGroup = value
}
}
}
})
return retWritable
},
// @ts-ignore
get writable() {
// @ts-ignore
if (!this.datagramwritablepolyfilled_) {
console.warn('datagrams.writable is deprecated')
}
// @ts-ignore
return (this.datagramwritablepolyfilled_ ||= this.createWritable())
},
get maxDatagramSize() {
// @ts-ignore
return this._getMaxDatagramSize()
},
// @ts-ignore
_getMaxDatagramSize: () => {
if (this.objint) {
this._lastGetMaxDatagramSize = this.objint.getMaxDatagramSize()
}
return this._lastGetMaxDatagramSize
}
}
/** @type {Array<(stream: WebTransportBidirectionalStream) => void>} */
this.resolveBiDi = []
/** @type {Array<(stream: WebTransportSendStream) => void>} */
this.resolveUniDi = []
/** @type {Array<(err?: Error) => void>} */
this.rejectBiDi = []
/** @type {Array<(err?: Error) => void>} */
this.rejectUniDi = []
/** @type {Array<(stats: WebTransportStats) => void>} */
this.resolveSessionStats = []
/** @type {Array<(err?: Error) => void>} */
this.rejectSessionStats = []
/** @type {Array<(stats: WebTransportDatagramStats) => void>} */
this.resolveDatagramStats = []
/** @type {Array<(err?: Error) => void>} */
this.rejectDatagramStats = []
/** @type {Set<WebTransportSendStream>} */
this.sendStreams = new Set()
/** @type {Set<WebTransportReceiveStream>} */
this.receiveStreams = new Set()
/** @type {Set<HttpWTStream>} */
this.streamObjs = new Set()
/** @type {Set<WritableStreamDefaultController>} */
this.sendStreamsController = new Set()
/** @type {Set<ReadableStreamDefaultController>} */
this.receiveStreamsController = new Set()
this._sendGroupNum = 1n // 0n is reserved for no sendgroup
/** @type {Map<bigint,WebTransportSendGroup>} */
this._sendGroupIndex = new Map()
/** @type {undefined|string} */
this._selectedProtocol = undefined
}
/**
* @param {NativeHttpWTSession} object
* @param {boolean} reliable
*/
setSessionObj(object, reliable) {
if (object) {
this.objint = object
this.objint.jsobj = this
this.reliable = !!reliable
if (this.objint.sendInitialParameters) {
this.objint.sendInitialParameters()
}
}
}
get protocol() {
return this._selectedProtocol
}
getStats() {
if (this.objint == null) {
throw new Error('this.objint not set')
}
const prom = new Promise((resolve, reject) => {
this.resolveSessionStats.push(resolve)
this.rejectSessionStats.push(reject)
})
this.objint.orderSessionStats()
return prom
}
/**
* @param {SessionStatsEvent} evt
*/
onSessionStats({
timestamp,
expiredOutgoing = BigInt(0),
lostOutgoing = BigInt(0),
// non Datagram
minRtt = 0,
smoothedRtt = 0,
rttVariation = 0,
estimatedSendRateBps
}) {
const res = this.resolveSessionStats.pop()
this.rejectSessionStats.pop()
if (res)
res({
timestamp,
bytesSent: BigInt(0),
packetsSent: BigInt(0),
packetsLost: BigInt(0),
numOutgoingStreamsCreated: 0,
numIncomingStreamsCreated: 0,
bytesReceived: BigInt(0),
packetsReceived: BigInt(0),
smoothedRtt,
rttVariation,
minRtt,
estimatedSendRate: estimatedSendRateBps,
datagrams: {
timestamp,
expiredOutgoing,
droppedIncoming: BigInt(0),
lostOutgoing
}
})
}
/**
* @param {DatagramStatsEvent} evt
*/
onDatagramStats({
timestamp,
expiredOutgoing = BigInt(0),
lostOutgoing = BigInt(0)
}) {
const res = this.resolveDatagramStats.pop()
this.rejectDatagramStats.pop()
if (res)
res({
timestamp,
expiredOutgoing,
droppedIncoming: BigInt(0),
lostOutgoing
})
}
notifySessionDraining() {
if (this.objint == null) {
throw new Error('this.objint not set')
}
this.objint.notifySessionDraining()
}
/**
* @param {HttpWTStream} stream
*/
addStreamObj(stream) {
this.streamObjs.add(stream)
}
/**
* @param {HttpWTStream} stream
*/
removeStreamObj(stream) {
this.streamObjs.delete(stream)
}
/**
* @param {WebTransportSendStream} stream
* @param {WritableStreamDefaultController} controller
*/
addSendStream(stream, controller) {
this.sendStreams.add(stream)
this.sendStreamsController.add(controller)
}
/**
* @param {WebTransportSendStream} stream
* @param {WritableStreamDefaultController} controller
*/
removeSendStream(stream, controller) {
this.sendStreams.delete(stream)
this.sendStreamsController.delete(controller)
}
/**
* @param {WebTransportReceiveStream } stream
* @param {ReadableStreamDefaultController} controller
*/
addReceiveStream(stream, controller) {
this.receiveStreams.add(stream)
this.receiveStreamsController.add(controller)
}
/**
* @param {WebTransportReceiveStream } stream
* @param {ReadableStreamDefaultController} controller
*/
removeReceiveStream(stream, controller) {
this.receiveStreams.delete(stream)
this.receiveStreamsController.delete(controller)
}
/**
* @param {WebTransportSendStreamOptions} [opts]
* @returns {Promise<WebTransportBidirectionalStream>}
*/
createBidirectionalStream(opts) {
if (this.objint == null) {
throw new Error('this.objint not set')
}
/** @type {Promise<WebTransportBidirectionalStream>} */
const prom = new Promise((resolve, reject) => {
this.resolveBiDi.push(resolve)
this.rejectBiDi.push(reject)
})
const notblocked = this.objint.orderBidiStream({
sendGroup: opts?.sendGroup || null, // maybe replace, when implemented
sendOrder: BigInt(opts?.sendOrder || 0n),
waitUntilAvailable: opts?.waitUntilAvailable || false
})
if (!notblocked) {
const rej = this.rejectBiDi.pop()
this.resolveBiDi.pop()
if (rej)
rej(new DOMException('No streams available', 'QuotaExceededError'))
}
return prom
}
/**
* @param {WebTransportSendStreamOptions} [opts]
* @returns {Promise<WebTransportSendStream>}
*/
createUnidirectionalStream(opts) {
if (this.objint == null) {
throw new Error('this.objint not set')
}
/** @type {Promise<WebTransportSendStream>} */
const prom = new Promise((resolve, reject) => {
this.resolveUniDi.push(resolve)
this.rejectUniDi.push(reject)
})
const notblocked = this.objint.orderUnidiStream({
sendGroup: opts?.sendGroup || null, // maybe replace, when implemented
sendOrder: opts?.sendOrder || 0n,
waitUntilAvailable: opts?.waitUntilAvailable || false
})
if (!notblocked) {
const rej = this.rejectUniDi.pop()
this.resolveUniDi.pop()
if (rej)
rej(new DOMException('No streams available', 'QuotaExceededError'))
}
return prom
}
/**
* @param {object} [closeInfo]
* @param {number} closeInfo.closeCode
* @param {string} closeInfo.reason
* @returns {void}
*/
close(closeInfo) {
log('closeinfo', closeInfo)
if (this.state === 'closed' || this.state === 'failed') return
if (this.objint) {
this.objint.close({
code: closeInfo?.closeCode ?? 0,
reason: closeInfo?.reason.substring(0, 1023) ?? ''
})
}
}
/**
* @returns {WebTransportSendGroup}
*/
createSendGroup() {
if (this.state === 'closed' || this.state === 'failed')
throw new Error('InvalidState')
const _sendGroupId = this._sendGroupNum++
const sendGroup = {
// @ts-ignore
_sendGroupId,
getStats: async () => {
// TODO implement
return {
bytesWritten: 0n,
bytesSent: 0n,
bytesAcknowledged: 0n
}
}
}
this._sendGroupIndex.set(_sendGroupId, sendGroup)
return sendGroup
}
/**
* @param {{protocol?: string}} arg
**/
onReady({ protocol }) {
if (protocol) this._selectedProtocol = protocol
this.state = 'connected'
if (!this.reliable) this.reliability = 'supports-unreliable'
else this.reliability = 'reliable-only'
if (this.readyResolve) this.readyResolve()
delete this.readyResolve
}
/**
* @param {SessionCloseEvent} args
*/
onClose(args) {
delete this.objint // not valid any more
if (this.state !== 'connected') {
log.error(
'session was closed before state was "connected" - it was "%s"',
this.state
)
this.state = 'failed'
// make sure the event loop can still exit
if (this.closeHook) {
this.closeHook()
delete this.closeHook
}
// closed before connected
const error = new WebTransportError('Opening handshake failed.')
this.readyReject(error)
this.closedReject(error)
return
}
log('onClose')
this.streamObjs.forEach((ele) => ele.finalDrain())
const error = new WebTransportError('Session closed')
for (const rej of this.rejectBiDi) rej(error)
for (const rej of this.rejectUniDi) rej(error)
for (const rej of this.rejectSessionStats) rej(error)
for (const rej of this.rejectDatagramStats) rej(error)
this.resolveBiDi = []
this.resolveUniDi = []
this.rejectBiDi = []
this.rejectUniDi = []
this.resolveSessionStats = []
this.rejectSessionStats = []
this.resolveDatagramStats = []
this.rejectDatagramStats = []
this.incomBiDiController.close()
this.incomUniDiController.close()
// @ts-ignore
;(
this.incomDatagramController_ || this.incomDatagramControllerBytes_
).close()
// this.outgoDatagramController.error(errorcode)
this.state = 'closed'
const wtError = new WebTransportError(
`Session closed (on process ${pid}) with code ` +
args.errorcode +
' and reason' +
args.error
)
this.sendStreamsController.forEach((ele) => ele.error(wtError))
this.receiveStreamsController.forEach((ele) => ele.error(wtError))
this.streamObjs.forEach((ele) => (ele.readableclosed = true))
this.sendStreams.clear()
this.receiveStreams.clear()
this.sendStreamsController.clear()
this.receiveStreamsController.clear()
this.streamObjs.clear()
if (this.closedResolve)
this.closedResolve({
closeCode: args.errorcode,
reason: args.error ? args.error : ''
})
if (this.closeHook) {
this.closeHook()
delete this.closeHook
}
}
/**
* @param {NewStreamEvent} args
*/
onStream(args) {
const strobj = new HttpWTStream({
object: args.stream,
parentobj: this,
transport: this.parentobj,
bidirectional: args.bidirectional,
incoming: args.incoming,
sendGroup: this._sendGroupIndex.get(args.sendGroupId || 0n),
sendOrder: args.sendOrder
})
this.addStreamObj(strobj)
if (args.incoming) {
if (args.bidirectional) {
this.incomBiDiController.enqueue(strobj)
} else {
this.incomUniDiController.enqueue(strobj.readable)
}
} else {
if (args.bidirectional) {
if (this.resolveBiDi.length === 0)
throw new Error('Got bidirectional stream without asking for it')
this.rejectBiDi.shift()
const curres = this.resolveBiDi.shift()
if (
curres != null &&
strobj.readable != null &&
strobj.writable != null
) {
curres({
readable: strobj.readable,
writable: strobj.writable
})
}
} else {
if (this.resolveUniDi.length === 0)
throw new Error('Got unidirectional stream without asking for it')
this.rejectUniDi.shift()
const curres = this.resolveUniDi.shift()
if (curres != null && strobj.writable != null) {
curres(strobj.writable)
}
}
}
}
/**
* @param {DatagramReceivedEvent} args
*/
onDatagramReceived(args) {
log('datagram received', args.datagram)
// streams spec says zero length chunk on byob stream is illegal
if (
args.datagram.byteLength === 0 &&
this.datagramsReadableMode_ === 'bytes'
) {
log('zerolength datagram dropped for a byte stream')
return
}
// console.log('datagram received', args.datagram, Date.now())
if (this.incomDatagramControllerBytes_?.byobRequest) {
/** @type {ReadableStreamBYOBRequest} */
const byob = this.incomDatagramControllerBytes_.byobRequest
/** @type {Uint8Array} */
// @ts-ignore
const view = byob?.view
// @ts-ignore
if (!(view instanceof Uint8Array))
throw new Error('byob view is not a Uint8Array')
if (view.byteLength < args.datagram.byteLength) {
throw new Error('supplied view is not large enough.')
}
const destview = new Uint8Array(
view.buffer,
0 + view.byteOffset,
args.datagram.byteLength
)
destview.set(args.datagram)
byob.respond(args.datagram.byteLength)
} else {
// @ts-ignore
;(
this.incomDatagramController_ || this.incomDatagramControllerBytes_
).enqueue(new Uint8Array(args.datagram))
}
}
/**
* @param {GoawayReceivedEvent} args
*/
// eslint-disable-next-line no-unused-vars
onGoAwayReceived(args) {
if (this.drainingResolve) this.drainingResolve(undefined)
this.state = 'draining'
}
get peerAddress() {
return this.peerAddress_
}
}