Skip to content

Commit a3efd2e

Browse files
committed
feat: evaluate @roamhq/wrtc as alternative to node-datachannel (#3034)
Replace node-datachannel with @roamhq/wrtc for WebRTC peer connections and stun package for STUN server functionality in WebRTC Direct. Changes: - Replace node-datachannel RTCPeerConnection with @roamhq/wrtc - Implement STUN server using stun package for WebRTC Direct - Remove DirectRTCPeerConnection wrapper class - Add extractRemoteFingerprint helper for SDP parsing - Remove node-datachannel-specific garbage collection workarounds - Add TypeScript definitions for stun package All unit tests pass (33 passing). WebRTC Direct functionality validated. Relates to #3034
1 parent 851395e commit a3efd2e

File tree

15 files changed

+207
-163
lines changed

15 files changed

+207
-163
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"docs:no-publish": "aegir docs --publish false -- --exclude interop --exclude doc"
4040
},
4141
"devDependencies": {
42+
"@types/yargs-parser": "^21.0.3",
4243
"aegir": "^47.0.22",
4344
"npm-run-all": "^4.1.5"
4445
},

packages/integration-tests/.aegir.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ export default {
156156
await before.goLibp2pRelay?.proc.kill()
157157
await before.libp2pLimitedRelay?.stop()
158158

159-
// node-datachannel sometimes causes the process to hang
159+
// Force exit after cleanup (WebRTC native modules may hold process open)
160160
process.exit(0)
161161
}
162162
}

packages/transport-webrtc/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,18 @@
4545
},
4646
"dependencies": {
4747
"@chainsafe/is-ip": "^2.1.0",
48+
"@chainsafe/libp2p-noise": "^17.0.0",
4849
"@libp2p/crypto": "^5.1.13",
4950
"@libp2p/interface": "^3.1.0",
5051
"@libp2p/interface-internal": "^3.0.9",
5152
"@libp2p/keychain": "^6.0.9",
52-
"@chainsafe/libp2p-noise": "^17.0.0",
5353
"@libp2p/peer-id": "^6.0.4",
5454
"@libp2p/utils": "^7.0.9",
5555
"@multiformats/multiaddr": "^13.0.1",
5656
"@multiformats/multiaddr-matcher": "^3.0.1",
5757
"@peculiar/webcrypto": "^1.5.0",
5858
"@peculiar/x509": "^1.13.0",
59+
"@roamhq/wrtc": "^0.9.1",
5960
"detect-browser": "^5.3.0",
6061
"get-port": "^7.1.0",
6162
"interface-datastore": "^9.0.1",
@@ -65,7 +66,6 @@
6566
"it-stream-types": "^2.0.2",
6667
"main-event": "^1.0.1",
6768
"multiformats": "^13.4.0",
68-
"node-datachannel": "^0.29.0",
6969
"p-defer": "^4.0.1",
7070
"p-event": "^7.0.0",
7171
"p-timeout": "^7.0.0",
@@ -74,6 +74,7 @@
7474
"protons-runtime": "^5.6.0",
7575
"race-signal": "^2.0.0",
7676
"react-native-webrtc": "^124.0.6",
77+
"stun": "^2.1.0",
7778
"uint8-varint": "^2.0.4",
7879
"uint8arraylist": "^2.4.8",
7980
"uint8arrays": "^5.1.0"

packages/transport-webrtc/src/private-to-private/initiate-connection.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -68,20 +68,7 @@ export async function initiateConnection ({ rtcConfiguration, dataChannel, signa
6868
const messageStream = pbStream(stream).pb(Message)
6969
const peerConnection = new RTCPeerConnection(rtcConfiguration)
7070

71-
// make sure C++ peer connection is garbage collected
72-
// https://github.com/murat-dogan/node-datachannel/issues/366#issuecomment-3228453155
73-
peerConnection.addEventListener('connectionstatechange', () => {
74-
switch (peerConnection.connectionState) {
75-
case 'closed':
76-
peerConnection.close()
77-
break
78-
default:
79-
break
80-
}
81-
})
82-
8371
const muxerFactory = new DataChannelMuxerFactory({
84-
// @ts-expect-error https://github.com/murat-dogan/node-datachannel/pull/370
8572
peerConnection,
8673
dataChannelOptions: dataChannel
8774
})
@@ -209,7 +196,6 @@ export async function initiateConnection ({ rtcConfiguration, dataChannel, signa
209196

210197
return {
211198
remoteAddress: ma,
212-
// @ts-expect-error https://github.com/murat-dogan/node-datachannel/pull/370
213199
peerConnection,
214200
muxerFactory
215201
}

packages/transport-webrtc/src/private-to-private/transport.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -199,19 +199,7 @@ export class WebRTCTransport implements Transport<WebRTCDialEvents>, Startable {
199199
async _onProtocol (stream: Stream, connection: Connection, signal: AbortSignal): Promise<void> {
200200
const peerConnection = new RTCPeerConnection(await getRtcConfiguration(this.init.rtcConfiguration))
201201

202-
// make sure C++ peer connection is garbage collected
203-
// https://github.com/murat-dogan/node-datachannel/issues/366#issuecomment-3228453155
204-
peerConnection.addEventListener('connectionstatechange', () => {
205-
switch (peerConnection.connectionState) {
206-
case 'closed':
207-
peerConnection.close()
208-
break
209-
default:
210-
break
211-
}
212-
})
213202
const muxerFactory = new DataChannelMuxerFactory({
214-
// @ts-expect-error https://github.com/murat-dogan/node-datachannel/pull/370
215203
peerConnection,
216204
dataChannelOptions: this.init.dataChannel
217205
})
@@ -229,7 +217,6 @@ export class WebRTCTransport implements Transport<WebRTCDialEvents>, Startable {
229217
})
230218

231219
const webRTCConn = toMultiaddrConnection({
232-
// @ts-expect-error https://github.com/murat-dogan/node-datachannel/pull/370
233220
peerConnection,
234221
remoteAddr: remoteAddress,
235222
metrics: this.metrics?.listenerEvents,
@@ -246,7 +233,6 @@ export class WebRTCTransport implements Transport<WebRTCDialEvents>, Startable {
246233
})
247234

248235
// close the connection on shut down
249-
// @ts-expect-error https://github.com/murat-dogan/node-datachannel/pull/370
250236
this._closeOnShutdown(peerConnection, webRTCConn)
251237
} catch (err: any) {
252238
this.log.error('incoming signaling error - %e', err)

packages/transport-webrtc/src/private-to-public/listener.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import { createDialerRTCPeerConnection } from './utils/get-rtcpeerconnection.js'
1111
import { stunListener } from './utils/stun-listener.js'
1212
import type { DataChannelOptions, TransportCertificate } from '../index.js'
1313
import type { WebRTCDirectTransportCertificateEvents } from './transport.js'
14-
import type { DirectRTCPeerConnection } from './utils/get-rtcpeerconnection.js'
1514
import type { StunServer } from './utils/stun-listener.js'
1615
import type { PeerId, ListenerEvents, Listener, Upgrader, ComponentLogger, Logger, CounterGroup, Metrics, PrivateKey } from '@libp2p/interface'
1716
import type { Keychain } from '@libp2p/keychain'
@@ -58,7 +57,7 @@ export class WebRTCDirectListener extends TypedEventEmitter<ListenerEvents> impl
5857
private listeningMultiaddr?: Multiaddr
5958
private certificate: TransportCertificate
6059
private stunServer?: StunServer
61-
private readonly connections: Map<string, DirectRTCPeerConnection>
60+
private readonly connections: Map<string, RTCPeerConnection>
6261
private readonly log: Logger
6362
private readonly init: WebRTCDirectListenerInit
6463
private readonly components: WebRTCDirectListenerComponents

packages/transport-webrtc/src/private-to-public/utils/connect.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { createStream } from '../../stream.js'
77
import { isFirefox } from '../../util.js'
88
import { generateNoisePrologue } from './generate-noise-prologue.js'
99
import * as sdp from './sdp.js'
10-
import type { DirectRTCPeerConnection } from './get-rtcpeerconnection.js'
10+
import { extractRemoteFingerprint } from './get-rtcpeerconnection.js'
1111
import type { DataChannelOptions } from '../../index.js'
1212
import type { ComponentLogger, Connection, CounterGroup, Logger, PeerId, PrivateKey, Upgrader } from '@libp2p/interface'
1313
import type { Multiaddr } from '@multiformats/multiaddr'
@@ -36,13 +36,9 @@ export interface ServerOptions extends ConnectOptions {
3636

3737
const CONNECTION_STATE_CHANGE_EVENT = isFirefox ? 'iceconnectionstatechange' : 'connectionstatechange'
3838

39-
function isServer (options: ClientOptions | ServerOptions, peerConnection: any): peerConnection is DirectRTCPeerConnection {
40-
return options.role === 'server'
41-
}
42-
4339
export async function connect (peerConnection: RTCPeerConnection, muxerFactory: DataChannelMuxerFactory, ufrag: string, options: ClientOptions): Promise<Connection>
44-
export async function connect (peerConnection: DirectRTCPeerConnection, muxerFactory: DataChannelMuxerFactory, ufrag: string, options: ServerOptions): Promise<void>
45-
export async function connect (peerConnection: RTCPeerConnection | DirectRTCPeerConnection, muxerFactory: DataChannelMuxerFactory, ufrag: string, options: ClientOptions | ServerOptions): Promise<any> {
40+
export async function connect (peerConnection: RTCPeerConnection, muxerFactory: DataChannelMuxerFactory, ufrag: string, options: ServerOptions): Promise<void>
41+
export async function connect (peerConnection: RTCPeerConnection, muxerFactory: DataChannelMuxerFactory, ufrag: string, options: ClientOptions | ServerOptions): Promise<any> {
4642
// create data channel for running the noise handshake. Once the data
4743
// channel is opened, the listener will initiate the noise handshake. This
4844
// is used to confirm the identity of the peer.
@@ -91,10 +87,10 @@ export async function connect (peerConnection: RTCPeerConnection | DirectRTCPeer
9187

9288
options.log.trace('%s handshake channel opened', options.role)
9389

94-
if (isServer(options, peerConnection)) {
90+
if (options.role === 'server') {
9591
// now that the connection has been opened, add the remote's certhash to
9692
// it's multiaddr so we can complete the noise handshake
97-
const remoteFingerprint = peerConnection.remoteFingerprint()?.value ?? ''
93+
const remoteFingerprint = extractRemoteFingerprint(peerConnection) ?? ''
9894
options.remoteAddr = options.remoteAddr.encapsulate(sdp.fingerprint2Ma(remoteFingerprint))
9995
}
10096

@@ -127,7 +123,6 @@ export async function connect (peerConnection: RTCPeerConnection | DirectRTCPeer
127123
// Creating the connection before completion of the noise
128124
// handshake ensures that the stream opening callback is set up
129125
const maConn = toMultiaddrConnection({
130-
// @ts-expect-error types are broken
131126
peerConnection,
132127
remoteAddr: options.remoteAddr,
133128
metrics: options.events,

packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.browser.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
11
import { DataChannelMuxerFactory } from '../../muxer.ts'
22
import type { CreateDialerRTCPeerConnectionOptions } from './get-rtcpeerconnection.ts'
33

4+
/**
5+
* Helper to extract remote fingerprint from RTCPeerConnection
6+
* Used by WebRTC Direct server to get remote certificate info
7+
*/
8+
export function extractRemoteFingerprint (pc: RTCPeerConnection): string | undefined {
9+
if (pc.remoteDescription?.sdp == null) {
10+
return undefined
11+
}
12+
13+
const match = pc.remoteDescription.sdp.match(/a=fingerprint:(\S+)\s+(\S+)/)
14+
if (match != null) {
15+
return match[2] // Return just the fingerprint hash, not the algorithm
16+
}
17+
18+
return undefined
19+
}
20+
421
export async function createDialerRTCPeerConnection (role: 'client' | 'server', ufrag: string, options: CreateDialerRTCPeerConnectionOptions = {}): Promise<{ peerConnection: RTCPeerConnection, muxerFactory: DataChannelMuxerFactory }> {
522
// @ts-expect-error options type is wrong
623
let certificate: RTCCertificate = options.certificate

packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.ts

Lines changed: 24 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,102 +1,38 @@
11
import { Crypto } from '@peculiar/webcrypto'
2-
import { PeerConnection } from 'node-datachannel'
3-
import { RTCPeerConnection } from 'node-datachannel/polyfill'
2+
import { RTCPeerConnection } from '../../webrtc/index.js'
43
import { DEFAULT_ICE_SERVERS, MAX_MESSAGE_SIZE } from '../../constants.js'
54
import { DataChannelMuxerFactory } from '../../muxer.ts'
65
import { generateTransportCertificate } from './generate-certificates.js'
76
import type { DataChannelOptions, TransportCertificate } from '../../index.js'
87
import type { CounterGroup } from '@libp2p/interface'
9-
import type { CertificateFingerprint } from 'node-datachannel'
108

119
const crypto = new Crypto()
1210

13-
interface DirectRTCPeerConnectionInit extends RTCConfiguration {
14-
ufrag: string
15-
peerConnection: PeerConnection
16-
}
17-
18-
export class DirectRTCPeerConnection extends RTCPeerConnection {
19-
private peerConnection: PeerConnection
20-
private readonly ufrag: string
21-
22-
constructor (init: DirectRTCPeerConnectionInit) {
23-
super(init)
24-
25-
this.peerConnection = init.peerConnection
26-
this.ufrag = init.ufrag
27-
28-
// make sure C++ peer connection is garbage collected
29-
// https://github.com/murat-dogan/node-datachannel/issues/366#issuecomment-3228453155
30-
this.addEventListener('connectionstatechange', () => {
31-
switch (this.connectionState) {
32-
case 'closed':
33-
this.peerConnection.close()
34-
break
35-
default:
36-
break
37-
}
38-
})
39-
}
40-
41-
async createOffer (): Promise<globalThis.RTCSessionDescriptionInit | any> {
42-
// have to set ufrag before creating offer
43-
if (this.connectionState === 'new') {
44-
this.peerConnection?.setLocalDescription('offer', {
45-
iceUfrag: this.ufrag,
46-
icePwd: this.ufrag
47-
})
48-
}
49-
50-
return super.createOffer()
51-
}
52-
53-
async createAnswer (): Promise<globalThis.RTCSessionDescriptionInit | any> {
54-
// have to set ufrag before creating answer
55-
if (this.connectionState === 'new') {
56-
this.peerConnection?.setLocalDescription('answer', {
57-
iceUfrag: this.ufrag,
58-
icePwd: this.ufrag
59-
})
60-
}
61-
62-
return super.createAnswer()
63-
}
64-
65-
remoteFingerprint (): CertificateFingerprint {
66-
if (this.peerConnection == null) {
67-
throw new Error('Invalid state: peer connection not set')
68-
}
69-
70-
return this.peerConnection.remoteFingerprint()
71-
}
72-
}
73-
74-
function mapIceServers (iceServers?: RTCIceServer[]): string[] {
75-
return iceServers
76-
?.map((server) => {
77-
const urls = Array.isArray(server.urls) ? server.urls : [server.urls]
78-
79-
return urls.map((url) => {
80-
if (server.username != null && server.credential != null) {
81-
const [protocol, rest] = url.split(/:(.*)/)
82-
return `${protocol}:${server.username}:${server.credential}@${rest}`
83-
}
84-
return url
85-
})
86-
})
87-
.flat() ?? []
88-
}
89-
9011
export interface CreateDialerRTCPeerConnectionOptions {
9112
rtcConfiguration?: RTCConfiguration | (() => RTCConfiguration | Promise<RTCConfiguration>)
9213
certificate?: TransportCertificate
9314
events?: CounterGroup
9415
dataChannel?: DataChannelOptions
9516
}
9617

97-
export async function createDialerRTCPeerConnection (role: 'client', ufrag: string, options?: CreateDialerRTCPeerConnectionOptions): Promise<{ peerConnection: globalThis.RTCPeerConnection, muxerFactory: DataChannelMuxerFactory }>
98-
export async function createDialerRTCPeerConnection (role: 'server', ufrag: string, options?: CreateDialerRTCPeerConnectionOptions): Promise<{ peerConnection: DirectRTCPeerConnection, muxerFactory: DataChannelMuxerFactory }>
99-
export async function createDialerRTCPeerConnection (role: 'client' | 'server', ufrag: string, options: CreateDialerRTCPeerConnectionOptions = {}): Promise<{ peerConnection: globalThis.RTCPeerConnection | DirectRTCPeerConnection, muxerFactory: DataChannelMuxerFactory }> {
18+
/**
19+
* Helper to extract remote fingerprint from RTCPeerConnection
20+
* Used by WebRTC Direct server to get remote certificate info
21+
*/
22+
export function extractRemoteFingerprint (pc: RTCPeerConnection): string | undefined {
23+
if (pc.remoteDescription?.sdp == null) {
24+
return undefined
25+
}
26+
27+
const match = pc.remoteDescription.sdp.match(/a=fingerprint:(\S+)\s+(\S+)/)
28+
if (match != null) {
29+
return match[2] // Return just the fingerprint hash, not the algorithm
30+
}
31+
32+
return undefined
33+
}
34+
35+
export async function createDialerRTCPeerConnection (role: 'client' | 'server', ufrag: string, options: CreateDialerRTCPeerConnectionOptions = {}): Promise<{ peerConnection: RTCPeerConnection, muxerFactory: DataChannelMuxerFactory }> {
10036
if (options.certificate == null) {
10137
// ECDSA is preferred over RSA here. From our testing we find that P-256
10238
// elliptic curve is supported by Pion, webrtc-rs, as well as Chromium
@@ -114,22 +50,15 @@ export async function createDialerRTCPeerConnection (role: 'client' | 'server',
11450

11551
const rtcConfig = typeof options.rtcConfiguration === 'function' ? await options.rtcConfiguration() : options.rtcConfiguration
11652

117-
const peerConnection = new DirectRTCPeerConnection({
53+
// @roamhq/wrtc uses standard browser-like RTCPeerConnection API
54+
// Certificate is handled differently - wrtc auto-generates certificates
55+
// We'll rely on SDP manipulation for ufrag (done in connect.ts via sdp.munge)
56+
const peerConnection = new RTCPeerConnection({
11857
...rtcConfig,
119-
ufrag,
120-
peerConnection: new PeerConnection(`${role}-${Date.now()}`, {
121-
disableFingerprintVerification: true,
122-
disableAutoNegotiation: true,
123-
certificatePemFile: options.certificate.pem,
124-
keyPemFile: options.certificate.privateKey,
125-
enableIceUdpMux: role === 'server',
126-
maxMessageSize: MAX_MESSAGE_SIZE,
127-
iceServers: mapIceServers(rtcConfig?.iceServers ?? DEFAULT_ICE_SERVERS.map(urls => ({ urls })))
128-
})
58+
iceServers: rtcConfig?.iceServers ?? DEFAULT_ICE_SERVERS.map(urls => ({ urls }))
12959
})
13060

13161
const muxerFactory = new DataChannelMuxerFactory({
132-
// @ts-expect-error https://github.com/murat-dogan/node-datachannel/pull/370
13362
peerConnection,
13463
metrics: options.events,
13564
dataChannelOptions: options.dataChannel

0 commit comments

Comments
 (0)