diff --git a/package-lock.json b/package-lock.json index ed6bb3a..0eb2d59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "node-datachannel", - "version": "0.25.0", + "version": "0.26.0", "hasInstallScript": true, "license": "MPL 2.0", "dependencies": { @@ -17,9 +17,11 @@ "@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", + "cross-env": "^7.0.3", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-jest": "^28.6.0", @@ -1716,6 +1718,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", @@ -3121,6 +3130,25 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", diff --git a/package.json b/package.json index e2a5c57..9a17e0c 100644 --- a/package.json +++ b/package.json @@ -1,117 +1,119 @@ { - "name": "node-datachannel", - "version": "0.26.0", - "description": "WebRTC For Node.js and Electron. libdatachannel node bindings.", - "main": "./dist/cjs/lib/index.cjs", - "module": "./dist/esm/lib/index.mjs", - "types": "./dist/types/lib/index.d.ts", - "typesVersions": { - "*": { - "*": [ - "dist/types/lib/index.d.ts" - ], - "polyfill": [ - "dist/types/polyfill/index.d.ts" - ] - } - }, - "exports": { - ".": { - "types": "./dist/types/lib/index.d.ts", - "require": "./dist/cjs/lib/index.cjs", - "import": "./dist/esm/lib/index.mjs", - "default": "./dist/lib/esm/index.mjs" + "name": "node-datachannel", + "version": "0.26.0", + "description": "WebRTC For Node.js and Electron. libdatachannel node bindings.", + "main": "./dist/cjs/lib/index.cjs", + "module": "./dist/esm/lib/index.mjs", + "types": "./dist/types/lib/index.d.ts", + "typesVersions": { + "*": { + "*": [ + "dist/types/lib/index.d.ts" + ], + "polyfill": [ + "dist/types/polyfill/index.d.ts" + ] + } }, - "./polyfill": { - "types": "./dist/types/polyfill/index.d.ts", - "require": "./dist/cjs/polyfill/index.cjs", - "import": "./dist/esm/polyfill/index.mjs", - "default": "./dist/polyfill/esm/index.mjs" - } - }, - "engines": { - "node": ">=18.20.0" - }, - "scripts": { - "install": "prebuild-install -r napi || (npm install --ignore-scripts --production=false && npm run _prebuild)", - "install:nice": "npm run clean && npm install --ignore-scripts --production=false && cmake-js configure --CDUSE_NICE=1 && cmake-js build", - "install:gnu": "npm run clean && npm install --ignore-scripts --production=false && cmake-js configure --CDUSE_GNUTLS=1 && cmake-js build", - "build": "npm run compile && npm run build:tsc", - "compile": "cmake-js build", - "compile:debug": "cmake-js build -D", - "build:tsc": "rimraf dist && rollup -c", - "build:tsc:watch": "rollup -c -w", - "clean": "rimraf dist build", - "lint": "eslint . --ext .ts --ext .mts", - "test": "NODE_OPTIONS=--experimental-vm-modules jest", - "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch", - "test:wpt": "npm run run:wpt:server & (sleep 8 && (npm run run:wpt:test | tee test/wpt-tests/last-test-results.md) )", - "wpt:server": "cd test/wpt-tests/wpt && ./wpt serve", - "wpt:test": "ts-node test/wpt-tests/index.ts", - "_prebuild": "prebuild -r napi --backend cmake-js", - "prepack": "npm run build:tsc" - }, - "binary": { - "napi_versions": [ - 8 - ] - }, - "repository": { - "type": "git", - "url": "git+https://github.com/murat-dogan/node-datachannel.git" - }, - "keywords": [ - "libdatachannel", - "webrtc", - "p2p", - "peer-to-peer", - "datachannel", - "data channel", - "websocket" - ], - "contributors": [ - { - "name": "Murat Doğan", - "url": "https://github.com/murat-dogan" + "exports": { + ".": { + "types": "./dist/types/lib/index.d.ts", + "require": "./dist/cjs/lib/index.cjs", + "import": "./dist/esm/lib/index.mjs", + "default": "./dist/lib/esm/index.mjs" + }, + "./polyfill": { + "types": "./dist/types/polyfill/index.d.ts", + "require": "./dist/cjs/polyfill/index.cjs", + "import": "./dist/esm/polyfill/index.mjs", + "default": "./dist/polyfill/esm/index.mjs" + } + }, + "engines": { + "node": ">=18.20.0" + }, + "scripts": { + "install": "prebuild-install -r napi || (npm install --ignore-scripts --production=false && npm run _prebuild)", + "install:nice": "npm run clean && npm install --ignore-scripts --production=false && cmake-js configure --CDUSE_NICE=1 && cmake-js build", + "install:gnu": "npm run clean && npm install --ignore-scripts --production=false && cmake-js configure --CDUSE_GNUTLS=1 && cmake-js build", + "build": "npm run compile && npm run build:tsc", + "compile": "cmake-js build", + "compile:debug": "cmake-js build -D", + "build:tsc": "rimraf dist && rollup -c", + "build:tsc:watch": "rollup -c -w", + "clean": "rimraf dist build", + "lint": "eslint . --ext .ts --ext .mts", + "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest", + "test:watch": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --watch", + "test:wpt": "npm run run:wpt:server & (sleep 8 && (npm run run:wpt:test | tee test/wpt-tests/last-test-results.md) )", + "wpt:server": "cd test/wpt-tests/wpt && ./wpt serve", + "wpt:test": "ts-node test/wpt-tests/index.ts", + "_prebuild": "prebuild -r napi --backend cmake-js", + "prepack": "npm run build:tsc" + }, + "binary": { + "napi_versions": [ + 8 + ] + }, + "repository": { + "type": "git", + "url": "git+https://github.com/murat-dogan/node-datachannel.git" + }, + "keywords": [ + "libdatachannel", + "webrtc", + "p2p", + "peer-to-peer", + "datachannel", + "data channel", + "websocket" + ], + "contributors": [ + { + "name": "Murat Doğan", + "url": "https://github.com/murat-dogan" + }, + { + "name": "Paul-Louis Ageneau", + "url": "https://github.com/paullouisageneau" + } + ], + "license": "MPL 2.0", + "bugs": { + "url": "https://github.com/murat-dogan/node-datachannel/issues" + }, + "homepage": "https://github.com/murat-dogan/node-datachannel#readme", + "devDependencies": { + "@rollup/plugin-esm-shim": "^0.1.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", + "cross-env": "^7.0.3", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-jest": "^28.6.0", + "eslint-plugin-prettier": "^5.2.1", + "jest": "^29.7.0", + "jsdom": "^24.1.1", + "node-addon-api": "^7.0.0", + "prebuild": "^13.0.1", + "prettier": "^3.3.3", + "puppeteer": "^22.14.0", + "rimraf": "^5.0.9", + "rollup": "^4.22.5", + "rollup-plugin-dts": "^6.1.1", + "rollup-plugin-esbuild": "^6.1.1", + "ts-api-utils": "^1.3.0", + "ts-jest": "^29.2.3", + "ts-node": "^10.9.2", + "typescript": "5.4" }, - { - "name": "Paul-Louis Ageneau", - "url": "https://github.com/paullouisageneau" + "dependencies": { + "prebuild-install": "^7.1.3" } - ], - "license": "MPL 2.0", - "bugs": { - "url": "https://github.com/murat-dogan/node-datachannel/issues" - }, - "homepage": "https://github.com/murat-dogan/node-datachannel#readme", - "devDependencies": { - "@rollup/plugin-esm-shim": "^0.1.7", - "@rollup/plugin-replace": "^6.0.1", - "@types/jest": "^29.5.12", - "@types/node": "^20.6.1", - "@typescript-eslint/eslint-plugin": "^7.17.0", - "@typescript-eslint/parser": "^7.17.0", - "cmake-js": "^7.3.0", - "eslint": "^8.57.0", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-jest": "^28.6.0", - "eslint-plugin-prettier": "^5.2.1", - "jest": "^29.7.0", - "jsdom": "^24.1.1", - "node-addon-api": "^7.0.0", - "prebuild": "^13.0.1", - "prettier": "^3.3.3", - "puppeteer": "^22.14.0", - "rimraf": "^5.0.9", - "rollup": "^4.22.5", - "rollup-plugin-dts": "^6.1.1", - "rollup-plugin-esbuild": "^6.1.1", - "ts-api-utils": "^1.3.0", - "ts-jest": "^29.2.3", - "ts-node": "^10.9.2", - "typescript": "5.4" - }, - "dependencies": { - "prebuild-install": "^7.1.3" - } } \ No newline at end of file diff --git a/src/polyfill/Events.ts b/src/polyfill/Events.ts index 8c1afc8..f4f59c1 100644 --- a/src/polyfill/Events.ts +++ b/src/polyfill/Events.ts @@ -1,3 +1,6 @@ +import RTCDataChannel from "./RTCDataChannel"; +import RTCError from "./RTCError"; + export class RTCPeerConnectionIceEvent extends Event implements globalThis.RTCPeerConnectionIceEvent { #candidate: globalThis.RTCIceCandidate; @@ -10,20 +13,63 @@ export class RTCPeerConnectionIceEvent extends Event implements globalThis.RTCPe get candidate(): globalThis.RTCIceCandidate { return this.#candidate; } + + get url (): string { + return '' // TODO ? + } } export class RTCDataChannelEvent extends Event implements globalThis.RTCDataChannelEvent { #channel: globalThis.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 globalThis.RTCDataChannel; + this.#channel = init.channel; } get channel(): globalThis.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/RTCCertificate.ts b/src/polyfill/RTCCertificate.ts index 4069837..de23d0e 100644 --- a/src/polyfill/RTCCertificate.ts +++ b/src/polyfill/RTCCertificate.ts @@ -1,11 +1,6 @@ export default class RTCCertificate implements globalThis.RTCCertificate { - #expires: number; - #fingerprints: globalThis.RTCDtlsFingerprint[]; - - constructor() { - this.#expires = null; - this.#fingerprints = []; - } + #expires: number = 0; + #fingerprints: globalThis.RTCDtlsFingerprint[] = []; get expires(): number { return this.#expires; @@ -14,4 +9,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 2c7e8a7..f9fff65 100644 --- a/src/polyfill/RTCDataChannel.ts +++ b/src/polyfill/RTCDataChannel.ts @@ -1,38 +1,38 @@ -/* 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'; +import RTCError from './RTCError'; export default class RTCDataChannel extends EventTarget implements globalThis.RTCDataChannel { #dataChannel: DataChannel; #readyState: globalThis.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: globalThis.RTCDataChannel, ev: Event) => any) | null; - onclose: ((this: globalThis.RTCDataChannel, ev: Event) => any) | null; - onclosing: ((this: globalThis.RTCDataChannel, ev: Event) => any) | null; - onerror: ((this: globalThis.RTCDataChannel, ev: Event) => any) | null; - onmessage: ((this: globalThis.RTCDataChannel, ev: MessageEvent) => any) | null; - onopen: ((this: globalThis.RTCDataChannel, ev: Event) => any) | null; - - constructor(dataChannel: DataChannel, opts: globalThis.RTCDataChannelInit = {}) { + onbufferedamountlow: globalThis.RTCDataChannel['onbufferedamountlow'] = null; + onclose: globalThis.RTCDataChannel['onclose'] = null; + onclosing: globalThis.RTCDataChannel['onclosing'] = null; + onerror: globalThis.RTCDataChannel['onerror'] = null; + onmessage: globalThis.RTCDataChannel['onmessage'] = null; + onopen: globalThis.RTCDataChannel['onopen'] = null; + + 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 +40,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 +74,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 +94,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 +167,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,30 +180,31 @@ 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 (data instanceof Uint8Array) { this.#dataChannel.sendMessageBinary(data); } 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; - setImmediate(() => { - 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 8dd4d9e..af76942 100644 --- a/src/polyfill/RTCDtlsTransport.ts +++ b/src/polyfill/RTCDtlsTransport.ts @@ -1,27 +1,24 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import RTCIceTransport from './RTCIceTransport'; +import RTCPeerConnection from './RTCPeerConnection'; export default class RTCDtlsTransport extends EventTarget implements globalThis.RTCDtlsTransport { - #pc: globalThis.RTCPeerConnection = null; - #iceTransport = null; + #pc: globalThis.RTCPeerConnection; + #iceTransport; - onstatechange: ((this: globalThis.RTCDtlsTransport, ev: Event) => any) | null = null; - onerror: ((this: globalThis.RTCDtlsTransport, ev: Event) => any) | null = null; + onstatechange: globalThis.RTCDtlsTransport['onstatechange'] = null; + onerror: globalThis.RTCDtlsTransport['onstatechange'] = null; - constructor(init: { pc: globalThis.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); }); } @@ -32,15 +29,12 @@ export default class RTCDtlsTransport extends EventTarget implements globalThis. get state(): globalThis.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 0dade6f..8c248e7 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: globalThis.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 | null { + 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 3f94cad..9ff9722 100644 --- a/src/polyfill/RTCIceCandidate.ts +++ b/src/polyfill/RTCIceCandidate.ts @@ -4,20 +4,20 @@ export default class RTCIceCandidate implements globalThis.RTCIceCandidate { - #address: string | null; + #address: string | null = null; #candidate: string; - #component: globalThis.RTCIceComponent | null; - #foundation: string | null; - #port: number | null; - #priority: number | null; - #protocol: globalThis.RTCIceProtocol | null; - #relatedAddress: string | null; - #relatedPort: number | null; - #sdpMLineIndex: number | null; - #sdpMid: string | null; - #tcpType: globalThis.RTCIceTcpCandidateType | null; - #type: globalThis.RTCIceCandidateType | null; - #usernameFragment: string | null; + #component: globalThis.RTCIceComponent | null = null; + #foundation: string | null = null; + #port: number | null = null; + #priority: number | null = null; + #protocol: globalThis.RTCIceProtocol | null = null; + #relatedAddress: string | null = null; + #relatedPort: number | null = null; + #sdpMLineIndex: number | null = null; + #sdpMid: string | null = null; + #tcpType: globalThis.RTCIceTcpCandidateType | null = null; + #type: globalThis.RTCIceCandidateType | null = null; + #usernameFragment: string | null = null; constructor({ candidate, sdpMLineIndex, sdpMid, usernameFragment }: globalThis.RTCIceCandidateInit) { if (sdpMLineIndex == null && sdpMid == null) @@ -30,12 +30,12 @@ export default class RTCIceCandidate implements globalThis.RTCIceCandidate { if (candidate) { const fields = candidate.split(' '); - this.#foundation = fields[0].replace('candidate:', ''); // remove text candidate: + this.#foundation = fields[0]!.replace('candidate:', ''); // remove text candidate: this.#component = fields[1] == '1' ? 'rtp' : 'rtcp'; this.#protocol = fields[2] as globalThis.RTCIceProtocol; - this.#priority = parseInt(fields[3], 10); - this.#address = fields[4]; - this.#port = parseInt(fields[5], 10); + this.#priority = parseInt(fields[3]!, 10); + this.#address = fields[4]!; + this.#port = parseInt(fields[5]!, 10); this.#type = fields[7] as globalThis.RTCIceCandidateType; this.#tcpType = null; this.#relatedAddress = null; @@ -45,9 +45,9 @@ export default class RTCIceCandidate implements globalThis.RTCIceCandidate { for (let i = 8; i < fields.length; i++) { const field = fields[i]; if (field === 'raddr') { - this.#relatedAddress = fields[i + 1]; + this.#relatedAddress = fields[i + 1]!; } else if (field === 'rport') { - this.#relatedPort = parseInt(fields[i + 1], 10); + this.#relatedPort = parseInt(fields[i + 1]!, 10); } if (this.#protocol === 'tcp' && field === 'tcptype') { @@ -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 cd0db0f..32a9b3f 100644 --- a/src/polyfill/RTCIceTransport.ts +++ b/src/polyfill/RTCIceTransport.ts @@ -1,72 +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: globalThis.RTCPeerConnection = null; - #extraFunctions = null; + #pc: RTCPeerConnection; - ongatheringstatechange: ((this: globalThis.RTCIceTransport, ev: Event) => any) | null = null; - onselectedcandidatepairchange: ((this: globalThis.RTCIceTransport, ev: Event) => any) | null = null; - onstatechange: ((this: globalThis.RTCIceTransport, ev: Event) => any) | null = null; + ongatheringstatechange: globalThis.RTCIceTransport['ongatheringstatechange'] = null; + onselectedcandidatepairchange: globalThis.RTCIceTransport['onselectedcandidatepairchange'] = null; + onstatechange: globalThis.RTCIceTransport['onstatechange'] = null; - constructor(init: { pc: globalThis.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 { + get component(): globalThis.RTCIceComponent | null { 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 { - return this.#pc.localDescription.type == 'offer' ? 'controlling' : 'controlled'; + 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(): globalThis.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(): globalThis.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({ @@ -80,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 6d20761..8aba60d 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 { DataChannel, DataChannelInitConfig, DescriptionType, PeerConnection, SelectedCandidateInfo } from '../lib/index'; import RTCSessionDescription from './RTCSessionDescription'; import RTCDataChannel from './RTCDataChannel'; import RTCIceCandidate from './RTCIceCandidate'; @@ -21,110 +19,83 @@ export default class RTCPeerConnection extends EventTarget implements globalThis } #peerConnection: PeerConnection; - #localOffer: any; - #localAnswer: any; - #dataChannels: Set; + #localOffer: ReturnType; + #localAnswer: ReturnType; + #dataChannels = new Set(); #dataChannelsClosed = 0; - #config: globalThis.RTCConfiguration; - #canTrickleIceCandidates: boolean | null; - #sctp: globalThis.RTCSctpTransport; + #config: RTCConfiguration; + #canTrickleIceCandidates: boolean | null = null; + #sctp: RTCSctpTransport; + #announceNegotiation: boolean | null = null; #localCandidates: globalThis.RTCIceCandidate[] = []; #remoteCandidates: globalThis.RTCIceCandidate[] = []; // events - onconnectionstatechange: ((this: globalThis.RTCPeerConnection, ev: Event) => any) | null; - ondatachannel: ((this: globalThis.RTCPeerConnection, ev: globalThis.RTCDataChannelEvent) => any) | null; - onicecandidate: ((this: globalThis.RTCPeerConnection, ev: globalThis.RTCPeerConnectionIceEvent) => any) | null; - onicecandidateerror: ((this: globalThis.RTCPeerConnection, ev: Event) => any) | null; - oniceconnectionstatechange: ((this: globalThis.RTCPeerConnection, ev: Event) => any) | null; - onicegatheringstatechange: ((this: globalThis.RTCPeerConnection, ev: Event) => any) | null; - onnegotiationneeded: ((this: globalThis.RTCPeerConnection, ev: Event) => any) | null; - onsignalingstatechange: ((this: globalThis.RTCPeerConnection, ev: Event) => any) | null; - ontrack: ((this: globalThis.RTCPeerConnection, ev: globalThis.RTCTrackEvent) => any) | null; - - private _checkConfiguration(config: globalThis.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'] = null; + ondatachannel: globalThis.RTCPeerConnection['ondatachannel'] = null; + onicecandidate: globalThis.RTCPeerConnection['onicecandidate'] = null; + // TODO: not implemented + onicecandidateerror: globalThis.RTCPeerConnection['onicecandidateerror'] = null; + oniceconnectionstatechange: globalThis.RTCPeerConnection['oniceconnectionstatechange'] = null; + onicegatheringstatechange: globalThis.RTCPeerConnection['onicegatheringstatechange'] = null; + onnegotiationneeded: globalThis.RTCPeerConnection['onnegotiationneeded'] = null; + onsignalingstatechange: globalThis.RTCPeerConnection['onsignalingstatechange'] = null; + ontrack: globalThis.RTCPeerConnection['ontrack'] = null; + + setConfiguration(config: RTCConfiguration): RTCConfiguration { + // 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') } - - // length of urls can not be 0 - if (config.iceServers[i].urls?.length === 0) - throw new exceptions.SyntaxError('IceServers urls cannot be empty'); } - } - if ( - config && - config.iceTransportPolicy && - config.iceTransportPolicy !== 'all' && - config.iceTransportPolicy !== 'relay' - ) - throw new TypeError('IceTransportPolicy must be either "all" or "relay"'); + return config; } - setConfiguration(config: globalThis.RTCConfiguration): void { - this._checkConfiguration(config); - this.#config = config; - } - - constructor(config: RTCConfiguration = { iceServers: [], iceTransportPolicy: 'all' }) { + constructor(config: RTCConfiguration = {}) { super(); - this._checkConfiguration(config); - this.#config = config; + this.#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 = config.peerConnection ?? new PeerConnection(peerIdentity, { - ...config, + ...this.#config, iceServers: config?.iceServers ?.map((server) => { @@ -141,7 +112,8 @@ export default class RTCPeerConnection extends EventTarget implements globalThis .flat() ?? [], }, ); - } catch (error) { + } catch (err) { + const error = err as Error; if (!error || !error.message) throw new exceptions.NotFoundError('Unknown error'); throw new exceptions.SyntaxError(error.message); } @@ -164,18 +136,16 @@ 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 })); } }); @@ -191,44 +161,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 globalThis.RTCDataChannelEvent); + this.ondatachannel?.(e as RTCDataChannelEvent); }); this.addEventListener('icecandidate', (e) => { - if (this.onicecandidate) this.onicecandidate(e as globalThis.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: (): globalThis.RTCIceCandidate[] => { - return this.#localCandidates; - }, - remoteCandidates: (): globalThis.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 (): globalThis.RTCIceCandidate[] { + return this.#localCandidates + } + + get remoteCandidates (): globalThis.RTCIceCandidate[] { + return this.#remoteCandidates } get canTrickleIceCandidates(): boolean | null { @@ -251,28 +219,33 @@ export default class RTCPeerConnection extends EventTarget implements globalThis return this.#peerConnection.gatheringState(); } - get currentLocalDescription(): globalThis.RTCSessionDescription { - return new RTCSessionDescription(this.#peerConnection.localDescription() as any); + #nullableDescription (desc: { type: DescriptionType; sdp: string } | null): RTCSessionDescription | null { + if (!desc) return null + // @ts-expect-error non-standard + return new RTCSessionDescription(desc) } - - get currentRemoteDescription(): globalThis.RTCSessionDescription { - return new RTCSessionDescription(this.#peerConnection.remoteDescription() as any); + get currentLocalDescription (): RTCSessionDescription | null { + return this.#nullableDescription(this.#peerConnection.localDescription()) } - - get localDescription(): globalThis.RTCSessionDescription { - return new RTCSessionDescription(this.#peerConnection.localDescription() as any); + + get currentRemoteDescription (): RTCSessionDescription | null { + return this.#nullableDescription(this.#peerConnection.remoteDescription()) } - - get pendingLocalDescription(): globalThis.RTCSessionDescription { - return new RTCSessionDescription(this.#peerConnection.localDescription() as any); + + get localDescription (): RTCSessionDescription | null { + return this.#nullableDescription(this.#peerConnection.localDescription()) } - - get pendingRemoteDescription():globalThis.RTCSessionDescription { - return new RTCSessionDescription(this.#peerConnection.remoteDescription() as any); + + get pendingLocalDescription (): RTCSessionDescription | null { + return this.#nullableDescription(this.#peerConnection.localDescription()) } - - get remoteDescription(): globalThis.RTCSessionDescription { - return new RTCSessionDescription(this.#peerConnection.remoteDescription() as any); + + get pendingRemoteDescription (): RTCSessionDescription | null { + return this.#nullableDescription(this.#peerConnection.remoteDescription()) + } + + get remoteDescription (): RTCSessionDescription | null { + return this.#nullableDescription(this.#peerConnection.remoteDescription()) } get sctp(): globalThis.RTCSctpTransport { @@ -284,16 +257,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 @@ -304,16 +278,17 @@ export default class RTCPeerConnection extends EventTarget implements globalThis } // We don't care about sdpMLineIndex, just for test - if (!candidate.sdpMid && candidate.sdpMLineIndex > 1) { + if (!candidate.sdpMid && candidate.sdpMLineIndex && candidate.sdpMLineIndex > 1) { throw new exceptions.OperationError('This is only for test case.'); } 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) { + } catch (err) { + const error = err as Error; if (!error || !error.message) throw new exceptions.NotFoundError('Unknown error'); // Check error Message if contains specific message @@ -321,18 +296,31 @@ 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'); + + addTrack (): globalThis.RTCRtpSender { + return {} as globalThis.RTCRtpSender } + - // eslint-disable-next-line @typescript-eslint/no-unused-vars - addTransceiver(_trackOrKind, _init): globalThis.RTCRtpTransceiver { - throw new DOMException('Not implemented'); + addTransceiver (): globalThis.RTCRtpTransceiver { + return {} as globalThis.RTCRtpTransceiver + } + + getReceivers (): globalThis.RTCRtpReceiver[] { + // receivers are created on ontrack + return [] + } + + getSenders (): globalThis.RTCRtpSender[] { + // senders are created on addTrack or addTransceiver + return [] + } + + getTracks (): globalThis.MediaStreamTrack[] { + return [] } close(): void { @@ -345,26 +333,48 @@ export default class RTCPeerConnection extends EventTarget implements globalThis 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 = {}): globalThis.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; } @@ -372,95 +382,96 @@ export default class RTCPeerConnection extends EventTarget implements globalThis return this.#config; } - getReceivers(): globalThis.RTCRtpReceiver[] { - throw new DOMException('Not implemented'); + getSelectedCandidatePair (): { + local: SelectedCandidateInfo; + remote: SelectedCandidateInfo; + } | null { + 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 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); + 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, + }); + + // @ts-expect-error dont support deprecated overload + return Promise.resolve(report as globalThis.RTCStatsReport); } getTransceivers(): globalThis.RTCRtpTransceiver[] { - return []; // throw new DOMException('Not implemented'); + return []; } removeTrack(): void { - throw new DOMException('Not implemented'); + console.warn('track detatching not supported') + // throw new DOMException('Not implemented'); } restartIce(): Promise { @@ -468,12 +479,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 { @@ -481,24 +497,23 @@ 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 { +function getRandomString(length: number): string { return Math.random() .toString(36) .substring(2, 2 + length); diff --git a/src/polyfill/RTCSctpTransport.ts b/src/polyfill/RTCSctpTransport.ts index d032a37..bcfbc4a 100644 --- a/src/polyfill/RTCSctpTransport.ts +++ b/src/polyfill/RTCSctpTransport.ts @@ -1,39 +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: globalThis.RTCPeerConnection = null; - #extraFunctions = null; - #transport: globalThis.RTCDtlsTransport = null; + #pc: RTCPeerConnection; + #transport: globalThis.RTCDtlsTransport; - onstatechange: ((this: globalThis.RTCSctpTransport, ev: Event) => any) | null = null; + onstatechange: globalThis.RTCSctpTransport['onstatechange'] = null; - constructor(initial: { pc: globalThis.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; + if (this.state !== 'connected') return 0; + return this.#pc.maxMessageSize ?? 65536; } get state(): globalThis.RTCSctpTransportState { diff --git a/src/polyfill/RTCSessionDescription.ts b/src/polyfill/RTCSessionDescription.ts index ef0fcb4..e96ef2a 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/test/jest-tests/polyfill.test.ts b/test/jest-tests/polyfill.test.ts index 84b265a..df90750 100644 --- a/test/jest-tests/polyfill.test.ts +++ b/test/jest-tests/polyfill.test.ts @@ -79,19 +79,19 @@ describe('polyfill', () => { let currentIndex: number = -1; // We run this function to analyze the data just after receiving it from the datachannel. - function analyzeData(idx: number, data: string|Buffer|ArrayBuffer): boolean { + async function analyzeData(idx: number, data: string|Blob|ArrayBuffer): Promise { switch(idx){ case 0: // binaryType is not used here because data is a string ("Hello"). return (data as string)==testMessages[idx].data; case 1: // binaryType is "arraybuffer" and data is expected to be an ArrayBuffer. return analyzeBinaryTestData(data as ArrayBufferLike); case 2: // binaryType is "blob" and data is expected to be a Buffer. - return analyzeBinaryTestData((data as Buffer).buffer); + return analyzeBinaryTestData(await (data as Blob).arrayBuffer()); } return false; } - function finalizeTest(): void { + async function finalizeTest(): Promise { peer1.close(); peer2.close(); @@ -119,25 +119,25 @@ describe('polyfill', () => { expect(p2MessageMock.mock.calls.length).toBe(3); // Analyze and compare received messages - expect(analyzeData(0, p1MessageMock.mock.calls[0][0] as any)).toEqual(true); - expect(analyzeData(1, p1MessageMock.mock.calls[1][0] as any)).toEqual(true); - expect(analyzeData(2, p1MessageMock.mock.calls[2][0] as any)).toEqual(true); + expect(await analyzeData(0, p1MessageMock.mock.calls[0][0] as any)).toEqual(true); + expect(await analyzeData(1, p1MessageMock.mock.calls[1][0] as any)).toEqual(true); + expect(await analyzeData(2, p1MessageMock.mock.calls[2][0] as any)).toEqual(true); - expect(analyzeData(0, p2MessageMock.mock.calls[0][0] as any)).toEqual(true); - expect(analyzeData(1, p2MessageMock.mock.calls[1][0] as any)).toEqual(true); - expect(analyzeData(2, p2MessageMock.mock.calls[2][0] as any)).toEqual(true); + expect(await analyzeData(0, p2MessageMock.mock.calls[0][0] as any)).toEqual(true); + expect(await analyzeData(1, p2MessageMock.mock.calls[1][0] as any)).toEqual(true); + expect(await analyzeData(2, p2MessageMock.mock.calls[2][0] as any)).toEqual(true); done(); } // starts the next message-sending test - function nextSendTest(): void { + async function nextSendTest(): Promise { // Get the next test data const current = testMessages[++currentIndex]; // If finished, quit if (!current){ - finalizeTest(); + await finalizeTest(); return; } diff --git a/test/polyfill-connectivity.ts b/test/polyfill-connectivity.ts index 3da1af3..ca6f30f 100644 --- a/test/polyfill-connectivity.ts +++ b/test/polyfill-connectivity.ts @@ -3,8 +3,8 @@ import nodeDataChannel from '../src/lib/index'; nodeDataChannel.initLogger('Info'); -let dc1: RTCDataChannel = null; -let dc2: RTCDataChannel = null; +let dc1: RTCDataChannel | null = null; +let dc2: RTCDataChannel | null = null; const peer1 = new RTCPeerConnection({ peerIdentity: 'peer1', @@ -22,7 +22,7 @@ peer1.onicegatheringstatechange = (): void => { console.log('Peer1 GatheringState:', peer1.iceGatheringState); }; peer1.onicecandidate = (e): void => { - console.log('Peer1 Candidate:', e.candidate.candidate); + console.log('Peer1 Candidate:', e.candidate!.candidate); peer2.addIceCandidate(e.candidate); }; @@ -42,12 +42,12 @@ peer2.onicegatheringstatechange = (): void => { console.log('Peer2 GatheringState:', peer2.iceGatheringState); }; peer2.onicecandidate = (e): void => { - console.log('Peer2 Candidate:', e.candidate.candidate); + console.log('Peer2 Candidate:', e.candidate!.candidate); peer1.addIceCandidate(e.candidate); }; peer2.ondatachannel = (dce): void => { console.log('Peer2 Got DataChannel: ', dce.channel.label); - dc2 = dce.channel; + dc2 = dce.channel as RTCDataChannel; dc2.onmessage = (msg): void => { console.log('Peer2 Received Msg:', msg.data.toString()); }; diff --git a/tsconfig.json b/tsconfig.json index 96d2379..003c20b 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