From a3474a123989ff63bd241a00b8a1da1ef41c1afe Mon Sep 17 00:00:00 2001 From: achingbrain Date: Wed, 25 Jun 2025 18:28:49 +0200 Subject: [PATCH] fix: use binaryType correctly The `.binaryType` field on the `RTCDataChannel` class defines what type the `.data` field on emitted `MessageEvent`s will be. It's either `"arraybuffer"` (the default) or `"blob"`. Docs: https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel/binaryType 1. Switches the default to `arraybuffer` 2. Converts incoming data to the correct type based on the `binaryType` I've done my best to assert the right types/values in the "P2P test" in `polyfill.test.ts`. Because getting the binary data out of a `Blob` is an async operation I had to make the test async, but it doesn't fit very well with the current nature of the test. TBH that test is very complicated and would be much better off split into multiple smaller tests, each testing one thing. --- src/polyfill/RTCDataChannel.ts | 29 ++- test/jest-tests/polyfill.test.ts | 410 +++++++++++++++++-------------- 2 files changed, 246 insertions(+), 193 deletions(-) diff --git a/src/polyfill/RTCDataChannel.ts b/src/polyfill/RTCDataChannel.ts index 4300361..38b9940 100644 --- a/src/polyfill/RTCDataChannel.ts +++ b/src/polyfill/RTCDataChannel.ts @@ -30,7 +30,7 @@ export default class RTCDataChannel extends EventTarget implements globalThis.RT super(); this.#dataChannel = dataChannel; - this.#binaryType = 'blob'; + this.#binaryType = 'arraybuffer'; this.#readyState = this.#dataChannel.isOpen() ? 'open' : 'connecting'; this.#bufferedAmountLowThreshold = 0; this.#maxPacketLifeTime = opts.maxPacketLifeTime ?? null; @@ -77,10 +77,29 @@ 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) => { + if (typeof message === 'string') { + this.dispatchEvent(new MessageEvent('message', { data: message })); + return + } + + let data: Blob | ArrayBuffer; + + if (message instanceof ArrayBuffer) { + data = message; + } else { + data = message.buffer; + + if (message.byteOffset !== 0 || message.byteLength !== message.buffer.byteLength) { + // message is view on underlying buffer, must create new + // ArrayBuffer that only contains message data + data = new ArrayBuffer(message.byteLength); + new Uint8Array(data, 0, message.byteLength).set(message); + } + } + + if (this.#binaryType === 'blob') { + data = new Blob([data]); } this.dispatchEvent(new MessageEvent('message', { data })); diff --git a/test/jest-tests/polyfill.test.ts b/test/jest-tests/polyfill.test.ts index 96ebf70..ee72770 100644 --- a/test/jest-tests/polyfill.test.ts +++ b/test/jest-tests/polyfill.test.ts @@ -4,6 +4,19 @@ import { RTCPeerConnection } from '../../src/polyfill/index'; import { PeerConnection } from '../../src/lib/index'; import { eventPromise } from '../fixtures/event-promise'; +// Polyfill for Promise.withResolvers for Node < 20 +if (!Promise.withResolvers) { + (Promise as any).withResolvers = function (): any { + let resolve: (value: T | PromiseLike) => void; + let reject: (reason?: any) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve: resolve!, reject: reject! }; + }; +} + describe('polyfill', () => { // Default is 5000 ms but we need more jest.setTimeout(30000); @@ -20,213 +33,234 @@ describe('polyfill', () => { expect(pc).toBeTruthy(); }); - test('P2P Test', () => { - return new Promise((done) => { - // Mocks - const p1ConnectionStateMock = jest.fn(); - const p1IceConnectionStateMock = jest.fn(); - const p1IceGatheringStateMock = jest.fn(); - const p1IceCandidateMock = jest.fn(); - const p1SDPMock = jest.fn(); - const p1DCMock = jest.fn(); - const p1MessageMock = jest.fn(); - const p2ConnectionStateMock = jest.fn(); - const p2IceConnectionStateMock = jest.fn(); - const p2IceGatheringStateMock = jest.fn(); - const p2IceCandidateMock = jest.fn(); - const p2SDPMock = jest.fn(); - const p2DCMock = jest.fn(); - const p2MessageMock = jest.fn(); - - const peer1 = new RTCPeerConnection({ - peerIdentity: 'peer1', - iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }], - }); - - const peer2 = new RTCPeerConnection({ - peerIdentity: 'peer2', - iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }], - }); - - let dc1: RTCDataChannel = null; - let dc2: RTCDataChannel = null; - - // Creates a fixed binary data for testing - function createBinaryTestData(): ArrayBufferView { - const binaryData = new Uint8Array(17); - const dv = new DataView(binaryData.buffer); - dv.setInt8(0, 123); - dv.setFloat32(1, 123.456); - dv.setUint32(5, 987654321); - dv.setFloat64(9, 789.012); - return binaryData; - } - - // Compares the received binary data to the expected value of the fixed binary data - function analyzeBinaryTestData(binaryData: ArrayBufferLike): boolean { - const dv = new DataView(binaryData); - return ( - dv.getInt8(0) == 123 && - dv.getFloat32(1) == Math.fround(123.456) && - dv.getUint32(5) == 987654321 && - dv.getFloat64(9) == 789.012 - ); - } + test('P2P Test', async () => { + // Mocks + const p1ConnectionStateMock = jest.fn(); + const p1IceConnectionStateMock = jest.fn(); + const p1IceGatheringStateMock = jest.fn(); + const p1IceCandidateMock = jest.fn(); + const p1SDPMock = jest.fn(); + const p1DCMock = jest.fn(); + const p1MessageMock = jest.fn(); + const p2ConnectionStateMock = jest.fn(); + const p2IceConnectionStateMock = jest.fn(); + const p2IceGatheringStateMock = jest.fn(); + const p2IceCandidateMock = jest.fn(); + const p2SDPMock = jest.fn(); + const p2DCMock = jest.fn(); + const p2MessageMock = jest.fn(); + + const peer1 = new RTCPeerConnection({ + peerIdentity: 'peer1', + iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }], + }); - // We will set the "binaryType" and then send/receive the "data" from the datachannel in each test, and then compare them. - // For example, the first line will send a "Hello" string after setting binaryType to "arraybuffer". - const testMessages = [ - { binaryType: 'arraybuffer', data: 'Hello' }, - { binaryType: 'arraybuffer', data: createBinaryTestData() }, - { binaryType: 'blob', data: createBinaryTestData() }, - ]; - - // Index of the message in testMessages that we are currently testing. - 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 { - 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 false; - } + const peer2 = new RTCPeerConnection({ + peerIdentity: 'peer2', + iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }], + }); - function finalizeTest(): void { - peer1.close(); - peer2.close(); + let dc1: RTCDataChannel = null; + let dc2: RTCDataChannel = null; + + // Creates a fixed binary data for testing + function createBinaryTestData(binaryData = new ArrayBuffer(17), offset = 0): ArrayBufferView { + const dv = new DataView(binaryData, offset); + dv.setInt8(0, 123); + dv.setFloat32(1, 123.456); + dv.setUint32(5, 987654321); + dv.setFloat64(9, 789.012); + return new Uint8Array(binaryData, offset, 17); + } - // State Callbacks - expect(p1ConnectionStateMock.mock.calls.length).toBeGreaterThanOrEqual(1); - expect(p2ConnectionStateMock.mock.calls.length).toBeGreaterThanOrEqual(1); - expect(p1IceConnectionStateMock.mock.calls.length).toBeGreaterThanOrEqual(1); - expect(p2IceConnectionStateMock.mock.calls.length).toBeGreaterThanOrEqual(1); - expect(p1IceGatheringStateMock.mock.calls.length).toBeGreaterThanOrEqual(1); - expect(p2IceGatheringStateMock.mock.calls.length).toBeGreaterThanOrEqual(1); + // Compares the received binary data to the expected value of the fixed binary data + function analyzeBinaryTestData(binaryData: ArrayBufferLike): boolean { + const dv = new DataView(binaryData); + return ( + dv.getInt8(0) == 123 && + dv.getFloat32(1) == Math.fround(123.456) && + dv.getUint32(5) == 987654321 && + dv.getFloat64(9) == 789.012 + ); + } - // SDP - expect(p1SDPMock.mock.calls.length).toBe(1); - expect(p2SDPMock.mock.calls.length).toBe(1); + // We will set the "binaryType" and then send/receive the "data" from the datachannel in each test, and then compare them. + // For example, the first line will send a "Hello" string after setting binaryType to "arraybuffer". + const testMessages = [ + { binaryType: 'arraybuffer', data: 'Hello' }, + { binaryType: 'arraybuffer', data: createBinaryTestData() }, + { binaryType: 'arraybuffer', data: createBinaryTestData(new ArrayBuffer(100)) }, + { binaryType: 'arraybuffer', data: createBinaryTestData(new ArrayBuffer(100), 50) }, + { binaryType: 'arraybuffer', data: createBinaryTestData(new ArrayBuffer(100), 50) }, + { binaryType: 'blob', data: 'Hello' }, + { binaryType: 'blob', data: new Blob([createBinaryTestData()]) }, + ]; + + const testMessageCount = Object.keys(testMessages).length; + + // Index of the message in testMessages that we are currently testing. + let currentIndex: number = -1; + + // We run this function to analyze the data just after receiving it from the datachannel. + async function analyzeData(idx: number, data: Blob | ArrayBuffer | string): Promise { + switch (idx) { + case 0: // binaryType is not used here because data is a string ("Hello"). + return data === 'Hello'; + case 1: // binaryType is "arraybuffer" and data is expected to be an ArrayBuffer. + return analyzeBinaryTestData(data as ArrayBufferLike); + case 2: // binaryType is "arraybuffer" and data is expected from a view on a larger ArrayBuffer + return analyzeBinaryTestData(data as ArrayBufferLike); + case 3: // binaryType is "arraybuffer" and data was created from a view on a larger ArrayBuffer with an offset + return analyzeBinaryTestData(data as ArrayBufferLike); + case 4: // binaryType is "arraybuffer" and data was created from a view on a larger ArrayBuffer with an offset + return analyzeBinaryTestData(data as ArrayBufferLike); + case 5: // binaryType is "blob" and data is expected to be a string ("Hello"). + return data === 'Hello' + case 6: // binaryType is "blob" and data is expected to be a Blob. + return analyzeBinaryTestData(await (data as Blob).arrayBuffer()); - // Candidates - expect(p1IceCandidateMock.mock.calls.length).toBeGreaterThanOrEqual(1); - expect(p2IceCandidateMock.mock.calls.length).toBeGreaterThanOrEqual(1); + } + return false; + } - // DataChannel - expect(p1DCMock.mock.calls.length).toBe(1); - expect(p2DCMock.mock.calls.length).toBe(1); + async function finalizeTest(): Promise { + peer1.close(); + peer2.close(); + + // State Callbacks + expect(p1ConnectionStateMock.mock.calls.length).toBeGreaterThanOrEqual(1); + expect(p2ConnectionStateMock.mock.calls.length).toBeGreaterThanOrEqual(1); + expect(p1IceConnectionStateMock.mock.calls.length).toBeGreaterThanOrEqual(1); + expect(p2IceConnectionStateMock.mock.calls.length).toBeGreaterThanOrEqual(1); + expect(p1IceGatheringStateMock.mock.calls.length).toBeGreaterThanOrEqual(1); + expect(p2IceGatheringStateMock.mock.calls.length).toBeGreaterThanOrEqual(1); + + // SDP + expect(p1SDPMock.mock.calls.length).toBe(1); + expect(p2SDPMock.mock.calls.length).toBe(1); + + // Candidates + expect(p1IceCandidateMock.mock.calls.length).toBeGreaterThanOrEqual(1); + expect(p2IceCandidateMock.mock.calls.length).toBeGreaterThanOrEqual(1); + + // DataChannel + expect(p1DCMock.mock.calls.length).toBe(1); + expect(p2DCMock.mock.calls.length).toBe(1); + + expect(p1MessageMock.mock.calls.length).toBe(testMessageCount); + expect(p2MessageMock.mock.calls.length).toBe(testMessageCount); + + // Analyze and compare received messages + for (let i = 0; i < testMessageCount; i++) { + expect(await analyzeData(i, p1MessageMock.mock.calls[i][0] as any)).toEqual(true); + expect(await analyzeData(i, p2MessageMock.mock.calls[i][0] as any)).toEqual(true); + } + } - expect(p1MessageMock.mock.calls.length).toBe(3); - expect(p2MessageMock.mock.calls.length).toBe(3); + // starts the next message-sending test + async function nextSendTest(): Promise { + // Get the next test data + const current = testMessages[++currentIndex]; - // 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); + if (!current) { + return; + } - 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); + // Assign the binaryType value + dc1.binaryType = current.binaryType as BinaryType; - done(); + // dc2 also is initialized ? + if (dc2) { + dc2.binaryType = current.binaryType as BinaryType; } - // starts the next message-sending test - function nextSendTest(): void { - // Get the next test data - const current = testMessages[++currentIndex]; - - // If finished, quit - if (!current) { - finalizeTest(); - return; - } - - // Assign the binaryType value - dc1.binaryType = current.binaryType as BinaryType; - // dc2 also is initialized ? - if (dc2) { - dc2.binaryType = current.binaryType as BinaryType; - } - - // Send the test message - // workaround for https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1973 - if (typeof current.data === 'string') { - dc1.send(current.data); - } else { - dc1.send(current.data); - } - } + // Send the test message + dc1.send(current.data); + } - // Set Callbacks - peer1.onconnectionstatechange = (): void => { - p1ConnectionStateMock(); - }; - peer1.oniceconnectionstatechange = (): void => { - p1IceConnectionStateMock(); - }; - peer1.onicegatheringstatechange = (): void => { - p1IceGatheringStateMock(); - }; - peer1.onicecandidate = (e): void => { - p1IceCandidateMock(); - peer2.addIceCandidate(e.candidate); + // Set Callbacks + peer1.onconnectionstatechange = (): void => { + p1ConnectionStateMock(); + }; + peer1.oniceconnectionstatechange = (): void => { + p1IceConnectionStateMock(); + }; + peer1.onicegatheringstatechange = (): void => { + p1IceGatheringStateMock(); + }; + peer1.onicecandidate = (e): void => { + p1IceCandidateMock(); + peer2.addIceCandidate(e.candidate); + }; + + // Set Callbacks + peer2.onconnectionstatechange = (): void => { + p2ConnectionStateMock(); + }; + peer2.oniceconnectionstatechange = (): void => { + p2IceConnectionStateMock(); + }; + peer2.onicegatheringstatechange = (): void => { + p2IceGatheringStateMock(); + }; + peer2.onicecandidate = (e): void => { + p2IceCandidateMock(); + peer1.addIceCandidate(e.candidate); + }; + peer2.ondatachannel = (dce): void => { + p2DCMock(); + dc2 = dce.channel; + dc2.onmessage = (msg): void => { + p2MessageMock(msg.data); + + // send the received message from peer2 back to peer1 + dc2.send(msg.data); }; + }; - // Set Callbacks - peer2.onconnectionstatechange = (): void => { - p2ConnectionStateMock(); - }; - peer2.oniceconnectionstatechange = (): void => { - p2IceConnectionStateMock(); - }; - peer2.onicegatheringstatechange = (): void => { - p2IceGatheringStateMock(); - }; - peer2.onicecandidate = (e): void => { - p2IceCandidateMock(); - peer1.addIceCandidate(e.candidate); - }; - peer2.ondatachannel = (dce): void => { - p2DCMock(); - dc2 = dce.channel; - dc2.onmessage = (msg): void => { - p2MessageMock(msg.data); - - // send the received message from peer2 back to peer1 - dc2.send(msg.data); - }; - }; + // Actions + peer1.createOffer().then((desc) => { + p1SDPMock(); + peer2.setRemoteDescription(desc); + }); + //.catch((err) => console.error(err)); - // Actions - peer1.createOffer().then((desc) => { - p1SDPMock(); - peer2.setRemoteDescription(desc); - }); - //.catch((err) => console.error(err)); + peer2.createAnswer().then((answerDesc) => { + p2SDPMock(); + peer1.setRemoteDescription(answerDesc); + }); + //.catch((err) => console.error('Couldn't create answer', err)); + + const sentAll = Promise.withResolvers(); - peer2.createAnswer().then((answerDesc) => { - p2SDPMock(); - peer1.setRemoteDescription(answerDesc); + dc1 = peer1.createDataChannel('test-p2p'); + dc1.onopen = (): void => { + p1DCMock(); + + nextSendTest().catch((err) => { + sentAll.reject(err); }); - //.catch((err) => console.error('Couldn't create answer', err)); + }; + dc1.onmessage = (msg): void => { + // peer2 sends all messages back to peer1 + p1MessageMock(msg.data); + + if (p1MessageMock.mock.calls.length === testMessageCount) { + finalizeTest() + .then(() => { + sentAll.resolve(); + }) + .catch((err) => { + sentAll.reject(err); + }); + } else { + nextSendTest().catch((err) => { + sentAll.reject(err); + }); + } + }; - dc1 = peer1.createDataChannel('test-p2p'); - dc1.onopen = (): void => { - p1DCMock(); - nextSendTest(); - }; - dc1.onmessage = (msg): void => { - // peer2 sends all messages back to peer1 - p1MessageMock(msg.data); - nextSendTest(); - }; - }); + await sentAll.promise; }); test('it can access datachannel informational fields after closing', async () => {