Skip to content

Commit de3f7ae

Browse files
authored
fix: WebTransport stream now extends abstract stream (#2514)
The PR pulls all of the non-`@fails/webtransport` parts out of #2422 There's a lot of work that's been done to re-use existing libp2p code such as the abstract stream class which handles a lot more closing scenarios than the existing implementation so it would be good to get that in.
1 parent c824323 commit de3f7ae

13 files changed

+372
-311
lines changed

packages/transport-webtransport/.aegir.js

+9-5
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1+
/* eslint-disable no-console */
12
import { spawn, exec } from 'child_process'
2-
import { existsSync } from 'fs'
3+
import { existsSync } from 'node:fs'
4+
import os from 'node:os'
35
import defer from 'p-defer'
46

57
/** @type {import('aegir/types').PartialOptions} */
68
export default {
79
test: {
8-
async before() {
10+
async before () {
11+
const main = os.platform() === 'win32' ? 'main.exe' : 'main'
12+
913
if (!existsSync('./go-libp2p-webtransport-server/main')) {
1014
await new Promise((resolve, reject) => {
11-
exec('go build -o main main.go',
15+
exec(`go build -o ${main} main.go`,
1216
{ cwd: './go-libp2p-webtransport-server' },
1317
(error, stdout, stderr) => {
1418
if (error) {
@@ -21,7 +25,7 @@ export default {
2125
})
2226
}
2327

24-
const server = spawn('./main', [], { cwd: './go-libp2p-webtransport-server', killSignal: 'SIGINT' })
28+
const server = spawn(`./${main}`, [], { cwd: './go-libp2p-webtransport-server', killSignal: 'SIGINT' })
2529
server.stderr.on('data', (data) => {
2630
console.log('stderr:', data.toString())
2731
})
@@ -53,7 +57,7 @@ export default {
5357
}
5458
}
5559
},
56-
async after(_, { server }) {
60+
async after (_, { server }) {
5761
server.kill('SIGINT')
5862
}
5963
},

packages/transport-webtransport/package.json

+12-3
Original file line numberDiff line numberDiff line change
@@ -53,25 +53,34 @@
5353
"@chainsafe/libp2p-noise": "^15.0.0",
5454
"@libp2p/interface": "^1.3.0",
5555
"@libp2p/peer-id": "^4.1.0",
56+
"@libp2p/utils": "^5.3.2",
5657
"@multiformats/multiaddr": "^12.2.1",
5758
"@multiformats/multiaddr-matcher": "^1.2.0",
5859
"it-stream-types": "^2.0.1",
5960
"multiformats": "^13.1.0",
61+
"race-signal": "^1.0.2",
6062
"uint8arraylist": "^2.4.8",
6163
"uint8arrays": "^5.0.3"
6264
},
6365
"devDependencies": {
6466
"@libp2p/logger": "^4.0.11",
6567
"@libp2p/peer-id-factory": "^4.1.0",
68+
"@noble/hashes": "^1.4.0",
6669
"aegir": "^42.2.5",
70+
"it-map": "^3.1.0",
71+
"it-to-buffer": "^4.0.7",
6772
"libp2p": "^1.4.3",
68-
"p-defer": "^4.0.1"
73+
"p-defer": "^4.0.1",
74+
"p-wait-for": "^5.0.2"
6975
},
7076
"browser": {
71-
"./dist/src/listener.js": "./dist/src/listener.browser.js"
77+
"./dist/src/listener.js": "./dist/src/listener.browser.js",
78+
"./dist/src/webtransport.js": "./dist/src/webtransport.browser.js"
7279
},
7380
"react-native": {
74-
"./dist/src/listener.js": "./dist/src/listener.browser.js"
81+
"./dist/src/listener.js": "./dist/src/listener.browser.js",
82+
"./dist/src/webtransport.js": "./dist/src/webtransport.browser.js",
83+
"./dist/src/utils/generate-certificates.js": "./dist/src/utils/generate-certificates.browser.js"
7584
},
7685
"sideEffects": false
7786
}

packages/transport-webtransport/src/index.ts

+57-120
Original file line numberDiff line numberDiff line change
@@ -30,23 +30,38 @@
3030
*/
3131

3232
import { noise } from '@chainsafe/libp2p-noise'
33-
import { type Transport, transportSymbol, type CreateListenerOptions, type DialOptions, type Listener, type ComponentLogger, type Logger, type Connection, type MultiaddrConnection, type Stream, type CounterGroup, type Metrics, type PeerId, type StreamMuxerFactory, type StreamMuxerInit, type StreamMuxer } from '@libp2p/interface'
34-
import { type Multiaddr, type AbortOptions } from '@multiformats/multiaddr'
33+
import { AbortError, CodeError, transportSymbol } from '@libp2p/interface'
3534
import { WebTransport as WebTransportMatcher } from '@multiformats/multiaddr-matcher'
36-
import { webtransportBiDiStreamToStream } from './stream.js'
35+
import { raceSignal } from 'race-signal'
36+
import createListener from './listener.js'
37+
import { webtransportMuxer } from './muxer.js'
3738
import { inertDuplex } from './utils/inert-duplex.js'
3839
import { isSubset } from './utils/is-subset.js'
3940
import { parseMultiaddr } from './utils/parse-multiaddr.js'
41+
import WebTransport from './webtransport.js'
42+
import type { Transport, CreateListenerOptions, DialOptions, Listener, ComponentLogger, Logger, Connection, MultiaddrConnection, CounterGroup, Metrics, PeerId } from '@libp2p/interface'
43+
import type { Multiaddr } from '@multiformats/multiaddr'
4044
import type { Source } from 'it-stream-types'
4145
import type { MultihashDigest } from 'multiformats/hashes/interface'
4246
import type { Uint8ArrayList } from 'uint8arraylist'
4347

48+
/**
49+
* PEM format server certificate and private key
50+
*/
51+
export interface WebTransportCertificate {
52+
privateKey: string
53+
pem: string
54+
hash: MultihashDigest<number>
55+
secret: string
56+
}
57+
4458
interface WebTransportSessionCleanup {
4559
(metric: string): void
4660
}
4761

4862
export interface WebTransportInit {
4963
maxInboundStreams?: number
64+
certificates?: WebTransportCertificate[]
5065
}
5166

5267
export interface WebTransportComponents {
@@ -69,7 +84,9 @@ class WebTransportTransport implements Transport {
6984
this.log = components.logger.forComponent('libp2p:webtransport')
7085
this.components = components
7186
this.config = {
72-
maxInboundStreams: init.maxInboundStreams ?? 1000
87+
...init,
88+
maxInboundStreams: init.maxInboundStreams ?? 1000,
89+
certificates: init.certificates ?? []
7390
}
7491

7592
if (components.metrics != null) {
@@ -87,24 +104,26 @@ class WebTransportTransport implements Transport {
87104
readonly [transportSymbol] = true
88105

89106
async dial (ma: Multiaddr, options: DialOptions): Promise<Connection> {
90-
options?.signal?.throwIfAborted()
107+
if (options?.signal?.aborted === true) {
108+
throw new AbortError()
109+
}
91110

92111
this.log('dialing %s', ma)
93112
const localPeer = this.components.peerId
94113
if (localPeer === undefined) {
95-
throw new Error('Need a local peerid')
114+
throw new CodeError('Need a local peerid', 'ERR_INVALID_PARAMETERS')
96115
}
97116

98117
options = options ?? {}
99118

100119
const { url, certhashes, remotePeer } = parseMultiaddr(ma)
101120

102121
if (remotePeer == null) {
103-
throw new Error('Need a target peerid')
122+
throw new CodeError('Need a target peerid', 'ERR_INVALID_PARAMETERS')
104123
}
105124

106125
if (certhashes.length === 0) {
107-
throw new Error('Expected multiaddr to contain certhashes')
126+
throw new CodeError('Expected multiaddr to contain certhashes', 'ERR_INVALID_PARAMETERS')
108127
}
109128

110129
let abortListener: (() => void) | undefined
@@ -159,10 +178,12 @@ class WebTransportTransport implements Transport {
159178
once: true
160179
})
161180

181+
this.log('wait for session to be ready')
162182
await Promise.race([
163183
wt.closed,
164184
wt.ready
165185
])
186+
this.log('session became ready')
166187

167188
ready = true
168189
this.metrics?.dialerEvents.increment({ ready: true })
@@ -175,15 +196,17 @@ class WebTransportTransport implements Transport {
175196
cleanUpWTSession('remote_close')
176197
})
177198

178-
if (!await this.authenticateWebTransport(wt, localPeer, remotePeer, certhashes)) {
179-
throw new Error('Failed to authenticate webtransport')
199+
authenticated = await raceSignal(this.authenticateWebTransport(wt, localPeer, remotePeer, certhashes), options.signal)
200+
201+
if (!authenticated) {
202+
throw new CodeError('Failed to authenticate webtransport', 'ERR_AUTHENTICATION_FAILED')
180203
}
181204

182205
this.metrics?.dialerEvents.increment({ open: true })
183206

184207
maConn = {
185208
close: async () => {
186-
this.log('Closing webtransport')
209+
this.log('closing webtransport')
187210
cleanUpWTSession('close')
188211
},
189212
abort: (err: Error) => {
@@ -199,9 +222,11 @@ class WebTransportTransport implements Transport {
199222
...inertDuplex()
200223
}
201224

202-
authenticated = true
203-
204-
return await options.upgrader.upgradeOutbound(maConn, { skipEncryption: true, muxerFactory: this.webtransportMuxer(wt), skipProtection: true })
225+
return await options.upgrader.upgradeOutbound(maConn, {
226+
skipEncryption: true,
227+
muxerFactory: webtransportMuxer(wt, wt.incomingBidirectionalStreams.getReader(), this.components.logger, this.config),
228+
skipProtection: true
229+
})
205230
} catch (err: any) {
206231
this.log.error('caught wt session err', err)
207232

@@ -221,11 +246,14 @@ class WebTransportTransport implements Transport {
221246
}
222247
}
223248

224-
async authenticateWebTransport (wt: InstanceType<typeof WebTransport>, localPeer: PeerId, remotePeer: PeerId, certhashes: Array<MultihashDigest<number>>): Promise<boolean> {
249+
async authenticateWebTransport (wt: WebTransport, localPeer: PeerId, remotePeer: PeerId, certhashes: Array<MultihashDigest<number>>, signal?: AbortSignal): Promise<boolean> {
250+
if (signal?.aborted === true) {
251+
throw new AbortError()
252+
}
253+
225254
const stream = await wt.createBidirectionalStream()
226255
const writer = stream.writable.getWriter()
227256
const reader = stream.readable.getReader()
228-
await writer.ready
229257

230258
const duplex = {
231259
source: (async function * () {
@@ -241,13 +269,15 @@ class WebTransportTransport implements Transport {
241269
}
242270
}
243271
})(),
244-
sink: async function (source: Source<Uint8Array | Uint8ArrayList>) {
272+
sink: async (source: Source<Uint8Array | Uint8ArrayList>) => {
245273
for await (const chunk of source) {
246-
if (chunk instanceof Uint8Array) {
247-
await writer.write(chunk)
248-
} else {
249-
await writer.write(chunk.subarray())
250-
}
274+
await raceSignal(writer.ready, signal)
275+
276+
const buf = chunk instanceof Uint8Array ? chunk : chunk.subarray()
277+
278+
writer.write(buf).catch(err => {
279+
this.log.error('could not write chunk during authentication of WebTransport stream', err)
280+
})
251281
}
252282
}
253283
}
@@ -273,105 +303,12 @@ class WebTransportTransport implements Transport {
273303
return true
274304
}
275305

276-
webtransportMuxer (wt: WebTransport): StreamMuxerFactory {
277-
let streamIDCounter = 0
278-
const config = this.config
279-
const self = this
280-
return {
281-
protocol: 'webtransport',
282-
createStreamMuxer: (init?: StreamMuxerInit): StreamMuxer => {
283-
// !TODO handle abort signal when WebTransport supports this.
284-
285-
if (typeof init === 'function') {
286-
// The api docs say that init may be a function
287-
init = { onIncomingStream: init }
288-
}
289-
290-
const activeStreams: Stream[] = [];
291-
292-
(async function () {
293-
//! TODO unclear how to add backpressure here?
294-
295-
const reader = wt.incomingBidirectionalStreams.getReader()
296-
while (true) {
297-
const { done, value: wtStream } = await reader.read()
298-
299-
if (done) {
300-
break
301-
}
302-
303-
if (activeStreams.length >= config.maxInboundStreams) {
304-
// We've reached our limit, close this stream.
305-
wtStream.writable.close().catch((err: Error) => {
306-
self.log.error(`Failed to close inbound stream that crossed our maxInboundStream limit: ${err.message}`)
307-
})
308-
wtStream.readable.cancel().catch((err: Error) => {
309-
self.log.error(`Failed to close inbound stream that crossed our maxInboundStream limit: ${err.message}`)
310-
})
311-
} else {
312-
const stream = await webtransportBiDiStreamToStream(
313-
wtStream,
314-
String(streamIDCounter++),
315-
'inbound',
316-
activeStreams,
317-
init?.onStreamEnd,
318-
self.components.logger
319-
)
320-
activeStreams.push(stream)
321-
init?.onIncomingStream?.(stream)
322-
}
323-
}
324-
})().catch(() => {
325-
this.log.error('WebTransport failed to receive incoming stream')
326-
})
327-
328-
const muxer: StreamMuxer = {
329-
protocol: 'webtransport',
330-
streams: activeStreams,
331-
newStream: async (name?: string): Promise<Stream> => {
332-
const wtStream = await wt.createBidirectionalStream()
333-
334-
const stream = await webtransportBiDiStreamToStream(
335-
wtStream,
336-
String(streamIDCounter++),
337-
init?.direction ?? 'outbound',
338-
activeStreams,
339-
init?.onStreamEnd,
340-
self.components.logger
341-
)
342-
activeStreams.push(stream)
343-
344-
return stream
345-
},
346-
347-
/**
348-
* Close or abort all tracked streams and stop the muxer
349-
*/
350-
close: async (options?: AbortOptions) => {
351-
this.log('Closing webtransport muxer')
352-
353-
await Promise.all(
354-
activeStreams.map(async s => s.close(options))
355-
)
356-
},
357-
abort: (err: Error) => {
358-
this.log('Aborting webtransport muxer with err:', err)
359-
360-
for (const stream of activeStreams) {
361-
stream.abort(err)
362-
}
363-
},
364-
// This stream muxer is webtransport native. Therefore it doesn't plug in with any other duplex.
365-
...inertDuplex()
366-
}
367-
368-
return muxer
369-
}
370-
}
371-
}
372-
373306
createListener (options: CreateListenerOptions): Listener {
374-
throw new Error('Webtransport servers are not supported in Node or the browser')
307+
return createListener(this.components, {
308+
...options,
309+
certificates: this.config.certificates,
310+
maxInboundStreams: this.config.maxInboundStreams
311+
})
375312
}
376313

377314
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { CreateListenerOptions, Listener } from '@libp2p/interface'
2+
3+
export default function createListener (options: CreateListenerOptions): Listener {
4+
throw new Error('Not implemented')
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { WebTransportCertificate } from './index.js'
2+
import type { Connection, Upgrader, Listener, CreateListenerOptions, PeerId, ComponentLogger, Metrics } from '@libp2p/interface'
3+
4+
export interface WebTransportListenerComponents {
5+
peerId: PeerId
6+
logger: ComponentLogger
7+
metrics?: Metrics
8+
}
9+
10+
export interface WebTransportListenerInit extends CreateListenerOptions {
11+
handler?(conn: Connection): void
12+
upgrader: Upgrader
13+
certificates?: WebTransportCertificate[]
14+
maxInboundStreams?: number
15+
}
16+
17+
export default function createListener (components: WebTransportListenerComponents, options: WebTransportListenerInit): Listener {
18+
throw new Error('Only supported in browsers')
19+
}

0 commit comments

Comments
 (0)