UNPKG

@libp2p/interface-mocks

Version:
448 lines (367 loc) 12.1 kB
import { CodeError } from '@libp2p/interfaces/errors' import { type Logger, logger } from '@libp2p/logger' import { abortableSource } from 'abortable-iterator' import { anySignal } from 'any-signal' import map from 'it-map' import * as ndjson from 'it-ndjson' import { pipe } from 'it-pipe' import { type Pushable, pushable } from 'it-pushable' import { Uint8ArrayList } from 'uint8arraylist' import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import type { Stream } from '@libp2p/interface-connection' import type { StreamMuxer, StreamMuxerFactory, StreamMuxerInit } from '@libp2p/interface-stream-muxer' import type { Source } from 'it-stream-types' let muxers = 0 let streams = 0 const MAX_MESSAGE_SIZE = 1024 * 1024 interface DataMessage { id: string type: 'data' direction: 'initiator' | 'recipient' chunk: string } interface ResetMessage { id: string type: 'reset' direction: 'initiator' | 'recipient' } interface CloseMessage { id: string type: 'close' direction: 'initiator' | 'recipient' } interface CreateMessage { id: string type: 'create' direction: 'initiator' } type StreamMessage = DataMessage | ResetMessage | CloseMessage | CreateMessage class MuxedStream { public id: string public input: Pushable<Uint8ArrayList> public stream: Stream public type: 'initiator' | 'recipient' private sinkEnded: boolean private sourceEnded: boolean private readonly abortController: AbortController private readonly resetController: AbortController private readonly closeController: AbortController private readonly log: Logger constructor (init: { id: string, type: 'initiator' | 'recipient', push: Pushable<StreamMessage>, onEnd: (err?: Error) => void }) { const { id, type, push, onEnd } = init this.log = logger(`libp2p:mock-muxer:stream:${id}:${type}`) this.id = id this.type = type this.abortController = new AbortController() this.resetController = new AbortController() this.closeController = new AbortController() this.sourceEnded = false this.sinkEnded = false let endErr: Error | undefined const onSourceEnd = (err?: Error): void => { if (this.sourceEnded) { return } this.log('onSourceEnd sink ended? %s', this.sinkEnded) this.sourceEnded = true if (err != null && endErr == null) { endErr = err } if (this.sinkEnded) { this.stream.stat.timeline.close = Date.now() if (onEnd != null) { onEnd(endErr) } } } const onSinkEnd = (err?: Error): void => { if (this.sinkEnded) { return } this.log('onSinkEnd source ended? %s', this.sourceEnded) this.sinkEnded = true if (err != null && endErr == null) { endErr = err } if (this.sourceEnded) { this.stream.stat.timeline.close = Date.now() if (onEnd != null) { onEnd(endErr) } } } this.input = pushable({ onEnd: onSourceEnd }) this.stream = { id, sink: async (source) => { if (this.sinkEnded) { throw new CodeError('stream closed for writing', 'ERR_SINK_ENDED') } const signal = anySignal([ this.abortController.signal, this.resetController.signal, this.closeController.signal ]) source = abortableSource(source, signal) try { if (this.type === 'initiator') { // If initiator, open a new stream const createMsg: CreateMessage = { id: this.id, type: 'create', direction: this.type } push.push(createMsg) } const list = new Uint8ArrayList() for await (const chunk of source) { list.append(chunk) while (list.length > 0) { const available = Math.min(list.length, MAX_MESSAGE_SIZE) const dataMsg: DataMessage = { id, type: 'data', chunk: uint8ArrayToString(list.subarray(0, available), 'base64pad'), direction: this.type } push.push(dataMsg) list.consume(available) } } } catch (err: any) { if (err.type === 'aborted' && err.message === 'The operation was aborted') { if (this.closeController.signal.aborted) { return } if (this.resetController.signal.aborted) { err.message = 'stream reset' err.code = 'ERR_STREAM_RESET' } if (this.abortController.signal.aborted) { err.message = 'stream aborted' err.code = 'ERR_STREAM_ABORT' } } // Send no more data if this stream was remotely reset if (err.code !== 'ERR_STREAM_RESET') { const resetMsg: ResetMessage = { id, type: 'reset', direction: this.type } push.push(resetMsg) } this.log('sink erred', err) this.input.end(err) onSinkEnd(err) return } finally { signal.clear() } this.log('sink ended') onSinkEnd() const closeMsg: CloseMessage = { id, type: 'close', direction: this.type } push.push(closeMsg) }, source: this.input, // Close for reading close: () => { this.stream.closeRead() this.stream.closeWrite() }, closeRead: () => { this.input.end() }, closeWrite: () => { this.closeController.abort() const closeMsg: CloseMessage = { id, type: 'close', direction: this.type } push.push(closeMsg) onSinkEnd() }, // Close for reading and writing (local error) abort: (err: Error) => { // End the source with the passed error this.input.end(err) this.abortController.abort() onSinkEnd(err) }, // Close immediately for reading and writing (remote error) reset: () => { const err = new CodeError('stream reset', 'ERR_STREAM_RESET') this.resetController.abort() this.input.end(err) onSinkEnd(err) }, stat: { direction: type === 'initiator' ? 'outbound' : 'inbound', timeline: { open: Date.now() } }, metadata: {} } } } class MockMuxer implements StreamMuxer { public source: AsyncGenerator<Uint8Array> public input: Pushable<Uint8Array> public streamInput: Pushable<StreamMessage> public name: string public protocol: string = '/mock-muxer/1.0.0' private readonly closeController: AbortController private readonly registryInitiatorStreams: Map<string, MuxedStream> private readonly registryRecipientStreams: Map<string, MuxedStream> private readonly options: StreamMuxerInit private readonly log: Logger constructor (init?: StreamMuxerInit) { this.name = `muxer:${muxers++}` this.log = logger(`libp2p:mock-muxer:${this.name}`) this.registryInitiatorStreams = new Map() this.registryRecipientStreams = new Map() this.log('create muxer') this.options = init ?? { direction: 'inbound' } this.closeController = new AbortController() // receives data from the muxer at the other end of the stream this.source = this.input = pushable({ onEnd: (err) => { this.close(err) } }) // receives messages from all of the muxed streams this.streamInput = pushable<StreamMessage>({ objectMode: true }) } // receive incoming messages async sink (source: Source<Uint8ArrayList | Uint8Array>): Promise<void> { try { await pipe( abortableSource(source, this.closeController.signal), (source) => map(source, buf => uint8ArrayToString(buf.subarray())), ndjson.parse<StreamMessage>, async (source) => { for await (const message of source) { this.log.trace('-> %s %s %s', message.type, message.direction, message.id) this.handleMessage(message) } } ) this.log('muxed stream ended') this.input.end() } catch (err: any) { this.log('muxed stream errored', err) this.input.end(err) } } handleMessage (message: StreamMessage): void { let muxedStream: MuxedStream | undefined const registry = message.direction === 'initiator' ? this.registryRecipientStreams : this.registryInitiatorStreams if (message.type === 'create') { if (registry.has(message.id)) { throw new Error(`Already had stream for ${message.id}`) } muxedStream = this.createStream(message.id, 'recipient') registry.set(muxedStream.stream.id, muxedStream) if (this.options.onIncomingStream != null) { this.options.onIncomingStream(muxedStream.stream) } } muxedStream = registry.get(message.id) if (muxedStream == null) { this.log.error(`No stream found for ${message.id}`) return } if (message.type === 'data') { muxedStream.input.push(new Uint8ArrayList(uint8ArrayFromString(message.chunk, 'base64pad'))) } else if (message.type === 'reset') { this.log('-> reset stream %s %s', muxedStream.type, muxedStream.stream.id) muxedStream.stream.reset() } else if (message.type === 'close') { this.log('-> closing stream %s %s', muxedStream.type, muxedStream.stream.id) muxedStream.stream.closeRead() } } get streams (): Stream[] { return Array.from(this.registryRecipientStreams.values()) .concat(Array.from(this.registryInitiatorStreams.values())) .map(({ stream }) => stream) } newStream (name?: string): Stream { if (this.closeController.signal.aborted) { throw new Error('Muxer already closed') } this.log('newStream %s', name) const storedStream = this.createStream(name, 'initiator') this.registryInitiatorStreams.set(storedStream.stream.id, storedStream) return storedStream.stream } createStream (name?: string, type: 'initiator' | 'recipient' = 'initiator'): MuxedStream { const id = name ?? `${this.name}:stream:${streams++}` this.log('createStream %s %s', type, id) const muxedStream: MuxedStream = new MuxedStream({ id, type, push: this.streamInput, onEnd: () => { this.log('stream ended %s %s', type, id) if (type === 'initiator') { this.registryInitiatorStreams.delete(id) } else { this.registryRecipientStreams.delete(id) } if (this.options.onStreamEnd != null) { this.options.onStreamEnd(muxedStream.stream) } } }) return muxedStream } close (err?: Error): void { if (this.closeController.signal.aborted) return this.log('closing muxed streams') if (err == null) { this.streams.forEach(s => { s.close() }) } else { this.streams.forEach(s => { s.abort(err) }) } this.closeController.abort() this.input.end(err) } } class MockMuxerFactory implements StreamMuxerFactory { public protocol: string = '/mock-muxer/1.0.0' createStreamMuxer (init?: StreamMuxerInit): StreamMuxer { const mockMuxer = new MockMuxer(init) void Promise.resolve().then(async () => { void pipe( mockMuxer.streamInput, ndjson.stringify, (source) => map(source, str => new Uint8ArrayList(uint8ArrayFromString(str))), async (source) => { for await (const buf of source) { mockMuxer.input.push(buf.subarray()) } } ) }) return mockMuxer } } export function mockMuxer (): MockMuxerFactory { return new MockMuxerFactory() }