UNPKG

@fails-components/webtransport

Version:

A component to add webtransport support (server and client) to node.js using libquiche

493 lines (468 loc) 14.9 kB
import { ReadableStream, WritableStream } from './webstreams.js' import { logger } from './utils.js' import { WebTransportError } from './error.js' import { canByteStream } from './features.js' const pid = typeof process !== 'undefined' ? process.pid : 0 const log = logger(`webtransport:http3wtstream(${pid})`) /** * WebTransport stream events * @typedef {import('./types').WebTransportStreamEventHandler} WebTransportStreamEventHandler * @typedef {import('./types').StreamRecvSignalEvent} StreamRecvSignalEvent * @typedef {import('./types').StreamReadEvent} StreamReadEvent * @typedef {import('./types').StreamWriteEvent} StreamWriteEvent * @typedef {import('./types').StreamNetworkFinishEvent} StreamNetworkFinishEvent * * @typedef {import('./types').NativeHttpWTStream} NativeHttpWTStream * @typedef {import('./types').ReadBuffer} ReadBuffer * * @typedef {import('./dom').WebTransportReceiveStream} WebTransportReceiveStream * @typedef {import('./dom').WebTransportSendStream} WebTransportSendStream * * @typedef {import('./dom').WebTransportSendGroup} WebTransportSendGroup * * @typedef {import('./session').HttpWTSession} HttpWTSession * * @typedef {import('stream/web').WritableStreamDefaultController} WritableStreamDefaultController */ export class HttpWTStream { /** * @param {object} args * @param {NativeHttpWTStream} args.object * @param {HttpWTSession} args.parentobj * @param {object} args.transport * @param {boolean} args.bidirectional * @param {boolean} args.incoming * @param {WebTransportSendGroup|undefined} args.sendGroup * @param {number} args.sendOrder */ constructor(args) { this.objint = args.object this.objint.jsobj = this this.parentobj = args.parentobj this.transport = args.transport this.bidirectional = args.bidirectional this.incoming = args.incoming this.closed = false this._sendGroup = args.sendGroup this._sendOrder = args.sendOrder if (this.objint.sendInitialParameters) { this.objint.sendInitialParameters() } /** @type {Promise<void> | null} */ this.pendingoperation = null this.pendingres = null /** @type {WebTransportReceiveStream} */ this.readable /** @type {WebTransportSendStream} */ this.writable /** @type {Promise<void> | null} */ this.pendingoperationRead = null this.pendingresRead = null if (this.bidirectional || this.incoming) { const readableopts = { start: ( /** @type {import("stream/web").ReadableByteStreamController} */ controller ) => { this.readableController = controller this.objint.startReading() }, pull: async ( // eslint-disable-next-line no-unused-vars /** @type {import("stream/web").ReadableByteStreamController} */ controller ) => { if (this.readableclosed) { return Promise.resolve() } // eslint-disable-next-line no-unused-vars this.pendingoperationRead = new Promise((resolve, reject) => { this.pendingresRead = resolve }) this.objint.startReading() await this.pendingoperationRead }, cancel: (/** @type {{ code: number; }} */ reason) => { /** @type {Promise<void>} */ // eslint-disable-next-line no-unused-vars const promise = new Promise((resolve, reject) => { this.cancelres = resolve }) let code = 0 if (reason && reason.code) { if (reason.code < 0) code = 0 else if (reason.code > 255) code = 255 else code = reason.code } this.readableclosed = true this.parentobj.removeReceiveStream( this.readable, this.readableController ) this.objint.stopSending(code) return promise }, type: 'bytes', autoAllocateChunkSize: 4096 // lets take this as buffer size } if (!canByteStream) { // @ts-ignore delete readableopts.type } /** @type {WebTransportReceiveStream} */ // @ts-expect-error `getStats` property is missing from ReadableStream this.readable = new ReadableStream( // @ts-ignore readableopts ) this.readable.getStats = () => { return Promise.resolve({ timestamp: 0, bytesReceived: 0n, bytesRead: 0n }) } // @ts-ignore this.parentobj.addReceiveStream(this.readable, this.readableController) } if (this.bidirectional || !this.incoming) { /** @type {WebTransportSendStream} */ // @ts-expect-error `getStats` property is missing from WritableStream this.writable = new WritableStream( { start: (controller) => { this.writableController = controller }, // eslint-disable-next-line no-unused-vars write: (chunk, controller) => { if (this.writableclosed) { return Promise.resolve() } let wchunk = chunk if (wchunk instanceof ArrayBuffer) { wchunk = new Uint8Array(wchunk) } if (wchunk instanceof Uint8Array) { if (wchunk.byteLength === 0) { // or should we throw an error ?, Ask the W3C people! return } // eslint-disable-next-line no-unused-vars this.pendingoperation = new Promise((resolve, reject) => { this.pendingres = resolve }) this.objint.writeChunk(wchunk) return this.pendingoperation } else { log.trace('chunk info:', chunk) throw new Error( 'chunk is not of instanceof Uint8Array or Arraybuffer' ) } }, close: () => { if (this.writableclosed) { return Promise.resolve() } this.writableclosed = true this.objint.streamFinal() this.parentobj.removeSendStream( this.writable, this.writableController ) // eslint-disable-next-line no-unused-vars this.pendingoperation = new Promise((resolve, reject) => { this.pendingres = resolve }) return this.pendingoperation }, abort: (reason) => { if (this.writableclosed) { // eslint-disable-next-line no-unused-vars return new Promise((resolve, reject) => { resolve() }) } this.writableclosed = true let code = 0 if (reason && reason.code) { if (reason.code < 0) code = 0 else if (reason.code > 255) code = 255 else code = reason.code } this.parentobj.removeSendStream( this.writable, this.writableController ) /** @type {Promise<void>} */ // eslint-disable-next-line no-unused-vars const promise = new Promise((resolve, reject) => { this.abortres = resolve }) this.objint.resetStream(code) return promise } }, { highWaterMark: 4 } ) this.writable.getStats = () => { return Promise.resolve({ timestamp: 0, bytesWritten: 0, bytesSent: 0, bytesAcknowledged: 0 }) } Object.defineProperties(this.writable, { sendOrder: { get: () => { return this._sendOrder }, /** * @param {number} value */ set: (value) => { if (value !== this._sendOrder) { this._sendOrder = value this.updateSendOrderAndGroup() } } }, sendGroup: { get: () => { return this._sendGroup }, /** * @param {WebTransportSendGroup} value */ set: (value) => { if (value !== this._sendGroup) { this._sendGroup = value this.updateSendOrderAndGroup() } } } }) // @ts-ignore this.parentobj.addSendStream(this.writable, this.writableController) } /** @type {(() => void) | null} */ this.cancelres = null /** @type {(() => void) | null} */ this.pendingres = null /** @type {(() => void) | null} */ this.abortres = null this.finaldrain_ = false } /** * @param {{byteSize: number}} args * @returns {ReadBuffer} */ getReadBuffer({ byteSize }) { const byob = this.readableController.byobRequest if (byob) { // @ts-ignore const buffer = byob?.view // @ts-ignore if (!(buffer instanceof Uint8Array)) { throw new Error('byob view is not a Uint8Array') } return { buffer, byob, readBytes: 0, fin: false } } else { const buffer = new Uint8Array(byteSize) return { buffer, byob: undefined, readBytes: 0, fin: false } } } /** * @param {ReadBuffer} args */ commitReadBuffer({ buffer, byob, drained, readBytes, fin }) { if (!this.readableclosed) { if (byob && readBytes !== undefined) { byob.respond(readBytes) } else if (buffer) { this.readableController.enqueue(buffer) } } const retObj = {} if (readBytes !== undefined && readBytes > 0 && !this.readableclosed) { log.trace('commitReadbuffer', readBytes) // console.log('stream read received', args.data, Date.now()) if (this.pendingoperationRead && drained) { if (this.readableController.desiredSize != null && !this.finaldrain_) { if (this.readableController.desiredSize < 0) retObj.stopReading = true } // this.readableController.enqueue(data) const res = this.pendingresRead this.pendingoperationRead = null this.pendingresRead = null if (res) res() } } if (fin) { if (this.cancelres) { const res = this.cancelres this.cancelres = null res() } const parentstate = this.parentobj.state if (parentstate === 'closed' || parentstate === 'failed') { log('no parent cleanup for fin as parent was closed or failed') } else { this.parentobj.removeReceiveStream( this.readable, this.readableController ) } if (!this.readableclosed) { this.readableController.close() this.readableclosed = true } } return retObj } updateSendOrderAndGroup() { this.objint.updateSendOrderAndGroup({ sendOrder: this._sendOrder, // 0n is reserved for no sendgroup // @ts-ignore _sendGroupId is internal, FIXME convert to symbol sendGroupId: this._sendGroup?._sendGroupId || 0n }) } /** * @param {import('./types').StreamRecvSignalEvent} args * @returns {void} */ onStreamRecvSignal(args) { log('callback', args?.nettask) log.trace('onStreamRecvSignal', args) // check if transport is closed let parentcleanup = true const parentstate = this.parentobj.state if (parentstate === 'closed' || parentstate === 'failed') { log('no parent cleanup as parent was closed or failed') parentcleanup = false } switch (args.nettask) { case 'resetStream': if (this.readable) { this.finalDrain() if (parentcleanup) this.parentobj.removeReceiveStream( this.readable, this.readableController ) if (!this.readableclosed) { this.readableclosed = true this.readableController.error( new WebTransportError('Resetstream with code:' + (args.code || 0)) ) } } else { log.error('resetStream without readable') } break case 'stopSending': if (this.writable) { if (parentcleanup) this.parentobj.removeSendStream( this.writable, this.writableController ) if (!this.writableclosed) { this.writableclosed = true this.writableController.error( new WebTransportError('StopSending with code:' + (args.code || 0)) ) } } else { log.error('stopSending without writable') } break default: log.error('unhandled onStreamRecvSignal') } if (this.pendingoperation) { const res = this.pendingres this.pendingoperation = null this.pendingres = null if (res != null) { res() } } if (this.pendingoperationRead) { const res = this.pendingresRead this.pendingoperationRead = null this.pendingresRead = null if (res != null) { res() } } } finalDrain() { this.finaldrain_ = true this.objint.drainReads() } /** * @param {StreamWriteEvent} args */ // eslint-disable-next-line no-unused-vars onStreamWrite(args) { // we ignore success if (this.pendingoperation) { const res = this.pendingres this.pendingoperation = null this.pendingres = null if (res != null) { res() } } } /** * @param {StreamNetworkFinishEvent} args */ onStreamNetworkFinish(args) { log('callback', args?.nettask) log.trace('networkfinish args', args) switch (args.nettask) { case 'stopSending': if (this.cancelres) { const res = this.cancelres this.cancelres = null res() } this.stopSendingRecv = true break case 'resetStream': if (this.abortres) { const res = this.abortres this.abortres = null res() if (this.readable) this.parentobj.removeReceiveStream( this.readable, this.readableController ) if (this.writable) this.parentobj.removeSendStream( this.writable, this.writableController ) this.readableclosed = true this.parentobj.removeStreamObj(this) } break case 'streamFinal': if (this.pendingoperation) { const res = this.pendingres this.pendingoperation = null this.pendingres = null if (res != null) { res() } } break default: log.error('onStreamNetworkFinish unknown task', args.nettask) } // we could differentiate.... } }