From 076764accf26321cb1de5260d02f0b92173e0122 Mon Sep 17 00:00:00 2001 From: ThaUnknown <6506529+ThaUnknown@users.noreply.github.com> Date: Mon, 20 Jan 2025 16:32:12 +0100 Subject: [PATCH 1/4] fix: polyfill W3C compatibility --- package.json | 3 +- src/polyfill/Events.ts | 53 ++- src/polyfill/MediaStream.ts | 177 ++++++++ src/polyfill/RTCCertificate.ts | 4 + src/polyfill/RTCDataChannel.ts | 121 +++--- src/polyfill/RTCDtlsTransport.ts | 29 +- src/polyfill/RTCError.ts | 35 +- src/polyfill/RTCIceCandidate.ts | 14 +- src/polyfill/RTCIceTransport.ts | 65 +-- src/polyfill/RTCPeerConnection.ts | 590 +++++++++++++++----------- src/polyfill/RTCRtp.ts | 206 +++++++++ src/polyfill/RTCSctpTransport.ts | 29 +- src/polyfill/RTCSessionDescription.ts | 15 +- src/polyfill/index.ts | 50 +-- tsconfig.json | 19 +- 15 files changed, 969 insertions(+), 441 deletions(-) create mode 100644 src/polyfill/MediaStream.ts create mode 100644 src/polyfill/RTCRtp.ts diff --git a/package.json b/package.json index 0ba72fa6..c9303a32 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "@rollup/plugin-replace": "^6.0.1", "@types/jest": "^29.5.12", "@types/node": "^20.6.1", + "@types/webrtc": "^0.0.44", "@typescript-eslint/eslint-plugin": "^7.17.0", "@typescript-eslint/parser": "^7.17.0", "cmake-js": "^7.3.0", @@ -104,4 +105,4 @@ "dependencies": { "prebuild-install": "^7.1.2" } -} +} \ No newline at end of file diff --git a/src/polyfill/Events.ts b/src/polyfill/Events.ts index d7ac9f88..8400114a 100644 --- a/src/polyfill/Events.ts +++ b/src/polyfill/Events.ts @@ -14,20 +14,63 @@ export class RTCPeerConnectionIceEvent extends Event implements globalThis.RTCPe get candidate(): RTCIceCandidate { return this.#candidate; } + + get url (): string { + return '' // TODO ? + } } export class RTCDataChannelEvent extends Event implements globalThis.RTCDataChannelEvent { #channel: RTCDataChannel; - constructor(type: string, eventInitDict: globalThis.RTCDataChannelEventInit) { - super(type); + // type is defined as a consturctor, but always overwritten, interesting spec + // eslint-disable-next-line @typescript-eslint/no-unused-vars + constructor(_type: string = 'datachannel', init: globalThis.RTCDataChannelEventInit) { + if (arguments.length === 0) throw new TypeError(`Failed to construct 'RTCDataChannelEvent': 2 arguments required, but only ${arguments.length} present.`) + if (typeof init !== 'object') throw new TypeError("Failed to construct 'RTCDataChannelEvent': The provided value is not of type 'RTCDataChannelEventInit'.") + if (!init.channel) throw new TypeError("Failed to construct 'RTCDataChannelEvent': Failed to read the 'channel' property from 'RTCDataChannelEventInit': Required member is undefined.") + if (init.channel.constructor !== RTCDataChannel) throw new TypeError("Failed to construct 'RTCDataChannelEvent': Failed to read the 'channel' property from 'RTCDataChannelEventInit': Failed to convert value to 'RTCDataChannel'.") + super('datachannel') - if (type && !eventInitDict.channel) throw new TypeError('channel member is required'); - - this.#channel = eventInitDict?.channel as RTCDataChannel; + this.#channel = init.channel; } get channel(): RTCDataChannel { return this.#channel; } } + +export class RTCErrorEvent extends Event implements globalThis.RTCErrorEvent { + #error: RTCError + constructor (type: string, init: globalThis.RTCErrorEventInit) { + if (arguments.length < 2) throw new TypeError(`Failed to construct 'RTCErrorEvent': 2 arguments required, but only ${arguments.length} present.`) + if (typeof init !== 'object') throw new TypeError("Failed to construct 'RTCErrorEvent': The provided value is not of type 'RTCErrorEventInit'.") + if (!init.error) throw new TypeError("Failed to construct 'RTCErrorEvent': Failed to read the 'error' property from 'RTCErrorEventInit': Required member is undefined.") + if (init.error.constructor !== RTCError) throw new TypeError("Failed to construct 'RTCErrorEvent': Failed to read the 'error' property from 'RTCErrorEventInit': Failed to convert value to 'RTCError'.") + super(type || 'error') + this.#error = init.error + } + + get error (): RTCError { + return this.#error + } +} + +export class MediaStreamTrackEvent extends Event implements globalThis.MediaStreamTrackEvent { + #track: MediaStreamTrack + + constructor (type, init) { + if (arguments.length === 0) throw new TypeError(`Failed to construct 'MediaStreamTrackEvent': 2 arguments required, but only ${arguments.length} present.`) + if (typeof init !== 'object') throw new TypeError("Failed to construct 'MediaStreamTrackEvent': The provided value is not of type 'MediaStreamTrackEventInit'.") + if (!init.track) throw new TypeError("Failed to construct 'MediaStreamTrackEvent': Failed to read the 'track' property from 'MediaStreamTrackEventInit': Required member is undefined.") + if (init.track.constructor !== MediaStreamTrack) throw new TypeError("Failed to construct 'MediaStreamTrackEvent': Failed to read the 'channel' property from 'MediaStreamTrackEventInit': Failed to convert value to 'RTCDataChannel'.") + + super(type) + + this.#track = init.track + } + + get track (): MediaStreamTrack { + return this.#track + } +} diff --git a/src/polyfill/MediaStream.ts b/src/polyfill/MediaStream.ts new file mode 100644 index 00000000..5e798428 --- /dev/null +++ b/src/polyfill/MediaStream.ts @@ -0,0 +1,177 @@ +import { Readable } from 'node:stream' +import { MediaStreamTrackEvent } from './Events.js' +import { Track } from '../lib/index.js' + +export class MediaStreamTrack extends EventTarget implements globalThis.MediaStreamTrack { + media + track: Track + stream = new Readable({ read: () => {} }) + #kind: string + #label: string + #id = crypto.randomUUID() + contentHint = '' + + onmute + onunmute + onended + + constructor ({ kind, label }: { kind: string, label: string }) { + super() + if (!kind) throw new TypeError("Failed to construct 'MediaStreamTrack': Failed to read the 'kind' property from 'MediaStreamTrackInit': Required member is undefined.") + this.#kind = kind + this.#label = label + + this.addEventListener('ended', e => { + this.onended?.(e) + this.track?.close() + this.stream.destroy() + }) + this.stream.on('close', () => { + this.stop() + }) + } + + async applyConstraints (): Promise { + console.warn('Constraints unsupported, ignored') + } + + stop (): void { + this.track?.close() + this.stream.destroy() + this.dispatchEvent(new Event('ended')) + } + + getSettings (): globalThis.MediaTrackSettings { + console.warn('Settings upsupported, ignored') + return {} + } + + getConstraints (): globalThis.MediaTrackConstraints { + console.warn('Constraints unsupported, ignored') + return {} + } + + getCapabilities (): globalThis.MediaTrackCapabilities { + console.warn('Capabilities unsupported, ignored') + return {} + } + + clone (): this { + console.warn('Track clonning is unsupported, returned this instance') + return this + } + + get kind (): string { + return this.#kind + } + + get enabled (): boolean | null { + return this.track?.isOpen() + } + + set enabled (_) { + console.warn('Track enabling and disabling is unsupported, ignored') + } + + get muted (): boolean { + return false + } + + get id (): string { + return this.#id + } + + get label (): string { + return this.#label + } + + get readyState (): 'ended' | 'live' { + return this.track?.isClosed() ? 'ended' : 'live' + } +} + +/** + * @class + * @implements {globalThis.MediaStream} + */ +export class MediaStream extends EventTarget { + #active = true + #id = crypto.randomUUID() + #tracks = new Set() + onaddtrack + onremovetrack + onactive + oninactive + + constructor (streamOrTracks) { + super() + if (streamOrTracks instanceof MediaStream) { + for (const track of streamOrTracks.getTracks()) { + this.addTrack(track) + } + } else if (Array.isArray(streamOrTracks)) { + for (const track of streamOrTracks) { + this.addTrack(track) + } + } + this.addEventListener('active', e => { + this.onactive?.(e) + }) + this.addEventListener('inactive', e => { + this.oninactive?.(e) + }) + this.addEventListener('removetrack', e => { + this.onremovetrack?.(e) + }) + this.addEventListener('addtrack', e => { + this.onaddtrack?.(e) + }) + this.dispatchEvent(new Event('active')) + } + + get active (): boolean { + return this.#active + } + + get id (): string { + return this.#id + } + + addTrack (track) { + this.#tracks.add(track) + this.dispatchEvent(new MediaStreamTrackEvent('addtrack', { track })) + } + + getTracks (): MediaStreamTrack[] { + return [...this.#tracks] + } + + getVideoTracks (): MediaStreamTrack[] { + return [...this.#tracks].filter(({ kind }) => kind === 'video') + } + + getAudioTracks (): MediaStreamTrack[] { + return [...this.#tracks].filter(({ kind }) => kind === 'audio') + } + + getTrackById (id): MediaStreamTrack { + return [...this.#tracks].find(track => track.id === id) ?? null + } + + removeTrack (track): void { + this.#tracks.delete(track) + this.dispatchEvent(new MediaStreamTrackEvent('removetrack', { track })) + } + + clone (): MediaStream { + return new MediaStream([...this.getTracks()]) + } + + stop ():void { + for (const track of this.getTracks()) { + track.stop() + } + this.#active = false + this.dispatchEvent(new Event('inactive')) + } +} diff --git a/src/polyfill/RTCCertificate.ts b/src/polyfill/RTCCertificate.ts index 4069837e..9a4651bb 100644 --- a/src/polyfill/RTCCertificate.ts +++ b/src/polyfill/RTCCertificate.ts @@ -14,4 +14,8 @@ export default class RTCCertificate implements globalThis.RTCCertificate { getFingerprints(): globalThis.RTCDtlsFingerprint[] { return this.#fingerprints; } + + getAlgorithm (): string { + return '' + } } diff --git a/src/polyfill/RTCDataChannel.ts b/src/polyfill/RTCDataChannel.ts index 3a097e92..ce47a83a 100644 --- a/src/polyfill/RTCDataChannel.ts +++ b/src/polyfill/RTCDataChannel.ts @@ -1,38 +1,37 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import * as exceptions from './Exception'; import { DataChannel } from '../lib/index'; +import RTCPeerConnection from './RTCPeerConnection'; +import { RTCErrorEvent } from './Events'; export default class RTCDataChannel extends EventTarget implements globalThis.RTCDataChannel { #dataChannel: DataChannel; #readyState: RTCDataChannelState; - #bufferedAmountLowThreshold: number; - #binaryType: BinaryType; + #bufferedAmountLowThreshold: number = 0; + #binaryType: BinaryType = 'blob'; #maxPacketLifeTime: number | null; #maxRetransmits: number | null; #negotiated: boolean; #ordered: boolean; - - #closeRequested = false; + #pc: RTCPeerConnection; // events - onbufferedamountlow: ((this: RTCDataChannel, ev: Event) => any) | null; - onclose: ((this: RTCDataChannel, ev: Event) => any) | null; - onclosing: ((this: RTCDataChannel, ev: Event) => any) | null; - onerror: ((this: RTCDataChannel, ev: Event) => any) | null; - onmessage: ((this: RTCDataChannel, ev: MessageEvent) => any) | null; - onopen: ((this: RTCDataChannel, ev: Event) => any) | null; - - constructor(dataChannel: DataChannel, opts: globalThis.RTCDataChannelInit = {}) { + onbufferedamountlow: globalThis.RTCDataChannel['onbufferedamountlow']; + onclose: globalThis.RTCDataChannel['onclose']; + onclosing: globalThis.RTCDataChannel['onclosing']; + onerror: globalThis.RTCDataChannel['onerror']; + onmessage: globalThis.RTCDataChannel['onmessage']; + onopen: globalThis.RTCDataChannel['onopen'] + + constructor(dataChannel: DataChannel, opts: globalThis.RTCDataChannelInit = {}, pc: RTCPeerConnection) { super(); this.#dataChannel = dataChannel; - this.#binaryType = 'blob'; this.#readyState = this.#dataChannel.isOpen() ? 'open' : 'connecting'; - this.#bufferedAmountLowThreshold = 0; - this.#maxPacketLifeTime = opts.maxPacketLifeTime || null; - this.#maxRetransmits = opts.maxRetransmits || null; - this.#negotiated = opts.negotiated || false; - this.#ordered = opts.ordered || true; + this.#maxPacketLifeTime = opts.maxPacketLifeTime ?? null; + this.#maxRetransmits = opts.maxRetransmits ?? null; + this.#negotiated = opts.negotiated ?? false; + this.#ordered = opts.ordered ?? true; + this.#pc = pc // forward dataChannel events this.#dataChannel.onOpen(() => { @@ -40,18 +39,22 @@ export default class RTCDataChannel extends EventTarget implements globalThis.RT this.dispatchEvent(new Event('open', {})); }); - this.#dataChannel.onClosed(() => { - // Simulate closing event - if (!this.#closeRequested) { - this.#readyState = 'closing'; - this.dispatchEvent(new Event('closing')); - } - setImmediate(() => { - this.#readyState = 'closed'; - this.dispatchEvent(new Event('close')); - }); - }); + // we need updated connectionstate, so this is delayed by a single event loop tick + // this is fucked and wonky, needs to be made better + this.#dataChannel.onClosed(() => setTimeout(() => { + if (this.#readyState !== 'closed') { + // this should be 'disconnected' but ldc doesn't support that + if (this.#pc.connectionState === 'closed') { + // if the remote connection suddently closes without closing dc first, throw this weird error + this.dispatchEvent(new RTCErrorEvent('error', { error: new RTCError({ errorDetail: 'sctp-failure', sctpCauseCode: 12 }, 'User-Initiated Abort, reason=Close called') })) + } + this.#readyState = 'closing' + this.dispatchEvent(new Event('closing')) + this.#readyState = 'closed' + } + this.dispatchEvent(new Event('close')) + })) this.#dataChannel.onError((msg) => { this.dispatchEvent( @@ -70,16 +73,17 @@ export default class RTCDataChannel extends EventTarget implements globalThis.RT this.dispatchEvent(new Event('bufferedamountlow')); }); - this.#dataChannel.onMessage((data) => { - if (ArrayBuffer.isView(data)) { - if (this.binaryType == 'arraybuffer') - data = data.buffer; - else - data = Buffer.from(data.buffer); + this.#dataChannel.onMessage(message => { + let data: Blob | ArrayBufferLike | string + if (!ArrayBuffer.isView(message)) { + data = message + } else if (this.#binaryType === 'blob') { + data = new Blob([message]) + } else { + data = message.buffer } - - this.dispatchEvent(new MessageEvent('message', { data })); - }); + this.dispatchEvent(new MessageEvent('message', { data })) + }) // forward events to properties this.addEventListener('message', (e) => { @@ -89,7 +93,7 @@ export default class RTCDataChannel extends EventTarget implements globalThis.RT if (this.onbufferedamountlow) this.onbufferedamountlow(e); }); this.addEventListener('error', (e) => { - if (this.onerror) this.onerror(e); + if (this.onerror) this.onerror(e as RTCErrorEvent); }); this.addEventListener('close', (e) => { if (this.onclose) this.onclose(e); @@ -162,7 +166,11 @@ export default class RTCDataChannel extends EventTarget implements globalThis.RT return this.#readyState; } - send(data): void { + get maxMessageSize (): number { + return this.#dataChannel.maxMessageSize() + } + + send(data: string | Blob | ArrayBuffer | ArrayBufferView | Buffer): void { if (this.#readyState !== 'open') { throw new exceptions.InvalidStateError( "Failed to execute 'send' on 'RTCDataChannel': RTCDataChannel.readyState is not 'open'", @@ -171,26 +179,29 @@ export default class RTCDataChannel extends EventTarget implements globalThis.RT // Needs network error, type error implemented if (typeof data === 'string') { + if (data.length > this.#dataChannel.maxMessageSize()) throw new TypeError('Max message size exceeded.') this.#dataChannel.sendMessage(data); - } else if (data instanceof Blob) { + } else if ('arrayBuffer' in data) { + if (data.size > this.#dataChannel.maxMessageSize()) throw new TypeError('Max message size exceeded.') data.arrayBuffer().then((ab) => { - if (process?.versions?.bun) { - this.#dataChannel.sendMessageBinary(Buffer.from(ab)); - } else { - this.#dataChannel.sendMessageBinary(new Uint8Array(ab)); - } + this.#dataChannel.sendMessageBinary( process?.versions?.bun ? Buffer.from(ab) : new Uint8Array(ab)); }); } else { - if (process?.versions?.bun) { - this.#dataChannel.sendMessageBinary(Buffer.from(data)); - } else { - this.#dataChannel.sendMessageBinary(new Uint8Array(data)); - } + if (data.byteLength > this.#dataChannel.maxMessageSize()) throw new TypeError('Max message size exceeded.') + this.#dataChannel.sendMessageBinary( process?.versions?.bun ? Buffer.from(data as ArrayBuffer) : new Uint8Array(data as ArrayBuffer)); } } - close(): void { - this.#closeRequested = true; - this.#dataChannel.close(); + close (): void { + this.#readyState = 'closed' + setTimeout(() => { + if (this.#pc.connectionState === 'closed') { + // if the remote connection suddently closes without closing dc first, throw this weird error + // can this be done better? + this.dispatchEvent(new RTCErrorEvent('error', { error: new RTCError({ errorDetail: 'sctp-failure', sctpCauseCode: 12 }, 'User-Initiated Abort, reason=Close called') })) + } + }) + + this.#dataChannel.close() } } diff --git a/src/polyfill/RTCDtlsTransport.ts b/src/polyfill/RTCDtlsTransport.ts index 34590eb6..0c4969f4 100644 --- a/src/polyfill/RTCDtlsTransport.ts +++ b/src/polyfill/RTCDtlsTransport.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import RTCIceTransport from './RTCIceTransport'; import RTCPeerConnection from './RTCPeerConnection'; @@ -6,23 +5,20 @@ export default class RTCDtlsTransport extends EventTarget implements globalThis. #pc: RTCPeerConnection = null; #iceTransport = null; - onstatechange: ((this: RTCDtlsTransport, ev: Event) => any) | null = null; - onerror: ((this: RTCDtlsTransport, ev: Event) => any) | null = null; + onstatechange: globalThis.RTCDtlsTransport['onstatechange']; + onerror: globalThis.RTCDtlsTransport['onstatechange']; - constructor(init: { pc: RTCPeerConnection, extraFunctions }) { + constructor({ pc }: { pc: RTCPeerConnection }) { super(); - this.#pc = init.pc; + this.#pc = pc; - this.#iceTransport = new RTCIceTransport({ pc: init.pc, extraFunctions: init.extraFunctions }); + this.#iceTransport = new RTCIceTransport({ pc }); // forward peerConnection events this.#pc.addEventListener('connectionstatechange', () => { - this.dispatchEvent(new Event('statechange')); - }); - - // forward events to properties - this.addEventListener('statechange', (e) => { - if (this.onstatechange) this.onstatechange(e); + const e = new Event('statechange'); + this.dispatchEvent(e); + this.onstatechange?.(e); }); } @@ -33,15 +29,12 @@ export default class RTCDtlsTransport extends EventTarget implements globalThis. get state(): RTCDtlsTransportState { // reduce state from new, connecting, connected, disconnected, failed, closed, unknown // to RTCDtlsTRansport states new, connecting, connected, closed, failed - let state = this.#pc ? this.#pc.connectionState : 'new'; - if (state === 'disconnected') { - state = 'closed'; - } - return state; + if (this.#pc.connectionState === 'disconnected') return 'closed' + return this.#pc.connectionState } getRemoteCertificates(): ArrayBuffer[] { - // TODO: implement + // TODO: implement, not supported by all browsers anyways return [new ArrayBuffer(0)]; } } diff --git a/src/polyfill/RTCError.ts b/src/polyfill/RTCError.ts index 1271bae8..0253fcd8 100644 --- a/src/polyfill/RTCError.ts +++ b/src/polyfill/RTCError.ts @@ -1,32 +1,33 @@ +const RTCErrorDetailType = [ + 'data-channel-failure', + 'dtls-failure', + 'fingerprint-failure', + 'sctp-failure', + 'sdp-syntax-error', + 'hardware-encoder-not-available', + 'hardware-encoder-error' +] + export default class RTCError extends DOMException implements globalThis.RTCError { #errorDetail: RTCErrorDetailType; #receivedAlert: number | null; #sctpCauseCode: number | null; #sdpLineNumber: number | null; #sentAlert: number | null; + #httpRequestStatusCode: number | null; constructor(init: globalThis.RTCErrorInit, message?: string) { - super(message, 'OperationError'); - - if (!init || !init.errorDetail) throw new TypeError('Cannot construct RTCError, errorDetail is required'); - if ( - [ - 'data-channel-failure', - 'dtls-failure', - 'fingerprint-failure', - 'hardware-encoder-error', - 'hardware-encoder-not-available', - 'sctp-failure', - 'sdp-syntax-error', - ].indexOf(init.errorDetail) === -1 - ) - throw new TypeError('Cannot construct RTCError, errorDetail is invalid'); + if (arguments.length === 0) throw new TypeError("Failed to construct 'RTCError': 1 argument required, but only 0 present.") + if (!init.errorDetail) throw new TypeError("Failed to construct 'RTCError': Failed to read the 'errorDetail' property from 'RTCErrorInit': Required member is undefined.") + if (!RTCErrorDetailType.includes(init.errorDetail)) throw new TypeError(`Failed to construct 'RTCError': Failed to read the 'errorDetail' property from 'RTCErrorInit': The provided value '${init.errorDetail}' is not a valid enum value of type RTCErrorDetailType.`) + super(message, 'OperationError') this.#errorDetail = init.errorDetail; this.#receivedAlert = init.receivedAlert ?? null; this.#sctpCauseCode = init.sctpCauseCode ?? null; this.#sdpLineNumber = init.sdpLineNumber ?? null; this.#sentAlert = init.sentAlert ?? null; + this.#httpRequestStatusCode = init.httpRequestStatusCode ?? null } get errorDetail(): globalThis.RTCErrorDetailType { @@ -57,6 +58,10 @@ export default class RTCError extends DOMException implements globalThis.RTCErro return this.#sdpLineNumber; } + get httpRequestStatusCode (): number { + return this.#httpRequestStatusCode ?? null + } + set sdpLineNumber(_value) { throw new TypeError('Cannot set sdpLineNumber, it is read-only'); } diff --git a/src/polyfill/RTCIceCandidate.ts b/src/polyfill/RTCIceCandidate.ts index 3f94cad2..f9133c98 100644 --- a/src/polyfill/RTCIceCandidate.ts +++ b/src/polyfill/RTCIceCandidate.ts @@ -58,7 +58,7 @@ export default class RTCIceCandidate implements globalThis.RTCIceCandidate { } get address(): string | null { - return this.#address || null; + return this.#address ?? null; } get candidate(): string { @@ -70,19 +70,19 @@ export default class RTCIceCandidate implements globalThis.RTCIceCandidate { } get foundation(): string | null { - return this.#foundation || null; + return this.#foundation ?? null; } get port(): number | null { - return this.#port || null; + return this.#port ?? null; } get priority(): number | null { - return this.#priority || null; + return this.#priority ?? null; } get protocol(): globalThis.RTCIceProtocol | null { - return this.#protocol || null; + return this.#protocol ?? null; } get relatedAddress(): string | null { @@ -90,7 +90,7 @@ export default class RTCIceCandidate implements globalThis.RTCIceCandidate { } get relatedPort(): number | null { - return this.#relatedPort || null; + return this.#relatedPort ?? null; } get sdpMLineIndex(): number | null { @@ -106,7 +106,7 @@ export default class RTCIceCandidate implements globalThis.RTCIceCandidate { } get type(): globalThis.RTCIceCandidateType | null { - return this.#type || null; + return this.#type ?? null; } get usernameFragment(): string | null { diff --git a/src/polyfill/RTCIceTransport.ts b/src/polyfill/RTCIceTransport.ts index 7827d81f..da630f7a 100644 --- a/src/polyfill/RTCIceTransport.ts +++ b/src/polyfill/RTCIceTransport.ts @@ -1,73 +1,66 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import RTCIceCandidate from './RTCIceCandidate'; import RTCPeerConnection from './RTCPeerConnection'; export default class RTCIceTransport extends EventTarget implements globalThis.RTCIceTransport { #pc: RTCPeerConnection = null; - #extraFunctions = null; - ongatheringstatechange: ((this: RTCIceTransport, ev: Event) => any) | null = null; - onselectedcandidatepairchange: ((this: RTCIceTransport, ev: Event) => any) | null = null; - onstatechange: ((this: RTCIceTransport, ev: Event) => any) | null = null; + ongatheringstatechange: globalThis.RTCIceTransport['ongatheringstatechange']; + onselectedcandidatepairchange: globalThis.RTCIceTransport['onselectedcandidatepairchange']; + onstatechange: globalThis.RTCIceTransport['onstatechange']; - constructor(init: { pc: RTCPeerConnection, extraFunctions }) { + constructor({ pc }: { pc: RTCPeerConnection }) { super(); - this.#pc = init.pc; - this.#extraFunctions = init.extraFunctions; + this.#pc = pc; // forward peerConnection events - this.#pc.addEventListener('icegatheringstatechange', () => { - this.dispatchEvent(new Event('gatheringstatechange')); + pc.addEventListener('icegatheringstatechange', () => { + const e = new Event('gatheringstatechange') + this.dispatchEvent(e) + this.ongatheringstatechange?.(e) }); - this.#pc.addEventListener('iceconnectionstatechange', () => { - this.dispatchEvent(new Event('statechange')); - }); - - // forward events to properties - this.addEventListener('gatheringstatechange', (e) => { - if (this.ongatheringstatechange) this.ongatheringstatechange(e); - }); - this.addEventListener('statechange', (e) => { - if (this.onstatechange) this.onstatechange(e); + pc.addEventListener('iceconnectionstatechange', () => { + const e = new Event('statechange') + this.dispatchEvent(e) + this.onstatechange?.(e) }); } get component(): globalThis.RTCIceComponent { const cp = this.getSelectedCandidatePair(); - if (!cp) return null; + if (!cp?.local) return null; return cp.local.component; } get gatheringState(): globalThis.RTCIceGatheringState { - return this.#pc ? this.#pc.iceGatheringState : 'new'; + return this.#pc.iceGatheringState; } - get role(): string { + get role(): globalThis.RTCIceRole { return this.#pc.localDescription.type == 'offer' ? 'controlling' : 'controlled'; } get state(): globalThis.RTCIceTransportState { - return this.#pc ? this.#pc.iceConnectionState : 'new'; + return this.#pc.iceConnectionState; } getLocalCandidates(): RTCIceCandidate[] { - return this.#pc ? this.#extraFunctions.localCandidates() : []; + return this.#pc.localCandidates; } - getLocalParameters(): any { - /** */ + getLocalParameters(): RTCIceParameters | null { + return new RTCIceParameters(new RTCIceCandidate({ candidate: this.#pc.getSelectedCandidatePair().local.candidate, sdpMLineIndex: 0 })) } getRemoteCandidates(): RTCIceCandidate[] { - return this.#pc ? this.#extraFunctions.remoteCandidates() : []; + return this.#pc.remoteCandidates; } - getRemoteParameters(): any { - /** */ + getRemoteParameters(): RTCIceParameters | null { + return new RTCIceParameters(new RTCIceCandidate({ candidate: this.#pc.getSelectedCandidatePair().remote.candidate, sdpMLineIndex: 0 })) } getSelectedCandidatePair(): globalThis.RTCIceCandidatePair | null { - const cp = this.#extraFunctions.selectedCandidatePair(); + const cp = this.#pc.getSelectedCandidatePair(); if (!cp) return null; return { local: new RTCIceCandidate({ @@ -81,3 +74,13 @@ export default class RTCIceTransport extends EventTarget implements globalThis.R }; } } + + +export class RTCIceParameters implements globalThis.RTCIceParameters { + usernameFragment = '' + password = '' + constructor ({ usernameFragment, password = '' }) { + this.usernameFragment = usernameFragment + this.password = password + } +} diff --git a/src/polyfill/RTCPeerConnection.ts b/src/polyfill/RTCPeerConnection.ts index 61920f32..dec5281c 100644 --- a/src/polyfill/RTCPeerConnection.ts +++ b/src/polyfill/RTCPeerConnection.ts @@ -1,6 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { SelectedCandidateInfo } from '../lib/types'; -import { PeerConnection } from '../lib/index'; +import { Audio, DataChannel, DataChannelInitConfig, Direction, PeerConnection, RtcpReceivingSession, Track, Video } from '../lib/index'; import RTCSessionDescription from './RTCSessionDescription'; import RTCDataChannel from './RTCDataChannel'; import RTCIceCandidate from './RTCIceCandidate'; @@ -8,6 +6,17 @@ import { RTCDataChannelEvent, RTCPeerConnectionIceEvent } from './Events'; import RTCSctpTransport from './RTCSctpTransport'; import * as exceptions from './Exception'; import RTCCertificate from './RTCCertificate'; +import { RTCRtpSender, RTCRtpTransceiver } from './RTCRtp'; +import { MediaStreamTrack } from './MediaStream'; + +const ndcDirectionMap: Record = { + inactive: 'Inactive', + recvonly: 'RecvOnly', + sendonly: 'SendOnly', + sendrecv: 'SendRecv', + stopped: 'Inactive', + undefined: 'Unknown' +} // extend RTCConfiguration with peerIdentity interface RTCConfiguration extends globalThis.RTCConfiguration { @@ -20,110 +29,86 @@ export default class RTCPeerConnection extends EventTarget implements globalThis } #peerConnection: PeerConnection; - #localOffer: any; - #localAnswer: any; - #dataChannels: Set; + #localOffer: ReturnType; + #localAnswer: ReturnType; + #dataChannels = new Set(); + #tracks = new Set() + #transceivers: RTCRtpTransceiver[] = [] + #unusedTransceivers: RTCRtpTransceiver[] = [] #dataChannelsClosed = 0; #config: RTCConfiguration; - #canTrickleIceCandidates: boolean | null; + #canTrickleIceCandidates: boolean | null = null; #sctp: RTCSctpTransport; + #announceNegotiation: boolean | null = null; #localCandidates: RTCIceCandidate[] = []; #remoteCandidates: RTCIceCandidate[] = []; // events - onconnectionstatechange: ((this: RTCPeerConnection, ev: Event) => any) | null; - ondatachannel: ((this: RTCPeerConnection, ev: RTCDataChannelEvent) => any) | null; - onicecandidate: ((this: RTCPeerConnection, ev: RTCPeerConnectionIceEvent) => any) | null; - onicecandidateerror: ((this: RTCPeerConnection, ev: Event) => any) | null; - oniceconnectionstatechange: ((this: RTCPeerConnection, ev: Event) => any) | null; - onicegatheringstatechange: ((this: RTCPeerConnection, ev: Event) => any) | null; - onnegotiationneeded: ((this: RTCPeerConnection, ev: Event) => any) | null; - onsignalingstatechange: ((this: RTCPeerConnection, ev: Event) => any) | null; - ontrack: ((this: RTCPeerConnection, ev: globalThis.RTCTrackEvent) => any) | null; - - private _checkConfiguration(config: RTCConfiguration): void { - if (config && config.iceServers === undefined) config.iceServers = []; - if (config && config.iceTransportPolicy === undefined) config.iceTransportPolicy = 'all'; - - if (config?.iceServers === null) throw new TypeError('IceServers cannot be null'); - - // Check for all the properties of iceServers - if (Array.isArray(config?.iceServers)) { - for (let i = 0; i < config.iceServers.length; i++) { - if (config.iceServers[i] === null) throw new TypeError('IceServers cannot be null'); - if (config.iceServers[i] === undefined) throw new TypeError('IceServers cannot be undefined'); - if (Object.keys(config.iceServers[i]).length === 0) throw new TypeError('IceServers cannot be empty'); - - // If iceServers is string convert to array - if (typeof config.iceServers[i].urls === 'string') - config.iceServers[i].urls = [config.iceServers[i].urls as string]; - - // urls can not be empty - if ((config.iceServers[i].urls as string[])?.some((url) => url == '')) - throw new exceptions.SyntaxError('IceServers urls cannot be empty'); - - // urls should be valid URLs and match the protocols "stun:|turn:|turns:" - if ( - (config.iceServers[i].urls as string[])?.some( - (url) => { - try { - const parsedURL = new URL(url) - - return !/^(stun:|turn:|turns:)$/.test(parsedURL.protocol) - } catch (error) { - return true - } - }, - ) - ) - throw new exceptions.SyntaxError('IceServers urls wrong format'); - - // If this is a turn server check for username and credential - if ((config.iceServers[i].urls as string[])?.some((url) => url.startsWith('turn'))) { - if (!config.iceServers[i].username) - throw new exceptions.InvalidAccessError('IceServers username cannot be null'); - if (!config.iceServers[i].credential) - throw new exceptions.InvalidAccessError('IceServers username cannot be undefined'); - } + onconnectionstatechange: globalThis.RTCPeerConnection['onconnectionstatechange']; + ondatachannel: globalThis.RTCPeerConnection['ondatachannel']; + onicecandidate: globalThis.RTCPeerConnection['onicecandidate']; + // TODO: not implemented + onicecandidateerror: globalThis.RTCPeerConnection['onicecandidateerror']; + oniceconnectionstatechange: globalThis.RTCPeerConnection['oniceconnectionstatechange']; + onicegatheringstatechange: globalThis.RTCPeerConnection['onicegatheringstatechange']; + onnegotiationneeded: globalThis.RTCPeerConnection['onnegotiationneeded']; + onsignalingstatechange: globalThis.RTCPeerConnection['onsignalingstatechange']; + ontrack: globalThis.RTCPeerConnection['ontrack'] | null; - // length of urls can not be 0 - if (config.iceServers[i].urls?.length === 0) - throw new exceptions.SyntaxError('IceServers urls cannot be empty'); + setConfiguration(config: RTCConfiguration): void { + // TODO: this doesn't actually update the configuration :/ + // most of these are unused x) + config ??= {} + if (config.bundlePolicy === undefined) config.bundlePolicy = 'balanced' + // @ts-expect-error non-standard + config.encodedInsertableStreams ??= false + config.iceCandidatePoolSize ??= 0 + config.iceServers ??= [] + for (let { urls } of config.iceServers) { + if (!Array.isArray(urls)) urls = [urls] + for (const url of urls) { + try { + new URL(url) + } catch (error) { + throw new DOMException(`Failed to execute 'setConfiguration' on 'RTCPeerConnection': '${url}' is not a valid URL.`, 'SyntaxError') + } + } + } + config.iceTransportPolicy ??= 'all' + // @ts-expect-error non-standard + config.rtcAudioJitterBufferFastAccelerate ??= false + // @ts-expect-error non-standard + config.rtcAudioJitterBufferMaxPackets ??= 200 + // @ts-expect-error non-standard + config.rtcAudioJitterBufferMinDelayMs ??= 0 + config.rtcpMuxPolicy ??= 'require' + + if (config.iceCandidatePoolSize < 0 || config.iceCandidatePoolSize > 255) throw new TypeError("Failed to execute 'setConfiguration' on 'RTCPeerConnection': Failed to read the 'iceCandidatePoolSize' property from 'RTCConfiguration': Value is outside the 'octet' value range.") + if (config.bundlePolicy !== 'balanced' && config.bundlePolicy !== 'max-compat' && config.bundlePolicy !== 'max-bundle') throw new TypeError("Failed to execute 'setConfiguration' on 'RTCPeerConnection': Failed to read the 'bundlePolicy' property from 'RTCConfiguration': The provided value '" + config.bundlePolicy + "' is not a valid enum value of type RTCBundlePolicy.") + if (this.#config) { + if (config.bundlePolicy !== this.#config.bundlePolicy) { + throw new DOMException("Failed to execute 'setConfiguration' on 'RTCPeerConnection': Attempted to modify the PeerConnection's configuration in an unsupported way.", 'InvalidModificationError') + } } - } - - if ( - config && - config.iceTransportPolicy && - config.iceTransportPolicy !== 'all' && - config.iceTransportPolicy !== 'relay' - ) - throw new TypeError('IceTransportPolicy must be either "all" or "relay"'); - } - setConfiguration(config: RTCConfiguration): void { - this._checkConfiguration(config); - this.#config = config; + this.#config = config } - constructor(config: RTCConfiguration = { iceServers: [], iceTransportPolicy: 'all' }) { + constructor(config: RTCConfiguration = {}) { super(); - this._checkConfiguration(config); - this.#config = config; + this.setConfiguration(config); this.#localOffer = createDeferredPromise(); this.#localAnswer = createDeferredPromise(); - this.#dataChannels = new Set(); - this.#canTrickleIceCandidates = null; try { - const peerIdentity = (config as any)?.peerIdentity ?? `peer-${getRandomString(7)}`; + const peerIdentity = config?.peerIdentity ?? `peer-${getRandomString(7)}`; this.#peerConnection = new PeerConnection(peerIdentity, { - ...config, + ...this.#config, iceServers: config?.iceServers ?.map((server) => { @@ -163,21 +148,35 @@ export default class RTCPeerConnection extends EventTarget implements globalThis }); this.#peerConnection.onDataChannel((channel) => { - const dc = new RTCDataChannel(channel); - this.#dataChannels.add(dc); - this.dispatchEvent(new RTCDataChannelEvent('datachannel', { channel: dc })); + this.dispatchEvent(new RTCDataChannelEvent('datachannel', { channel: this.#handleDataChannel(channel) })) }); this.#peerConnection.onLocalDescription((sdp, type) => { if (type === 'offer') { - this.#localOffer.resolve({ sdp, type }); + this.#localOffer.resolve(new RTCSessionDescription({ sdp, type })); } if (type === 'answer') { - this.#localAnswer.resolve({ sdp, type }); + this.#localAnswer.resolve(new RTCSessionDescription({ sdp, type })); } }); + this.#peerConnection.onTrack(track => { + const transceiver = new RTCRtpTransceiver({ transceiver: track, pc: this }) + this.#tracks.add(track) + transceiver._setNDCTrack(track) + this.#transceivers.push(transceiver) + const mediastream = new MediaStreamTrack({ kind: track.type(), label: track.mid() }) + mediastream.track = track + track.onClosed(() => { + this.#tracks.delete(track) + mediastream.dispatchEvent(new Event('ended')) + }) + track.onMessage(buf => mediastream.stream.push(buf)) + transceiver.receiver.track = mediastream + this.dispatchEvent(new RTCTrackEvent('track', { track: mediastream, receiver: transceiver.receiver, transceiver })) + }) + this.#peerConnection.onLocalCandidate((candidate, sdpMid) => { if (sdpMid === 'unspec') { this.#localAnswer.reject(new Error(`Invalid description type ${sdpMid}`)); @@ -190,44 +189,42 @@ export default class RTCPeerConnection extends EventTarget implements globalThis // forward events to properties this.addEventListener('connectionstatechange', (e) => { - if (this.onconnectionstatechange) this.onconnectionstatechange(e); + this.onconnectionstatechange?.(e); }); this.addEventListener('signalingstatechange', (e) => { - if (this.onsignalingstatechange) this.onsignalingstatechange(e); + this.onsignalingstatechange?.(e); }); this.addEventListener('iceconnectionstatechange', (e) => { - if (this.oniceconnectionstatechange) this.oniceconnectionstatechange(e); + this.oniceconnectionstatechange?.(e); }); this.addEventListener('icegatheringstatechange', (e) => { - if (this.onicegatheringstatechange) this.onicegatheringstatechange(e); + this.onicegatheringstatechange?.(e); }); this.addEventListener('datachannel', (e) => { - if (this.ondatachannel) this.ondatachannel(e as RTCDataChannelEvent); + this.ondatachannel?.(e as RTCDataChannelEvent); }); this.addEventListener('icecandidate', (e) => { - if (this.onicecandidate) this.onicecandidate(e as RTCPeerConnectionIceEvent); + this.onicecandidate?.(e as RTCPeerConnectionIceEvent); }); - this.#sctp = new RTCSctpTransport({ - pc: this, - extraFunctions: { - maxDataChannelId: (): number => { - return this.#peerConnection.maxDataChannelId(); - }, - maxMessageSize: (): number => { - return this.#peerConnection.maxMessageSize(); - }, - localCandidates: (): RTCIceCandidate[] => { - return this.#localCandidates; - }, - remoteCandidates: (): RTCIceCandidate[] => { - return this.#remoteCandidates; - }, - selectedCandidatePair: (): { local: SelectedCandidateInfo; remote: SelectedCandidateInfo } | null => { - return this.#peerConnection.getSelectedCandidatePair(); - }, - }, - }); + this.addEventListener('track', e => { + this.ontrack?.(e as RTCTrackEvent) + }) + + this.addEventListener('negotiationneeded', e => { + this.#announceNegotiation = true + this.onnegotiationneeded?.(e) + }) + + this.#sctp = new RTCSctpTransport({ pc: this }); + } + + get localCandidates (): RTCIceCandidate[] { + return this.#localCandidates + } + + get remoteCandidates (): RTCIceCandidate[] { + return this.#remoteCandidates } get canTrickleIceCandidates(): boolean | null { @@ -250,28 +247,32 @@ export default class RTCPeerConnection extends EventTarget implements globalThis return this.#peerConnection.gatheringState(); } - get currentLocalDescription(): RTCSessionDescription { - return new RTCSessionDescription(this.#peerConnection.localDescription() as any); + #nullableDescription (desc): RTCSessionDescription | null { + if (!desc) return null + return new RTCSessionDescription(desc) } - - get currentRemoteDescription(): RTCSessionDescription { - return new RTCSessionDescription(this.#peerConnection.remoteDescription() as any); + get currentLocalDescription (): RTCSessionDescription { + return this.#nullableDescription(this.#peerConnection.localDescription()) } - - get localDescription(): RTCSessionDescription { - return new RTCSessionDescription(this.#peerConnection.localDescription() as any); + + get currentRemoteDescription (): RTCSessionDescription { + return this.#nullableDescription(this.#peerConnection.remoteDescription()) } - - get pendingLocalDescription(): RTCSessionDescription { - return new RTCSessionDescription(this.#peerConnection.localDescription() as any); + + get localDescription (): RTCSessionDescription { + return this.#nullableDescription(this.#peerConnection.localDescription()) } - - get pendingRemoteDescription(): RTCSessionDescription { - return new RTCSessionDescription(this.#peerConnection.remoteDescription() as any); + + get pendingLocalDescription (): RTCSessionDescription { + return this.#nullableDescription(this.#peerConnection.localDescription()) } - - get remoteDescription(): RTCSessionDescription { - return new RTCSessionDescription(this.#peerConnection.remoteDescription() as any); + + get pendingRemoteDescription (): RTCSessionDescription { + return this.#nullableDescription(this.#peerConnection.remoteDescription()) + } + + get remoteDescription (): RTCSessionDescription { + return this.#nullableDescription(this.#peerConnection.remoteDescription()) } get sctp(): RTCSctpTransport { @@ -283,16 +284,17 @@ export default class RTCPeerConnection extends EventTarget implements globalThis } async addIceCandidate(candidate?: globalThis.RTCIceCandidateInit | null): Promise { + // TODO: only resolve this once the candidate is added and not right away if (!candidate || !candidate.candidate) { return; } if (candidate.sdpMid === null && candidate.sdpMLineIndex === null) { - throw new TypeError('sdpMid must be set'); + throw new DOMException('Candidate invalid'); } if (candidate.sdpMid === undefined && candidate.sdpMLineIndex == undefined) { - throw new TypeError('sdpMid must be set'); + throw new DOMException('Candidate invalid'); } // Reject if sdpMid format is not valid @@ -308,9 +310,9 @@ export default class RTCPeerConnection extends EventTarget implements globalThis } try { - this.#peerConnection.addRemoteCandidate(candidate.candidate, candidate.sdpMid || '0'); + this.#peerConnection.addRemoteCandidate(candidate.candidate, candidate.sdpMid ?? '0'); this.#remoteCandidates.push( - new RTCIceCandidate({ candidate: candidate.candidate, sdpMid: candidate.sdpMid || '0' }), + new RTCIceCandidate({ candidate: candidate.candidate, sdpMid: candidate.sdpMid ?? '0' }), ); } catch (error) { if (!error || !error.message) throw new exceptions.NotFoundError('Unknown error'); @@ -320,18 +322,87 @@ export default class RTCPeerConnection extends EventTarget implements globalThis throw new exceptions.InvalidStateError(error.message); if (error.message.includes('Invalid candidate format')) throw new exceptions.OperationError(error.message); - throw new exceptions.NotFoundError(error.message); + throw new DOMException(error.message, 'UnknownError'); } } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - addTrack(_track, ..._streams): globalThis.RTCRtpSender { - throw new DOMException('Not implemented'); + #findUnusedTransceiver (kind): RTCRtpTransceiver | null { + const unused = this.#unusedTransceivers.find(tr => tr.track.type() === kind && tr.direction === 'sendonly') + if (!unused) return null + this.#unusedTransceivers.splice(this.#unusedTransceivers.indexOf(unused), 1) + return unused + } + + #setUpTrack (media: Video | Audio, track: MediaStreamTrack, transceiver: RTCRtpTransceiver, direction): void { + const session = new RtcpReceivingSession() + const pctrack = this.#peerConnection.addTrack(media) + this.#tracks.add(pctrack) + pctrack.onClosed(() => { + this.#tracks.delete(pctrack) + track.dispatchEvent(new Event('ended')) + }) + pctrack.setMediaHandler(session) + track.media = media + track.track = pctrack + transceiver._setNDCTrack(pctrack) + track.stream.on('data', buf => { + pctrack.sendMessageBinary(buf) + }) + if (direction === 'recvonly') { + transceiver.receiver.track = track + } else if (direction === 'sendonly') { + transceiver.sender.track = track + } + if (this.#announceNegotiation) { + this.#announceNegotiation = false + this.dispatchEvent(new Event('negotiationneeded')) + } } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - addTransceiver(_trackOrKind, _init): globalThis.RTCRtpTransceiver { - throw new DOMException('Not implemented'); + + addTrack (track, ...streams): RTCRtpSender { + for (const stream of streams) stream.addTrack(track) + + const kind = track.kind + + const unused = this.#findUnusedTransceiver(kind) + if (unused) { + this.#setUpTrack(unused.media, track, unused, 'sendonly') + return unused.sender + } else { + const transceiver = this.addTransceiver(track, { direction: 'sendonly' }) + return transceiver.sender + } + } + + + addTransceiver (trackOrKind: MediaStreamTrack | string, { direction = 'inactive', sendEncodings = undefined, streams = undefined }: RTCRtpTransceiverInit = {}): RTCRtpTransceiver { + if (direction === 'sendrecv') throw new TypeError('unsupported') + const track = trackOrKind instanceof MediaStreamTrack && trackOrKind + const kind = (track && track.kind) || trackOrKind + const ndcMedia = kind === 'video' ? new Video('video', ndcDirectionMap[direction]) : new Audio('audio', ndcDirectionMap[direction]) + + const transceiver = new RTCRtpTransceiver({ transceiver: ndcMedia, pc: this }) + this.#transceivers.push(transceiver) + if (track) { + this.#setUpTrack(ndcMedia, track, transceiver, direction) + } else { + this.#unusedTransceivers.push(transceiver) + } + return transceiver + } + + getReceivers (): RTCRtpReceiver[] { + // receivers are created on ontrack + return this.#transceivers.map(tr => tr.direction === 'recvonly' && tr.receiver).filter(re => re) + } + + getSenders (): RTCRtpSender[] { + // senders are created on addTrack or addTransceiver + return this.#transceivers.map(tr => tr.direction === 'sendonly' && tr.sender).filter(se => se) + } + + getTracks (): Track[] { + return [...this.#tracks] } close(): void { @@ -341,29 +412,58 @@ export default class RTCPeerConnection extends EventTarget implements globalThis this.#dataChannelsClosed++; }); + for (const transceiver of this.#transceivers) { + transceiver.close() + } + for (const track of this.#tracks) { + track.close() + } + this.#peerConnection.close(); } - createAnswer(): Promise { - return this.#localAnswer; + get maxMessageSize (): number { + return this.#peerConnection.maxMessageSize() + } + + get maxChannels (): number { + return this.#peerConnection.maxDataChannelId() } + createAnswer(): Promise & Promise { + // @ts-expect-error dont support deprecated overload + return this.#localAnswer; + } - createDataChannel(label, opts = {}): RTCDataChannel { - const channel = this.#peerConnection.createDataChannel(label, opts); - const dataChannel = new RTCDataChannel(channel, opts); - + #handleDataChannel (channel: DataChannel, opts?: DataChannelInitConfig): RTCDataChannel { + const dataChannel = new RTCDataChannel(channel, opts, this) + // ensure we can close all channels when shutting down - this.#dataChannels.add(dataChannel); + this.#dataChannels.add(dataChannel) dataChannel.addEventListener('close', () => { - this.#dataChannels.delete(dataChannel); - this.#dataChannelsClosed++; - }); - - return dataChannel; + this.#dataChannels.delete(dataChannel) + }) + + return dataChannel + } + + + createDataChannel (label: string, opts: globalThis.RTCDataChannelInit = {}): RTCDataChannel { + const conf: DataChannelInitConfig = opts + if (opts.ordered === false) conf.unordered = true + const channel = this.#peerConnection.createDataChannel('' + label, conf) + const dataChannel = this.#handleDataChannel(channel, opts) + + if (this.#announceNegotiation == null) { + this.#announceNegotiation = false + this.dispatchEvent(new Event('negotiationneeded')) + } + + return dataChannel } - createOffer(): Promise { + createOffer(): Promise & Promise { + // @ts-expect-error dont support deprecated overload return this.#localOffer; } @@ -371,95 +471,93 @@ export default class RTCPeerConnection extends EventTarget implements globalThis return this.#config; } - getReceivers(): globalThis.RTCRtpReceiver[] { - throw new DOMException('Not implemented'); + getSelectedCandidatePair () { + return this.#peerConnection.getSelectedCandidatePair() } - getSenders(): globalThis.RTCRtpSender[] { - throw new DOMException('Not implemented'); - } + + getStats(): Promise & Promise { + const report = new Map(); + const cp = this.getSelectedCandidatePair(); + const bytesSent = this.#peerConnection.bytesSent(); + const bytesReceived = this.#peerConnection.bytesReceived(); + const rtt = this.#peerConnection.rtt(); - getStats(): Promise { - return new Promise((resolve) => { - const report = new Map(); - const cp = this.#peerConnection?.getSelectedCandidatePair(); - const bytesSent = this.#peerConnection?.bytesSent(); - const bytesReceived = this.#peerConnection?.bytesReceived(); - const rtt = this.#peerConnection?.rtt(); + if(!cp) { + // @ts-expect-error dont support deprecated overload + return Promise.resolve(report as globalThis.RTCStatsReport); + } - if(!cp) { - return resolve(report); - } + const localIdRs = getRandomString(8); + const localId = 'RTCIceCandidate_' + localIdRs; + report.set(localId, { + id: localId, + type: 'local-candidate', + timestamp: Date.now(), + candidateType: cp.local.type, + ip: cp.local.address, + port: cp.local.port, + }); + + const remoteIdRs = getRandomString(8); + const remoteId = 'RTCIceCandidate_' + remoteIdRs; + report.set(remoteId, { + id: remoteId, + type: 'remote-candidate', + timestamp: Date.now(), + candidateType: cp.remote.type, + ip: cp.remote.address, + port: cp.remote.port, + }); + + const candidateId = 'RTCIceCandidatePair_' + localIdRs + '_' + remoteIdRs; + report.set(candidateId, { + id: candidateId, + type: 'candidate-pair', + timestamp: Date.now(), + localCandidateId: localId, + remoteCandidateId: remoteId, + state: 'succeeded', + nominated: true, + writable: true, + bytesSent: bytesSent, + bytesReceived: bytesReceived, + totalRoundTripTime: rtt, + currentRoundTripTime: rtt, + }); + + const transportId = 'RTCTransport_0_1'; + report.set(transportId, { + id: transportId, + timestamp: Date.now(), + type: 'transport', + bytesSent: bytesSent, + bytesReceived: bytesReceived, + dtlsState: 'connected', + selectedCandidatePairId: candidateId, + selectedCandidatePairChanges: 1, + }); - const localIdRs = getRandomString(8); - const localId = 'RTCIceCandidate_' + localIdRs; - report.set(localId, { - id: localId, - type: 'local-candidate', - timestamp: Date.now(), - candidateType: cp.local.type, - ip: cp.local.address, - port: cp.local.port, - }); - - const remoteIdRs = getRandomString(8); - const remoteId = 'RTCIceCandidate_' + remoteIdRs; - report.set(remoteId, { - id: remoteId, - type: 'remote-candidate', - timestamp: Date.now(), - candidateType: cp.remote.type, - ip: cp.remote.address, - port: cp.remote.port, - }); - - const candidateId = 'RTCIceCandidatePair_' + localIdRs + '_' + remoteIdRs; - report.set(candidateId, { - id: candidateId, - type: 'candidate-pair', - timestamp: Date.now(), - localCandidateId: localId, - remoteCandidateId: remoteId, - state: 'succeeded', - nominated: true, - writable: true, - bytesSent: bytesSent, - bytesReceived: bytesReceived, - totalRoundTripTime: rtt, - currentRoundTripTime: rtt, - }); - - const transportId = 'RTCTransport_0_1'; - report.set(transportId, { - id: transportId, - timestamp: Date.now(), - type: 'transport', - bytesSent: bytesSent, - bytesReceived: bytesReceived, - dtlsState: 'connected', - selectedCandidatePairId: candidateId, - selectedCandidatePairChanges: 1, - }); - - // peer-connection' - report.set('P', { - id: 'P', - type: 'peer-connection', - timestamp: Date.now(), - dataChannelsOpened: this.#dataChannels.size, - dataChannelsClosed: this.#dataChannelsClosed, - }); - - return resolve(report); + // peer-connection' + report.set('P', { + id: 'P', + type: 'peer-connection', + timestamp: Date.now(), + dataChannelsOpened: this.#dataChannels.size, + dataChannelsClosed: this.#dataChannelsClosed, }); + + // @ts-expect-error dont support deprecated overload + return Promise.resolve(report as globalThis.RTCStatsReport); } getTransceivers(): globalThis.RTCRtpTransceiver[] { - return []; // throw new DOMException('Not implemented'); + return this.#transceivers; } removeTrack(): void { - throw new DOMException('Not implemented'); + console.warn('track detatching not supported') + // throw new DOMException('Not implemented'); } restartIce(): Promise { @@ -467,12 +565,17 @@ export default class RTCPeerConnection extends EventTarget implements globalThis } async setLocalDescription(description: globalThis.RTCSessionDescriptionInit): Promise { - if (description?.type !== 'offer') { + if (description == null || description.type == null) { + return this.#peerConnection.setLocalDescription() + } + // TODO: error and state checking + + if (description.type !== 'offer') { // any other type causes libdatachannel to throw - return; + return this.#peerConnection.setLocalDescription() } - this.#peerConnection.setLocalDescription(description?.type as any); + this.#peerConnection.setLocalDescription(description?.type); } async setRemoteDescription(description: globalThis.RTCSessionDescriptionInit): Promise { @@ -480,21 +583,20 @@ export default class RTCPeerConnection extends EventTarget implements globalThis throw new DOMException('Remote SDP must be set'); } - this.#peerConnection.setRemoteDescription(description.sdp, description.type as any); + this.#peerConnection.setRemoteDescription(description.sdp, description.type); } } -function createDeferredPromise(): any { - let resolve: any, reject: any; +function createDeferredPromise(): Promise & { resolve: (value: T) => void; reject: (reason?: unknown) => void } { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; - const promise = new Promise(function (_resolve, _reject) { + const promise = new Promise((_resolve, _reject) => { resolve = _resolve; reject = _reject; }); - (promise as any).resolve = resolve; - (promise as any).reject = reject; - return promise; + return Object.assign(promise, { resolve, reject }); } function getRandomString(length): string { diff --git a/src/polyfill/RTCRtp.ts b/src/polyfill/RTCRtp.ts new file mode 100644 index 00000000..cf19274d --- /dev/null +++ b/src/polyfill/RTCRtp.ts @@ -0,0 +1,206 @@ +import { Audio, Direction, Track, Video } from '../lib/index.js' +import RTCDtlsTransport from './RTCDtlsTransport.js' +import RTCPeerConnection from './RTCPeerConnection.js' + +const ndcDirectionMapFrom: Record = { + Inactive: 'inactive', + RecvOnly: 'recvonly', + SendOnly: 'sendonly', + SendRecv: 'sendrecv', + Unknown: 'inactive' +} + +const ndcDirectionMapTo: Record = { + inactive: 'Inactive', + recvonly: 'RecvOnly', + sendonly: 'SendOnly', + sendrecv: 'SendRecv', + stopped: 'Inactive' +} + +export class RTCRtpTransceiver implements globalThis.RTCRtpTransceiver { + #transceiver: Video | Audio | Track + #track: Track + #desiredDirection: globalThis.RTCRtpTransceiverDirection + #sender: RTCRtpSender + #receiver: RTCRtpReceiver + + constructor ({ transceiver, pc }: { pc: RTCPeerConnection, transceiver: Video | Audio | Track }) { + this.#transceiver = transceiver + this.#sender = new RTCRtpSender({ pc }) + this.#receiver = new RTCRtpReceiver({ pc }) + } + + _setNDCTrack (track: Track): void { + if (this.#track) return + this.#track = track + } + + get currentDirection (): RTCRtpTransceiverDirection { + return ndcDirectionMapFrom[this.#transceiver.direction()] + } + + close (): void { + this.#track?.close() + this.#transceiver.close?.() + } + + get track (): Track { + return this.#track + } + + get media (): Video | Audio { + return this.#transceiver + } + + get direction (): RTCRtpTransceiverDirection { + return this.#desiredDirection + } + + set direction (dir: RTCRtpTransceiverDirection) { + this.#desiredDirection = dir + if (!this.#sender) return + (this.#transceiver as Video | Audio).setDirection(ndcDirectionMapTo[dir]) + } + + get mid (): string { + return this.#transceiver.mid() + } + + get sender (): RTCRtpSender { + return this.#sender + } + + get receiver (): RTCRtpReceiver { + return this.#receiver + } + + get stopped (): boolean | undefined { + return this.#track?.isClosed() + } + + setDirection (direction: RTCRtpTransceiverDirection): void { + (this.#transceiver as Video | Audio).setDirection(ndcDirectionMapTo[direction]) + } + + + setCodecPreferences (_codecs): void { + // TODO + // addVideoCodec(payloadType: number, codec: string, profile?: string): void; + // addH264Codec(payloadType: number, profile?: string): void; + // addVP8Codec(payloadType: number): void; + // addVP9Codec(payloadType: number): void; + } + + stop (): void { + this.#track?.close() + } +} + +export class RTCRtpSender implements globalThis.RTCRtpSender { + track + transform // TODO, is it worth tho? + #transport: RTCDtlsTransport + #pc: RTCPeerConnection + constructor ({ pc }: { pc: RTCPeerConnection }) { + this.#transport = new RTCDtlsTransport({ pc }) + this.#pc = pc + } + + get dtmf (): null { + return null + } + + get transport (): RTCDtlsTransport | null { + return this.#transport ?? null + } + + async getStats (): Promise { + return new Map() + } + + getParameters (): RTCRtpSendParameters { + return { encodings: [], codecs: [], transactionId: '', headerExtensions: [], rtcp: { reducedSize: false } } + } + + async setParameters (): Promise { + // TODO + // addVideoCodec(payloadType: number, codec: string, profile?: string): void; + // addH264Codec(payloadType: number, profile?: string): void; + // addVP8Codec(payloadType: number): void; + // addVP9Codec(payloadType: number): void; + // setBitrate + } + + setStreams (streams): void { + if (this.#pc.connectionState !== 'connected') throw new DOMException('Sender\'s connection is closed', 'InvalidStateError') + if (!this.track) return + for (const stream of streams) { + stream.addTrack(this.track) + } + } + + async replaceTrack (): Promise { + throw new TypeError('Method unsupported') + } +} + +/** + * @class + * @implements {globalThis.RTCRtpReceiver} + */ +export class RTCRtpReceiver { + transform // TODO, is it worth tho? + #transport: RTCDtlsTransport + track + #jitterBufferTarget = 0 + + constructor ({ pc }) { + this.#transport = new RTCDtlsTransport({ pc }) + } + + get transport (): RTCDtlsTransport | null { + return this.#transport ?? null + } + + get jitterBufferTarget (): number { + return this.#jitterBufferTarget + } + + static getCapabilities (kind): globalThis.RTCRtpCapabilities { + if (!kind) throw new TypeError("Failed to execute 'getCapabilities' on 'RTCRtpSender': 1 argument required, but only 0 present.") + if (kind === 'video') { + return { + headerExtensions: [], + codecs: [ + { mimeType: 'video/H264', clockRate: -1 }, + { mimeType: 'video/VP8', clockRate: -1}, + { mimeType: 'video/VP9', clockRate: -1 } + ] + } + } else { + return { + headerExtensions: [], + codecs: [ + { mimeType: 'video/opus', clockRate: -1 } + ] + } + } + } + + async getStats (): Promise { + return new Map() + } + + getParameters (): RTCRtpSendParameters { + return { encodings: [], codecs: [], transactionId: '', headerExtensions: [], rtcp: { reducedSize: false } } + } + + getContributingSources (): [] { + return [] + } + + getSynchronizationSources (): [] { + return [] + } +} diff --git a/src/polyfill/RTCSctpTransport.ts b/src/polyfill/RTCSctpTransport.ts index ade26065..a31d8ff5 100644 --- a/src/polyfill/RTCSctpTransport.ts +++ b/src/polyfill/RTCSctpTransport.ts @@ -1,40 +1,33 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import RTCDtlsTransport from './RTCDtlsTransport'; import RTCPeerConnection from './RTCPeerConnection'; export default class RTCSctpTransport extends EventTarget implements globalThis.RTCSctpTransport { #pc: RTCPeerConnection = null; - #extraFunctions = null; #transport: RTCDtlsTransport = null; - onstatechange: ((this: RTCSctpTransport, ev: Event) => any) | null = null; + onstatechange: globalThis.RTCSctpTransport['onstatechange']; - constructor(initial: { pc: RTCPeerConnection, extraFunctions }) { + constructor({ pc }: { pc: RTCPeerConnection }) { super(); - this.#pc = initial.pc; - this.#extraFunctions = initial.extraFunctions; + this.#pc = pc; - this.#transport = new RTCDtlsTransport({ pc: initial.pc, extraFunctions: initial.extraFunctions }); + this.#transport = new RTCDtlsTransport({ pc }); - // forward peerConnection events - this.#pc.addEventListener('connectionstatechange', () => { - this.dispatchEvent(new Event('statechange')); - }); - - // forward events to properties - this.addEventListener('statechange', (e) => { - if (this.onstatechange) this.onstatechange(e); - }); + pc.addEventListener('connectionstatechange', () => { + const e = new Event('statechange') + this.dispatchEvent(e) + this.onstatechange?.(e) + }) } get maxChannels(): number | null { if (this.state !== 'connected') return null; - return this.#pc ? this.#extraFunctions.maxDataChannelId() : 0; + return this.#pc.maxChannels; } get maxMessageSize(): number { if (this.state !== 'connected') return null; - return this.#pc ? this.#extraFunctions.maxMessageSize() : 0; + return this.#pc.maxMessageSize ?? 65536; } get state(): globalThis.RTCSctpTransportState { diff --git a/src/polyfill/RTCSessionDescription.ts b/src/polyfill/RTCSessionDescription.ts index ef0fcb49..e96ef2a9 100644 --- a/src/polyfill/RTCSessionDescription.ts +++ b/src/polyfill/RTCSessionDescription.ts @@ -6,19 +6,26 @@ // sdp: 'v=0\r\no=- 1234567890 1234567890 IN IP4 192.168.1.1\r\ns=-\r\nt=0 0\r\na=ice-ufrag:abcd\r\na=ice-pwd:efgh\r\n' // }; -export default class RTCSessionDescription implements globalThis.RTCSessionDescriptionInit { +export default class RTCSessionDescription implements globalThis.RTCSessionDescription { #type: globalThis.RTCSdpType; #sdp: string; - constructor(init: globalThis.RTCSessionDescriptionInit) { - this.#type = init ? init.type : null; - this.#sdp = init ? init.sdp : null; + constructor(init: globalThis.RTCSessionDescriptionInit | null | undefined) { + this.#type = init?.type; + this.#sdp = init?.sdp ?? ''; } get type(): globalThis.RTCSdpType { return this.#type; } + set type (type) { + if (type !== 'offer' && type !== 'answer' && type !== 'pranswer' && type !== 'rollback') { + throw new TypeError(`Failed to set the 'type' property on 'RTCSessionDescription': The provided value '${type}' is not a valid enum value of type RTCSdpType.`) + } + this.#type = type + } + get sdp(): string { return this.#sdp; } diff --git a/src/polyfill/index.ts b/src/polyfill/index.ts index e8eb53e6..3fecfcaf 100644 --- a/src/polyfill/index.ts +++ b/src/polyfill/index.ts @@ -1,38 +1,14 @@ -import RTCCertificate from './RTCCertificate'; -import RTCDataChannel from './RTCDataChannel'; -import RTCDtlsTransport from './RTCDtlsTransport'; -import RTCIceCandidate from './RTCIceCandidate'; -import RTCIceTransport from './RTCIceTransport'; -import RTCPeerConnection from './RTCPeerConnection'; -import RTCSctpTransport from './RTCSctpTransport'; -import RTCSessionDescription from './RTCSessionDescription'; -import { RTCDataChannelEvent, RTCPeerConnectionIceEvent } from './Events'; -import RTCError from './RTCError'; +export { default as RTCPeerConnection } from './RTCPeerConnection.ts' +export { default as RTCSessionDescription } from './RTCSessionDescription.ts' +export { default as RTCIceCandidate } from './RTCIceCandidate.ts' +export { default as RTCIceTransport } from './RTCIceTransport.ts' +export { default as RTCDataChannel } from './RTCDataChannel.ts' +export { default as RTCSctpTransport } from './RTCSctpTransport.ts' +export { default as RTCDtlsTransport } from './RTCDtlsTransport.ts' +export { default as RTCCertificate } from './RTCCertificate.ts' +export * from './MediaStream.ts' +export * from './Events.ts' +export * from './RTCError.ts' +export * from './RTCRtp.ts' -export { - RTCCertificate, - RTCDataChannel, - RTCDtlsTransport, - RTCIceCandidate, - RTCIceTransport, - RTCPeerConnection, - RTCSctpTransport, - RTCSessionDescription, - RTCDataChannelEvent, - RTCPeerConnectionIceEvent, - RTCError, -}; - -export default { - RTCCertificate, - RTCDataChannel, - RTCDtlsTransport, - RTCIceCandidate, - RTCIceTransport, - RTCPeerConnection, - RTCSctpTransport, - RTCSessionDescription, - RTCDataChannelEvent, - RTCPeerConnectionIceEvent, - RTCError, -}; +export * as default from './index.ts' \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 96d23793..003c20b1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,15 +7,22 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "noImplicitAny": false, - "noImplicitThis": false, "noImplicitReturns": true, - "strictNullChecks": false, "noUnusedLocals": true, "alwaysStrict": true, + "allowImportingTsExtensions": true, + "noEmit": true, "outDir": "./dist", "module": "CommonJS" }, - "include": ["src/**/*", "test/**/*"], - "exclude": ["node_modules", "dist", "src/cpp", "test/wpt-tests/wpt"] -} + "include": [ + "src/**/*", + "test/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "src/cpp", + "test/wpt-tests/wpt" + ] +} \ No newline at end of file From c478a529d458988e895a5c7cc26dff902f7e0623 Mon Sep 17 00:00:00 2001 From: ThaUnknown <6506529+ThaUnknown@users.noreply.github.com> Date: Mon, 20 Jan 2025 16:44:45 +0100 Subject: [PATCH 2/4] fix: lint, update lockfile --- package-lock.json | 8 ++++++++ src/polyfill/MediaStream.ts | 2 +- src/polyfill/RTCPeerConnection.ts | 2 +- src/polyfill/RTCRtp.ts | 1 + 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index e51b2fea..72ae2321 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@rollup/plugin-replace": "^6.0.1", "@types/jest": "^29.5.12", "@types/node": "^20.6.1", + "@types/webrtc": "^0.0.44", "@typescript-eslint/eslint-plugin": "^7.17.0", "@typescript-eslint/parser": "^7.17.0", "cmake-js": "^7.3.0", @@ -1716,6 +1717,13 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "node_modules/@types/webrtc": { + "version": "0.0.44", + "resolved": "https://registry.npmjs.org/@types/webrtc/-/webrtc-0.0.44.tgz", + "integrity": "sha512-4BJZdzrApNFeuXgucyqs24k69f7oti3wUcGEbFbaV08QBh7yEe3tnRRuYXlyXJNXiumpZujiZqUZZ2/gMSeO0g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.24", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", diff --git a/src/polyfill/MediaStream.ts b/src/polyfill/MediaStream.ts index 5e798428..73dcd80d 100644 --- a/src/polyfill/MediaStream.ts +++ b/src/polyfill/MediaStream.ts @@ -137,7 +137,7 @@ export class MediaStream extends EventTarget { return this.#id } - addTrack (track) { + addTrack (track): void { this.#tracks.add(track) this.dispatchEvent(new MediaStreamTrackEvent('addtrack', { track })) } diff --git a/src/polyfill/RTCPeerConnection.ts b/src/polyfill/RTCPeerConnection.ts index dec5281c..ac3b659a 100644 --- a/src/polyfill/RTCPeerConnection.ts +++ b/src/polyfill/RTCPeerConnection.ts @@ -375,7 +375,7 @@ export default class RTCPeerConnection extends EventTarget implements globalThis } - addTransceiver (trackOrKind: MediaStreamTrack | string, { direction = 'inactive', sendEncodings = undefined, streams = undefined }: RTCRtpTransceiverInit = {}): RTCRtpTransceiver { + addTransceiver (trackOrKind: MediaStreamTrack | string, { direction = 'inactive' }: RTCRtpTransceiverInit = {}): RTCRtpTransceiver { if (direction === 'sendrecv') throw new TypeError('unsupported') const track = trackOrKind instanceof MediaStreamTrack && trackOrKind const kind = (track && track.kind) || trackOrKind diff --git a/src/polyfill/RTCRtp.ts b/src/polyfill/RTCRtp.ts index cf19274d..063908c2 100644 --- a/src/polyfill/RTCRtp.ts +++ b/src/polyfill/RTCRtp.ts @@ -84,6 +84,7 @@ export class RTCRtpTransceiver implements globalThis.RTCRtpTransceiver { } + // eslint-disable-next-line @typescript-eslint/no-unused-vars setCodecPreferences (_codecs): void { // TODO // addVideoCodec(payloadType: number, codec: string, profile?: string): void; From 03ac9d303fa2cb558f6262b8b519701fae93113c Mon Sep 17 00:00:00 2001 From: ThaUnknown <6506529+ThaUnknown@users.noreply.github.com> Date: Sat, 25 Jan 2025 16:25:07 +0100 Subject: [PATCH 3/4] fix: force assert types [probably a bad idea] --- src/polyfill/RTCRtp.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/polyfill/RTCRtp.ts b/src/polyfill/RTCRtp.ts index 063908c2..1bfe1670 100644 --- a/src/polyfill/RTCRtp.ts +++ b/src/polyfill/RTCRtp.ts @@ -42,7 +42,7 @@ export class RTCRtpTransceiver implements globalThis.RTCRtpTransceiver { close (): void { this.#track?.close() - this.#transceiver.close?.() + (this.#transceiver as Video | Audio).close?.() } get track (): Track { @@ -50,7 +50,7 @@ export class RTCRtpTransceiver implements globalThis.RTCRtpTransceiver { } get media (): Video | Audio { - return this.#transceiver + return this.#transceiver as Video | Audio } get direction (): RTCRtpTransceiverDirection { From 8b46beffe8b4ad2f701399fba8d7e4e63c36aaa6 Mon Sep 17 00:00:00 2001 From: ThaUnknown <6506529+ThaUnknown@users.noreply.github.com> Date: Mon, 27 Jan 2025 15:45:46 +0100 Subject: [PATCH 4/4] fix: nuke mediastream impl --- src/polyfill/MediaStream.ts | 177 ------------------------- src/polyfill/RTCPeerConnection.ts | 117 ++--------------- src/polyfill/RTCRtp.ts | 207 ------------------------------ src/polyfill/index.ts | 2 - 4 files changed, 12 insertions(+), 491 deletions(-) delete mode 100644 src/polyfill/MediaStream.ts delete mode 100644 src/polyfill/RTCRtp.ts diff --git a/src/polyfill/MediaStream.ts b/src/polyfill/MediaStream.ts deleted file mode 100644 index 73dcd80d..00000000 --- a/src/polyfill/MediaStream.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { Readable } from 'node:stream' -import { MediaStreamTrackEvent } from './Events.js' -import { Track } from '../lib/index.js' - -export class MediaStreamTrack extends EventTarget implements globalThis.MediaStreamTrack { - media - track: Track - stream = new Readable({ read: () => {} }) - #kind: string - #label: string - #id = crypto.randomUUID() - contentHint = '' - - onmute - onunmute - onended - - constructor ({ kind, label }: { kind: string, label: string }) { - super() - if (!kind) throw new TypeError("Failed to construct 'MediaStreamTrack': Failed to read the 'kind' property from 'MediaStreamTrackInit': Required member is undefined.") - this.#kind = kind - this.#label = label - - this.addEventListener('ended', e => { - this.onended?.(e) - this.track?.close() - this.stream.destroy() - }) - this.stream.on('close', () => { - this.stop() - }) - } - - async applyConstraints (): Promise { - console.warn('Constraints unsupported, ignored') - } - - stop (): void { - this.track?.close() - this.stream.destroy() - this.dispatchEvent(new Event('ended')) - } - - getSettings (): globalThis.MediaTrackSettings { - console.warn('Settings upsupported, ignored') - return {} - } - - getConstraints (): globalThis.MediaTrackConstraints { - console.warn('Constraints unsupported, ignored') - return {} - } - - getCapabilities (): globalThis.MediaTrackCapabilities { - console.warn('Capabilities unsupported, ignored') - return {} - } - - clone (): this { - console.warn('Track clonning is unsupported, returned this instance') - return this - } - - get kind (): string { - return this.#kind - } - - get enabled (): boolean | null { - return this.track?.isOpen() - } - - set enabled (_) { - console.warn('Track enabling and disabling is unsupported, ignored') - } - - get muted (): boolean { - return false - } - - get id (): string { - return this.#id - } - - get label (): string { - return this.#label - } - - get readyState (): 'ended' | 'live' { - return this.track?.isClosed() ? 'ended' : 'live' - } -} - -/** - * @class - * @implements {globalThis.MediaStream} - */ -export class MediaStream extends EventTarget { - #active = true - #id = crypto.randomUUID() - #tracks = new Set() - onaddtrack - onremovetrack - onactive - oninactive - - constructor (streamOrTracks) { - super() - if (streamOrTracks instanceof MediaStream) { - for (const track of streamOrTracks.getTracks()) { - this.addTrack(track) - } - } else if (Array.isArray(streamOrTracks)) { - for (const track of streamOrTracks) { - this.addTrack(track) - } - } - this.addEventListener('active', e => { - this.onactive?.(e) - }) - this.addEventListener('inactive', e => { - this.oninactive?.(e) - }) - this.addEventListener('removetrack', e => { - this.onremovetrack?.(e) - }) - this.addEventListener('addtrack', e => { - this.onaddtrack?.(e) - }) - this.dispatchEvent(new Event('active')) - } - - get active (): boolean { - return this.#active - } - - get id (): string { - return this.#id - } - - addTrack (track): void { - this.#tracks.add(track) - this.dispatchEvent(new MediaStreamTrackEvent('addtrack', { track })) - } - - getTracks (): MediaStreamTrack[] { - return [...this.#tracks] - } - - getVideoTracks (): MediaStreamTrack[] { - return [...this.#tracks].filter(({ kind }) => kind === 'video') - } - - getAudioTracks (): MediaStreamTrack[] { - return [...this.#tracks].filter(({ kind }) => kind === 'audio') - } - - getTrackById (id): MediaStreamTrack { - return [...this.#tracks].find(track => track.id === id) ?? null - } - - removeTrack (track): void { - this.#tracks.delete(track) - this.dispatchEvent(new MediaStreamTrackEvent('removetrack', { track })) - } - - clone (): MediaStream { - return new MediaStream([...this.getTracks()]) - } - - stop ():void { - for (const track of this.getTracks()) { - track.stop() - } - this.#active = false - this.dispatchEvent(new Event('inactive')) - } -} diff --git a/src/polyfill/RTCPeerConnection.ts b/src/polyfill/RTCPeerConnection.ts index ac3b659a..463bd69c 100644 --- a/src/polyfill/RTCPeerConnection.ts +++ b/src/polyfill/RTCPeerConnection.ts @@ -1,4 +1,4 @@ -import { Audio, DataChannel, DataChannelInitConfig, Direction, PeerConnection, RtcpReceivingSession, Track, Video } from '../lib/index'; +import { DataChannel, DataChannelInitConfig, PeerConnection } from '../lib/index'; import RTCSessionDescription from './RTCSessionDescription'; import RTCDataChannel from './RTCDataChannel'; import RTCIceCandidate from './RTCIceCandidate'; @@ -6,17 +6,6 @@ import { RTCDataChannelEvent, RTCPeerConnectionIceEvent } from './Events'; import RTCSctpTransport from './RTCSctpTransport'; import * as exceptions from './Exception'; import RTCCertificate from './RTCCertificate'; -import { RTCRtpSender, RTCRtpTransceiver } from './RTCRtp'; -import { MediaStreamTrack } from './MediaStream'; - -const ndcDirectionMap: Record = { - inactive: 'Inactive', - recvonly: 'RecvOnly', - sendonly: 'SendOnly', - sendrecv: 'SendRecv', - stopped: 'Inactive', - undefined: 'Unknown' -} // extend RTCConfiguration with peerIdentity interface RTCConfiguration extends globalThis.RTCConfiguration { @@ -32,9 +21,6 @@ export default class RTCPeerConnection extends EventTarget implements globalThis #localOffer: ReturnType; #localAnswer: ReturnType; #dataChannels = new Set(); - #tracks = new Set() - #transceivers: RTCRtpTransceiver[] = [] - #unusedTransceivers: RTCRtpTransceiver[] = [] #dataChannelsClosed = 0; #config: RTCConfiguration; #canTrickleIceCandidates: boolean | null = null; @@ -161,22 +147,6 @@ export default class RTCPeerConnection extends EventTarget implements globalThis } }); - this.#peerConnection.onTrack(track => { - const transceiver = new RTCRtpTransceiver({ transceiver: track, pc: this }) - this.#tracks.add(track) - transceiver._setNDCTrack(track) - this.#transceivers.push(transceiver) - const mediastream = new MediaStreamTrack({ kind: track.type(), label: track.mid() }) - mediastream.track = track - track.onClosed(() => { - this.#tracks.delete(track) - mediastream.dispatchEvent(new Event('ended')) - }) - track.onMessage(buf => mediastream.stream.push(buf)) - transceiver.receiver.track = mediastream - this.dispatchEvent(new RTCTrackEvent('track', { track: mediastream, receiver: transceiver.receiver, transceiver })) - }) - this.#peerConnection.onLocalCandidate((candidate, sdpMid) => { if (sdpMid === 'unspec') { this.#localAnswer.reject(new Error(`Invalid description type ${sdpMid}`)); @@ -325,84 +295,28 @@ export default class RTCPeerConnection extends EventTarget implements globalThis throw new DOMException(error.message, 'UnknownError'); } } - - #findUnusedTransceiver (kind): RTCRtpTransceiver | null { - const unused = this.#unusedTransceivers.find(tr => tr.track.type() === kind && tr.direction === 'sendonly') - if (!unused) return null - this.#unusedTransceivers.splice(this.#unusedTransceivers.indexOf(unused), 1) - return unused - } - #setUpTrack (media: Video | Audio, track: MediaStreamTrack, transceiver: RTCRtpTransceiver, direction): void { - const session = new RtcpReceivingSession() - const pctrack = this.#peerConnection.addTrack(media) - this.#tracks.add(pctrack) - pctrack.onClosed(() => { - this.#tracks.delete(pctrack) - track.dispatchEvent(new Event('ended')) - }) - pctrack.setMediaHandler(session) - track.media = media - track.track = pctrack - transceiver._setNDCTrack(pctrack) - track.stream.on('data', buf => { - pctrack.sendMessageBinary(buf) - }) - if (direction === 'recvonly') { - transceiver.receiver.track = track - } else if (direction === 'sendonly') { - transceiver.sender.track = track - } - if (this.#announceNegotiation) { - this.#announceNegotiation = false - this.dispatchEvent(new Event('negotiationneeded')) - } - } - - addTrack (track, ...streams): RTCRtpSender { - for (const stream of streams) stream.addTrack(track) - - const kind = track.kind - - const unused = this.#findUnusedTransceiver(kind) - if (unused) { - this.#setUpTrack(unused.media, track, unused, 'sendonly') - return unused.sender - } else { - const transceiver = this.addTransceiver(track, { direction: 'sendonly' }) - return transceiver.sender - } + addTrack (): globalThis.RTCRtpSender { + return {} as globalThis.RTCRtpSender } - addTransceiver (trackOrKind: MediaStreamTrack | string, { direction = 'inactive' }: RTCRtpTransceiverInit = {}): RTCRtpTransceiver { - if (direction === 'sendrecv') throw new TypeError('unsupported') - const track = trackOrKind instanceof MediaStreamTrack && trackOrKind - const kind = (track && track.kind) || trackOrKind - const ndcMedia = kind === 'video' ? new Video('video', ndcDirectionMap[direction]) : new Audio('audio', ndcDirectionMap[direction]) - - const transceiver = new RTCRtpTransceiver({ transceiver: ndcMedia, pc: this }) - this.#transceivers.push(transceiver) - if (track) { - this.#setUpTrack(ndcMedia, track, transceiver, direction) - } else { - this.#unusedTransceivers.push(transceiver) - } - return transceiver + addTransceiver (): globalThis.RTCRtpTransceiver { + return {} as globalThis.RTCRtpTransceiver } - getReceivers (): RTCRtpReceiver[] { + getReceivers (): globalThis.RTCRtpReceiver[] { // receivers are created on ontrack - return this.#transceivers.map(tr => tr.direction === 'recvonly' && tr.receiver).filter(re => re) + return [] } - getSenders (): RTCRtpSender[] { + getSenders (): globalThis.RTCRtpSender[] { // senders are created on addTrack or addTransceiver - return this.#transceivers.map(tr => tr.direction === 'sendonly' && tr.sender).filter(se => se) + return [] } - getTracks (): Track[] { - return [...this.#tracks] + getTracks (): globalThis.MediaStreamTrack[] { + return [] } close(): void { @@ -412,13 +326,6 @@ export default class RTCPeerConnection extends EventTarget implements globalThis this.#dataChannelsClosed++; }); - for (const transceiver of this.#transceivers) { - transceiver.close() - } - for (const track of this.#tracks) { - track.close() - } - this.#peerConnection.close(); } @@ -552,7 +459,7 @@ export default class RTCPeerConnection extends EventTarget implements globalThis } getTransceivers(): globalThis.RTCRtpTransceiver[] { - return this.#transceivers; + return []; } removeTrack(): void { diff --git a/src/polyfill/RTCRtp.ts b/src/polyfill/RTCRtp.ts deleted file mode 100644 index 1bfe1670..00000000 --- a/src/polyfill/RTCRtp.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { Audio, Direction, Track, Video } from '../lib/index.js' -import RTCDtlsTransport from './RTCDtlsTransport.js' -import RTCPeerConnection from './RTCPeerConnection.js' - -const ndcDirectionMapFrom: Record = { - Inactive: 'inactive', - RecvOnly: 'recvonly', - SendOnly: 'sendonly', - SendRecv: 'sendrecv', - Unknown: 'inactive' -} - -const ndcDirectionMapTo: Record = { - inactive: 'Inactive', - recvonly: 'RecvOnly', - sendonly: 'SendOnly', - sendrecv: 'SendRecv', - stopped: 'Inactive' -} - -export class RTCRtpTransceiver implements globalThis.RTCRtpTransceiver { - #transceiver: Video | Audio | Track - #track: Track - #desiredDirection: globalThis.RTCRtpTransceiverDirection - #sender: RTCRtpSender - #receiver: RTCRtpReceiver - - constructor ({ transceiver, pc }: { pc: RTCPeerConnection, transceiver: Video | Audio | Track }) { - this.#transceiver = transceiver - this.#sender = new RTCRtpSender({ pc }) - this.#receiver = new RTCRtpReceiver({ pc }) - } - - _setNDCTrack (track: Track): void { - if (this.#track) return - this.#track = track - } - - get currentDirection (): RTCRtpTransceiverDirection { - return ndcDirectionMapFrom[this.#transceiver.direction()] - } - - close (): void { - this.#track?.close() - (this.#transceiver as Video | Audio).close?.() - } - - get track (): Track { - return this.#track - } - - get media (): Video | Audio { - return this.#transceiver as Video | Audio - } - - get direction (): RTCRtpTransceiverDirection { - return this.#desiredDirection - } - - set direction (dir: RTCRtpTransceiverDirection) { - this.#desiredDirection = dir - if (!this.#sender) return - (this.#transceiver as Video | Audio).setDirection(ndcDirectionMapTo[dir]) - } - - get mid (): string { - return this.#transceiver.mid() - } - - get sender (): RTCRtpSender { - return this.#sender - } - - get receiver (): RTCRtpReceiver { - return this.#receiver - } - - get stopped (): boolean | undefined { - return this.#track?.isClosed() - } - - setDirection (direction: RTCRtpTransceiverDirection): void { - (this.#transceiver as Video | Audio).setDirection(ndcDirectionMapTo[direction]) - } - - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - setCodecPreferences (_codecs): void { - // TODO - // addVideoCodec(payloadType: number, codec: string, profile?: string): void; - // addH264Codec(payloadType: number, profile?: string): void; - // addVP8Codec(payloadType: number): void; - // addVP9Codec(payloadType: number): void; - } - - stop (): void { - this.#track?.close() - } -} - -export class RTCRtpSender implements globalThis.RTCRtpSender { - track - transform // TODO, is it worth tho? - #transport: RTCDtlsTransport - #pc: RTCPeerConnection - constructor ({ pc }: { pc: RTCPeerConnection }) { - this.#transport = new RTCDtlsTransport({ pc }) - this.#pc = pc - } - - get dtmf (): null { - return null - } - - get transport (): RTCDtlsTransport | null { - return this.#transport ?? null - } - - async getStats (): Promise { - return new Map() - } - - getParameters (): RTCRtpSendParameters { - return { encodings: [], codecs: [], transactionId: '', headerExtensions: [], rtcp: { reducedSize: false } } - } - - async setParameters (): Promise { - // TODO - // addVideoCodec(payloadType: number, codec: string, profile?: string): void; - // addH264Codec(payloadType: number, profile?: string): void; - // addVP8Codec(payloadType: number): void; - // addVP9Codec(payloadType: number): void; - // setBitrate - } - - setStreams (streams): void { - if (this.#pc.connectionState !== 'connected') throw new DOMException('Sender\'s connection is closed', 'InvalidStateError') - if (!this.track) return - for (const stream of streams) { - stream.addTrack(this.track) - } - } - - async replaceTrack (): Promise { - throw new TypeError('Method unsupported') - } -} - -/** - * @class - * @implements {globalThis.RTCRtpReceiver} - */ -export class RTCRtpReceiver { - transform // TODO, is it worth tho? - #transport: RTCDtlsTransport - track - #jitterBufferTarget = 0 - - constructor ({ pc }) { - this.#transport = new RTCDtlsTransport({ pc }) - } - - get transport (): RTCDtlsTransport | null { - return this.#transport ?? null - } - - get jitterBufferTarget (): number { - return this.#jitterBufferTarget - } - - static getCapabilities (kind): globalThis.RTCRtpCapabilities { - if (!kind) throw new TypeError("Failed to execute 'getCapabilities' on 'RTCRtpSender': 1 argument required, but only 0 present.") - if (kind === 'video') { - return { - headerExtensions: [], - codecs: [ - { mimeType: 'video/H264', clockRate: -1 }, - { mimeType: 'video/VP8', clockRate: -1}, - { mimeType: 'video/VP9', clockRate: -1 } - ] - } - } else { - return { - headerExtensions: [], - codecs: [ - { mimeType: 'video/opus', clockRate: -1 } - ] - } - } - } - - async getStats (): Promise { - return new Map() - } - - getParameters (): RTCRtpSendParameters { - return { encodings: [], codecs: [], transactionId: '', headerExtensions: [], rtcp: { reducedSize: false } } - } - - getContributingSources (): [] { - return [] - } - - getSynchronizationSources (): [] { - return [] - } -} diff --git a/src/polyfill/index.ts b/src/polyfill/index.ts index 3fecfcaf..24e65e61 100644 --- a/src/polyfill/index.ts +++ b/src/polyfill/index.ts @@ -6,9 +6,7 @@ export { default as RTCDataChannel } from './RTCDataChannel.ts' export { default as RTCSctpTransport } from './RTCSctpTransport.ts' export { default as RTCDtlsTransport } from './RTCDtlsTransport.ts' export { default as RTCCertificate } from './RTCCertificate.ts' -export * from './MediaStream.ts' export * from './Events.ts' export * from './RTCError.ts' -export * from './RTCRtp.ts' export * as default from './index.ts' \ No newline at end of file