├── .gitignore ├── .gitmodules ├── browser.js ├── index.js ├── jsconfig.json ├── lib ├── Blob.js ├── Events.js ├── MediaStream.js ├── RTCCertificate.js ├── RTCDataChannel.js ├── RTCDtlsTransport.js ├── RTCError.js ├── RTCIceCandidate.js ├── RTCIceTransport.js ├── RTCPeerConnection.js ├── RTCRtp.js ├── RTCSctpTransport.js └── RTCSessionDescription.js ├── package.json ├── polyfill-browser.js ├── polyfill.js ├── readme.md ├── test ├── constants.js ├── util.js └── wpt.js └── types.d.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | package-lock.json 3 | pnpm-lock.yaml 4 | tests.txt 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "test/wpt"] 2 | path = test/wpt 3 | url = https://github.com/MattiasBuelens/wpt 4 | -------------------------------------------------------------------------------- /browser.js: -------------------------------------------------------------------------------- 1 | const scope = typeof window !== 'undefined' ? window : self 2 | 3 | // @ts-ignore 4 | export const RTCPeerConnection = scope.RTCPeerConnection || scope.mozRTCPeerConnection || scope.webkitRTCPeerConnection 5 | // @ts-ignore 6 | export const RTCSessionDescription = scope.RTCSessionDescription || scope.mozRTCSessionDescription || scope.webkitRTCSessionDescription 7 | // @ts-ignore 8 | export const RTCIceCandidate = scope.RTCIceCandidate || scope.mozRTCIceCandidate || scope.webkitRTCIceCandidate 9 | export const RTCIceTransport = scope.RTCIceTransport 10 | export const RTCDataChannel = scope.RTCDataChannel 11 | export const RTCSctpTransport = scope.RTCSctpTransport 12 | export const RTCDtlsTransport = scope.RTCDtlsTransport 13 | export const RTCCertificate = scope.RTCCertificate 14 | export const MediaStream = scope.MediaStream 15 | export const MediaStreamTrack = scope.MediaStreamTrack 16 | export const MediaStreamTrackEvent = scope.MediaStreamTrackEvent 17 | export const RTCPeerConnectionIceEvent = scope.RTCPeerConnectionIceEvent 18 | export const RTCDataChannelEvent = scope.RTCDataChannelEvent 19 | export const RTCTrackEvent = scope.RTCTrackEvent 20 | export const RTCError = scope.RTCError 21 | export const RTCErrorEvent = scope.RTCErrorEvent 22 | export const RTCRtpTransceiver = scope.RTCRtpTransceiver 23 | export const RTCRtpReceiver = scope.RTCRtpReceiver 24 | export const RTCRtpSender = scope.RTCRtpSender 25 | 26 | export * as default from './browser.js' 27 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export { default as RTCPeerConnection } from './lib/RTCPeerConnection.js' 2 | export { default as RTCSessionDescription } from './lib/RTCSessionDescription.js' 3 | export { default as RTCIceCandidate } from './lib/RTCIceCandidate.js' 4 | export { default as RTCIceTransport } from './lib/RTCIceTransport.js' 5 | export { default as RTCDataChannel } from './lib/RTCDataChannel.js' 6 | export { default as RTCSctpTransport } from './lib/RTCSctpTransport.js' 7 | export { default as RTCDtlsTransport } from './lib/RTCDtlsTransport.js' 8 | export { default as RTCCertificate } from './lib/RTCCertificate.js' 9 | export * from './lib/MediaStream.js' 10 | export * from './lib/Events.js' 11 | export * from './lib/RTCError.js' 12 | export * from './lib/RTCRtp.js' 13 | 14 | export * as default from './index.js' 15 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "baseUrl": "./", 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "target": "ESNext", 12 | "moduleResolution": "nodenext", 13 | "module": "NodeNext", 14 | "types": ["@types/webrtc"], 15 | "allowSyntheticDefaultImports": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/Blob.js: -------------------------------------------------------------------------------- 1 | const _Blob = globalThis.Blob || (await import('node:buffer')).Blob 2 | 3 | export default _Blob 4 | -------------------------------------------------------------------------------- /lib/Events.js: -------------------------------------------------------------------------------- 1 | import { MediaStreamTrack } from './MediaStream.js' 2 | import RTCDataChannel from './RTCDataChannel.js' 3 | import { RTCRtpReceiver, RTCRtpTransceiver } from './RTCRtp.js' 4 | 5 | /** 6 | * @class 7 | * @implements {globalThis.RTCPeerConnectionIceEvent} 8 | */ 9 | export class RTCPeerConnectionIceEvent extends Event { 10 | #candidate 11 | 12 | constructor (candidate) { 13 | super('icecandidate') 14 | 15 | this.#candidate = candidate 16 | } 17 | 18 | get candidate () { 19 | return this.#candidate 20 | } 21 | 22 | get url () { 23 | return '' // TODO ? 24 | } 25 | } 26 | /** 27 | * @class 28 | * @implements {globalThis.RTCDataChannelEvent} 29 | */ 30 | export class RTCDataChannelEvent extends Event { 31 | #channel 32 | 33 | constructor (type = 'datachannel', init) { 34 | if (arguments.length === 0) throw new TypeError(`Failed to construct 'RTCDataChannelEvent': 2 arguments required, but only ${arguments.length} present.`) 35 | if (typeof init !== 'object') throw new TypeError("Failed to construct 'RTCDataChannelEvent': The provided value is not of type 'RTCDataChannelEventInit'.") 36 | if (!init.channel) throw new TypeError("Failed to construct 'RTCDataChannelEvent': Failed to read the 'channel' property from 'RTCDataChannelEventInit': Required member is undefined.") 37 | 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'.") 38 | super('datachannel') 39 | 40 | this.#channel = init.channel 41 | } 42 | 43 | get channel () { 44 | return this.#channel 45 | } 46 | } 47 | /** 48 | * @class 49 | * @implements {globalThis.RTCTrackEvent} 50 | */ 51 | export class RTCTrackEvent extends Event { 52 | #track 53 | #receiver 54 | #transceiver 55 | #streams 56 | 57 | constructor (type = 'track', init) { 58 | if (arguments.length === 0) throw new TypeError(`Failed to construct 'RTCTrackEvent': 2 arguments required, but only ${arguments.length} present.`) 59 | if (typeof init !== 'object') throw new TypeError("Failed to construct 'RTCTrackEvent': The provided value is not of type 'RTCTrackEventInit'.") 60 | if (!init.channel) throw new TypeError("Failed to construct 'RTCTrackEvent': Failed to read the 'channel' property from 'RTCTrackEventInit': Required member is undefined.") 61 | if (init.receiver.constructor !== RTCRtpReceiver) throw new TypeError("Failed to construct 'RTCTrackEvent': Failed to read the 'channel' property from 'RTCTrackEventInit': Failed to convert value to 'RTCRtpReceiver'.") 62 | if (init.track.constructor !== MediaStreamTrack) throw new TypeError("Failed to construct 'RTCTrackEvent': Failed to read the 'channel' property from 'RTCTrackEventInit': Failed to convert value to 'MediaStreamTrack'.") 63 | if (init.transceiver.constructor !== RTCRtpTransceiver) throw new TypeError("Failed to construct 'RTCTrackEvent': Failed to read the 'channel' property from 'RTCTrackEventInit': Failed to convert value to 'RTCRtpTransceiver'.") 64 | 65 | super('track') 66 | 67 | const { track, receiver, transceiver, streams } = init 68 | 69 | this.#track = track 70 | this.#receiver = receiver 71 | this.#transceiver = transceiver 72 | this.#streams = streams 73 | } 74 | 75 | get track () { 76 | return this.#track 77 | } 78 | 79 | get receiver () { 80 | return this.#receiver 81 | } 82 | 83 | get transceiver () { 84 | return this.#transceiver 85 | } 86 | 87 | get streams () { 88 | return this.#streams ?? [] 89 | } 90 | } 91 | /** 92 | * @class 93 | * @implements {globalThis.MediaStreamTrackEvent} 94 | */ 95 | export class MediaStreamTrackEvent extends Event { 96 | #track 97 | 98 | constructor (type, init) { 99 | if (arguments.length === 0) throw new TypeError(`Failed to construct 'MediaStreamTrackEvent': 2 arguments required, but only ${arguments.length} present.`) 100 | if (typeof init !== 'object') throw new TypeError("Failed to construct 'MediaStreamTrackEvent': The provided value is not of type 'MediaStreamTrackEventInit'.") 101 | if (!init.track) throw new TypeError("Failed to construct 'MediaStreamTrackEvent': Failed to read the 'track' property from 'MediaStreamTrackEventInit': Required member is undefined.") 102 | 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'.") 103 | 104 | super(type) 105 | 106 | this.#track = init.track 107 | } 108 | 109 | get track () { 110 | return this.#track 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /lib/MediaStream.js: -------------------------------------------------------------------------------- 1 | import { Readable } from 'node:stream' 2 | import { MediaStreamTrackEvent } from './Events.js' 3 | 4 | /** 5 | * @class 6 | * @implements {globalThis.MediaStreamTrack} 7 | */ 8 | export class MediaStreamTrack extends EventTarget { 9 | media 10 | track 11 | stream = new Readable({ read: () => {} }) 12 | #kind 13 | #label 14 | #id = crypto.randomUUID() 15 | contentHint = '' 16 | 17 | onmute 18 | onunmute 19 | onended 20 | 21 | constructor ({ kind, label }) { 22 | super() 23 | if (!kind) throw new TypeError("Failed to construct 'MediaStreamTrack': Failed to read the 'kind' property from 'MediaStreamTrackInit': Required member is undefined.") 24 | this.#kind = kind 25 | this.#label = label 26 | 27 | this.addEventListener('ended', e => { 28 | this.onended?.(e) 29 | this.track?.close() 30 | this.stream.destroy() 31 | }) 32 | this.stream.on('close', () => { 33 | this.stop() 34 | }) 35 | } 36 | 37 | async applyConstraints () { 38 | console.warn('Constraints unsupported, ignored') 39 | } 40 | 41 | stop () { 42 | this.track?.close() 43 | this.stream.destroy() 44 | this.dispatchEvent(new Event('ended')) 45 | } 46 | 47 | getSettings () { 48 | console.warn('Settings upsupported, ignored') 49 | return {} 50 | } 51 | 52 | getConstraints () { 53 | console.warn('Constraints unsupported, ignored') 54 | return {} 55 | } 56 | 57 | getCapabilities () { 58 | console.warn('Capabilities unsupported, ignored') 59 | return {} 60 | } 61 | 62 | clone () { 63 | console.warn('Track clonning is unsupported, returned this instance') 64 | return this 65 | } 66 | 67 | get kind () { 68 | return this.#kind 69 | } 70 | 71 | get enabled () { 72 | return this.track?.isOpen() 73 | } 74 | 75 | set enabled (_) { 76 | console.warn('Track enabling and disabling is unsupported, ignored') 77 | } 78 | 79 | get muted () { 80 | return false 81 | } 82 | 83 | get id () { 84 | return this.#id 85 | } 86 | 87 | get label () { 88 | return this.#label 89 | } 90 | 91 | get readyState () { 92 | return this.track?.isClosed() ? 'ended' : 'live' 93 | } 94 | } 95 | 96 | /** 97 | * @class 98 | * @implements {globalThis.MediaStream} 99 | */ 100 | export class MediaStream extends EventTarget { 101 | #active = true 102 | #id = crypto.randomUUID() 103 | /** @type {Set} */ 104 | #tracks = new Set() 105 | onaddtrack 106 | onremovetrack 107 | onactive 108 | oninactive 109 | 110 | constructor (streamOrTracks) { 111 | super() 112 | if (streamOrTracks instanceof MediaStream) { 113 | for (const track of streamOrTracks.getTracks()) { 114 | this.addTrack(track) 115 | } 116 | } else if (Array.isArray(streamOrTracks)) { 117 | for (const track of streamOrTracks) { 118 | this.addTrack(track) 119 | } 120 | } 121 | this.addEventListener('active', e => { 122 | this.onactive?.(e) 123 | }) 124 | this.addEventListener('inactive', e => { 125 | this.oninactive?.(e) 126 | }) 127 | this.addEventListener('removetrack', e => { 128 | this.onremovetrack?.(e) 129 | }) 130 | this.addEventListener('addtrack', e => { 131 | this.onaddtrack?.(e) 132 | }) 133 | this.dispatchEvent(new Event('active')) 134 | } 135 | 136 | get active () { 137 | return this.#active 138 | } 139 | 140 | get id () { 141 | return this.#id 142 | } 143 | 144 | addTrack (track) { 145 | this.#tracks.add(track) 146 | this.dispatchEvent(new MediaStreamTrackEvent('addtrack', { track })) 147 | } 148 | 149 | getTracks () { 150 | return [...this.#tracks] 151 | } 152 | 153 | getVideoTracks () { 154 | return [...this.#tracks].filter(({ kind }) => kind === 'video') 155 | } 156 | 157 | getAudioTracks () { 158 | return [...this.#tracks].filter(({ kind }) => kind === 'audio') 159 | } 160 | 161 | getTrackById (id) { 162 | return [...this.#tracks].find(track => track.id === id) ?? null 163 | } 164 | 165 | removeTrack (track) { 166 | this.#tracks.delete(track) 167 | this.dispatchEvent(new MediaStreamTrackEvent('removetrack', { track })) 168 | } 169 | 170 | clone () { 171 | return new MediaStream([...this.getTracks()]) 172 | } 173 | 174 | stop () { 175 | for (const track of this.getTracks()) { 176 | track.stop() 177 | } 178 | this.#active = false 179 | this.dispatchEvent(new Event('inactive')) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /lib/RTCCertificate.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class 3 | * @implements {globalThis.RTCCertificate} 4 | */ 5 | export default class RTCCertificate { 6 | #expires = null 7 | #fingerprints = [] 8 | 9 | get expires () { 10 | return this.#expires 11 | } 12 | 13 | getFingerprints () { 14 | return this.#fingerprints 15 | } 16 | 17 | getAlgorithm () { 18 | return '' 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /lib/RTCDataChannel.js: -------------------------------------------------------------------------------- 1 | import 'node-domexception' 2 | import { RTCErrorEvent, RTCError } from './RTCError.js' 3 | import Blob from './Blob.js' 4 | 5 | /** 6 | * @class 7 | * @implements {globalThis.RTCDataChannel} 8 | */ 9 | export default class RTCDataChannel extends EventTarget { 10 | /** @type {import("node-datachannel").DataChannel} dataChannel */ 11 | #dataChannel 12 | /** @type {RTCDataChannelState} */ 13 | #readyState 14 | #bufferedAmountLowThreshold 15 | /** @type {BinaryType} */ 16 | #binaryType = 'blob' 17 | #maxPacketLifeTime 18 | #maxRetransmits 19 | #negotiated 20 | #ordered 21 | /** @type {import('./RTCPeerConnection.js').default} */ 22 | #pc 23 | 24 | onbufferedamountlow 25 | onclose 26 | onclosing 27 | onerror 28 | onmessage 29 | onopen 30 | 31 | /** 32 | * @param {import("node-datachannel").DataChannel} dataChannel 33 | * @param {any} opts 34 | * @param {import('./RTCPeerConnection.js').default} pc 35 | */ 36 | constructor (dataChannel, opts = {}, pc) { 37 | super() 38 | 39 | this.#dataChannel = dataChannel 40 | this.#readyState = this.#dataChannel.isOpen() ? 'open' : 'connecting' 41 | this.#bufferedAmountLowThreshold = 0 42 | this.#maxPacketLifeTime = opts.maxPacketLifeTime ?? null 43 | this.#maxRetransmits = opts.maxRetransmits ?? null 44 | this.#negotiated = opts.negotiated ?? false 45 | this.#ordered = opts.ordered ?? true 46 | this.#pc = pc 47 | 48 | this.#dataChannel.onOpen(() => { 49 | this.#readyState = 'open' 50 | this.dispatchEvent(new Event('open')) 51 | }) 52 | 53 | // we need updated connectionstate, so this is delayed by a single event loop tick 54 | // this is fucked and wonky, needs to be made better 55 | this.#dataChannel.onClosed(() => setTimeout(() => { 56 | if (this.#readyState !== 'closed') { 57 | // this should be 'disconnected' but ldc doesn't support that 58 | if (this.#pc.connectionState === 'closed') { 59 | // if the remote connection suddently closes without closing dc first, throw this weird error 60 | this.dispatchEvent(new RTCErrorEvent('error', { error: new RTCError({ errorDetail: 'sctp-failure', sctpCauseCode: 12 }, 'User-Initiated Abort, reason=Close called') })) 61 | } 62 | this.#readyState = 'closing' 63 | this.dispatchEvent(new Event('closing')) 64 | this.#readyState = 'closed' 65 | } 66 | this.dispatchEvent(new Event('close')) 67 | })) 68 | 69 | this.#dataChannel.onError((msg) => { 70 | this.dispatchEvent( 71 | new RTCErrorEvent('error', { 72 | error: new RTCError( 73 | { errorDetail: 'data-channel-failure' }, 74 | msg 75 | ) 76 | }) 77 | ) 78 | }) 79 | 80 | this.#dataChannel.onBufferedAmountLow(() => { 81 | this.dispatchEvent(new Event('bufferedamountlow')) 82 | }) 83 | 84 | this.#dataChannel.onMessage(message => { 85 | /** @type {any} */ 86 | let data 87 | if (!ArrayBuffer.isView(message)) { 88 | data = message 89 | } else if (this.#binaryType === 'blob') { 90 | data = new Blob([message]) 91 | } else { 92 | data = message.buffer 93 | } 94 | this.dispatchEvent(new MessageEvent('message', { data })) 95 | }) 96 | 97 | // forward events to properties 98 | this.addEventListener('message', e => { 99 | this.onmessage?.(e) 100 | }) 101 | this.addEventListener('bufferedamountlow', e => { 102 | this.onbufferedamountlow?.(e) 103 | }) 104 | this.addEventListener('error', e => { 105 | this.onerror?.(e) 106 | }) 107 | this.addEventListener('close', e => { 108 | this.onclose?.(e) 109 | }) 110 | this.addEventListener('closing', e => { 111 | this.onclosing?.(e) 112 | }) 113 | this.addEventListener('open', e => { 114 | this.onopen?.(e) 115 | }) 116 | } 117 | 118 | set binaryType (type) { 119 | if (type !== 'blob' && type !== 'arraybuffer') { 120 | throw new DOMException( 121 | "Failed to set the 'binaryType' property on 'RTCDataChannel': Unknown binary type : " + type, 122 | 'TypeMismatchError' 123 | ) 124 | } 125 | this.#binaryType = type 126 | } 127 | 128 | get binaryType () { 129 | return this.#binaryType 130 | } 131 | 132 | get bufferedAmount () { 133 | return this.#dataChannel.bufferedAmount() 134 | } 135 | 136 | get bufferedAmountLowThreshold () { 137 | return this.#bufferedAmountLowThreshold 138 | } 139 | 140 | set bufferedAmountLowThreshold (value) { 141 | const number = Number(value) || 0 142 | this.#bufferedAmountLowThreshold = number 143 | this.#dataChannel.setBufferedAmountLowThreshold(number) 144 | } 145 | 146 | get id () { 147 | return this.#dataChannel.getId() 148 | } 149 | 150 | get label () { 151 | return this.#dataChannel.getLabel() 152 | } 153 | 154 | get maxPacketLifeTime () { 155 | return this.#maxPacketLifeTime 156 | } 157 | 158 | get maxRetransmits () { 159 | return this.#maxRetransmits 160 | } 161 | 162 | get negotiated () { 163 | return this.#negotiated 164 | } 165 | 166 | get ordered () { 167 | return this.#ordered 168 | } 169 | 170 | get protocol () { 171 | return this.#dataChannel.getProtocol() 172 | } 173 | 174 | get readyState () { 175 | return this.#readyState 176 | } 177 | 178 | get maxMessageSize () { 179 | return this.#dataChannel.maxMessageSize() 180 | } 181 | 182 | /** @param {string | Blob | ArrayBuffer | ArrayBufferView} data */ 183 | send (data) { 184 | if (this.#readyState !== 'open') { 185 | throw new DOMException( 186 | "Failed to execute 'send' on 'RTCDataChannel': RTCDataChannel.readyState is not 'open'", 187 | 'InvalidStateError' 188 | ) 189 | } 190 | 191 | if (typeof data === 'string') { 192 | if (data.length > this.#dataChannel.maxMessageSize()) throw new TypeError('Max message size exceeded.') 193 | this.#dataChannel.sendMessage(data) 194 | } else if ('arrayBuffer' in data) { 195 | if (data.size > this.#dataChannel.maxMessageSize()) throw new TypeError('Max message size exceeded.') 196 | return data.arrayBuffer().then(ab => { 197 | if (this.readyState === 'open') this.#dataChannel.sendMessageBinary(new Uint8Array(ab)) 198 | }) 199 | } else { 200 | if (data.byteLength > this.#dataChannel.maxMessageSize()) throw new TypeError('Max message size exceeded.') 201 | // if (ArrayBuffer.isView(data)) data = data.buffer 202 | // @ts-ignore - NDC doesn't like transfering raw buffers, so we need to clone 203 | this.#dataChannel.sendMessageBinary(new Uint8Array(data)) 204 | } 205 | } 206 | 207 | close () { 208 | this.#readyState = 'closed' 209 | setTimeout(() => { 210 | if (this.#pc.connectionState === 'closed') { 211 | // if the remote connection suddently closes without closing dc first, throw this weird error 212 | // can this be done better? 213 | this.dispatchEvent(new RTCErrorEvent('error', { error: new RTCError({ errorDetail: 'sctp-failure', sctpCauseCode: 12 }, 'User-Initiated Abort, reason=Close called') })) 214 | } 215 | }) 216 | 217 | this.#dataChannel.close() 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /lib/RTCDtlsTransport.js: -------------------------------------------------------------------------------- 1 | import RTCIceTransport from './RTCIceTransport.js' 2 | 3 | /** 4 | * @class 5 | * @implements {globalThis.RTCDtlsTransport} 6 | */ 7 | export default class RTCDtlsTransport extends EventTarget { 8 | #iceTransport 9 | /** @type {import('./RTCPeerConnection.js').default} */ 10 | #pc 11 | 12 | onerror 13 | onstatechange 14 | 15 | constructor ({ pc }) { 16 | super() 17 | this.#pc = pc 18 | this.#iceTransport = new RTCIceTransport({ pc }) 19 | } 20 | 21 | get iceTransport () { 22 | return this.#iceTransport 23 | } 24 | 25 | get state () { 26 | if (this.#pc.connectionState === 'disconnected') return 'closed' 27 | return this.#pc.connectionState 28 | } 29 | 30 | getRemoteCertificates () { 31 | // not supported by all browsers anyways 32 | return [new ArrayBuffer(0)] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/RTCError.js: -------------------------------------------------------------------------------- 1 | import 'node-domexception' 2 | 3 | const RTCErrorDetailType = [ 4 | 'data-channel-failure', 5 | 'dtls-failure', 6 | 'fingerprint-failure', 7 | 'sctp-failure', 8 | 'sdp-syntax-error', 9 | 'hardware-encoder-not-available', 10 | 'hardware-encoder-error' 11 | ] 12 | 13 | /** 14 | * @class 15 | * @implements {globalThis.RTCError} 16 | */ 17 | export class RTCError extends DOMException { 18 | #errorDetail 19 | #sdpLineNumber 20 | #sctpCauseCode 21 | #receivedAlert 22 | #sentAlert 23 | #httpRequestStatusCode 24 | /** 25 | * @param {RTCErrorInit} init 26 | * @param {string=} message 27 | */ 28 | constructor (init, message) { 29 | if (arguments.length === 0) throw new TypeError("Failed to construct 'RTCError': 1 argument required, but only 0 present.") 30 | if (!init.errorDetail) throw new TypeError("Failed to construct 'RTCError': Failed to read the 'errorDetail' property from 'RTCErrorInit': Required member is undefined.") 31 | 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.`) 32 | super(message, 'OperationError') 33 | 34 | this.#errorDetail = init.errorDetail 35 | 36 | this.#receivedAlert = init.receivedAlert ?? null 37 | this.#sentAlert = init.sentAlert ?? null 38 | this.#sctpCauseCode = init.sctpCauseCode ?? null 39 | this.#sdpLineNumber = init.sdpLineNumber ?? null 40 | this.#httpRequestStatusCode = init.httpRequestStatusCode ?? null 41 | } 42 | 43 | get errorDetail () { 44 | return this.#errorDetail 45 | } 46 | 47 | get sdpLineNumber () { 48 | return this.#sdpLineNumber ?? null 49 | } 50 | 51 | get sctpCauseCode () { 52 | return this.#sctpCauseCode ?? null 53 | } 54 | 55 | get receivedAlert () { 56 | return this.#receivedAlert ?? null 57 | } 58 | 59 | get sentAlert () { 60 | return this.#sentAlert ?? null 61 | } 62 | 63 | get httpRequestStatusCode () { 64 | return this.#httpRequestStatusCode ?? null 65 | } 66 | 67 | set errorDetail (_) { 68 | throw new TypeError('RTCError.errorDetail is readonly.') 69 | } 70 | 71 | set sdpLineNumber (_) { 72 | throw new TypeError('RTCError.sdpLineNumber is readonly.') 73 | } 74 | 75 | set sctpCauseCode (_) { 76 | throw new TypeError('RTCError.sctpCauseCode is readonly.') 77 | } 78 | 79 | set receivedAlert (_) { 80 | throw new TypeError('RTCError.receivedAlert is readonly.') 81 | } 82 | 83 | set sentAlert (_) { 84 | throw new TypeError('RTCError.sentAlert is readonly.') 85 | } 86 | 87 | set httpRequestStatusCode (_) { 88 | throw new TypeError('RTCError.httpRequestStatusCode is readonly.') 89 | } 90 | } 91 | 92 | /** 93 | * @class 94 | * @implements {globalThis.RTCErrorEvent} 95 | */ 96 | export class RTCErrorEvent extends Event { 97 | #error 98 | /** 99 | * @param {string} type 100 | * @param {RTCErrorEventInit} init 101 | */ 102 | constructor (type, init) { 103 | if (arguments.length < 2) throw new TypeError(`Failed to construct 'RTCErrorEvent': 2 arguments required, but only ${arguments.length} present.`) 104 | if (typeof init !== 'object') throw new TypeError("Failed to construct 'RTCErrorEvent': The provided value is not of type 'RTCErrorEventInit'.") 105 | if (!init.error) throw new TypeError("Failed to construct 'RTCErrorEvent': Failed to read the 'error' property from 'RTCErrorEventInit': Required member is undefined.") 106 | 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'.") 107 | super(type || 'error') 108 | this.#error = init.error 109 | } 110 | 111 | get error () { 112 | return this.#error 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /lib/RTCIceCandidate.js: -------------------------------------------------------------------------------- 1 | const componentMap = { 2 | 1: 'rtp', 3 | 2: 'rtcp' 4 | } 5 | 6 | /** 7 | * @class 8 | * @implements {globalThis.RTCIceCandidate} 9 | */ 10 | export default class RTCIceCandidate { 11 | #address 12 | #candidate 13 | #component 14 | #foundation 15 | #port 16 | #priority 17 | #protocol 18 | #relatedAddress 19 | #relatedPort 20 | #sdpMLineIndex 21 | #sdpMid 22 | #tcpType 23 | #type 24 | #usernameFragment 25 | 26 | /** 27 | * @param {RTCIceCandidateInit} init={} 28 | */ 29 | constructor ({ candidate, sdpMLineIndex, sdpMid, usernameFragment } = {}) { 30 | if (sdpMLineIndex == null && sdpMid == null) { 31 | throw new TypeError("Failed to construct 'RTCIceCandidate': sdpMid and sdpMLineIndex are both null.") 32 | } 33 | this.#candidate = candidate 34 | this.#sdpMLineIndex = sdpMLineIndex ?? null 35 | this.#sdpMid = sdpMid ?? null 36 | this.#usernameFragment = usernameFragment ?? null 37 | 38 | if (candidate && candidate.indexOf('candidate:') !== -1) { 39 | const interest = candidate.slice(candidate.indexOf('candidate:') + 10) 40 | 41 | // TODO: can this extract usernameFragment? *pc->localDescription()->iceUfrag() 42 | /** @type {any[]} split */ 43 | // eslint-disable-next-line no-unused-vars 44 | const [foundation, componentID, protocol, priority, ip, port, _typ, type, ...rest] = interest.split(' ') 45 | 46 | this.#foundation = foundation 47 | this.#component = componentMap[componentID] 48 | 49 | this.#protocol = protocol 50 | this.#priority = Number(priority) 51 | this.#address = ip 52 | this.#port = Number(port) 53 | this.#type = type 54 | 55 | if (protocol === 'tcp') { 56 | const tcptypeIndex = rest.indexOf('tcptype') 57 | if (tcptypeIndex !== -1) this.#tcpType = rest[tcptypeIndex + 1] 58 | } 59 | 60 | if (type !== 'host') { 61 | const raddrIndex = rest.indexOf('raddr') 62 | if (raddrIndex !== -1) this.#relatedAddress = rest[raddrIndex + 1] 63 | 64 | const rportIndex = rest.indexOf('rport') 65 | if (rportIndex !== -1) this.#relatedPort = Number(rest[rportIndex + 1]) 66 | } 67 | } 68 | } 69 | 70 | get address () { 71 | return this.#address ?? null 72 | } 73 | 74 | get candidate () { 75 | return this.#candidate ?? '' 76 | } 77 | 78 | get component () { 79 | return this.#component 80 | } 81 | 82 | get foundation () { 83 | return this.#foundation ?? null 84 | } 85 | 86 | get port () { 87 | return this.#port ?? null 88 | } 89 | 90 | get priority () { 91 | return this.#priority ?? null 92 | } 93 | 94 | get protocol () { 95 | return this.#protocol ?? null 96 | } 97 | 98 | get relatedAddress () { 99 | return this.#relatedAddress ?? null 100 | } 101 | 102 | get relatedPort () { 103 | return this.#relatedPort ?? null 104 | } 105 | 106 | get sdpMLineIndex () { 107 | return this.#sdpMLineIndex 108 | } 109 | 110 | get sdpMid () { 111 | return this.#sdpMid 112 | } 113 | 114 | get tcpType () { 115 | return this.#tcpType ?? null 116 | } 117 | 118 | get type () { 119 | return this.#type ?? null 120 | } 121 | 122 | get usernameFragment () { 123 | return this.#usernameFragment 124 | } 125 | 126 | toJSON () { 127 | return { 128 | candidate: this.#candidate, 129 | sdpMLineIndex: this.#sdpMLineIndex, 130 | sdpMid: this.#sdpMid, 131 | usernameFragment: this.#usernameFragment 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /lib/RTCIceTransport.js: -------------------------------------------------------------------------------- 1 | import RTCIceCandidate from './RTCIceCandidate.js' 2 | 3 | /** 4 | * @class 5 | * @implements {globalThis.RTCIceTransport} 6 | */ 7 | export default class RTCIceTransport extends EventTarget { 8 | #component = null 9 | #role = null 10 | /** @type {import('./RTCPeerConnection.js').default} */ 11 | #pc 12 | 13 | ongatheringstatechange 14 | // TODO: not implemented 15 | onselectedcandidatepairchange 16 | onstatechange 17 | 18 | constructor ({ pc }) { 19 | super() 20 | this.#pc = pc 21 | 22 | pc.addEventListener('icegatheringstatechange', () => { 23 | const e = new Event('gatheringstatechange') 24 | this.dispatchEvent(e) 25 | this.ongatheringstatechange?.(e) 26 | }) 27 | pc.addEventListener('iceconnectionstatechange', () => { 28 | const e = new Event('statechange') 29 | this.dispatchEvent(e) 30 | this.onstatechange?.(e) 31 | }) 32 | } 33 | 34 | get component () { 35 | const pair = this.getSelectedCandidatePair() 36 | if (!pair?.local) return null 37 | return pair.local.component 38 | } 39 | 40 | get role () { 41 | return this.#pc.localDescription.type === 'offer' ? 'controlling' : 'controlled' 42 | } 43 | 44 | get gatheringState () { 45 | return this.#pc.iceGatheringState 46 | } 47 | 48 | get state () { 49 | return this.#pc.iceConnectionState 50 | } 51 | 52 | getLocalCandidates () { 53 | return this.#pc.localCandidates 54 | } 55 | 56 | getRemoteCandidates () { 57 | return this.#pc.remoteCandidates 58 | } 59 | 60 | getLocalParameters () { 61 | return new RTCIceParameters(new RTCIceCandidate({ candidate: this.#pc.getSelectedCandidatePair().local.candidate, sdpMLineIndex: 0 })) 62 | } 63 | 64 | getRemoteParameters () { 65 | return new RTCIceParameters(new RTCIceCandidate({ candidate: this.#pc.getSelectedCandidatePair().remote.candidate, sdpMLineIndex: 0 })) 66 | } 67 | 68 | getSelectedCandidatePair () { 69 | const pair = this.#pc.getSelectedCandidatePair() 70 | if (!pair?.local || !pair?.remote) return null 71 | return { 72 | local: new RTCIceCandidate({ 73 | candidate: pair.local.candidate, 74 | sdpMid: pair.local.mid 75 | }), 76 | remote: new RTCIceCandidate({ 77 | candidate: pair.remote.candidate, 78 | sdpMid: pair.remote.mid 79 | }) 80 | } 81 | } 82 | } 83 | 84 | export class RTCIceParameters { 85 | usernameFragment = '' 86 | password = '' 87 | constructor ({ usernameFragment, password = '' }) { 88 | this.usernameFragment = usernameFragment 89 | this.password = password 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /lib/RTCPeerConnection.js: -------------------------------------------------------------------------------- 1 | import { PeerConnection, RtcpReceivingSession, Video, Audio } from 'node-datachannel' 2 | import 'node-domexception' 3 | import RTCSessionDescription from './RTCSessionDescription.js' 4 | import RTCDataChannel from './RTCDataChannel.js' 5 | import RTCIceCandidate from './RTCIceCandidate.js' 6 | import { RTCDataChannelEvent, RTCPeerConnectionIceEvent, RTCTrackEvent } from './Events.js' 7 | import { RTCRtpTransceiver } from './RTCRtp.js' 8 | import { MediaStreamTrack } from './MediaStream.js' 9 | import RTCSctpTransport from './RTCSctpTransport.js' 10 | 11 | const ndcDirectionMap = { 12 | inactive: 'Inactive', 13 | recvonly: 'RecvOnly', 14 | sendonly: 'SendOnly', 15 | sendrecv: 'SendRecv', 16 | stopped: 'Inactive', 17 | undefined: 'Unknown' 18 | } 19 | 20 | /** 21 | * @class 22 | * @implements {globalThis.RTCPeerConnection} 23 | */ 24 | export default class RTCPeerConnection extends EventTarget { 25 | #peerConnection 26 | #localOffer 27 | #localAnswer 28 | /** @type {Set} */ 29 | #dataChannels = new Set() 30 | #config 31 | #canTrickleIceCandidates = null 32 | #sctp 33 | /** @type {RTCIceCandidate[]} */ 34 | #localCandidates = [] 35 | /** @type {RTCIceCandidate[]} */ 36 | #remoteCandidates = [] 37 | #announceNegotiation 38 | 39 | #tracks = new Set() 40 | #transceivers = [] 41 | #unusedTransceivers = [] 42 | 43 | onconnectionstatechange 44 | ondatachannel 45 | onicecandidate 46 | // TODO: not implemented 47 | onicecandidateerror 48 | oniceconnectionstatechange 49 | onicegatheringstatechange 50 | onnegotiationneeded 51 | onsignalingstatechange 52 | ontrack 53 | 54 | constructor (init = {}) { 55 | super() 56 | 57 | this.setConfiguration(init) 58 | this.#localOffer = createDeferredPromise() 59 | this.#localAnswer = createDeferredPromise() 60 | this.#sctp = new RTCSctpTransport({ pc: this }) 61 | 62 | this.#peerConnection = new PeerConnection(this.#config.peerIdentity || `peer-${getRandomString(7)}`, { 63 | ...init, 64 | iceServers: this.#config.iceServers 65 | .map(server => { 66 | const urls = Array.isArray(server.urls) ? server.urls : [server.urls] 67 | 68 | return urls.map(url => { 69 | if (server.username && server.credential) { 70 | const [protocol, rest] = url.split(/:(.*)/) 71 | return `${protocol}:${server.username}:${server.credential}@${rest}` 72 | } 73 | return url 74 | }) 75 | }) 76 | .flat(), 77 | iceTransportPolicy: this.#config.iceTransportPolicy 78 | }) 79 | 80 | // forward peerConnection events 81 | this.#peerConnection.onStateChange(() => { 82 | this.dispatchEvent(new Event('connectionstatechange')) 83 | }) 84 | 85 | this.#peerConnection.onIceStateChange(() => { 86 | this.dispatchEvent(new Event('iceconnectionstatechange')) 87 | }) 88 | 89 | this.#peerConnection.onSignalingStateChange(() => { 90 | this.dispatchEvent(new Event('signalingstatechange')) 91 | }) 92 | 93 | this.#peerConnection.onGatheringStateChange(() => { 94 | this.dispatchEvent(new Event('icegatheringstatechange')) 95 | }) 96 | 97 | this.#peerConnection.onDataChannel(dataChannel => { 98 | this.dispatchEvent(new RTCDataChannelEvent('datachannel', { channel: this.#handleDataChannel(dataChannel) })) 99 | }) 100 | 101 | this.#peerConnection.onLocalDescription((sdp, type) => { 102 | if (type === 'offer') { 103 | this.#localOffer.resolve(new RTCSessionDescription({ sdp, type })) 104 | } 105 | 106 | if (type === 'answer') { 107 | this.#localAnswer.resolve(new RTCSessionDescription({ sdp, type })) 108 | } 109 | }) 110 | 111 | this.#peerConnection.onLocalCandidate((candidate, sdpMid) => { 112 | if (sdpMid === 'unspec') { 113 | this.#localAnswer.reject(new Error(`Invalid description type ${sdpMid}`)) 114 | return 115 | } 116 | 117 | this.dispatchEvent(new RTCPeerConnectionIceEvent(new RTCIceCandidate({ candidate, sdpMid }))) 118 | }) 119 | 120 | this.#peerConnection.onTrack(track => { 121 | const transceiver = new RTCRtpTransceiver({ transceiver: track, pc: this }) 122 | this.#tracks.add(track) 123 | transceiver._setNDCTrack(track) 124 | this.#transceivers.push(transceiver) 125 | const mediastream = new MediaStreamTrack() 126 | mediastream.track = track 127 | track.onClosed(() => { 128 | this.#tracks.delete(track) 129 | mediastream.dispatchEvent(new Event('ended')) 130 | }) 131 | track.onMessage(buf => mediastream.stream.push(buf)) 132 | transceiver.receiver.track = mediastream 133 | this.dispatchEvent(new RTCTrackEvent('track', { track: mediastream, receiver: transceiver.receiver, transceiver })) 134 | }) 135 | 136 | // forward events to properties 137 | this.addEventListener('connectionstatechange', e => { 138 | this.onconnectionstatechange?.(e) 139 | }) 140 | this.addEventListener('signalingstatechange', e => { 141 | this.onsignalingstatechange?.(e) 142 | }) 143 | this.addEventListener('iceconnectionstatechange', e => { 144 | this.oniceconnectionstatechange?.(e) 145 | }) 146 | this.addEventListener('icegatheringstatechange', e => { 147 | this.onicegatheringstatechange?.(e) 148 | }) 149 | this.addEventListener('datachannel', e => { 150 | this.ondatachannel?.(e) 151 | }) 152 | this.addEventListener('icecandidate', e => { 153 | // @ts-ignore 154 | this.#localCandidates.push(e.candidate) 155 | this.onicecandidate?.(e) 156 | }) 157 | this.addEventListener('track', e => { 158 | this.ontrack?.(e) 159 | }) 160 | this.addEventListener('negotiationneeded', e => { 161 | this.#announceNegotiation = true 162 | this.onnegotiationneeded?.(e) 163 | }) 164 | } 165 | 166 | get localCandidates () { 167 | return this.#localCandidates 168 | } 169 | 170 | get remoteCandidates () { 171 | return this.#remoteCandidates 172 | } 173 | 174 | get canTrickleIceCandidates () { 175 | return this.#canTrickleIceCandidates 176 | } 177 | 178 | get connectionState () { 179 | return this.#peerConnection.state() 180 | } 181 | 182 | get iceConnectionState () { 183 | const state = this.#peerConnection.iceState() 184 | if (state === 'completed') return 'connected' 185 | return state 186 | } 187 | 188 | get iceGatheringState () { 189 | return this.#peerConnection.gatheringState() 190 | } 191 | 192 | /** @param {{ type: string; sdp: string; } | null} desc */ 193 | #nullableDescription (desc) { 194 | if (!desc) return null 195 | return new RTCSessionDescription(desc) 196 | } 197 | 198 | get currentLocalDescription () { 199 | return this.#nullableDescription(this.#peerConnection.localDescription()) 200 | } 201 | 202 | get currentRemoteDescription () { 203 | return this.#nullableDescription(this.#peerConnection.remoteDescription()) 204 | } 205 | 206 | get localDescription () { 207 | return this.#nullableDescription(this.#peerConnection.localDescription()) 208 | } 209 | 210 | get pendingLocalDescription () { 211 | return this.#nullableDescription(this.#peerConnection.localDescription()) 212 | } 213 | 214 | get pendingRemoteDescription () { 215 | return this.#nullableDescription(this.#peerConnection.remoteDescription()) 216 | } 217 | 218 | get remoteDescription () { 219 | return this.#nullableDescription(this.#peerConnection.remoteDescription()) 220 | } 221 | 222 | get sctp () { 223 | return this.#sctp 224 | } 225 | 226 | get signalingState () { 227 | return this.#peerConnection.signalingState() 228 | } 229 | 230 | /** @type {typeof globalThis.RTCPeerConnection['generateCertificate']} */ 231 | static async generateCertificate (keygenAlgorithm) { 232 | throw new DOMException('Not implemented') 233 | } 234 | 235 | /** @param {RTCIceCandidateInit} candidate */ 236 | async addIceCandidate (candidate) { 237 | // TODO: only resolve this once the candidate is added 238 | if (candidate?.candidate == null) { 239 | throw new DOMException('Candidate invalid') 240 | } 241 | 242 | // re-throw as w3c errors 243 | try { 244 | this.#peerConnection.addRemoteCandidate(candidate.candidate, candidate.sdpMid ?? '0') 245 | this.#remoteCandidates.push(new RTCIceCandidate(candidate)) 246 | } catch (e) { 247 | if (!e?.message) throw new DOMException(JSON.stringify(e), 'UnknownError') 248 | 249 | const { message } = e 250 | if (message.includes('remote candidate without remote description')) throw new DOMException(message, 'InvalidStateError') 251 | if (message.includes('Invalid candidate format')) throw new DOMException(message, 'OperationError') 252 | 253 | throw new DOMException(message, 'UnknownError') 254 | } 255 | } 256 | 257 | #findUnusedTransceiver (kind) { 258 | const unused = this.#unusedTransceivers.find(tr => tr.track.kind === kind && tr.direction === 'sendonly') 259 | if (!unused) return 260 | this.#unusedTransceivers.splice(this.#unusedTransceivers.indexOf(unused), 1) 261 | return unused 262 | } 263 | 264 | #setUpTrack (media, track, transceiver, direction) { 265 | const session = new RtcpReceivingSession() 266 | const pctrack = this.#peerConnection.addTrack(media) 267 | this.#tracks.add(pctrack) 268 | pctrack.onClosed(() => { 269 | this.#tracks.delete(pctrack) 270 | track.dispatchEvent(new Event('ended')) 271 | }) 272 | pctrack.setMediaHandler(session) 273 | track.media = media 274 | track.track = pctrack 275 | transceiver._setNDCTrack(pctrack) 276 | track.stream.on('data', buf => { 277 | pctrack.sendMessageBinary(buf) 278 | }) 279 | if (direction === 'recvonly') { 280 | transceiver.receiver.track = track 281 | } else if (direction === 'sendonly') { 282 | transceiver.sender.track = track 283 | } 284 | if (this.#announceNegotiation) { 285 | this.#announceNegotiation = false 286 | this.dispatchEvent(new Event('negotiationneeded')) 287 | } 288 | } 289 | 290 | addTrack (track, ...streams) { 291 | for (const stream of streams) stream.addTrack(track) 292 | 293 | const kind = track.kind 294 | 295 | const unused = this.#findUnusedTransceiver(kind) 296 | if (unused) { 297 | this.#setUpTrack(unused.media, track, unused, 'sendonly') 298 | return unused.sender 299 | } else { 300 | const transceiver = this.addTransceiver(track, { direction: 'sendonly' }) 301 | return transceiver.sender 302 | } 303 | } 304 | 305 | /** 306 | * @param {MediaStreamTrack | string} trackOrKind 307 | * @param {RTCRtpTransceiverInit=} opts 308 | */ 309 | addTransceiver (trackOrKind, { direction = 'inactive', sendEncodings = undefined, streams = undefined } = {}) { 310 | if (direction === 'sendrecv') throw new TypeError('unsupported') 311 | const track = trackOrKind instanceof MediaStreamTrack && trackOrKind 312 | const kind = (track && track.kind) || trackOrKind 313 | // @ts-ignore 314 | const ndcMedia = kind === 'video' ? new Video('video', ndcDirectionMap[direction]) : new Audio('audio', ndcDirectionMap[direction]) 315 | 316 | const transceiver = new RTCRtpTransceiver({ transceiver: ndcMedia, pc: this }) 317 | this.#transceivers.push(transceiver) 318 | if (track) { 319 | this.#setUpTrack(ndcMedia, track, transceiver, direction) 320 | } else { 321 | this.#unusedTransceivers.push(transceiver) 322 | } 323 | return transceiver 324 | } 325 | 326 | getReceivers () { 327 | // receivers are created on ontrack 328 | return this.#transceivers.map(tr => tr.direction === 'recvonly' && tr.receiver).filter(re => re) 329 | } 330 | 331 | getSenders () { 332 | // senders are created on addTrack or addTransceiver 333 | return this.#transceivers.map(tr => tr.direction === 'sendonly' && tr.sender).filter(se => se) 334 | } 335 | 336 | getTracks () { 337 | return [...this.#tracks] 338 | } 339 | 340 | get maxMessageSize () { 341 | return this.#peerConnection.maxMessageSize() 342 | } 343 | 344 | get maxChannels () { 345 | return this.#peerConnection.maxDataChannelId() 346 | } 347 | 348 | close () { 349 | // close all channels before shutting down 350 | for (const channel of this.#dataChannels) { 351 | channel.close() 352 | } 353 | for (const transceiver of this.#transceivers) { 354 | transceiver.close() 355 | } 356 | for (const track of this.#tracks) { 357 | track.close() 358 | } 359 | 360 | this.#peerConnection.close() 361 | } 362 | 363 | createAnswer () { 364 | return this.#localAnswer 365 | } 366 | 367 | #handleDataChannel (channel, opts) { 368 | const dataChannel = new RTCDataChannel(channel, opts, this) 369 | 370 | // ensure we can close all channels when shutting down 371 | this.#dataChannels.add(dataChannel) 372 | dataChannel.addEventListener('close', () => { 373 | this.#dataChannels.delete(dataChannel) 374 | }) 375 | 376 | return dataChannel 377 | } 378 | 379 | createDataChannel (label, opts = {}) { 380 | if (opts.ordered === false) opts.unordered = true 381 | const channel = this.#peerConnection.createDataChannel('' + label, opts) 382 | const dataChannel = this.#handleDataChannel(channel, opts) 383 | 384 | if (this.#announceNegotiation == null) { 385 | this.#announceNegotiation = false 386 | this.dispatchEvent(new Event('negotiationneeded')) 387 | } 388 | 389 | return dataChannel 390 | } 391 | 392 | createOffer () { 393 | return this.#localOffer 394 | } 395 | 396 | getConfiguration () { 397 | return this.#config 398 | } 399 | 400 | getSelectedCandidatePair () { 401 | return this.#peerConnection.getSelectedCandidatePair() 402 | } 403 | 404 | // @ts-expect-error dont support callback based stats 405 | getStats () { 406 | const report = new Map() 407 | const cp = this.getSelectedCandidatePair() 408 | const bytesSent = this.#peerConnection.bytesSent() 409 | const bytesReceived = this.#peerConnection.bytesReceived() 410 | const rtt = this.#peerConnection.rtt() 411 | 412 | const localIdRs = getRandomString(8) 413 | const localId = 'RTCIceCandidate_' + localIdRs 414 | report.set(localId, { 415 | id: localId, 416 | type: 'local-candidate', 417 | timestamp: Date.now(), 418 | candidateType: cp?.local.type, 419 | ip: cp?.local.address, 420 | port: cp?.local.port 421 | }) 422 | 423 | const remoteIdRs = getRandomString(8) 424 | const remoteId = 'RTCIceCandidate_' + remoteIdRs 425 | report.set(remoteId, { 426 | id: remoteId, 427 | type: 'remote-candidate', 428 | timestamp: Date.now(), 429 | candidateType: cp?.remote.type, 430 | ip: cp?.remote.address, 431 | port: cp?.remote.port 432 | }) 433 | 434 | const candidateId = 'RTCIceCandidatePair_' + localIdRs + '_' + remoteIdRs 435 | report.set(candidateId, { 436 | id: candidateId, 437 | type: 'candidate-pair', 438 | timestamp: Date.now(), 439 | localCandidateId: localId, 440 | remoteCandidateId: remoteId, 441 | state: 'succeeded', 442 | nominated: true, 443 | writable: true, 444 | bytesSent, 445 | bytesReceived, 446 | totalRoundTripTime: rtt, 447 | currentRoundTripTime: rtt 448 | }) 449 | 450 | const transportId = 'RTCTransport_0_1' 451 | report.set(transportId, { 452 | id: transportId, 453 | timestamp: Date.now(), 454 | type: 'transport', 455 | bytesSent, 456 | bytesReceived, 457 | dtlsState: 'connected', 458 | selectedCandidatePairId: candidateId, 459 | selectedCandidatePairChanges: 1 460 | }) 461 | 462 | const dataChannels = [...this.#dataChannels] 463 | 464 | report.set('P', { 465 | id: 'P', 466 | timestamp: Date.now(), 467 | type: 'peer-connection', 468 | // TODO: this isn't accurate as it shows currently open/closed channels, not the history count 469 | dataChannelsClosed: dataChannels.filter(channel => channel.readyState === 'open').length, 470 | dataChannelsOpened: dataChannels.filter(channel => channel.readyState !== 'open').length 471 | }) 472 | 473 | return Promise.resolve(report) 474 | } 475 | 476 | getTransceivers () { 477 | return this.#transceivers 478 | } 479 | 480 | removeTrack () { 481 | console.warn('track detatching not supported') 482 | } 483 | 484 | restartIce () { 485 | throw new DOMException('Not implemented') 486 | } 487 | 488 | setConfiguration (config) { 489 | // TODO: this doesn't actually update the configuration :/ 490 | // most of these are unused x) 491 | config ??= {} 492 | if (config.bundlePolicy === undefined) config.bundlePolicy = 'balanced' 493 | // @ts-ignore 494 | config.encodedInsertableStreams ??= false 495 | config.iceCandidatePoolSize ??= 0 496 | config.iceServers ??= [] 497 | for (let { urls } of config.iceServers) { 498 | if (!Array.isArray(urls)) urls = [urls] 499 | for (const url of urls) { 500 | try { 501 | // eslint-disable-next-line no-new 502 | new URL(url) 503 | } catch (error) { 504 | throw new DOMException(`Failed to execute 'setConfiguration' on 'RTCPeerConnection': '${url}' is not a valid URL.`, 'SyntaxError') 505 | } 506 | } 507 | } 508 | config.iceTransportPolicy ??= 'all' 509 | // @ts-ignore 510 | config.rtcAudioJitterBufferFastAccelerate ??= false 511 | // @ts-ignore 512 | config.rtcAudioJitterBufferMaxPackets ??= 200 513 | // @ts-ignore 514 | config.rtcAudioJitterBufferMinDelayMs ??= 0 515 | config.rtcpMuxPolicy ??= 'require' 516 | 517 | 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.") 518 | 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.") 519 | if (this.#config) { 520 | if (config.bundlePolicy !== this.#config.bundlePolicy) { 521 | throw new DOMException("Failed to execute 'setConfiguration' on 'RTCPeerConnection': Attempted to modify the PeerConnection's configuration in an unsupported way.", 'InvalidModificationError') 522 | } 523 | } 524 | 525 | this.#config = config 526 | } 527 | 528 | async setLocalDescription (description) { 529 | if (description == null || description.type == null) { 530 | return this.#peerConnection.setLocalDescription() 531 | } 532 | // TODO: error and state checking 533 | 534 | if (description.type !== 'offer') { 535 | // any other type causes libdatachannel to throw 536 | return this.#peerConnection.setLocalDescription() 537 | } 538 | this.#peerConnection.setLocalDescription(description.type) 539 | } 540 | 541 | async setRemoteDescription (description) { 542 | if (description.sdp == null) { 543 | throw new DOMException('Remote SDP must be set') 544 | } 545 | // TODO: error and state checking 546 | 547 | this.#peerConnection.setRemoteDescription(description.sdp, description.type) 548 | } 549 | } 550 | 551 | function createDeferredPromise () { 552 | let resolve, reject 553 | /** @type {any} */ 554 | const promise = new Promise((_resolve, _reject) => { 555 | resolve = _resolve 556 | reject = _reject 557 | }) 558 | 559 | promise.resolve = resolve 560 | promise.reject = reject 561 | return promise 562 | } 563 | 564 | function getRandomString (length = 0) { 565 | return Math.random() 566 | .toString(36) 567 | .substring(2, 2 + length) 568 | } 569 | -------------------------------------------------------------------------------- /lib/RTCRtp.js: -------------------------------------------------------------------------------- 1 | import 'node-domexception' 2 | import RTCDtlsTransport from './RTCDtlsTransport.js' 3 | 4 | const ndcDirectionMapFrom = { 5 | Inactive: 'inactive', 6 | RecvOnly: 'recvonly', 7 | SendOnly: 'sendonly', 8 | SendRecv: 'sendrecv', 9 | Unknown: 'undefined' 10 | } 11 | 12 | const ndcDirectionMapTo = { 13 | inactive: 'Inactive', 14 | recvonly: 'RecvOnly', 15 | sendonly: 'SendOnly', 16 | sendrecv: 'SendRecv', 17 | stopped: 'Inactive', 18 | undefined: 'Unknown' 19 | } 20 | 21 | /** 22 | * @class 23 | * @implements {globalThis.RTCRtpTransceiver} 24 | */ 25 | export class RTCRtpTransceiver { 26 | #transceiver 27 | #track 28 | #desiredDirection 29 | #sender 30 | #receiver 31 | constructor ({ transceiver, pc }) { 32 | this.#transceiver = transceiver 33 | this.#sender = new RTCRtpSender({ pc }) 34 | this.#receiver = new RTCRtpReceiver({ pc }) 35 | } 36 | 37 | _setNDCTrack (track) { 38 | if (this.#track) return 39 | this.#track = track 40 | } 41 | 42 | get currentDirection () { 43 | return ndcDirectionMapFrom[this.#transceiver.direction()] 44 | } 45 | 46 | get direction () { 47 | return this.#desiredDirection 48 | } 49 | 50 | set direction (dir) { 51 | this.#desiredDirection = dir 52 | if (!this.#sender) return 53 | this.#transceiver.setDirection(ndcDirectionMapTo[dir]) 54 | } 55 | 56 | get mid () { 57 | return this.#transceiver.mid() 58 | } 59 | 60 | get sender () { 61 | return this.#sender 62 | } 63 | 64 | get receiver () { 65 | return this.#receiver 66 | } 67 | 68 | get stopped () { 69 | return this.#track?.isClosed() 70 | } 71 | 72 | setDirection (direction) { 73 | this.#track?.setDirection(ndcDirectionMapTo[direction]) 74 | } 75 | 76 | setCodecPreferences (codecs) { 77 | // TODO 78 | // addVideoCodec(payloadType: number, codec: string, profile?: string): void; 79 | // addH264Codec(payloadType: number, profile?: string): void; 80 | // addVP8Codec(payloadType: number): void; 81 | // addVP9Codec(payloadType: number): void; 82 | } 83 | 84 | stop () { 85 | this.#track?.close() 86 | } 87 | } 88 | 89 | /** 90 | * @class 91 | * @implements {globalThis.RTCRtpSender} 92 | */ 93 | export class RTCRtpSender { 94 | track 95 | transform // TODO, is it worth tho? 96 | #transport 97 | #pc 98 | constructor ({ pc }) { 99 | this.#transport = new RTCDtlsTransport({ pc }) 100 | this.#pc = pc 101 | } 102 | 103 | get dtmf () { 104 | return null 105 | } 106 | 107 | get transport () { 108 | return this.#transport ?? null 109 | } 110 | 111 | static getCapabilities (kind) { 112 | if (!kind) throw new TypeError("Failed to execute 'getCapabilities' on 'RTCRtpSender': 1 argument required, but only 0 present.") 113 | if (kind === 'video') { 114 | return { 115 | codecs: [ 116 | { mimeType: 'video/H264' }, 117 | { mimeType: 'video/VP8' }, 118 | { mimeType: 'video/VP9' } 119 | ] 120 | } 121 | } else { 122 | return { 123 | codecs: [ 124 | { mimeType: 'video/opus' } 125 | ] 126 | } 127 | } 128 | } 129 | 130 | async getStats () { 131 | return new Map() 132 | } 133 | 134 | getParameters () { 135 | return { encodings: [], codecs: [], transactionId: '', headerExtensions: [], rtcp: { reducedSize: false } } 136 | } 137 | 138 | async setParameters () { 139 | // TODO 140 | // addVideoCodec(payloadType: number, codec: string, profile?: string): void; 141 | // addH264Codec(payloadType: number, profile?: string): void; 142 | // addVP8Codec(payloadType: number): void; 143 | // addVP9Codec(payloadType: number): void; 144 | // setBitrate 145 | } 146 | 147 | setStreams (streams) { 148 | if (this.#pc.connectionState !== 'connected') throw new DOMException('Sender\'s connection is closed', 'InvalidStateError') 149 | if (!this.track) return 150 | for (const stream of streams) { 151 | stream.addTrack(this.track) 152 | } 153 | } 154 | 155 | async replaceTrack () { 156 | throw new TypeError('Method unsupported') 157 | } 158 | } 159 | 160 | /** 161 | * @class 162 | * @implements {globalThis.RTCRtpReceiver} 163 | */ 164 | export class RTCRtpReceiver { 165 | transform // TODO, is it worth tho? 166 | #transport 167 | track 168 | constructor ({ pc }) { 169 | this.#transport = new RTCDtlsTransport({ pc }) 170 | } 171 | 172 | get transport () { 173 | return this.#transport ?? null 174 | } 175 | 176 | static getCapabilities (kind) { 177 | if (!kind) throw new TypeError("Failed to execute 'getCapabilities' on 'RTCRtpSender': 1 argument required, but only 0 present.") 178 | if (kind === 'video') { 179 | return { 180 | codecs: [ 181 | { mimeType: 'video/H264' }, 182 | { mimeType: 'video/VP8' }, 183 | { mimeType: 'video/VP9' } 184 | ] 185 | } 186 | } else { 187 | return { 188 | codecs: [ 189 | { mimeType: 'video/opus' } 190 | ] 191 | } 192 | } 193 | } 194 | 195 | async getStats () { 196 | return new Map() 197 | } 198 | 199 | getParameters () { 200 | return { encodings: [], codecs: [], transactionId: '', headerExtensions: [], rtcp: { reducedSize: false } } 201 | } 202 | 203 | getContributingSources () { 204 | return [] 205 | } 206 | 207 | getSynchronizationSources () { 208 | return [] 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /lib/RTCSctpTransport.js: -------------------------------------------------------------------------------- 1 | import RTCDtlsTransport from './RTCDtlsTransport.js' 2 | 3 | /** 4 | * @class 5 | * @implements {globalThis.RTCSctpTransport} 6 | */ 7 | export default class RTCSctpTransport extends EventTarget { 8 | #maxChannels = null 9 | /** @type {import('./RTCPeerConnection.js').default} */ 10 | #pc 11 | #transport 12 | 13 | onstatechange 14 | onerror 15 | 16 | constructor ({ pc }) { 17 | super() 18 | this.#pc = pc 19 | this.#transport = new RTCDtlsTransport({ pc }) 20 | 21 | pc.addEventListener('connectionstatechange', () => { 22 | const e = new Event('statechange') 23 | this.dispatchEvent(e) 24 | this.onstatechange?.(e) 25 | }) 26 | } 27 | 28 | get maxChannels () { 29 | if (this.state !== 'connected') return null 30 | return this.#pc.maxChannels 31 | } 32 | 33 | get maxMessageSize () { 34 | return this.#pc.maxMessageSize ?? 65536 35 | } 36 | 37 | get state () { 38 | const state = this.#pc.connectionState 39 | if (state === 'new' || state === 'connecting') { 40 | return 'connecting' 41 | } else if (state === 'disconnected' || state === 'failed' || state === 'closed') { 42 | return 'closed' 43 | } 44 | return state 45 | } 46 | 47 | get transport () { 48 | return this.#transport 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/RTCSessionDescription.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class 3 | * @implements {globalThis.RTCSessionDescription} 4 | */ 5 | export default class RTCSessionDescription { 6 | #type 7 | sdp 8 | /** 9 | * @param {RTCSessionDescriptionInit | null | undefined | object} init 10 | */ 11 | constructor (init) { 12 | this.#type = init?.type 13 | this.sdp = init?.sdp ?? '' 14 | } 15 | 16 | get type () { 17 | return this.#type 18 | } 19 | 20 | set type (type) { 21 | if (type !== 'offer' && type !== 'answer' && type !== 'pranswer' && type !== 'rollback') { 22 | throw new TypeError(`Failed to set the 'type' property on 'RTCSessionDescription': The provided value '${type}' is not a valid enum value of type RTCSdpType.`) 23 | } 24 | this.#type = type 25 | } 26 | 27 | toJSON () { 28 | return { 29 | sdp: this.sdp, 30 | type: this.#type 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webrtc-polyfill", 3 | "version": "1.1.11", 4 | "description": "High performance WebRTC polyfill for Node.JS via libdatachannel using Node Native/NAPI.", 5 | "type": "module", 6 | "types": "./types.d.ts", 7 | "browser": "./browser.js", 8 | "chromeapp": "./browser.js", 9 | "engines": { 10 | "node": ">=16.0.0" 11 | }, 12 | "exports": { 13 | "node": { 14 | "types": "./types.d.ts", 15 | "import": "./index.js", 16 | "polyfill": "./polyfill.js" 17 | }, 18 | "browser": { 19 | "types": "./types.d.ts", 20 | "import": "./browser.js", 21 | "polyfill": "./polyfill-browser.js" 22 | }, 23 | "default": { 24 | "types": "./types.d.ts", 25 | "import": "./index.js", 26 | "polyfill": "./polyfill.js" 27 | } 28 | }, 29 | "files": [ 30 | "lib/*", 31 | "index.js", 32 | "browser.js", 33 | "polyfill.js", 34 | "polyfill-browser.js", 35 | "types.d.ts" 36 | ], 37 | "keywords": [ 38 | "webrtc", 39 | "w3c", 40 | "nodejs", 41 | "polyfill", 42 | "p2p", 43 | "peer", 44 | "libdatachannel", 45 | "peer-to-peer", 46 | "datachannel", 47 | "data channel" 48 | ], 49 | "author": "ThaUnknown", 50 | "license": "MIT", 51 | "homepage": "https://github.com/ThaUnknown/webrtc-polyfill#readme", 52 | "bugs": "https://github.com/ThaUnknown/webrtc-polyfill/issues", 53 | "repository": "https://github.com/ThaUnknown/webrtc-polyfill.git", 54 | "dependencies": { 55 | "node-datachannel": "^0.27.0", 56 | "node-domexception": "^2.0.2" 57 | }, 58 | "devDependencies": { 59 | "@types/webrtc": "^0.0.46", 60 | "concurrently": "^9.1.2", 61 | "jsdom": "^26.1.0", 62 | "tap-spec": "^5.0.0", 63 | "tape": "^5.9.0", 64 | "typescript": "^5.8.3", 65 | "uint8-util": "^2.2.5" 66 | }, 67 | "scripts": { 68 | "test": "concurrently --kill-others --raw \"npm run wpt:server\" \"npm run wrtc:test\"", 69 | "wrtc:test": "tape test/wpt.js | tap-spec", 70 | "wpt:server": "python test/wpt/wpt.py serve", 71 | "wpt:make-hosts": "python test/wpt/wpt.py make-hosts-file" 72 | } 73 | } -------------------------------------------------------------------------------- /polyfill-browser.js: -------------------------------------------------------------------------------- 1 | import WebRTC from './browser.js' 2 | 3 | const scope = typeof window !== 'undefined' ? window : self 4 | 5 | // this is kinda stupid, but I guess it unprefixes the WebRTC API? 6 | for (const [key, value] of Object.entries(WebRTC)) { 7 | if (key !== 'default') { 8 | scope[key] ??= value 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /polyfill.js: -------------------------------------------------------------------------------- 1 | import WebRTC from './index.js' 2 | 3 | for (const [key, value] of Object.entries(WebRTC)) { 4 | if (key !== 'default') { 5 | globalThis[key] ??= value 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

2 |      3 | 4 |

5 | 6 | webrtc-polyfill is a Node.js Native Addon that provides bindings to [libdatachannel](https://github.com/paullouisageneau/libdatachannel). This project aims for spec-compliance and is tested using the W3C's [web-platform-tests](https://github.com/web-platform-tests/wpt) project, but doesn't pass all of its tests, instead focuses on the important ones, like close, error and data events, states, while offering performance better than any alternative. A number of [nonstandard APIs](docs/nonstandard-apis.md) for testing are also included. 7 | 8 | Notably most of the core WebRTC functionality is async in contrast to how web handles it. Notable examples are: 9 | ```js 10 | datachannel.send(new Blob([new Uint8Array([1,2,3,4])])) 11 | datachannel.send(new Uint8Array([1,2,3,4])) // this will arrive before the blob, as blob internals are asynchronous 12 | ``` 13 | and 14 | ```js 15 | datachannel.send(new Uint8Array([1,2,3,4])) 16 | datachannel.bufferedAmount // not always 4, as the thread that runs it is async and might flush instantly! 17 | ``` 18 | 19 | Install 20 | ------- 21 | 22 | ``` 23 | pnpm install webrtc-polyfill 24 | ``` 25 | Supported Platforms 26 | ------------------- 27 | 28 | For supported platforms see the [node-channel bindings compatibility table](https://github.com/murat-dogan/node-datachannel/#supported-platforms). Note that node versions under 14 require a global EventTarget and Event polyfill. 29 | 30 | Testing 31 | ------- 32 | Requirements: Python, Node.js 33 | -------------------------------------------------------------------------------- /test/constants.js: -------------------------------------------------------------------------------- 1 | export const EXCLUSIONS = [ 2 | // utility files 3 | 'RTCCertificate-postMessage.html', 4 | // getConfig/setConfig which is not implemented 5 | 'RTCConfiguration-iceServers.html', 6 | 'RTCConfiguration-iceTransportPolicy.html', 7 | 'RTCConfiguration-rtcpMuxPolicy.html', 8 | // generateCerficicate which is not implemented 9 | 'RTCPeerConnection-generateCertificate.html', 10 | // RTCCertificate which is not implemented 11 | 'RTCCertificate.html', 12 | // RTCDTMFSender which is not implemented 13 | 'RTCDTMFSender-insertDTMF.https.html', 14 | 'RTCDTMFSender-ontonechange-long.https.html', 15 | 'RTCDTMFSender-ontonechange.https.html', 16 | // canvas 17 | 'RTCPeerConnection-relay-canvas.https.html' 18 | ] 19 | 20 | export const WPT_SERVER_URL = 'http://web-platform.test:8000' 21 | -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | import { equal } from 'uint8-util' 2 | import DOMException from 'node-domexception' 3 | 4 | import { Blob } from 'node:buffer' 5 | 6 | import WebRTC from '../index.js' 7 | import { MediaStream, MediaStreamTrack } from '../lib/MediaStream.js' 8 | 9 | function blobToArrayBuffer (blob) { 10 | return blob.arrayBuffer() 11 | } 12 | 13 | function assertEqualsTypedArray (a, b) { 14 | return equal(new Uint8Array(a), new Uint8Array(b)) 15 | } 16 | 17 | const mediaDevices = { 18 | async getUserMedia (opts = {}) { 19 | const tracks = [] 20 | if (opts.video) tracks.push(video()) 21 | if (opts.audio) tracks.push(audio()) 22 | return new MediaStream(tracks) 23 | } 24 | } 25 | 26 | function audio () { 27 | return new MediaStreamTrack({ kind: 'audio' }) 28 | } 29 | 30 | function video () { 31 | return new MediaStreamTrack({ kind: 'video' }) 32 | } 33 | 34 | function canCreate () { 35 | return true 36 | } 37 | 38 | export function overwriteGlobals (window) { 39 | Object.assign(window, WebRTC) 40 | if (window.trackFactories) { 41 | window.trackFactories.audio = audio 42 | window.trackFactories.video = video 43 | window.trackFactories.canCreate = canCreate 44 | } 45 | window.navigator.mediaDevices = mediaDevices 46 | window.TypeError = TypeError 47 | window.DOMException = DOMException 48 | window.Blob = Blob 49 | window.ArrayBuffer = ArrayBuffer 50 | window.assert_equals_typed_array = assertEqualsTypedArray 51 | window.blobToArrayBuffer = blobToArrayBuffer 52 | } 53 | -------------------------------------------------------------------------------- /test/wpt.js: -------------------------------------------------------------------------------- 1 | import test from 'tape' 2 | import { JSDOM, VirtualConsole } from 'jsdom' 3 | 4 | import { readdir } from 'node:fs/promises' 5 | 6 | import { overwriteGlobals } from './util.js' 7 | import { EXCLUSIONS, WPT_SERVER_URL } from './constants.js' 8 | 9 | const files = await readdir('./test/wpt/webrtc') 10 | const pathList = files.filter(f => f.endsWith('.html') && !EXCLUSIONS.includes(f)) 11 | // const pathList = ['RTCDataChannel-send.html', 'RTCIceTransport.html'] 12 | 13 | // wait for WPT to load 14 | await new Promise(resolve => setTimeout(resolve, 8000)) 15 | 16 | // call runTest for each test path 17 | for (const [i, testPath] of pathList.entries()) { 18 | const isLast = i === pathList.length - 1 19 | test(testPath, async t => { 20 | await new Promise(resolve => setTimeout(resolve, 5000)) 21 | try { 22 | const virtualConsole = new VirtualConsole() 23 | virtualConsole.sendTo(console) 24 | const { window } = await JSDOM.fromURL(WPT_SERVER_URL + '/webrtc/' + testPath, { 25 | runScripts: 'dangerously', 26 | resources: 'usable', 27 | omitJSDOMErrors: true, 28 | virtualConsole, 29 | beforeParse: window => { 30 | // JSDom is a different VM, and WPT does strict checking, so to pass tests these need to be overwritten, this is an awful idea! 31 | overwriteGlobals(window) 32 | } 33 | }) 34 | 35 | const e = await new Promise(resolve => { 36 | const cleanup = e => { 37 | window.close() 38 | resolve(e) 39 | process.off('unhandledRejection', cleanup) 40 | process.off('uncaughtException', cleanup) 41 | } 42 | window.addEventListener('load', () => { 43 | overwriteGlobals(window) 44 | window.add_completion_callback((tests, status, records) => { 45 | for (const { name, status, message, stack } of tests) { 46 | t.comment(name) 47 | t.ok(status === 0, (message || name) + (stack || '')) 48 | } 49 | cleanup() 50 | }) 51 | process.once('unhandledRejection', cleanup) 52 | process.once('uncaughtException', cleanup) 53 | }) 54 | }) 55 | t.end(e) 56 | } catch (e) { 57 | t.end(e) 58 | } 59 | if (isLast) process.exit() 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | export { 4 | RTCPeerConnection, 5 | RTCSessionDescription, 6 | RTCIceCandidate, 7 | RTCIceTransport, 8 | RTCDataChannel, 9 | RTCSctpTransport, 10 | RTCDtlsTransport, 11 | RTCCertificate, 12 | MediaStream, 13 | MediaStreamTrack, 14 | MediaStreamTrackEvent, 15 | RTCPeerConnectionIceEvent, 16 | RTCDataChannelEvent, 17 | RTCTrackEvent, 18 | RTCError, 19 | RTCErrorEvent, 20 | RTCRtpTransceiver, 21 | RTCRtpReceiver, 22 | RTCRtpSender 23 | } 24 | 25 | export default { 26 | RTCPeerConnection, 27 | RTCSessionDescription, 28 | RTCIceCandidate, 29 | RTCIceTransport, 30 | RTCDataChannel, 31 | RTCSctpTransport, 32 | RTCDtlsTransport, 33 | RTCCertificate, 34 | MediaStream, 35 | MediaStreamTrack, 36 | MediaStreamTrackEvent, 37 | RTCPeerConnectionIceEvent, 38 | RTCDataChannelEvent, 39 | RTCTrackEvent, 40 | RTCError, 41 | RTCErrorEvent, 42 | RTCRtpTransceiver, 43 | RTCRtpReceiver, 44 | RTCRtpSender 45 | } 46 | --------------------------------------------------------------------------------