@libp2p/multistream-select
Version:
JavaScript implementation of multistream-select
366 lines (308 loc) • 11.8 kB
text/typescript
import { UnsupportedProtocolError } from '@libp2p/interface'
import { lpStream } from 'it-length-prefixed-stream'
import pDefer from 'p-defer'
import { raceSignal } from 'race-signal'
import * as varint from 'uint8-varint'
import { Uint8ArrayList } from 'uint8arraylist'
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
import { MAX_PROTOCOL_LENGTH } from './constants.js'
import * as multistream from './multistream.js'
import { PROTOCOL_ID } from './index.js'
import type { MultistreamSelectInit, ProtocolStream } from './index.js'
import type { AbortOptions } from '@libp2p/interface'
import type { Duplex } from 'it-stream-types'
export interface SelectStream extends Duplex<any, any, any> {
readStatus?: string
closeWrite?(options?: AbortOptions): Promise<void>
closeRead?(options?: AbortOptions): Promise<void>
close?(options?: AbortOptions): Promise<void>
}
/**
* Negotiate a protocol to use from a list of protocols.
*
* @param stream - A duplex iterable stream to dial on
* @param protocols - A list of protocols (or single protocol) to negotiate with. Protocols are attempted in order until a match is made.
* @param options - An options object containing an AbortSignal and an optional boolean `writeBytes` - if this is true, `Uint8Array`s will be written into `duplex`, otherwise `Uint8ArrayList`s will
* @returns A stream for the selected protocol and the protocol that was selected from the list of protocols provided to `select`.
* @example
*
* ```TypeScript
* import { pipe } from 'it-pipe'
* import * as mss from '@libp2p/multistream-select'
* import { Mplex } from '@libp2p/mplex'
*
* const muxer = new Mplex()
* const muxedStream = muxer.newStream()
*
* // mss.select(protocol(s))
* // Select from one of the passed protocols (in priority order)
* // Returns selected stream and protocol
* const { stream: dhtStream, protocol } = await mss.select(muxedStream, [
* // This might just be different versions of DHT, but could be different implementations
* '/ipfs-dht/2.0.0', // Most of the time this will probably just be one item.
* '/ipfs-dht/1.0.0'
* ])
*
* // Typically this stream will be passed back to the caller of libp2p.dialProtocol
* //
* // ...it might then do something like this:
* // try {
* // await pipe(
* // [uint8ArrayFromString('Some DHT data')]
* // dhtStream,
* // async source => {
* // for await (const chunk of source)
* // // DHT response data
* // }
* // )
* // } catch (err) {
* // // Error in stream
* // }
* ```
*/
export async function select <Stream extends SelectStream> (stream: Stream, protocols: string | string[], options: MultistreamSelectInit): Promise<ProtocolStream<Stream>> {
protocols = Array.isArray(protocols) ? [...protocols] : [protocols]
if (protocols.length === 1 && options.negotiateFully === false) {
return optimisticSelect(stream, protocols[0], options)
}
const lp = lpStream(stream, {
...options,
maxDataLength: MAX_PROTOCOL_LENGTH
})
const protocol = protocols.shift()
if (protocol == null) {
throw new Error('At least one protocol must be specified')
}
options.log.trace('select: write ["%s", "%s"]', PROTOCOL_ID, protocol)
const p1 = uint8ArrayFromString(`${PROTOCOL_ID}\n`)
const p2 = uint8ArrayFromString(`${protocol}\n`)
await multistream.writeAll(lp, [p1, p2], options)
options.log.trace('select: reading multistream-select header')
let response = await multistream.readString(lp, options)
options.log.trace('select: read "%s"', response)
// Read the protocol response if we got the protocolId in return
if (response === PROTOCOL_ID) {
options.log.trace('select: reading protocol response')
response = await multistream.readString(lp, options)
options.log.trace('select: read "%s"', response)
}
// We're done
if (response === protocol) {
return { stream: lp.unwrap(), protocol }
}
// We haven't gotten a valid ack, try the other protocols
for (const protocol of protocols) {
options.log.trace('select: write "%s"', protocol)
await multistream.write(lp, uint8ArrayFromString(`${protocol}\n`), options)
options.log.trace('select: reading protocol response')
const response = await multistream.readString(lp, options)
options.log.trace('select: read "%s" for "%s"', response, protocol)
if (response === protocol) {
return { stream: lp.unwrap(), protocol }
}
}
throw new UnsupportedProtocolError('protocol selection failed')
}
/**
* Optimistically negotiates a protocol.
*
* It *does not* block writes waiting for the other end to respond. Instead, it
* simply assumes the negotiation went successfully and starts writing data.
*
* Use when it is known that the receiver supports the desired protocol.
*/
function optimisticSelect <Stream extends SelectStream> (stream: Stream, protocol: string, options: MultistreamSelectInit): ProtocolStream<Stream> {
const originalSink = stream.sink.bind(stream)
const originalSource = stream.source
let negotiated = false
let negotiating = false
const doneNegotiating = pDefer()
let sentProtocol = false
let sendingProtocol = false
const doneSendingProtocol = pDefer()
let readProtocol = false
let readingProtocol = false
const doneReadingProtocol = pDefer()
const lp = lpStream({
sink: originalSink,
source: originalSource
}, {
...options,
maxDataLength: MAX_PROTOCOL_LENGTH
})
stream.sink = async source => {
const { sink } = lp.unwrap()
await sink(async function * () {
let sentData = false
for await (const buf of source) {
// started reading before the source yielded, wait for protocol send
if (sendingProtocol) {
await doneSendingProtocol.promise
}
// writing before reading, send the protocol and the first chunk of data
if (!sentProtocol) {
sendingProtocol = true
options.log.trace('optimistic: write ["%s", "%s", data(%d)] in sink', PROTOCOL_ID, protocol, buf.byteLength)
const protocolString = `${protocol}\n`
// send protocols in first chunk of data written to transport
yield new Uint8ArrayList(
Uint8Array.from([19]), // length of PROTOCOL_ID plus newline
uint8ArrayFromString(`${PROTOCOL_ID}\n`),
varint.encode(protocolString.length),
uint8ArrayFromString(protocolString),
buf
).subarray()
options.log.trace('optimistic: wrote ["%s", "%s", data(%d)] in sink', PROTOCOL_ID, protocol, buf.byteLength)
sentProtocol = true
sendingProtocol = false
doneSendingProtocol.resolve()
// read the negotiation response but don't block more sending
negotiate()
.catch(err => {
options.log.error('could not finish optimistic protocol negotiation of %s', protocol, err)
})
} else {
yield buf
}
sentData = true
}
// special case - the source passed to the sink has ended but we didn't
// negotiated the protocol yet so do it now
if (!sentData) {
await negotiate()
}
}())
}
async function negotiate (): Promise<void> {
if (negotiating) {
options.log.trace('optimistic: already negotiating %s stream', protocol)
await doneNegotiating.promise
return
}
negotiating = true
try {
// we haven't sent the protocol yet, send it now
if (!sentProtocol) {
options.log.trace('optimistic: doing send protocol for %s stream', protocol)
await doSendProtocol()
}
// if we haven't read the protocol response yet, do it now
if (!readProtocol) {
options.log.trace('optimistic: doing read protocol for %s stream', protocol)
await doReadProtocol()
}
} finally {
negotiating = false
negotiated = true
doneNegotiating.resolve()
}
}
async function doSendProtocol (): Promise<void> {
if (sendingProtocol) {
await doneSendingProtocol.promise
return
}
sendingProtocol = true
try {
options.log.trace('optimistic: write ["%s", "%s", data] in source', PROTOCOL_ID, protocol)
await lp.writeV([
uint8ArrayFromString(`${PROTOCOL_ID}\n`),
uint8ArrayFromString(`${protocol}\n`)
])
options.log.trace('optimistic: wrote ["%s", "%s", data] in source', PROTOCOL_ID, protocol)
} finally {
sentProtocol = true
sendingProtocol = false
doneSendingProtocol.resolve()
}
}
async function doReadProtocol (): Promise<void> {
if (readingProtocol) {
await doneReadingProtocol.promise
return
}
readingProtocol = true
try {
options.log.trace('optimistic: reading multistream select header')
let response = await multistream.readString(lp, options)
options.log.trace('optimistic: read multistream select header "%s"', response)
if (response === PROTOCOL_ID) {
response = await multistream.readString(lp, options)
}
options.log.trace('optimistic: read protocol "%s", expecting "%s"', response, protocol)
if (response !== protocol) {
throw new UnsupportedProtocolError('protocol selection failed')
}
} finally {
readProtocol = true
readingProtocol = false
doneReadingProtocol.resolve()
}
}
stream.source = (async function * () {
// make sure we've done protocol negotiation before we read stream data
await negotiate()
options.log.trace('optimistic: reading data from "%s" stream', protocol)
yield * lp.unwrap().source
})()
if (stream.closeRead != null) {
const originalCloseRead = stream.closeRead.bind(stream)
stream.closeRead = async (opts) => {
// we need to read & write to negotiate the protocol so ensure we've done
// this before closing the readable end of the stream
if (!negotiated) {
await negotiate().catch(err => {
options.log.error('could not negotiate protocol before close read', err)
})
}
// protocol has been negotiated, ok to close the readable end
await originalCloseRead(opts)
}
}
if (stream.closeWrite != null) {
const originalCloseWrite = stream.closeWrite.bind(stream)
stream.closeWrite = async (opts) => {
// we need to read & write to negotiate the protocol so ensure we've done
// this before closing the writable end of the stream
if (!negotiated) {
await negotiate().catch(err => {
options.log.error('could not negotiate protocol before close write', err)
})
}
// protocol has been negotiated, ok to close the writable end
await originalCloseWrite(opts)
}
}
if (stream.close != null) {
const originalClose = stream.close.bind(stream)
stream.close = async (opts) => {
// if we are in the process of negotiation, let it finish before closing
// because we may have unsent early data
const tasks = []
if (sendingProtocol) {
tasks.push(doneSendingProtocol.promise)
}
if (readingProtocol) {
tasks.push(doneReadingProtocol.promise)
}
if (tasks.length > 0) {
// let the in-flight protocol negotiation finish gracefully
await raceSignal(
Promise.all(tasks),
opts?.signal
)
} else {
// no protocol negotiation attempt has occurred so don't start one
negotiated = true
negotiating = false
doneNegotiating.resolve()
}
// protocol has been negotiated, ok to close the writable end
await originalClose(opts)
}
}
return {
stream,
protocol
}
}