├── .github ├── CODEOWNERS ├── workflows │ ├── automerge.yml │ ├── semantic-pull-request.yml │ ├── stale.yml │ └── js-test-and-release.yml └── dependabot.yml ├── .vscode └── settings.json ├── .aegir.js ├── test ├── util.ts ├── compliance.spec.ts ├── codec.util.ts ├── bench │ ├── codec.bench.ts │ └── comparison.bench.ts ├── codec.spec.ts ├── muxer.spec.ts ├── decode.spec.ts └── stream.spec.ts ├── typedoc.json ├── .gitignore ├── tsconfig.json ├── benchmark ├── package.json └── stream-transfer.js ├── src ├── encode.ts ├── constants.ts ├── errors.ts ├── frame.ts ├── index.ts ├── config.ts ├── decode.ts ├── stream.ts └── muxer.ts ├── LICENSE-MIT ├── README.md ├── package.json ├── LICENSE-APACHE └── CHANGELOG.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @ChainSafe/lodestar @achingbrain 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /.aegir.js: -------------------------------------------------------------------------------- 1 | 2 | /** @type {import('aegir/types').PartialOptions} */ 3 | export default { 4 | build: { 5 | bundlesizeMax: '9.5kB' 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/util.ts: -------------------------------------------------------------------------------- 1 | export async function sleep (ms: number): Promise { 2 | return new Promise(resolve => setTimeout(() => { resolve(ms) }, ms)) 3 | } 4 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "readme": "none", 3 | "entryPoints": [ 4 | "./src/index.ts", 5 | "./src/config.ts", 6 | "./src/stream.ts" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | dist 4 | .docs 5 | .coverage 6 | node_modules 7 | package-lock.json 8 | .vscode 9 | .tmp-compiled-docs 10 | tsconfig-doc-check.aegir.json 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "aegir/src/config/tsconfig.aegir.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": [ 7 | "src", 8 | "test" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/automerge.yml: -------------------------------------------------------------------------------- 1 | name: Automerge 2 | on: [ pull_request ] 3 | 4 | jobs: 5 | automerge: 6 | uses: protocol/.github/.github/workflows/automerge.yml@master 7 | with: 8 | job: 'automerge' 9 | -------------------------------------------------------------------------------- /.github/workflows/semantic-pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Semantic PR 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | jobs: 11 | main: 12 | uses: ipdxco/unified-github-workflows/.github/workflows/reusable-semantic-pull-request.yml@v1 13 | -------------------------------------------------------------------------------- /test/compliance.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | import tests from '@libp2p/interface-compliance-tests/stream-muxer' 4 | import { yamux } from '../src/index.ts' 5 | 6 | describe('compliance', () => { 7 | tests({ 8 | async setup () { 9 | return yamux()() 10 | }, 11 | async teardown () {} 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Close and mark stale issue 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | 12 | jobs: 13 | stale: 14 | uses: ipdxco/unified-github-workflows/.github/workflows/reusable-stale-issue.yml@v1 15 | -------------------------------------------------------------------------------- /benchmark/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yamux-benchmarks", 3 | "version": "0.0.1", 4 | "description": "Benchmark for Libp2p's Yamux specification in Js", 5 | "main": "benchmark.js", 6 | "type": "module", 7 | "author": "", 8 | "license": "ISC", 9 | "dependencies": { 10 | "it-drain": "^3.0.3", 11 | "it-pair": "^2.0.6", 12 | "it-pipe": "^3.0.1", 13 | "uint8arraylist": "^2.4.3" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "10:00" 8 | open-pull-requests-limit: 20 9 | commit-message: 10 | prefix: "deps" 11 | prefix-development: "chore" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | commit-message: 17 | prefix: chore 18 | -------------------------------------------------------------------------------- /src/encode.ts: -------------------------------------------------------------------------------- 1 | import { HEADER_LENGTH } from './frame.js' 2 | import type { FrameHeader } from './frame.js' 3 | 4 | export function encodeHeader (header: FrameHeader): Uint8Array { 5 | const frame = new Uint8Array(HEADER_LENGTH) 6 | 7 | // always assume version 0 8 | // frameView.setUint8(0, header.version) 9 | 10 | frame[1] = header.type 11 | 12 | frame[2] = header.flag >>> 8 13 | frame[3] = header.flag 14 | 15 | frame[4] = header.streamID >>> 24 16 | frame[5] = header.streamID >>> 16 17 | frame[6] = header.streamID >>> 8 18 | frame[7] = header.streamID 19 | 20 | frame[8] = header.length >>> 24 21 | frame[9] = header.length >>> 16 22 | frame[10] = header.length >>> 8 23 | frame[11] = header.length 24 | 25 | return frame 26 | } 27 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | // Protocol violation errors 2 | 3 | import { BothClientsError, DecodeInvalidVersionError, InvalidFrameError, NotMatchingPingError, ReceiveWindowExceededError, StreamAlreadyExistsError, UnRequestedPingError } from './errors.js' 4 | 5 | export const PROTOCOL_ERRORS = new Set([ 6 | InvalidFrameError.name, 7 | UnRequestedPingError.name, 8 | NotMatchingPingError.name, 9 | StreamAlreadyExistsError.name, 10 | DecodeInvalidVersionError.name, 11 | BothClientsError.name, 12 | ReceiveWindowExceededError.name 13 | ]) 14 | 15 | /** 16 | * INITIAL_STREAM_WINDOW is the initial stream window size. 17 | * 18 | * Not an implementation choice, this is defined in the specification 19 | */ 20 | export const INITIAL_STREAM_WINDOW = 256 * 1024 21 | 22 | /** 23 | * Default max stream window 24 | */ 25 | export const MAX_STREAM_WINDOW = 16 * 1024 * 1024 26 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /test/codec.util.ts: -------------------------------------------------------------------------------- 1 | import { InvalidFrameError } from '../src/errors.js' 2 | import { HEADER_LENGTH, YAMUX_VERSION } from '../src/frame.js' 3 | import type { FrameHeader } from '../src/frame.js' 4 | 5 | // Slower encode / decode functions that use dataview 6 | 7 | export function decodeHeaderNaive (data: Uint8Array): FrameHeader { 8 | const view = new DataView(data.buffer, data.byteOffset, data.byteLength) 9 | 10 | if (view.getUint8(0) !== YAMUX_VERSION) { 11 | throw new InvalidFrameError('Invalid frame version') 12 | } 13 | return { 14 | type: view.getUint8(1), 15 | flag: view.getUint16(2, false), 16 | streamID: view.getUint32(4, false), 17 | length: view.getUint32(8, false) 18 | } 19 | } 20 | 21 | export function encodeHeaderNaive (header: FrameHeader): Uint8Array { 22 | const frame = new Uint8Array(HEADER_LENGTH) 23 | 24 | const frameView = new DataView(frame.buffer, frame.byteOffset, frame.byteLength) 25 | 26 | // always assume version 0 27 | // frameView.setUint8(0, header.version) 28 | 29 | frameView.setUint8(1, header.type) 30 | frameView.setUint16(2, header.flag, false) 31 | frameView.setUint32(4, header.streamID, false) 32 | frameView.setUint32(8, header.length, false) 33 | 34 | return frame 35 | } 36 | -------------------------------------------------------------------------------- /test/bench/codec.bench.ts: -------------------------------------------------------------------------------- 1 | import { itBench } from '@dapplion/benchmark' 2 | import { decodeHeader } from '../../src/decode.js' 3 | import { encodeHeader } from '../../src/encode.js' 4 | import { Flag, FrameType } from '../../src/frame.js' 5 | import { decodeHeaderNaive, encodeHeaderNaive } from '../codec.util.js' 6 | import type { FrameHeader } from '../../src/frame.js' 7 | 8 | describe('codec benchmark', () => { 9 | for (const { encode, name } of [ 10 | { encode: encodeHeader, name: 'encodeFrameHeader' }, 11 | { encode: encodeHeaderNaive, name: 'encodeFrameHeaderNaive' } 12 | ]) { 13 | itBench({ 14 | id: `frame header - ${name}`, 15 | timeoutBench: 100000000, 16 | beforeEach: () => { 17 | return { 18 | type: FrameType.WindowUpdate, 19 | flag: Flag.ACK, 20 | streamID: 0xffffffff, 21 | length: 0xffffffff 22 | } 23 | }, 24 | fn: (header) => { 25 | encode(header) 26 | } 27 | }) 28 | } 29 | 30 | for (const { decode, name } of [ 31 | { decode: decodeHeader, name: 'decodeHeader' }, 32 | { decode: decodeHeaderNaive, name: 'decodeHeaderNaive' } 33 | ]) { 34 | itBench({ 35 | id: `frame header ${name}`, 36 | beforeEach: () => { 37 | const header = new Uint8Array(12) 38 | for (let i = 1; i < 12; i++) { 39 | header[i] = 255 40 | } 41 | return header 42 | }, 43 | fn: (header) => { 44 | decode(header) 45 | } 46 | }) 47 | } 48 | }) 49 | -------------------------------------------------------------------------------- /benchmark/stream-transfer.js: -------------------------------------------------------------------------------- 1 | import drain from 'it-drain' 2 | import { duplexPair } from 'it-pair/duplex' 3 | import { pipe } from 'it-pipe' 4 | import { Uint8ArrayList } from 'uint8arraylist' 5 | import { yamux } from '../dist/src/index.js' 6 | 7 | const DATA_LENGTH = 1024 * 1024 * 1024 * 5 8 | const CHUNK_SIZE = (1024 * 1024) / 4 9 | const ITERATIONS = 10 10 | 11 | const results = [] 12 | 13 | for (let i = 0; i < ITERATIONS; i++) { 14 | const p = duplexPair() 15 | const muxerA = yamux()().createStreamMuxer({ 16 | direction: 'outbound' 17 | }) 18 | const muxerB = yamux()().createStreamMuxer({ 19 | direction: 'inbound', 20 | onIncomingStream: (stream) => { 21 | // echo stream back to itself 22 | pipe(stream, stream) 23 | } 24 | }) 25 | 26 | // pipe data through muxers 27 | pipe(p[0], muxerA, p[0]) 28 | pipe(p[1], muxerB, p[1]) 29 | 30 | const stream = await muxerA.newStream() 31 | 32 | const start = Date.now() 33 | 34 | await pipe( 35 | async function * () { 36 | for (let i = 0; i < DATA_LENGTH; i += CHUNK_SIZE) { 37 | yield * new Uint8ArrayList(new Uint8Array(CHUNK_SIZE)) 38 | } 39 | }, 40 | stream, 41 | (source) => drain(source) 42 | ) 43 | 44 | const finish = Date.now() - start 45 | 46 | muxerA.close() 47 | muxerB.close() 48 | 49 | results.push(finish) 50 | } 51 | 52 | const megs = DATA_LENGTH / (1024 * 1024) 53 | const secs = (results.reduce((acc, curr) => acc + curr, 0) / results.length) / 1000 54 | 55 | // eslint-disable-next-line no-console 56 | console.info((megs / secs).toFixed(2), 'MB/s') 57 | -------------------------------------------------------------------------------- /test/codec.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'aegir/chai' 2 | import { decodeHeader } from '../src/decode.js' 3 | import { encodeHeader } from '../src/encode.js' 4 | import { Flag, FrameType, GoAwayCode, stringifyHeader } from '../src/frame.js' 5 | import { decodeHeaderNaive, encodeHeaderNaive } from './codec.util.js' 6 | import type { FrameHeader } from '../src/frame.js' 7 | 8 | const frames: Array<{ header: FrameHeader, data?: Uint8Array }> = [ 9 | { header: { type: FrameType.Ping, flag: Flag.SYN, streamID: 0, length: 1 } }, 10 | { header: { type: FrameType.WindowUpdate, flag: Flag.SYN, streamID: 1, length: 1 } }, 11 | { header: { type: FrameType.GoAway, flag: 0, streamID: 0, length: GoAwayCode.NormalTermination } }, 12 | { header: { type: FrameType.Ping, flag: Flag.ACK, streamID: 0, length: 100 } }, 13 | { header: { type: FrameType.WindowUpdate, flag: 0, streamID: 99, length: 1000 } }, 14 | { header: { type: FrameType.WindowUpdate, flag: 0, streamID: 0xffffffff, length: 0xffffffff } }, 15 | { header: { type: FrameType.GoAway, flag: 0, streamID: 0, length: GoAwayCode.ProtocolError } } 16 | ] 17 | 18 | describe('codec', () => { 19 | for (const { header } of frames) { 20 | it(`should round trip encode/decode header ${stringifyHeader(header)}`, () => { 21 | expect(decodeHeader(encodeHeader(header))).to.deep.equal(header) 22 | }) 23 | } 24 | 25 | for (const { header } of frames) { 26 | it(`should match naive implementations of encode/decode for header ${stringifyHeader(header)}`, () => { 27 | expect(encodeHeader(header)).to.deep.equal(encodeHeaderNaive(header)) 28 | expect(decodeHeader(encodeHeader(header))).to.deep.equal(decodeHeaderNaive(encodeHeaderNaive(header))) 29 | }) 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /test/bench/comparison.bench.ts: -------------------------------------------------------------------------------- 1 | import { itBench } from '@dapplion/benchmark' 2 | import { mplex } from '@libp2p/mplex' 3 | import { multiaddrConnectionPair } from '@libp2p/utils' 4 | import { pEvent } from 'p-event' 5 | import { yamux } from '../../src/index.ts' 6 | import type { StreamMuxer } from '@libp2p/interface' 7 | 8 | interface Fixture { 9 | client: StreamMuxer 10 | server: StreamMuxer 11 | } 12 | 13 | describe('comparison benchmark', () => { 14 | for (const { impl, name } of [ 15 | { impl: yamux()(), name: 'yamux' }, 16 | { impl: mplex()(), name: 'mplex' } 17 | ]) { 18 | for (const { numMessages, msgSize } of [ 19 | { numMessages: 1, msgSize: 2 ** 6 }, 20 | { numMessages: 1, msgSize: 2 ** 10 }, 21 | { numMessages: 1, msgSize: 2 ** 16 }, 22 | { numMessages: 1, msgSize: 2 ** 20 }, 23 | { numMessages: 1000, msgSize: 2 ** 6 }, 24 | { numMessages: 1000, msgSize: 2 ** 10 }, 25 | { numMessages: 1000, msgSize: 2 ** 16 }, 26 | { numMessages: 1000, msgSize: 2 ** 20 } 27 | ]) { 28 | itBench({ 29 | id: `${name} send and receive ${numMessages} ${msgSize / 1024}KB chunks`, 30 | beforeEach: () => { 31 | const [outboundConnection, inboundConnection] = multiaddrConnectionPair() 32 | 33 | return { 34 | client: impl.createStreamMuxer(outboundConnection), 35 | server: impl.createStreamMuxer(inboundConnection) 36 | } 37 | }, 38 | fn: async ({ client, server }) => { 39 | const stream = await client.createStream() 40 | 41 | for (let i = 0; i < numMessages; i++) { 42 | const sendMore = stream.send(new Uint8Array(msgSize)) 43 | 44 | if (!sendMore) { 45 | await pEvent(stream, 'drain') 46 | } 47 | } 48 | 49 | await stream.close() 50 | } 51 | }) 52 | } 53 | } 54 | }) 55 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | import { GoAwayCode } from './frame.ts' 2 | 3 | export class ProtocolError extends Error { 4 | static name = 'ProtocolError' 5 | 6 | public reason: GoAwayCode 7 | 8 | constructor (message: string, reason: GoAwayCode) { 9 | super(message) 10 | this.name = 'ProtocolError' 11 | this.reason = reason 12 | } 13 | } 14 | 15 | export function isProtocolError (err?: any): err is ProtocolError { 16 | return err?.reason !== null 17 | } 18 | 19 | export class InvalidFrameError extends ProtocolError { 20 | static name = 'InvalidFrameError' 21 | 22 | constructor (message = 'The frame was invalid') { 23 | super(message, GoAwayCode.ProtocolError) 24 | this.name = 'InvalidFrameError' 25 | } 26 | } 27 | 28 | export class UnRequestedPingError extends ProtocolError { 29 | static name = 'UnRequestedPingError' 30 | 31 | constructor (message = 'Un-requested ping error') { 32 | super(message, GoAwayCode.ProtocolError) 33 | this.name = 'UnRequestedPingError' 34 | } 35 | } 36 | 37 | export class NotMatchingPingError extends ProtocolError { 38 | static name = 'NotMatchingPingError' 39 | 40 | constructor (message = 'Not matching ping error') { 41 | super(message, GoAwayCode.ProtocolError) 42 | this.name = 'NotMatchingPingError' 43 | } 44 | } 45 | 46 | export class InvalidStateError extends Error { 47 | static name = 'InvalidStateError' 48 | 49 | constructor (message = 'Invalid state') { 50 | super(message) 51 | this.name = 'InvalidStateError' 52 | } 53 | } 54 | 55 | export class StreamAlreadyExistsError extends ProtocolError { 56 | static name = 'StreamAlreadyExistsError' 57 | 58 | constructor (message = 'Stream already exists') { 59 | super(message, GoAwayCode.ProtocolError) 60 | this.name = 'StreamAlreadyExistsError' 61 | } 62 | } 63 | 64 | export class DecodeInvalidVersionError extends ProtocolError { 65 | static name = 'DecodeInvalidVersionError' 66 | 67 | constructor (message = 'Decode invalid version') { 68 | super(message, GoAwayCode.ProtocolError) 69 | this.name = 'DecodeInvalidVersionError' 70 | } 71 | } 72 | 73 | export class BothClientsError extends ProtocolError { 74 | static name = 'BothClientsError' 75 | 76 | constructor (message = 'Both clients') { 77 | super(message, GoAwayCode.ProtocolError) 78 | this.name = 'BothClientsError' 79 | } 80 | } 81 | 82 | export class ReceiveWindowExceededError extends ProtocolError { 83 | static name = 'ReceiveWindowExceededError' 84 | 85 | constructor (message = 'Receive window exceeded') { 86 | super(message, GoAwayCode.ProtocolError) 87 | this.name = 'ReceiveWindowExceededError' 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/frame.ts: -------------------------------------------------------------------------------- 1 | export enum FrameType { 2 | /** Used to transmit data. May transmit zero length payloads depending on the flags. */ 3 | Data = 0x0, 4 | /** Used to updated the senders receive window size. This is used to implement per-session flow control. */ 5 | WindowUpdate = 0x1, 6 | /** Used to measure RTT. It can also be used to heart-beat and do keep-alive over TCP. */ 7 | Ping = 0x2, 8 | /** Used to close a session. */ 9 | GoAway = 0x3 10 | } 11 | 12 | export enum Flag { 13 | /** Signals the start of a new stream. May be sent with a data or window update message. Also sent with a ping to indicate outbound. */ 14 | SYN = 0x1, 15 | /** Acknowledges the start of a new stream. May be sent with a data or window update message. Also sent with a ping to indicate response. */ 16 | ACK = 0x2, 17 | /** Performs a half-close of a stream. May be sent with a data message or window update. */ 18 | FIN = 0x4, 19 | /** Reset a stream immediately. May be sent with a data or window update message. */ 20 | RST = 0x8 21 | } 22 | 23 | const flagCodes = Object.values(Flag).filter((x) => typeof x !== 'string') as Flag[] 24 | 25 | export const YAMUX_VERSION = 0 26 | 27 | export enum GoAwayCode { 28 | NormalTermination = 0x0, 29 | ProtocolError = 0x1, 30 | InternalError = 0x2 31 | } 32 | 33 | export const HEADER_LENGTH = 12 34 | 35 | export interface FrameHeader { 36 | /** 37 | * The version field is used for future backward compatibility. 38 | * At the current time, the field is always set to 0, to indicate the initial version. 39 | */ 40 | version?: number 41 | /** The type field is used to switch the frame message type. */ 42 | type: FrameType 43 | /** The flags field is used to provide additional information related to the message type. */ 44 | flag: number 45 | /** 46 | * The StreamID field is used to identify the logical stream the frame is addressing. 47 | * The client side should use odd ID's, and the server even. 48 | * This prevents any collisions. Additionally, the 0 ID is reserved to represent the session. 49 | */ 50 | streamID: number 51 | /** 52 | * The meaning of the length field depends on the message type: 53 | * Data - provides the length of bytes following the header 54 | * Window update - provides a delta update to the window size 55 | * Ping - Contains an opaque value, echoed back 56 | * Go Away - Contains an error code 57 | */ 58 | length: number 59 | } 60 | 61 | export function stringifyHeader (header: FrameHeader): string { 62 | const flags = flagCodes.filter(f => (header.flag & f) === f).map(f => Flag[f]).join('|') 63 | return `streamID=${header.streamID} type=${FrameType[header.type]} flag=${flags} length=${header.length}` 64 | } 65 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * 4 | * This module is a JavaScript implementation of [Yamux from Hashicorp](https://github.com/hashicorp/yamux/blob/master/spec.md) designed to be used with [js-libp2p](https://github.com/libp2p/js-libp2p). 5 | * 6 | * @example Configure libp2p with Yamux 7 | * 8 | * ```typescript 9 | * import { createLibp2p } from 'libp2p' 10 | * import { yamux } from '@chainsafe/libp2p-yamux' 11 | * 12 | * const node = await createLibp2p({ 13 | * // ... other options 14 | * streamMuxers: [ 15 | * yamux() 16 | * ] 17 | * }) 18 | * ``` 19 | * 20 | * @example Using the low-level API 21 | * 22 | * ```js 23 | * import { yamux } from '@chainsafe/libp2p-yamux' 24 | * import { pipe } from 'it-pipe' 25 | * import { duplexPair } from 'it-pair/duplex' 26 | * import all from 'it-all' 27 | * 28 | * // Connect two yamux muxers to demo basic stream multiplexing functionality 29 | * 30 | * const clientMuxer = yamux({ 31 | * client: true, 32 | * onIncomingStream: stream => { 33 | * // echo data on incoming streams 34 | * pipe(stream, stream) 35 | * }, 36 | * onStreamEnd: stream => { 37 | * // do nothing 38 | * } 39 | * })() 40 | * 41 | * const serverMuxer = yamux({ 42 | * client: false, 43 | * onIncomingStream: stream => { 44 | * // echo data on incoming streams 45 | * pipe(stream, stream) 46 | * }, 47 | * onStreamEnd: stream => { 48 | * // do nothing 49 | * } 50 | * })() 51 | * 52 | * // `p` is our "connections", what we use to connect the two sides 53 | * // In a real application, a connection is usually to a remote computer 54 | * const p = duplexPair() 55 | * 56 | * // connect the muxers together 57 | * pipe(p[0], clientMuxer, p[0]) 58 | * pipe(p[1], serverMuxer, p[1]) 59 | * 60 | * // now either side can open streams 61 | * const stream0 = clientMuxer.newStream() 62 | * const stream1 = serverMuxer.newStream() 63 | * 64 | * // Send some data to the other side 65 | * const encoder = new TextEncoder() 66 | * const data = [encoder.encode('hello'), encoder.encode('world')] 67 | * pipe(data, stream0) 68 | * 69 | * // Receive data back 70 | * const result = await pipe(stream0, all) 71 | * 72 | * // close a stream 73 | * stream1.close() 74 | * 75 | * // close the muxer 76 | * clientMuxer.close() 77 | * ``` 78 | */ 79 | 80 | import { Yamux } from './muxer.js' 81 | import type { YamuxMuxer, YamuxMuxerInit } from './muxer.js' 82 | import type { StreamMuxerFactory } from '@libp2p/interface' 83 | 84 | export { GoAwayCode } from './frame.js' 85 | export type { FrameHeader, FrameType } from './frame.js' 86 | export type { YamuxMuxerInit } 87 | 88 | export function yamux (init: YamuxMuxerInit = {}): () => StreamMuxerFactory { 89 | return () => new Yamux(init) 90 | } 91 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { InvalidParametersError } from '@libp2p/interface' 2 | import { INITIAL_STREAM_WINDOW, MAX_STREAM_WINDOW } from './constants.js' 3 | import type { StreamMuxerOptions, StreamOptions } from '@libp2p/interface' 4 | 5 | export interface YamuxStreamOptions extends StreamOptions { 6 | /** 7 | * Used to control the initial window size that we allow for a stream. 8 | * 9 | * measured in bytes 10 | */ 11 | initialStreamWindowSize?: number 12 | 13 | /** 14 | * Used to control the maximum window size that we allow for a stream. 15 | */ 16 | maxStreamWindowSize?: number 17 | } 18 | 19 | // TODO use config items or delete them 20 | export interface Config extends StreamMuxerOptions { 21 | /** 22 | * Used to do periodic keep alive messages using a ping 23 | */ 24 | enableKeepAlive?: boolean 25 | 26 | /** 27 | * How often to perform the keep alive 28 | * 29 | * measured in milliseconds 30 | */ 31 | keepAliveInterval?: number 32 | } 33 | 34 | export const defaultConfig: Required & { streamOptions: Required } = { 35 | enableKeepAlive: true, 36 | keepAliveInterval: 30_000, 37 | maxInboundStreams: 1_000, 38 | maxOutboundStreams: 1_000, 39 | maxMessageSize: 64 * 1024, 40 | maxEarlyStreams: 10, 41 | streamOptions: { 42 | initialStreamWindowSize: INITIAL_STREAM_WINDOW, 43 | maxStreamWindowSize: MAX_STREAM_WINDOW, 44 | inactivityTimeout: 120_000, 45 | maxReadBufferLength: 4_194_304, 46 | maxWriteBufferLength: Infinity 47 | } 48 | } 49 | 50 | export function verifyConfig (config: Config): void { 51 | if (config.keepAliveInterval != null && config.keepAliveInterval <= 0) { 52 | throw new InvalidParametersError('keep-alive interval must be positive') 53 | } 54 | if (config.maxInboundStreams != null && config.maxInboundStreams < 0) { 55 | throw new InvalidParametersError('max inbound streams must be larger or equal 0') 56 | } 57 | if (config.maxOutboundStreams != null && config.maxOutboundStreams < 0) { 58 | throw new InvalidParametersError('max outbound streams must be larger or equal 0') 59 | } 60 | if (config.maxMessageSize != null && config.maxMessageSize < 1024) { 61 | throw new InvalidParametersError('MaxMessageSize must be greater than a kilobyte') 62 | } 63 | if (config.streamOptions?.initialStreamWindowSize != null && config.streamOptions?.initialStreamWindowSize < INITIAL_STREAM_WINDOW) { 64 | throw new InvalidParametersError('InitialStreamWindowSize must be larger or equal 256 kB') 65 | } 66 | if (config.streamOptions?.maxStreamWindowSize != null && config.streamOptions?.initialStreamWindowSize != null && config.streamOptions?.maxStreamWindowSize < config.streamOptions?.initialStreamWindowSize) { 67 | throw new InvalidParametersError('MaxStreamWindowSize must be larger than the InitialStreamWindowSize') 68 | } 69 | if (config.streamOptions?.maxStreamWindowSize != null && config.streamOptions?.maxStreamWindowSize > 2 ** 32 - 1) { 70 | throw new InvalidParametersError('MaxStreamWindowSize must be less than equal MAX_UINT32') 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/decode.ts: -------------------------------------------------------------------------------- 1 | import { Uint8ArrayList } from 'uint8arraylist' 2 | import { InvalidFrameError } from './errors.js' 3 | import { FrameType, HEADER_LENGTH, YAMUX_VERSION } from './frame.js' 4 | import type { FrameHeader } from './frame.js' 5 | 6 | export interface Frame { 7 | header: FrameHeader 8 | data?: Uint8ArrayList 9 | } 10 | 11 | export interface DataFrame { 12 | header: FrameHeader 13 | data: Uint8ArrayList 14 | } 15 | 16 | export function isDataFrame (frame: Frame): frame is DataFrame { 17 | return frame.header.type === FrameType.Data && frame.data !== null 18 | } 19 | 20 | // used to bit shift in decoding 21 | // native bit shift can overflow into a negative number, so we bit shift by 22 | // multiplying by a power of 2 23 | const twoPow24 = 2 ** 24 24 | 25 | /** 26 | * Decode a header from the front of a buffer 27 | * 28 | * @param data - Assumed to have enough bytes for a header 29 | */ 30 | export function decodeHeader (data: Uint8Array): FrameHeader { 31 | if (data[0] !== YAMUX_VERSION) { 32 | throw new InvalidFrameError('Invalid frame version') 33 | } 34 | 35 | return { 36 | type: data[1], 37 | flag: (data[2] << 8) + data[3], 38 | streamID: (data[4] * twoPow24) + (data[5] << 16) + (data[6] << 8) + data[7], 39 | length: (data[8] * twoPow24) + (data[9] << 16) + (data[10] << 8) + data[11] 40 | } 41 | } 42 | 43 | /** 44 | * Decodes yamux frames from a source 45 | */ 46 | export class Decoder { 47 | /** Buffer for in-progress frames */ 48 | private readonly buffer: Uint8ArrayList 49 | 50 | constructor () { 51 | this.buffer = new Uint8ArrayList() 52 | } 53 | 54 | /** 55 | * Emits frames from the decoder source. 56 | * 57 | * Note: If `readData` is emitted, it _must_ be called before the next iteration 58 | * Otherwise an error is thrown 59 | */ 60 | * emitFrames (buf: Uint8Array | Uint8ArrayList): Generator { 61 | this.buffer.append(buf) 62 | 63 | // Loop to consume as many bytes from the buffer as possible 64 | // Eg: when a single chunk contains several frames 65 | while (true) { 66 | const frame = this.readFrame() 67 | 68 | if (frame === undefined) { 69 | break 70 | } 71 | 72 | yield frame 73 | } 74 | } 75 | 76 | private readFrame (): Frame | undefined { 77 | let frameSize = HEADER_LENGTH 78 | 79 | if (this.buffer.byteLength < HEADER_LENGTH) { 80 | // not enough data yet 81 | return 82 | } 83 | 84 | // TODO: use sublist? 85 | const header = decodeHeader(this.buffer.subarray(0, HEADER_LENGTH)) 86 | 87 | if (header.type === FrameType.Data) { 88 | frameSize += header.length 89 | 90 | if (this.buffer.byteLength < frameSize) { 91 | // not enough data yet 92 | return 93 | } 94 | 95 | const data = this.buffer.sublist(HEADER_LENGTH, frameSize) 96 | this.buffer.consume(frameSize) 97 | 98 | return { header, data } 99 | } 100 | 101 | this.buffer.consume(frameSize) 102 | 103 | return { header } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /test/muxer.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | import { multiaddrConnectionPair } from '@libp2p/utils' 4 | import { expect } from 'aegir/chai' 5 | import { YamuxMuxer } from '../src/muxer.ts' 6 | import { sleep } from './util.js' 7 | import type { MultiaddrConnection } from '@libp2p/interface' 8 | 9 | describe('muxer', () => { 10 | let client: YamuxMuxer 11 | let server: YamuxMuxer 12 | let outboundConnection: MultiaddrConnection 13 | let inboundConnection: MultiaddrConnection 14 | 15 | beforeEach(() => { 16 | ([outboundConnection, inboundConnection] = multiaddrConnectionPair()) 17 | client = new YamuxMuxer(outboundConnection) 18 | server = new YamuxMuxer(inboundConnection) 19 | }) 20 | 21 | afterEach(async () => { 22 | if (client != null) { 23 | await client.close() 24 | } 25 | 26 | if (server != null) { 27 | await server.close() 28 | } 29 | }) 30 | 31 | it('test repeated close', async () => { 32 | // inspect logs to ensure its only closed once 33 | await client.close() 34 | await client.close() 35 | await client.close() 36 | }) 37 | 38 | it('test client<->client', async () => { 39 | server['client'] = true 40 | 41 | await client.createStream().catch(() => {}) 42 | await server.createStream().catch(() => {}) 43 | 44 | await sleep(20) 45 | 46 | expect(client).to.have.property('status', 'closed') 47 | expect(server).to.have.property('status', 'closed') 48 | }) 49 | 50 | it('test server<->server', async () => { 51 | client['client'] = false 52 | 53 | await client.createStream().catch(() => {}) 54 | await server.createStream().catch(() => {}) 55 | 56 | await sleep(20) 57 | 58 | expect(client).to.have.property('status', 'closed') 59 | expect(server).to.have.property('status', 'closed') 60 | }) 61 | 62 | it('test ping', async () => { 63 | inboundConnection.pause() 64 | const clientRTT = client.ping() 65 | await sleep(10) 66 | inboundConnection.resume() 67 | await expect(clientRTT).to.eventually.not.equal(0) 68 | 69 | outboundConnection.pause() 70 | const serverRTT = server.ping() 71 | await sleep(10) 72 | outboundConnection.resume() 73 | expect(await serverRTT).to.not.equal(0) 74 | }) 75 | 76 | it('test multiple simultaneous pings', async () => { 77 | inboundConnection.pause() 78 | const promise = [ 79 | client.ping(), 80 | client.ping(), 81 | client.ping() 82 | ] 83 | await sleep(10) 84 | inboundConnection.resume() 85 | 86 | const clientRTTs = await Promise.all(promise) 87 | expect(clientRTTs[0]).to.not.equal(0) 88 | expect(clientRTTs[0]).to.equal(clientRTTs[1]) 89 | expect(clientRTTs[1]).to.equal(clientRTTs[2]) 90 | 91 | expect(client['nextPingID']).to.equal(1) 92 | 93 | await client.close() 94 | }) 95 | 96 | it('test go away', async () => { 97 | await client.close() 98 | 99 | await expect(client.createStream()).to.eventually.be.rejected() 100 | .with.property('name', 'MuxerClosedError', 'should not be able to open a stream after close') 101 | }) 102 | 103 | it('test keep alive', async () => { 104 | client['keepAlive']?.setInterval(10) 105 | 106 | await sleep(1000) 107 | 108 | expect(client['nextPingID']).to.be.gt(2) 109 | }) 110 | 111 | it('test max inbound streams', async () => { 112 | server['maxInboundStreams'] = 1 113 | 114 | await client.createStream() 115 | await client.createStream() 116 | await sleep(10) 117 | 118 | expect(server.streams.length).to.equal(1) 119 | expect(client.streams.length).to.equal(1) 120 | }) 121 | 122 | it('test max outbound streams', async () => { 123 | client['maxOutboundStreams'] = 1 124 | 125 | await client.createStream() 126 | await sleep(10) 127 | 128 | try { 129 | await client.createStream() 130 | expect.fail('stream creation should fail if exceeding maxOutboundStreams') 131 | } catch (e) { 132 | expect(server.streams.length).to.equal(1) 133 | expect(client.streams.length).to.equal(1) 134 | } 135 | }) 136 | }) 137 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @chainsafe/libp2p-yamux 2 | 3 | [![codecov](https://img.shields.io/codecov/c/github/ChainSafe/js-libp2p-yamux.svg?style=flat-square)](https://codecov.io/gh/ChainSafe/js-libp2p-yamux) 4 | [![CI](https://img.shields.io/github/actions/workflow/status/ChainSafe/js-libp2p-yamux/js-test-and-release.yml?branch=master\&style=flat-square)](https://github.com/ChainSafe/js-libp2p-yamux/actions/workflows/js-test-and-release.yml?query=branch%3Amaster) 5 | 6 | > Yamux stream multiplexer for libp2p 7 | 8 | # About 9 | 10 | 24 | 25 | This module is a JavaScript implementation of [Yamux from Hashicorp](https://github.com/hashicorp/yamux/blob/master/spec.md) designed to be used with [js-libp2p](https://github.com/libp2p/js-libp2p). 26 | 27 | ## Example - Configure libp2p with Yamux 28 | 29 | ```typescript 30 | import { createLibp2p } from 'libp2p' 31 | import { yamux } from '@chainsafe/libp2p-yamux' 32 | 33 | const node = await createLibp2p({ 34 | // ... other options 35 | streamMuxers: [ 36 | yamux() 37 | ] 38 | }) 39 | ``` 40 | 41 | ## Example - Using the low-level API 42 | 43 | ```js 44 | import { yamux } from '@chainsafe/libp2p-yamux' 45 | import { pipe } from 'it-pipe' 46 | import { duplexPair } from 'it-pair/duplex' 47 | import all from 'it-all' 48 | 49 | // Connect two yamux muxers to demo basic stream multiplexing functionality 50 | 51 | const clientMuxer = yamux({ 52 | client: true, 53 | onIncomingStream: stream => { 54 | // echo data on incoming streams 55 | pipe(stream, stream) 56 | }, 57 | onStreamEnd: stream => { 58 | // do nothing 59 | } 60 | })() 61 | 62 | const serverMuxer = yamux({ 63 | client: false, 64 | onIncomingStream: stream => { 65 | // echo data on incoming streams 66 | pipe(stream, stream) 67 | }, 68 | onStreamEnd: stream => { 69 | // do nothing 70 | } 71 | })() 72 | 73 | // `p` is our "connections", what we use to connect the two sides 74 | // In a real application, a connection is usually to a remote computer 75 | const p = duplexPair() 76 | 77 | // connect the muxers together 78 | pipe(p[0], clientMuxer, p[0]) 79 | pipe(p[1], serverMuxer, p[1]) 80 | 81 | // now either side can open streams 82 | const stream0 = clientMuxer.newStream() 83 | const stream1 = serverMuxer.newStream() 84 | 85 | // Send some data to the other side 86 | const encoder = new TextEncoder() 87 | const data = [encoder.encode('hello'), encoder.encode('world')] 88 | pipe(data, stream0) 89 | 90 | // Receive data back 91 | const result = await pipe(stream0, all) 92 | 93 | // close a stream 94 | stream1.close() 95 | 96 | // close the muxer 97 | clientMuxer.close() 98 | ``` 99 | 100 | # Install 101 | 102 | ```console 103 | $ npm i @chainsafe/libp2p-yamux 104 | ``` 105 | 106 | ## Browser ` 112 | ``` 113 | 114 | # API Docs 115 | 116 | - 117 | 118 | # License 119 | 120 | Licensed under either of 121 | 122 | - Apache 2.0, ([LICENSE-APACHE](https://github.com/ChainSafe/js-libp2p-yamux/LICENSE-APACHE) / ) 123 | - MIT ([LICENSE-MIT](https://github.com/ChainSafe/js-libp2p-yamux/LICENSE-MIT) / ) 124 | 125 | # Contribution 126 | 127 | Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. 128 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@chainsafe/libp2p-yamux", 3 | "version": "8.0.1", 4 | "description": "Yamux stream multiplexer for libp2p", 5 | "license": "Apache-2.0 OR MIT", 6 | "homepage": "https://github.com/ChainSafe/js-libp2p-yamux#readme", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/ChainSafe/js-libp2p-yamux.git" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/ChainSafe/js-libp2p-yamux/issues" 13 | }, 14 | "publishConfig": { 15 | "access": "public", 16 | "provenance": true 17 | }, 18 | "keywords": [ 19 | "IPFS", 20 | "libp2p", 21 | "multiplexer", 22 | "muxer", 23 | "stream" 24 | ], 25 | "packageManager": "pnpm@10.24.0", 26 | "type": "module", 27 | "types": "./dist/src/index.d.ts", 28 | "typesVersions": { 29 | "*": { 30 | "*": [ 31 | "*", 32 | "dist/*", 33 | "dist/src/*", 34 | "dist/src/*/index" 35 | ], 36 | "src/*": [ 37 | "*", 38 | "dist/*", 39 | "dist/src/*", 40 | "dist/src/*/index" 41 | ] 42 | } 43 | }, 44 | "files": [ 45 | "src", 46 | "dist", 47 | "!dist/test", 48 | "!**/*.tsbuildinfo" 49 | ], 50 | "exports": { 51 | ".": { 52 | "types": "./dist/src/index.d.ts", 53 | "import": "./dist/src/index.js" 54 | }, 55 | "./config": { 56 | "types": "./dist/src/config.d.ts", 57 | "import": "./dist/src/config.js" 58 | }, 59 | "./stream": { 60 | "types": "./dist/src/stream.d.ts", 61 | "import": "./dist/src/stream.js" 62 | } 63 | }, 64 | "release": { 65 | "branches": [ 66 | "master" 67 | ], 68 | "plugins": [ 69 | [ 70 | "@semantic-release/commit-analyzer", 71 | { 72 | "preset": "conventionalcommits", 73 | "releaseRules": [ 74 | { 75 | "breaking": true, 76 | "release": "major" 77 | }, 78 | { 79 | "revert": true, 80 | "release": "patch" 81 | }, 82 | { 83 | "type": "feat", 84 | "release": "minor" 85 | }, 86 | { 87 | "type": "fix", 88 | "release": "patch" 89 | }, 90 | { 91 | "type": "docs", 92 | "release": "patch" 93 | }, 94 | { 95 | "type": "test", 96 | "release": "patch" 97 | }, 98 | { 99 | "type": "deps", 100 | "release": "patch" 101 | }, 102 | { 103 | "scope": "no-release", 104 | "release": false 105 | } 106 | ] 107 | } 108 | ], 109 | [ 110 | "@semantic-release/release-notes-generator", 111 | { 112 | "preset": "conventionalcommits", 113 | "presetConfig": { 114 | "types": [ 115 | { 116 | "type": "feat", 117 | "section": "Features" 118 | }, 119 | { 120 | "type": "fix", 121 | "section": "Bug Fixes" 122 | }, 123 | { 124 | "type": "chore", 125 | "section": "Trivial Changes" 126 | }, 127 | { 128 | "type": "docs", 129 | "section": "Documentation" 130 | }, 131 | { 132 | "type": "deps", 133 | "section": "Dependencies" 134 | }, 135 | { 136 | "type": "test", 137 | "section": "Tests" 138 | } 139 | ] 140 | } 141 | } 142 | ], 143 | "@semantic-release/changelog", 144 | "@semantic-release/npm", 145 | "@semantic-release/github", 146 | [ 147 | "@semantic-release/git", 148 | { 149 | "assets": [ 150 | "CHANGELOG.md", 151 | "package.json" 152 | ] 153 | } 154 | ] 155 | ] 156 | }, 157 | "scripts": { 158 | "clean": "aegir clean", 159 | "lint": "aegir lint", 160 | "dep-check": "aegir dep-check", 161 | "doc-check": "aegir doc-check", 162 | "benchmark": "benchmark dist/test/bench/*.bench.js --timeout 400000", 163 | "build": "aegir build", 164 | "test": "aegir test", 165 | "test:chrome": "aegir test -t browser", 166 | "test:chrome-webworker": "aegir test -t webworker", 167 | "test:firefox": "aegir test -t browser -- --browser firefox", 168 | "test:firefox-webworker": "aegir test -t webworker -- --browser firefox", 169 | "test:node": "aegir test -t node --cov", 170 | "test:electron-main": "aegir test -t electron-main", 171 | "release": "aegir release", 172 | "docs": "aegir docs" 173 | }, 174 | "dependencies": { 175 | "@libp2p/interface": "^3.0.0", 176 | "@libp2p/utils": "^7.0.0", 177 | "race-signal": "^2.0.0", 178 | "uint8arraylist": "^2.4.8" 179 | }, 180 | "devDependencies": { 181 | "@dapplion/benchmark": "^1.0.0", 182 | "@libp2p/interface-compliance-tests": "^7.0.0", 183 | "@libp2p/mplex": "^12.0.0", 184 | "@types/mocha": "^10.0.10", 185 | "aegir": "^47.0.22", 186 | "it-all": "^3.0.9", 187 | "it-drain": "^3.0.10", 188 | "it-pushable": "^3.2.3", 189 | "p-event": "^7.0.0" 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /test/decode.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'aegir/chai' 2 | import all from 'it-all' 3 | import { Uint8ArrayList } from 'uint8arraylist' 4 | import { Decoder } from '../src/decode.js' 5 | import { encodeHeader } from '../src/encode.js' 6 | import { Flag, FrameType, GoAwayCode } from '../src/frame.js' 7 | import type { FrameHeader } from '../src/frame.js' 8 | 9 | const frames: Array<{ header: FrameHeader, data?: Uint8Array }> = [ 10 | { header: { type: FrameType.Ping, flag: Flag.SYN, streamID: 0, length: 1 } }, 11 | { header: { type: FrameType.WindowUpdate, flag: Flag.SYN, streamID: 1, length: 1 } }, 12 | { header: { type: FrameType.GoAway, flag: 0, streamID: 0, length: GoAwayCode.NormalTermination } }, 13 | { header: { type: FrameType.Ping, flag: Flag.ACK, streamID: 0, length: 100 } }, 14 | { header: { type: FrameType.WindowUpdate, flag: 0, streamID: 99, length: 1000 } }, 15 | { header: { type: FrameType.GoAway, flag: 0, streamID: 0, length: GoAwayCode.ProtocolError } } 16 | ] 17 | 18 | const data = (length: number): Uint8Array => Uint8Array.from(Array.from({ length }), (_, i) => i) 19 | 20 | const expectEqualBytes = (actual: Uint8Array | Uint8ArrayList, expected: Uint8Array | Uint8ArrayList, reason?: string): void => { 21 | expect(actual instanceof Uint8Array ? actual : actual.subarray(), reason).to.deep.equal(expected instanceof Uint8Array ? expected : expected.subarray()) 22 | } 23 | 24 | const expectEqualDataFrame = (actual: { header: FrameHeader, data?: Uint8Array | Uint8ArrayList }, expected: { header: FrameHeader, data?: Uint8Array | Uint8ArrayList }, reason = ''): void => { 25 | expect(actual.header, reason + ' header').to.deep.equal(expected.header) 26 | if (actual.data == null && expected.data != null) { 27 | expect.fail('actual has no data but expected does') 28 | } 29 | if (actual.data != null && expected.data == null) { 30 | expect.fail('actual has data but expected does not') 31 | } 32 | if (actual.data != null && expected.data != null) { 33 | expectEqualBytes(actual.data, expected.data, reason + ' data?: string') 34 | } 35 | } 36 | 37 | const expectEqualDataFrames = (actual: Array<{ header: FrameHeader, data?: Uint8Array | Uint8ArrayList }>, expected: Array<{ header: FrameHeader, data?: Uint8Array | Uint8ArrayList }>): void => { 38 | if (actual.length !== expected.length) { 39 | expect.fail('actual') 40 | } 41 | for (let i = 0; i < actual.length; i++) { 42 | expectEqualDataFrame(actual[i], expected[i], String(i)) 43 | } 44 | } 45 | 46 | const dataFrame = (length: number): { header: FrameHeader, data: Uint8Array } => ({ 47 | header: { type: FrameType.Data, flag: 0, streamID: 1, length }, 48 | data: data(length) 49 | }) 50 | 51 | export const randomRanges = (length: number): number[][] => { 52 | const indices = [] 53 | let i = 0 54 | let j = 0 55 | while (i < length) { 56 | j = i 57 | i += Math.floor(Math.random() * length) 58 | indices.push([j, i]) 59 | } 60 | return indices 61 | } 62 | 63 | describe('Decoder internals', () => { 64 | describe('readHeader', () => { 65 | const frame = frames[0] 66 | const d = new Decoder() 67 | 68 | afterEach(() => { 69 | d['buffer'].consume(d['buffer'].length) 70 | }) 71 | 72 | it('should handle an empty buffer', async () => { 73 | expect(d['buffer'].length, 'a freshly created decoder should have an empty buffer').to.equal(0) 74 | expect(all(d.emitFrames(new Uint8Array()))).to.be.empty('an empty buffer should read no header') 75 | }) 76 | 77 | it('should handle buffer length == header length', async () => { 78 | expect(all(d.emitFrames(encodeHeader(frame.header)))).to.deep.equal([frame]) 79 | expect(d['buffer'].length, 'the buffer should be fully drained').to.equal(0) 80 | }) 81 | 82 | it('should handle buffer length < header length', async () => { 83 | const upTo = 2 84 | 85 | const buf = encodeHeader(frame.header) 86 | 87 | expect(all(d.emitFrames(buf.slice(0, upTo)))).to.be.empty('an buffer that has insufficient bytes should read no header') 88 | expect(d['buffer'].length, 'a buffer that has insufficient bytes should not be consumed').to.equal(upTo) 89 | 90 | expect(all(d.emitFrames(buf.slice(upTo)))).to.deep.equal([frame], 'the decoded header should match the input') 91 | expect(d['buffer'].length, 'the buffer should be fully drained').to.equal(0) 92 | }) 93 | 94 | it('should handle buffer length > header length', async () => { 95 | const more = 10 96 | 97 | const buf = new Uint8ArrayList( 98 | encodeHeader(frame.header), 99 | new Uint8Array(more) 100 | ) 101 | 102 | expect(all(d.emitFrames(buf.subarray()))).to.deep.equal([frame], 'the decoded header should match the input') 103 | expect(d['buffer'].length, 'the buffer should be partially drained').to.equal(more) 104 | }) 105 | }) 106 | }) 107 | 108 | describe('Decoder', () => { 109 | describe('emitFrames', () => { 110 | let d: Decoder 111 | 112 | beforeEach(() => { 113 | d = new Decoder() 114 | }) 115 | 116 | it('should emit frames from source chunked by frame', async () => { 117 | const input = new Uint8ArrayList() 118 | const expected = [] 119 | for (const [i, frame] of frames.entries()) { 120 | input.append(encodeHeader(frame.header)) 121 | expected.push(frame) 122 | 123 | // sprinkle in more data frames 124 | if (i % 2 === 1) { 125 | const df = dataFrame(i * 100) 126 | input.append(encodeHeader(df.header)) 127 | input.append(df.data) 128 | expected.push(df) 129 | } 130 | } 131 | 132 | const actual = all(d.emitFrames(input.subarray())) 133 | 134 | expectEqualDataFrames(actual, expected) 135 | }) 136 | 137 | it('should emit frames from source chunked by partial frame', async () => { 138 | const chunkSize = 5 139 | const input = new Uint8ArrayList() 140 | const expected = [] 141 | for (const [i, frame] of frames.entries()) { 142 | const encoded = encodeHeader(frame.header) 143 | for (let i = 0; i < encoded.length; i += chunkSize) { 144 | input.append(encoded.slice(i, i + chunkSize)) 145 | } 146 | expected.push(frame) 147 | 148 | // sprinkle in more data frames 149 | if (i % 2 === 1) { 150 | const df = dataFrame(i * 100) 151 | const encoded = Uint8Array.from([...encodeHeader(df.header), ...df.data]) 152 | for (let i = 0; i < encoded.length; i += chunkSize) { 153 | input.append(encoded.slice(i, i + chunkSize)) 154 | } 155 | expected.push(df) 156 | } 157 | } 158 | 159 | const actual = all(d.emitFrames(input.subarray())) 160 | 161 | expectEqualDataFrames(actual, expected) 162 | }) 163 | 164 | it('should emit frames from source chunked by multiple frames', async () => { 165 | const input = new Uint8ArrayList() 166 | const expected = [] 167 | for (let i = 0; i < frames.length; i++) { 168 | const encoded1 = encodeHeader(frames[i].header) 169 | expected.push(frames[i]) 170 | 171 | i++ 172 | const encoded2 = encodeHeader(frames[i].header) 173 | expected.push(frames[i]) 174 | 175 | // sprinkle in more data frames 176 | const df = dataFrame(i * 100) 177 | const encoded3 = Uint8Array.from([...encodeHeader(df.header), ...df.data]) 178 | expected.push(df) 179 | 180 | const encodedChunk = new Uint8Array(encoded1.length + encoded2.length + encoded3.length) 181 | encodedChunk.set(encoded1, 0) 182 | encodedChunk.set(encoded2, encoded1.length) 183 | encodedChunk.set(encoded3, encoded1.length + encoded2.length) 184 | 185 | input.append(encodedChunk) 186 | } 187 | 188 | const actual = all(d.emitFrames(input.subarray())) 189 | 190 | expectEqualDataFrames(actual, expected) 191 | }) 192 | 193 | it('should emit frames from source chunked chaotically', async () => { 194 | const input = new Uint8ArrayList() 195 | const expected = [] 196 | const encodedFrames = [] 197 | for (const [i, frame] of frames.entries()) { 198 | encodedFrames.push(encodeHeader(frame.header)) 199 | expected.push(frame) 200 | 201 | // sprinkle in more data frames 202 | if (i % 2 === 1) { 203 | const df = dataFrame(i * 100) 204 | encodedFrames.push(encodeHeader(df.header)) 205 | encodedFrames.push(df.data) 206 | expected.push(df) 207 | } 208 | } 209 | 210 | // create a single byte array of all frames to send 211 | // so that we can chunk them chaotically 212 | const encoded = new Uint8Array(encodedFrames.reduce((a, b) => a + b.length, 0)) 213 | let i = 0 214 | for (const e of encodedFrames) { 215 | encoded.set(e, i) 216 | i += e.length 217 | } 218 | 219 | for (const [i, j] of randomRanges(encoded.length)) { 220 | input.append(encoded.slice(i, j)) 221 | } 222 | 223 | const actual = all(d.emitFrames(input.subarray())) 224 | 225 | expectEqualDataFrames(actual, expected) 226 | }) 227 | }) 228 | }) 229 | -------------------------------------------------------------------------------- /test/stream.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | import { multiaddrConnectionPair, pipe } from '@libp2p/utils' 4 | import { expect } from 'aegir/chai' 5 | import drain from 'it-drain' 6 | import { pushable } from 'it-pushable' 7 | import { pEvent } from 'p-event' 8 | import { defaultConfig } from '../src/config.js' 9 | import { GoAwayCode } from '../src/frame.js' 10 | import { YamuxMuxer } from '../src/muxer.ts' 11 | import { StreamState, YamuxStream } from '../src/stream.js' 12 | import { sleep } from './util.js' 13 | import type { MultiaddrConnection } from '@libp2p/interface' 14 | import type { Pushable } from 'it-pushable' 15 | 16 | describe('stream', () => { 17 | let inboundConnection: MultiaddrConnection 18 | let outboundConnection: MultiaddrConnection 19 | let client: YamuxMuxer 20 | let server: YamuxMuxer 21 | 22 | beforeEach(() => { 23 | ([inboundConnection, outboundConnection] = multiaddrConnectionPair()) 24 | client = new YamuxMuxer(inboundConnection, { 25 | maxEarlyStreams: 2000 26 | }) 27 | server = new YamuxMuxer(outboundConnection, { 28 | maxEarlyStreams: 2000 29 | }) 30 | }) 31 | 32 | afterEach(async () => { 33 | await client?.close().catch(err => { 34 | client.abort(err) 35 | }) 36 | await server?.close().catch(err => { 37 | server.abort(err) 38 | }) 39 | }) 40 | 41 | it('test send data - small', async () => { 42 | const [ 43 | s1, c1 44 | ] = await Promise.all([ 45 | pEvent<'stream', CustomEvent>(server, 'stream').then(evt => evt.detail), 46 | client.createStream() 47 | ]) 48 | 49 | await Promise.all([ 50 | Promise.resolve().then(async () => { 51 | for (let i = 0; i < 10; i++) { 52 | const sendMore = c1.send(new Uint8Array(256)) 53 | 54 | if (!sendMore) { 55 | await pEvent(c1, 'drain') 56 | } 57 | } 58 | 59 | await c1.close() 60 | }), 61 | drain(s1) 62 | ]) 63 | 64 | // the window capacities should have refilled via window updates as received data was consumed 65 | expect(c1['sendWindowCapacity']).to.be.gte(defaultConfig.streamOptions.initialStreamWindowSize) 66 | expect(s1['recvWindowCapacity']).to.be.gte(defaultConfig.streamOptions.initialStreamWindowSize) 67 | }) 68 | 69 | it('test send data - large', async () => { 70 | const [ 71 | s1, c1 72 | ] = await Promise.all([ 73 | pEvent<'stream', CustomEvent>(server, 'stream').then(evt => evt.detail), 74 | client.createStream() 75 | ]) 76 | 77 | await Promise.all([ 78 | Promise.resolve().then(async () => { 79 | // amount of data is greater than initial window size 80 | // and each payload is also greater than the max message size 81 | // this will payload chunking and also waiting for window updates before 82 | // continuing to send 83 | for (let i = 0; i < 10; i++) { 84 | const sendMore = c1.send(new Uint8Array(defaultConfig.streamOptions.initialStreamWindowSize)) 85 | 86 | if (!sendMore) { 87 | await pEvent(c1, 'drain') 88 | } 89 | } 90 | 91 | await c1.close() 92 | }), 93 | drain(s1) 94 | ]) 95 | 96 | // the window capacities should have refilled via window updates as received data was consumed 97 | expect(c1['sendWindowCapacity']).to.be.gte(defaultConfig.streamOptions.initialStreamWindowSize) 98 | expect(s1['recvWindowCapacity']).to.be.gte(defaultConfig.streamOptions.initialStreamWindowSize) 99 | }) 100 | 101 | it('test send data - large with increasing recv window size', async () => { 102 | const [ 103 | s1, c1 104 | ] = await Promise.all([ 105 | pEvent<'stream', CustomEvent>(server, 'stream').then(evt => evt.detail), 106 | client.createStream(), 107 | server.ping() 108 | ]) 109 | 110 | await Promise.all([ 111 | Promise.resolve().then(async () => { 112 | // amount of data is greater than initial window size 113 | // and each payload is also greater than the max message size 114 | // this will payload chunking and also waiting for window updates before 115 | // continuing to send 116 | for (let i = 0; i < 10; i++) { 117 | const sendMore = c1.send(new Uint8Array(defaultConfig.streamOptions.initialStreamWindowSize)) 118 | 119 | if (!sendMore) { 120 | await pEvent(c1, 'drain') 121 | } 122 | } 123 | await c1.close() 124 | }), 125 | drain(s1) 126 | ]) 127 | 128 | // the window capacities should have refilled via window updates as received data was consumed 129 | expect(c1['sendWindowCapacity']).to.be.gte(defaultConfig.streamOptions.initialStreamWindowSize) 130 | expect(s1['recvWindowCapacity']).to.be.gte(defaultConfig.streamOptions.initialStreamWindowSize) 131 | }) 132 | 133 | it('test many streams', async () => { 134 | for (let i = 0; i < 1000; i++) { 135 | await client.createStream() 136 | } 137 | await sleep(100) 138 | 139 | expect(client.streams.length).to.equal(1000) 140 | expect(server.streams.length).to.equal(1000) 141 | }) 142 | 143 | it('test many streams - ping pong', async () => { 144 | server.addEventListener('stream', (evt) => { 145 | // echo on incoming streams 146 | pipe(evt.detail, evt.detail) 147 | }) 148 | 149 | const numStreams = 10 150 | 151 | const p: Array> = [] 152 | for (let i = 0; i < numStreams; i++) { 153 | client.createStream() 154 | p.push(pushable()) 155 | } 156 | await sleep(100) 157 | 158 | for (let i = 0; i < numStreams; i++) { 159 | const s = client.streams[i] 160 | void pipe(p[i], s) 161 | p[i].push(new Uint8Array(16)) 162 | } 163 | await sleep(100) 164 | 165 | expect(client.streams.length).to.equal(numStreams) 166 | expect(server.streams.length).to.equal(numStreams) 167 | 168 | await client.close() 169 | }) 170 | 171 | it('test stream close', async () => { 172 | server.addEventListener('stream', (evt) => { 173 | // close incoming streams 174 | evt.detail.close() 175 | }) 176 | 177 | const c1 = await client.createStream() 178 | await c1.close() 179 | await sleep(100) 180 | 181 | expect(c1.state).to.equal(StreamState.Finished) 182 | 183 | expect(client.streams).to.be.empty() 184 | expect(server.streams).to.be.empty() 185 | }) 186 | 187 | it('test stream close write', async () => { 188 | const c1 = await client.createStream() 189 | await c1.close() 190 | await sleep(100) 191 | 192 | expect(c1.state).to.equal(StreamState.SYNSent) 193 | expect(c1.writeStatus).to.equal('closed') 194 | 195 | const s1 = server.streams[0] 196 | expect(s1).to.not.be.undefined() 197 | expect(s1.state).to.equal(StreamState.SYNReceived) 198 | }) 199 | 200 | it('test stream close read', async () => { 201 | const c1 = await client.createStream() 202 | await c1.closeRead() 203 | await sleep(5) 204 | 205 | const s1 = server.streams[0] 206 | expect(s1).to.not.be.undefined() 207 | expect(s1.readStatus).to.equal('readable') 208 | expect(s1.writeStatus).to.equal('writable') 209 | }) 210 | 211 | it('test stream close write', async () => { 212 | const c1 = await client.createStream() 213 | await c1.close() 214 | await sleep(5) 215 | 216 | expect(c1.readStatus).to.equal('readable') 217 | expect(c1.writeStatus).to.equal('closed') 218 | 219 | const s1 = server.streams[0] 220 | expect(s1).to.not.be.undefined() 221 | expect(s1.readStatus).to.equal('readable') 222 | expect(s1.writeStatus).to.equal('writable') 223 | }) 224 | 225 | it('test window overflow', async () => { 226 | const [ 227 | s1, c1 228 | ] = await Promise.all([ 229 | pEvent<'stream', CustomEvent>(server, 'stream').then(evt => evt.detail), 230 | client.createStream() 231 | ]) 232 | 233 | await expect( 234 | Promise.all([ 235 | (async () => { 236 | const data = new Array(10).fill(new Uint8Array(s1['recvWindowCapacity'] * 2)) 237 | 238 | for (const buf of data) { 239 | c1['maxMessageSize'] = s1['recvWindowCapacity'] * 2 240 | c1['sendWindowCapacity'] = s1['recvWindowCapacity'] * 2 241 | const sendMore = c1.send(buf) 242 | 243 | if (!sendMore) { 244 | await pEvent(c1, 'drain') 245 | } 246 | } 247 | 248 | await c1.close() 249 | })(), 250 | drain(s1) 251 | ]) 252 | ).to.eventually.be.rejected() 253 | .with.property('name', 'ReceiveWindowExceededError') 254 | 255 | expect(client).to.have.property('remoteGoAway', GoAwayCode.ProtocolError) 256 | expect(server).to.have.property('localGoAway', GoAwayCode.ProtocolError) 257 | }) 258 | 259 | it('test stream sink error', async () => { 260 | // make the 'drain' event slow to fire 261 | // @ts-expect-error private fields 262 | inboundConnection.local.delay = 1000 263 | 264 | const c1 = await client.createStream() 265 | 266 | // send more data than the window size, will trigger a wait 267 | while (c1.send(new Uint8Array(defaultConfig.streamOptions.initialStreamWindowSize))) { 268 | 269 | } 270 | 271 | // the client should fail to close gracefully because there is unsent data 272 | // that will never be sent 273 | await expect(client.close({ 274 | signal: AbortSignal.timeout(10) 275 | })).to.eventually.be.rejected() 276 | }) 277 | }) 278 | -------------------------------------------------------------------------------- /src/stream.ts: -------------------------------------------------------------------------------- 1 | import { AbstractStream } from '@libp2p/utils' 2 | import { Uint8ArrayList } from 'uint8arraylist' 3 | import { INITIAL_STREAM_WINDOW, MAX_STREAM_WINDOW } from './constants.js' 4 | import { isDataFrame } from './decode.ts' 5 | import { InvalidFrameError, ReceiveWindowExceededError } from './errors.js' 6 | import { Flag, FrameType, HEADER_LENGTH } from './frame.js' 7 | import type { Frame } from './decode.ts' 8 | import type { FrameHeader } from './frame.js' 9 | import type { AbortOptions } from '@libp2p/interface' 10 | import type { AbstractStreamInit, SendResult } from '@libp2p/utils' 11 | 12 | export enum StreamState { 13 | Init, 14 | SYNSent, 15 | SYNReceived, 16 | Established, 17 | Finished, 18 | Paused 19 | } 20 | 21 | export interface YamuxStreamInit extends AbstractStreamInit { 22 | streamId: number 23 | sendFrame(header: FrameHeader, body?: Uint8ArrayList): boolean 24 | getRTT(): number 25 | initialStreamWindowSize?: number 26 | maxMessageSize?: number 27 | maxStreamWindowSize?: number 28 | state: StreamState 29 | } 30 | 31 | /** YamuxStream is used to represent a logical stream within a session */ 32 | export class YamuxStream extends AbstractStream { 33 | streamId: number 34 | state: StreamState 35 | 36 | /** The number of available bytes to send */ 37 | private sendWindowCapacity: number 38 | /** The number of bytes available to receive in a full window */ 39 | private recvWindow: number 40 | /** The number of available bytes to receive */ 41 | private recvWindowCapacity: number 42 | private maxStreamWindowSize: number 43 | 44 | /** 45 | * An 'epoch' is the time it takes to process and read data 46 | * 47 | * Used in conjunction with RTT to determine whether to increase the recvWindow 48 | */ 49 | private epochStart: number 50 | private readonly getRTT: () => number 51 | 52 | private readonly sendFrame: (header: FrameHeader, body?: Uint8ArrayList) => boolean 53 | 54 | constructor (init: YamuxStreamInit) { 55 | const initialWindowSize = init.initialStreamWindowSize ?? INITIAL_STREAM_WINDOW 56 | 57 | super({ 58 | ...init, 59 | maxMessageSize: initialWindowSize - HEADER_LENGTH 60 | }) 61 | 62 | this.streamId = init.streamId 63 | this.state = init.state 64 | this.sendWindowCapacity = initialWindowSize 65 | this.recvWindow = initialWindowSize 66 | this.recvWindowCapacity = this.recvWindow 67 | this.maxStreamWindowSize = init.maxStreamWindowSize ?? MAX_STREAM_WINDOW 68 | this.epochStart = Date.now() 69 | this.getRTT = init.getRTT 70 | this.sendFrame = init.sendFrame 71 | 72 | const setStateToFinishedOnCloseListener = (): void => { 73 | this.state = StreamState.Finished 74 | } 75 | this.addEventListener('close', setStateToFinishedOnCloseListener) 76 | } 77 | 78 | /** 79 | * Send a data message to the remote muxer 80 | */ 81 | sendData (buf: Uint8ArrayList): SendResult { 82 | const totalBytes = buf.byteLength 83 | let sentBytes = 0 84 | let canSendMore = true 85 | 86 | this.log?.trace('send window capacity is %d bytes', this.sendWindowCapacity) 87 | 88 | // send in chunks, waiting for window updates 89 | while (buf.byteLength > 0) { 90 | // we exhausted the send window, sending will resume later 91 | if (this.sendWindowCapacity === 0) { 92 | canSendMore = false 93 | this.log?.trace('sent %d/%d bytes, exhausted send window, waiting for window update', sentBytes, totalBytes) 94 | break 95 | } 96 | 97 | // send as much as we can 98 | const toSend = Math.min(this.sendWindowCapacity, buf.byteLength) 99 | const flags = this.getSendFlags() 100 | 101 | const data = buf.sublist(0, toSend) 102 | buf.consume(toSend) 103 | 104 | const muxerSendMore = this.sendFrame({ 105 | type: FrameType.Data, 106 | flag: flags, 107 | streamID: this.streamId, 108 | length: toSend 109 | }, data) 110 | 111 | this.sendWindowCapacity -= toSend 112 | sentBytes += toSend 113 | 114 | if (!muxerSendMore) { 115 | canSendMore = muxerSendMore 116 | this.log.trace('sent %d/%d bytes, wait for muxer to have more send capacity', sentBytes, totalBytes) 117 | break 118 | } 119 | } 120 | 121 | return { 122 | sentBytes, 123 | canSendMore 124 | } 125 | } 126 | 127 | /** 128 | * Send a reset message to the remote muxer 129 | */ 130 | sendReset (): void { 131 | this.sendFrame({ 132 | type: FrameType.WindowUpdate, 133 | flag: Flag.RST, 134 | streamID: this.streamId, 135 | length: 0 136 | }) 137 | } 138 | 139 | /** 140 | * Send a message to the remote muxer, informing them no more data messages 141 | * will be sent by this end of the stream 142 | */ 143 | async sendCloseWrite (): Promise { 144 | const flags = this.getSendFlags() | Flag.FIN 145 | this.sendFrame({ 146 | type: FrameType.WindowUpdate, 147 | flag: flags, 148 | streamID: this.streamId, 149 | length: 0 150 | }) 151 | } 152 | 153 | /** 154 | * Send a message to the remote muxer, informing them no more data messages 155 | * will be read by this end of the stream - this is a no-op on Yamux streams 156 | */ 157 | async sendCloseRead (options?: AbortOptions): Promise { 158 | options?.signal?.throwIfAborted() 159 | } 160 | 161 | /** 162 | * Stop sending window updates temporarily - in the interim the the remote 163 | * send window will exhaust and the remote will stop sending data 164 | */ 165 | sendPause (): void { 166 | this.state = StreamState.Paused 167 | } 168 | 169 | /** 170 | * Start sending window updates as normal 171 | */ 172 | sendResume (): void { 173 | this.state = StreamState.Established 174 | this.sendWindowUpdate() 175 | } 176 | 177 | /** 178 | * handleWindowUpdate is called when the stream receives a window update frame 179 | */ 180 | handleWindowUpdate (frame: Frame): void { 181 | this.processFlags(frame.header.flag) 182 | 183 | // increase send window 184 | this.sendWindowCapacity += frame.header.length 185 | 186 | // change the chunk size the superclass uses 187 | this.maxMessageSize = this.sendWindowCapacity - HEADER_LENGTH 188 | 189 | if (this.maxMessageSize < 0) { 190 | this.maxMessageSize = 0 191 | } 192 | 193 | if (this.maxMessageSize === 0) { 194 | return 195 | } 196 | 197 | // if writing is paused and the update increases our send window, notify 198 | // writers that writing can resume 199 | if (this.writeBuffer.byteLength > 0) { 200 | this.log?.trace('window update of %d bytes allows more data to be sent, have %d bytes queued, sending data %s', frame.header.length, this.writeBuffer.byteLength, this.sendingData) 201 | this.safeDispatchEvent('drain') 202 | } 203 | } 204 | 205 | /** 206 | * handleData is called when the stream receives a data frame 207 | */ 208 | handleData (frame: Frame): void { 209 | if (!isDataFrame(frame)) { 210 | throw new InvalidFrameError('Frame was not data frame') 211 | } 212 | 213 | this.processFlags(frame.header.flag) 214 | 215 | // check that our recv window is not exceeded 216 | if (this.recvWindowCapacity < frame.header.length) { 217 | throw new ReceiveWindowExceededError('Receive window exceeded') 218 | } 219 | 220 | this.recvWindowCapacity -= frame.header.length 221 | 222 | this.onData(frame.data) 223 | 224 | this.sendWindowUpdate() 225 | } 226 | 227 | /** 228 | * processFlags is used to update the state of the stream based on set flags, if any. 229 | */ 230 | private processFlags (flags: number): void { 231 | if ((flags & Flag.ACK) === Flag.ACK) { 232 | if (this.state === StreamState.SYNSent) { 233 | this.state = StreamState.Established 234 | } 235 | } 236 | 237 | if ((flags & Flag.FIN) === Flag.FIN) { 238 | this.onRemoteCloseWrite() 239 | } 240 | 241 | if ((flags & Flag.RST) === Flag.RST) { 242 | this.onRemoteReset() 243 | } 244 | } 245 | 246 | /** 247 | * getSendFlags determines any flags that are appropriate 248 | * based on the current stream state. 249 | * 250 | * The state is updated as a side-effect. 251 | */ 252 | private getSendFlags (): number { 253 | switch (this.state) { 254 | case StreamState.Init: 255 | this.state = StreamState.SYNSent 256 | return Flag.SYN 257 | case StreamState.SYNReceived: 258 | this.state = StreamState.Established 259 | return Flag.ACK 260 | default: 261 | return 0 262 | } 263 | } 264 | 265 | /** 266 | * Potentially sends a window update enabling further remote writes to take 267 | * place. 268 | */ 269 | sendWindowUpdate (): void { 270 | if (this.state === StreamState.Paused) { 271 | // we don't want any more data from the remote right now - update the 272 | // epoch start as otherwise when we unpause we'd be looking at the epoch 273 | // start from before we were paused 274 | this.epochStart = Date.now() 275 | 276 | return 277 | } 278 | 279 | // determine the flags if any 280 | const flags = this.getSendFlags() 281 | 282 | // If the stream has already been established 283 | // and we've processed data within the time it takes for 4 round trips 284 | // then we (up to) double the recvWindow 285 | const now = Date.now() 286 | const rtt = this.getRTT() 287 | 288 | if (flags === 0 && rtt > -1 && (now - this.epochStart) <= (rtt * 4)) { 289 | // we've already validated that maxStreamWindowSize can't be more than MAX_UINT32 290 | this.recvWindow = Math.min(this.recvWindow * 2, this.maxStreamWindowSize) 291 | } 292 | 293 | if (this.recvWindowCapacity >= this.recvWindow && flags === 0) { 294 | // a window update isn't needed 295 | return 296 | } 297 | 298 | // update the receive window 299 | const delta = this.recvWindow - this.recvWindowCapacity 300 | this.recvWindowCapacity = this.recvWindow 301 | 302 | // update the epoch start 303 | this.epochStart = now 304 | 305 | // send window update 306 | this.sendFrame({ 307 | type: FrameType.WindowUpdate, 308 | flag: flags, 309 | streamID: this.streamId, 310 | length: delta 311 | }) 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /.github/workflows/js-test-and-release.yml: -------------------------------------------------------------------------------- 1 | name: test & maybe release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | permissions: 11 | contents: write 12 | id-token: write 13 | packages: write 14 | pull-requests: write 15 | 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'push' && github.sha || github.ref }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | build: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v6 25 | - uses: pnpm/action-setup@v4 26 | - uses: actions/setup-node@v6 27 | with: 28 | node-version: lts/* 29 | - uses: ipfs/aegir/actions/cache-node-modules@main 30 | 31 | check: 32 | needs: build 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v6 36 | - uses: pnpm/action-setup@v4 37 | - uses: actions/setup-node@v6 38 | with: 39 | node-version: lts/* 40 | - uses: ipfs/aegir/actions/cache-node-modules@main 41 | - run: pnpm run --if-present lint 42 | - run: pnpm run --if-present dep-check 43 | - run: pnpm run --if-present doc-check 44 | - run: pnpm run --if-present spell-check 45 | 46 | test-node: 47 | needs: build 48 | runs-on: ${{ matrix.os }} 49 | strategy: 50 | matrix: 51 | os: [windows-latest, ubuntu-latest, macos-latest] 52 | node: [lts/*] 53 | fail-fast: true 54 | steps: 55 | - uses: actions/checkout@v6 56 | - uses: pnpm/action-setup@v4 57 | - uses: actions/setup-node@v6 58 | with: 59 | node-version: ${{ matrix.node }} 60 | - uses: ipfs/aegir/actions/cache-node-modules@main 61 | - run: pnpm run --if-present test:node 62 | - uses: codecov/codecov-action@fdcc8476540edceab3de004e990f80d881c6cc00 # v5.5.0 63 | with: 64 | flags: node 65 | files: .coverage/*,packages/*/.coverage/* 66 | token: ${{ secrets.CODECOV_TOKEN }} 67 | fail_ci_if_error: false 68 | disable_safe_directory: ${{ runner.os == 'Windows' }} # NOTE: The workspace on Windows runners is on the C: drive and the codecov action is unable to lock the git directory on it 69 | 70 | test-chrome: 71 | needs: build 72 | runs-on: ubuntu-latest 73 | steps: 74 | - uses: actions/checkout@v6 75 | - uses: pnpm/action-setup@v4 76 | - uses: actions/setup-node@v6 77 | with: 78 | node-version: lts/* 79 | - uses: ipfs/aegir/actions/cache-node-modules@main 80 | - run: pnpm run --if-present test:chrome 81 | - uses: codecov/codecov-action@fdcc8476540edceab3de004e990f80d881c6cc00 # v5.5.0 82 | with: 83 | flags: chrome 84 | files: .coverage/*,packages/*/.coverage/* 85 | token: ${{ secrets.CODECOV_TOKEN }} 86 | fail_ci_if_error: false 87 | 88 | test-chrome-webworker: 89 | needs: build 90 | runs-on: ubuntu-latest 91 | steps: 92 | - uses: actions/checkout@v6 93 | - uses: pnpm/action-setup@v4 94 | - uses: actions/setup-node@v6 95 | with: 96 | node-version: lts/* 97 | - uses: ipfs/aegir/actions/cache-node-modules@main 98 | - run: pnpm run --if-present test:chrome-webworker 99 | - uses: codecov/codecov-action@fdcc8476540edceab3de004e990f80d881c6cc00 # v5.5.0 100 | with: 101 | flags: chrome-webworker 102 | files: .coverage/*,packages/*/.coverage/* 103 | token: ${{ secrets.CODECOV_TOKEN }} 104 | fail_ci_if_error: false 105 | 106 | test-firefox: 107 | needs: build 108 | runs-on: ubuntu-latest 109 | steps: 110 | - uses: actions/checkout@v6 111 | - uses: pnpm/action-setup@v4 112 | - uses: actions/setup-node@v6 113 | with: 114 | node-version: lts/* 115 | - uses: ipfs/aegir/actions/cache-node-modules@main 116 | - run: pnpm run --if-present test:firefox 117 | - uses: codecov/codecov-action@fdcc8476540edceab3de004e990f80d881c6cc00 # v5.5.0 118 | with: 119 | flags: firefox 120 | files: .coverage/*,packages/*/.coverage/* 121 | token: ${{ secrets.CODECOV_TOKEN }} 122 | fail_ci_if_error: false 123 | 124 | test-firefox-webworker: 125 | needs: build 126 | runs-on: ubuntu-latest 127 | steps: 128 | - uses: actions/checkout@v6 129 | - uses: pnpm/action-setup@v4 130 | - uses: actions/setup-node@v6 131 | with: 132 | node-version: lts/* 133 | - uses: ipfs/aegir/actions/cache-node-modules@main 134 | - run: pnpm run --if-present test:firefox-webworker 135 | - uses: codecov/codecov-action@fdcc8476540edceab3de004e990f80d881c6cc00 # v5.5.0 136 | with: 137 | flags: firefox-webworker 138 | files: .coverage/*,packages/*/.coverage/* 139 | token: ${{ secrets.CODECOV_TOKEN }} 140 | fail_ci_if_error: false 141 | 142 | test-webkit: 143 | needs: build 144 | runs-on: ${{ matrix.os }} 145 | strategy: 146 | matrix: 147 | os: [ubuntu-latest, macos-latest] 148 | node: [lts/*] 149 | fail-fast: true 150 | steps: 151 | - uses: actions/checkout@v6 152 | - uses: pnpm/action-setup@v4 153 | - uses: actions/setup-node@v6 154 | with: 155 | node-version: lts/* 156 | - uses: ipfs/aegir/actions/cache-node-modules@main 157 | - run: npx playwright install-deps 158 | - run: pnpm run --if-present test:webkit 159 | - uses: codecov/codecov-action@fdcc8476540edceab3de004e990f80d881c6cc00 # v5.5.0 160 | with: 161 | flags: webkit 162 | files: .coverage/*,packages/*/.coverage/* 163 | token: ${{ secrets.CODECOV_TOKEN }} 164 | fail_ci_if_error: false 165 | disable_safe_directory: ${{ runner.os == 'Windows' }} # NOTE: The workspace on Windows runners is on the C: drive and the codecov action is unable to lock the git directory on it 166 | 167 | test-webkit-webworker: 168 | needs: build 169 | runs-on: ${{ matrix.os }} 170 | strategy: 171 | matrix: 172 | os: [ubuntu-latest, macos-latest] 173 | node: [lts/*] 174 | fail-fast: true 175 | steps: 176 | - uses: actions/checkout@v6 177 | - uses: pnpm/action-setup@v4 178 | - uses: actions/setup-node@v6 179 | with: 180 | node-version: lts/* 181 | - uses: ipfs/aegir/actions/cache-node-modules@main 182 | - run: npx playwright install-deps 183 | - run: pnpm run --if-present test:webkit-webworker 184 | - uses: codecov/codecov-action@fdcc8476540edceab3de004e990f80d881c6cc00 # v5.5.0 185 | with: 186 | flags: webkit-webworker 187 | files: .coverage/*,packages/*/.coverage/* 188 | token: ${{ secrets.CODECOV_TOKEN }} 189 | fail_ci_if_error: false 190 | disable_safe_directory: ${{ runner.os == 'Windows' }} # NOTE: The workspace on Windows runners is on the C: drive and the codecov action is unable to lock the git directory on it 191 | 192 | test-electron-main: 193 | needs: build 194 | runs-on: ubuntu-latest 195 | steps: 196 | - uses: actions/checkout@v6 197 | - uses: pnpm/action-setup@v4 198 | - uses: actions/setup-node@v6 199 | with: 200 | node-version: lts/* 201 | - uses: ipfs/aegir/actions/cache-node-modules@main 202 | - run: npx xvfb-maybe pnpm run --if-present test:electron-main 203 | - uses: codecov/codecov-action@fdcc8476540edceab3de004e990f80d881c6cc00 # v5.5.0 204 | with: 205 | flags: electron-main 206 | files: .coverage/*,packages/*/.coverage/* 207 | token: ${{ secrets.CODECOV_TOKEN }} 208 | fail_ci_if_error: false 209 | 210 | test-electron-renderer: 211 | needs: build 212 | runs-on: ubuntu-latest 213 | steps: 214 | - uses: actions/checkout@v6 215 | - uses: pnpm/action-setup@v4 216 | - uses: actions/setup-node@v6 217 | with: 218 | node-version: lts/* 219 | - uses: ipfs/aegir/actions/cache-node-modules@main 220 | - run: npx xvfb-maybe pnpm run --if-present test:electron-renderer 221 | - uses: codecov/codecov-action@fdcc8476540edceab3de004e990f80d881c6cc00 # v5.5.0 222 | with: 223 | flags: electron-renderer 224 | files: .coverage/*,packages/*/.coverage/* 225 | token: ${{ secrets.CODECOV_TOKEN }} 226 | fail_ci_if_error: false 227 | 228 | release-check: 229 | needs: [test-node, test-chrome, test-chrome-webworker, test-firefox, test-firefox-webworker, test-webkit, test-webkit-webworker, test-electron-main, test-electron-renderer] 230 | runs-on: ubuntu-latest 231 | if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' 232 | outputs: 233 | release: ${{ steps.branch.outputs.release }} 234 | steps: 235 | - id: branch 236 | name: Check if the branch is a release branch 237 | env: 238 | BRANCHES: ${{ inputs.branches }} 239 | REF: ${{ github.ref }} 240 | uses: actions/github-script@v7 241 | with: 242 | script: | 243 | const branches = JSON.parse(process.env.BRANCHES); 244 | const ref = process.env.REF.replace(/^refs\/heads\//, ''); 245 | const release = branches.some(b => { 246 | const regexPattern = b.replace(/\*/g, '.*'); 247 | const regex = new RegExp(`^${regexPattern}$`); 248 | return regex.test(ref); 249 | }); 250 | console.log(`This is a release branch: ${release}`); 251 | core.setOutput('release', release); 252 | 253 | release: 254 | needs: [release-check] 255 | runs-on: ubuntu-latest 256 | if: needs.release-check.outputs.release == 'true' 257 | steps: 258 | - uses: actions/checkout@v6 259 | with: 260 | fetch-depth: 0 261 | persist-credentials: false 262 | - uses: pnpm/action-setup@v4 263 | - uses: actions/setup-node@v6 264 | with: 265 | node-version: lts/* 266 | - uses: ipfs/aegir/actions/cache-node-modules@main 267 | - uses: ipfs/aegir/actions/docker-login@main 268 | with: 269 | docker-token: ${{ secrets.DOCKER_TOKEN }} 270 | docker-username: ${{ secrets.DOCKER_USERNAME }} 271 | docker-registry: ${{ inputs.docker-registry }} 272 | - run: pnpm run --if-present release 273 | env: 274 | GITHUB_TOKEN: ${{ secrets.UCI_GITHUB_TOKEN || github.token }} 275 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [8.0.1](https://github.com/ChainSafe/js-libp2p-yamux/compare/v8.0.0...v8.0.1) (2025-10-15) 2 | 3 | ### Bug Fixes 4 | 5 | * make sendReset synchronous to avoid unhandled rejections ([#113](https://github.com/ChainSafe/js-libp2p-yamux/issues/113)) ([232fb1b](https://github.com/ChainSafe/js-libp2p-yamux/commit/232fb1b6aec679d61a31f4b97deeed135ef6f5c4)) 6 | 7 | ## [8.0.0](https://github.com/ChainSafe/js-libp2p-yamux/compare/v7.0.4...v8.0.0) (2025-09-25) 8 | 9 | ### ⚠ BREAKING CHANGES 10 | 11 | * Must be used with `libp2p@3.x.x`, it cannot be used with earlier versions 12 | 13 | ### Features 14 | 15 | * update to libp2p v3 api ([#105](https://github.com/ChainSafe/js-libp2p-yamux/issues/105)) ([bc5e09e](https://github.com/ChainSafe/js-libp2p-yamux/commit/bc5e09ebd207b8bc3a6154accef0a59e6c6f1792)) 16 | 17 | ## [7.0.4](https://github.com/ChainSafe/js-libp2p-yamux/compare/v7.0.3...v7.0.4) (2025-06-25) 18 | 19 | ### Dependencies 20 | 21 | * **dev:** bump @dapplion/benchmark from 0.2.5 to 1.0.0 ([#97](https://github.com/ChainSafe/js-libp2p-yamux/issues/97)) ([844c13e](https://github.com/ChainSafe/js-libp2p-yamux/commit/844c13ec60efb0f3b763630a7769f6377fe21e40)) 22 | 23 | ## [7.0.3](https://github.com/ChainSafe/js-libp2p-yamux/compare/v7.0.2...v7.0.3) (2025-06-25) 24 | 25 | ### Bug Fixes 26 | 27 | * keep-alive memory leak ([#102](https://github.com/ChainSafe/js-libp2p-yamux/issues/102)) ([b470fc6](https://github.com/ChainSafe/js-libp2p-yamux/commit/b470fc6e1bb838677ebcb24fff72930a609aa88b)) 28 | 29 | ## [7.0.2](https://github.com/ChainSafe/js-libp2p-yamux/compare/v7.0.1...v7.0.2) (2025-06-18) 30 | 31 | ### Dependencies 32 | 33 | * **dev:** bump aegir from 44.1.4 to 45.0.8 ([#98](https://github.com/ChainSafe/js-libp2p-yamux/issues/98)) ([1f97536](https://github.com/ChainSafe/js-libp2p-yamux/commit/1f97536483b758f988fc716f190a3d8d6253ed3b)) 34 | 35 | ## [7.0.1](https://github.com/ChainSafe/js-libp2p-yamux/compare/v7.0.0...v7.0.1) (2024-09-25) 36 | 37 | ### Bug Fixes 38 | 39 | * switch missing stream log to trace ([#90](https://github.com/ChainSafe/js-libp2p-yamux/issues/90)) ([96bfe96](https://github.com/ChainSafe/js-libp2p-yamux/commit/96bfe96d6df52d0b9ea55a87ac8c1f87fdd50a6b)) 40 | 41 | ## [7.0.0](https://github.com/ChainSafe/js-libp2p-yamux/compare/v6.0.2...v7.0.0) (2024-09-11) 42 | 43 | ### ⚠ BREAKING CHANGES 44 | 45 | * requires libp2p@2.x.x 46 | 47 | ### Features 48 | 49 | * add service capabilities definition ([#79](https://github.com/ChainSafe/js-libp2p-yamux/issues/79)) ([0aff43d](https://github.com/ChainSafe/js-libp2p-yamux/commit/0aff43dfb6bc6b4b78ccff9f9186ef65b6a5bbe6)) 50 | 51 | ### Bug Fixes 52 | 53 | * upgrade to libp2p@2.x.x ([#84](https://github.com/ChainSafe/js-libp2p-yamux/issues/84)) ([47556c0](https://github.com/ChainSafe/js-libp2p-yamux/commit/47556c0f2c9da48ef8b40cb2fb03431a9c6ae5c9)) 54 | 55 | ### Trivial Changes 56 | 57 | * bump aegir from 42.2.11 to 43.0.1 ([#78](https://github.com/ChainSafe/js-libp2p-yamux/issues/78)) ([46af2f8](https://github.com/ChainSafe/js-libp2p-yamux/commit/46af2f8ca49f1a60ae965b70ace6a856c6d03ecc)) 58 | * update aegir to 42.x.x ([#74](https://github.com/ChainSafe/js-libp2p-yamux/issues/74)) ([87b4db9](https://github.com/ChainSafe/js-libp2p-yamux/commit/87b4db9a8deb300cb2ba6efe09b0cc13a08c90b3)) 59 | * update project config ([ca413ad](https://github.com/ChainSafe/js-libp2p-yamux/commit/ca413ad236e76b836f6c3d2613c0ccc678b29fbd)) 60 | 61 | ### Dependencies 62 | 63 | * **dev:** bump aegir from 43.0.3 to 44.1.1 ([#83](https://github.com/ChainSafe/js-libp2p-yamux/issues/83)) ([0a8b06f](https://github.com/ChainSafe/js-libp2p-yamux/commit/0a8b06fdc8d6f3d83dfd2d25b8c6a614ceeefc38)) 64 | 65 | ## [6.0.2](https://github.com/ChainSafe/js-libp2p-yamux/compare/v6.0.1...v6.0.2) (2024-02-08) 66 | 67 | 68 | ### Bug Fixes 69 | 70 | * send data during graceful close ([#75](https://github.com/ChainSafe/js-libp2p-yamux/issues/75)) ([f77b9ec](https://github.com/ChainSafe/js-libp2p-yamux/commit/f77b9ec7118720d23073bbfcd96504c0a179f4b2)) 71 | * send data during graceful close. ([#73](https://github.com/ChainSafe/js-libp2p-yamux/issues/73)) ([f5d92da](https://github.com/ChainSafe/js-libp2p-yamux/commit/f5d92da81cefe2edbf504c187d87bd9dde357005)) 72 | 73 | ## [6.0.1](https://github.com/ChainSafe/js-libp2p-yamux/compare/v6.0.0...v6.0.1) (2023-11-30) 74 | 75 | 76 | ### Bug Fixes 77 | 78 | * update interface import path ([#68](https://github.com/ChainSafe/js-libp2p-yamux/issues/68)) ([bfad61c](https://github.com/ChainSafe/js-libp2p-yamux/commit/bfad61cf1e7293c5f7e2bb5db35bb62a17b34d48)) 79 | 80 | ## [6.0.0](https://github.com/ChainSafe/js-libp2p-yamux/compare/v5.0.4...v6.0.0) (2023-11-29) 81 | 82 | 83 | ### ⚠ BREAKING CHANGES 84 | 85 | * yield uint8arraylists (#65) 86 | 87 | ### Features 88 | 89 | * yield uint8arraylists ([#65](https://github.com/ChainSafe/js-libp2p-yamux/issues/65)) ([9ffc44d](https://github.com/ChainSafe/js-libp2p-yamux/commit/9ffc44dd2d924562da02a616f601b21bf9c13858)) 90 | 91 | ## [5.0.4](https://github.com/ChainSafe/js-libp2p-yamux/compare/v5.0.3...v5.0.4) (2023-11-29) 92 | 93 | 94 | ### Dependencies 95 | 96 | * **dev:** update aegir to 41.x.x ([#67](https://github.com/ChainSafe/js-libp2p-yamux/issues/67)) ([f2492e9](https://github.com/ChainSafe/js-libp2p-yamux/commit/f2492e9ea8a8387d9f6aea36321afd690521e538)) 97 | 98 | ## [5.0.3](https://github.com/ChainSafe/js-libp2p-yamux/compare/v5.0.2...v5.0.3) (2023-11-14) 99 | 100 | 101 | ### Bug Fixes 102 | 103 | * establish RTT at start of connection ([#64](https://github.com/ChainSafe/js-libp2p-yamux/issues/64)) ([672523b](https://github.com/ChainSafe/js-libp2p-yamux/commit/672523bcf0c4c2ccfaf27b6b06e07f28294f0077)) 104 | 105 | ## [5.0.2](https://github.com/ChainSafe/js-libp2p-yamux/compare/v5.0.1...v5.0.2) (2023-11-12) 106 | 107 | 108 | ### Bug Fixes 109 | 110 | * remove abortable iterator from muxer ([#63](https://github.com/ChainSafe/js-libp2p-yamux/issues/63)) ([064bf1c](https://github.com/ChainSafe/js-libp2p-yamux/commit/064bf1cc56bc4bdaa0f950a1f434f25c66e036a9)) 111 | 112 | ## [5.0.1](https://github.com/ChainSafe/js-libp2p-yamux/compare/v5.0.0...v5.0.1) (2023-11-12) 113 | 114 | 115 | ### Bug Fixes 116 | 117 | * do not stringify headers for logging ([#61](https://github.com/ChainSafe/js-libp2p-yamux/issues/61)) ([59e73d8](https://github.com/ChainSafe/js-libp2p-yamux/commit/59e73d8bd5db59ec2b606a37f19b30d801dcf80a)) 118 | * silence max listeners exceeded warning ([#62](https://github.com/ChainSafe/js-libp2p-yamux/issues/62)) ([cce9446](https://github.com/ChainSafe/js-libp2p-yamux/commit/cce94469cbdac581baf0375b88aa6d38b0778a88)) 119 | 120 | ## [5.0.0](https://github.com/ChainSafe/js-libp2p-yamux/compare/v4.0.2...v5.0.0) (2023-08-03) 121 | 122 | 123 | ### ⚠ BREAKING CHANGES 124 | 125 | * stream close methods are now asyc, requires libp2p@0.46.x or later 126 | 127 | * chore: pr comments 128 | 129 | * chore: remove readState/writeState as they are not used any more 130 | 131 | ### Features 132 | 133 | * close streams gracefully ([#57](https://github.com/ChainSafe/js-libp2p-yamux/issues/57)) ([2bd88a8](https://github.com/ChainSafe/js-libp2p-yamux/commit/2bd88a8a8ec123bd220fad450645f61aea44258a)) 134 | 135 | ## [4.0.2](https://github.com/ChainSafe/js-libp2p-yamux/compare/v4.0.1...v4.0.2) (2023-05-17) 136 | 137 | 138 | ### Bug Fixes 139 | 140 | * improve decode performance with subarray ([#49](https://github.com/ChainSafe/js-libp2p-yamux/issues/49)) ([684de7c](https://github.com/ChainSafe/js-libp2p-yamux/commit/684de7cd5f8614ab34122c3f4bb6671c9288618c)) 141 | 142 | 143 | ### Dependencies 144 | 145 | * upgrade deps ([#52](https://github.com/ChainSafe/js-libp2p-yamux/issues/52)) ([d00570c](https://github.com/ChainSafe/js-libp2p-yamux/commit/d00570c9313c7f141559827be58f122db719dbaf)) 146 | 147 | ## [4.0.1](https://github.com/ChainSafe/js-libp2p-yamux/compare/v4.0.0...v4.0.1) (2023-05-01) 148 | 149 | 150 | ### Bug Fixes 151 | 152 | * updated reset for abort controller ([#26](https://github.com/ChainSafe/js-libp2p-yamux/issues/26)) ([6fc5ebd](https://github.com/ChainSafe/js-libp2p-yamux/commit/6fc5ebd6296286e40f761271f42c60d70b729b14)) 153 | 154 | ## [4.0.0](https://github.com/ChainSafe/js-libp2p-yamux/compare/v3.0.10...v4.0.0) (2023-04-19) 155 | 156 | 157 | ### ⚠ BREAKING CHANGES 158 | 159 | * the type of the source/sink properties have changed 160 | 161 | ### Dependencies 162 | 163 | * update to new stream type deps ([#36](https://github.com/ChainSafe/js-libp2p-yamux/issues/36)) ([a2d841d](https://github.com/ChainSafe/js-libp2p-yamux/commit/a2d841d7e5bac4a5659bdbe98e962bcaab61ed65)) 164 | 165 | ## [3.0.10](https://github.com/ChainSafe/js-libp2p-yamux/compare/v3.0.9...v3.0.10) (2023-04-16) 166 | 167 | 168 | ### Bug Fixes 169 | 170 | * use trace logging for happy paths ([#35](https://github.com/ChainSafe/js-libp2p-yamux/issues/35)) ([2c64584](https://github.com/ChainSafe/js-libp2p-yamux/commit/2c64584bc20692ab9bad7d96621579c8f1c9fc6f)) 171 | 172 | ## [3.0.9](https://github.com/ChainSafe/js-libp2p-yamux/compare/v3.0.8...v3.0.9) (2023-04-13) 173 | 174 | 175 | ### Dependencies 176 | 177 | * bump @libp2p/interface-connection from 3.1.1 to 4.0.0 ([#32](https://github.com/ChainSafe/js-libp2p-yamux/issues/32)) ([e8ac91d](https://github.com/ChainSafe/js-libp2p-yamux/commit/e8ac91d6ba448cba75adc43a4fc580e46129398f)) 178 | * bump it-pipe from 2.0.5 to 3.0.1 ([#30](https://github.com/ChainSafe/js-libp2p-yamux/issues/30)) ([e396e6e](https://github.com/ChainSafe/js-libp2p-yamux/commit/e396e6ed68e7cccf9a3e58e793ca91d94ad35e3e)) 179 | 180 | ## [3.0.8](https://github.com/ChainSafe/js-libp2p-yamux/compare/v3.0.7...v3.0.8) (2023-04-13) 181 | 182 | 183 | ### Dependencies 184 | 185 | * update any-signal to 4.x.x ([#33](https://github.com/ChainSafe/js-libp2p-yamux/issues/33)) ([5f3e5aa](https://github.com/ChainSafe/js-libp2p-yamux/commit/5f3e5aad85b659cb18a0e901e10e3f0466bedd6b)) 186 | 187 | ## [3.0.7](https://github.com/ChainSafe/js-libp2p-yamux/compare/v3.0.6...v3.0.7) (2023-03-01) 188 | 189 | 190 | ### Bug Fixes 191 | 192 | * catch stream sink errors ([#25](https://github.com/ChainSafe/js-libp2p-yamux/issues/25)) ([7c7fd07](https://github.com/ChainSafe/js-libp2p-yamux/commit/7c7fd07338379d57b6d0bd1dde12e36797cf3c50)) 193 | 194 | ## [3.0.6](https://github.com/ChainSafe/js-libp2p-yamux/compare/v3.0.5...v3.0.6) (2023-02-24) 195 | 196 | 197 | ### Dependencies 198 | 199 | * **dev:** bump it-pair from 2.0.3 to 2.0.4 ([#22](https://github.com/ChainSafe/js-libp2p-yamux/issues/22)) ([f908735](https://github.com/ChainSafe/js-libp2p-yamux/commit/f908735bbbd921b0806ffe4a3cec6176662e1f3c)) 200 | 201 | ## [3.0.5](https://github.com/ChainSafe/js-libp2p-yamux/compare/v3.0.4...v3.0.5) (2023-01-16) 202 | 203 | 204 | ### Dependencies 205 | 206 | * **dev:** bump aegir from 37.12.1 to 38.1.0 ([#20](https://github.com/ChainSafe/js-libp2p-yamux/issues/20)) ([0cf9a86](https://github.com/ChainSafe/js-libp2p-yamux/commit/0cf9a865bff5f82b3fe03bf2a718b22f1cd1ef5d)) 207 | 208 | 209 | ### Trivial Changes 210 | 211 | * replace err-code with CodeError ([#21](https://github.com/ChainSafe/js-libp2p-yamux/issues/21)) ([8c2ba01](https://github.com/ChainSafe/js-libp2p-yamux/commit/8c2ba01f5dbeb736e94cf6df3ab140494a2b184d)) 212 | 213 | ## [3.0.4](https://github.com/ChainSafe/js-libp2p-yamux/compare/v3.0.3...v3.0.4) (2023-01-06) 214 | 215 | 216 | ### Bug Fixes 217 | 218 | * remove unused deps ([#19](https://github.com/ChainSafe/js-libp2p-yamux/issues/19)) ([beb4707](https://github.com/ChainSafe/js-libp2p-yamux/commit/beb47073fc1f919def45db262ed58f7d1f3a7a96)) 219 | 220 | ## [3.0.3](https://github.com/ChainSafe/js-libp2p-yamux/compare/v3.0.2...v3.0.3) (2022-11-05) 221 | 222 | 223 | ### Bug Fixes 224 | 225 | * remove metrics from component ([#17](https://github.com/ChainSafe/js-libp2p-yamux/issues/17)) ([c396f8c](https://github.com/ChainSafe/js-libp2p-yamux/commit/c396f8c1b99f3c68104c894a1ac88a805bff68a3)) 226 | 227 | ## [3.0.2](https://github.com/ChainSafe/js-libp2p-yamux/compare/v3.0.1...v3.0.2) (2022-10-17) 228 | 229 | 230 | ### Dependencies 231 | 232 | * **dev:** bump @libp2p/mplex from 6.0.2 to 7.0.0 ([#14](https://github.com/ChainSafe/js-libp2p-yamux/issues/14)) ([4085a05](https://github.com/ChainSafe/js-libp2p-yamux/commit/4085a05d169b6aea212f995044512ee011e15e07)) 233 | 234 | ## [3.0.1](https://github.com/ChainSafe/js-libp2p-yamux/compare/v3.0.0...v3.0.1) (2022-10-17) 235 | 236 | 237 | ### Dependencies 238 | 239 | * **dev:** bump @libp2p/interface-stream-muxer-compliance-tests from 5.0.0 to 6.0.0 ([#15](https://github.com/ChainSafe/js-libp2p-yamux/issues/15)) ([b6a02d1](https://github.com/ChainSafe/js-libp2p-yamux/commit/b6a02d1613df746f626ea75bfa3b9d601d34e071)) 240 | * **dev:** bump it-drain from 1.0.5 to 2.0.0 ([#16](https://github.com/ChainSafe/js-libp2p-yamux/issues/16)) ([399a49c](https://github.com/ChainSafe/js-libp2p-yamux/commit/399a49ce7b539ab5643491938cb13cb1857a2bc1)) 241 | 242 | ## [3.0.0](https://github.com/ChainSafe/js-libp2p-yamux/compare/v2.0.0...v3.0.0) (2022-10-12) 243 | 244 | 245 | ### ⚠ BREAKING CHANGES 246 | 247 | * modules no longer implement `Initializable` instead switching to constructor injection 248 | 249 | ### Bug Fixes 250 | 251 | * remove @libp2p/components ([#13](https://github.com/ChainSafe/js-libp2p-yamux/issues/13)) ([3fafe00](https://github.com/ChainSafe/js-libp2p-yamux/commit/3fafe0053c6e752e86d0c68549a62b231b16d4ac)) 252 | 253 | ## [2.0.0](https://github.com/ChainSafe/js-libp2p-yamux/compare/v1.0.1...v2.0.0) (2022-10-07) 254 | 255 | 256 | ### ⚠ BREAKING CHANGES 257 | 258 | * **deps:** bump @libp2p/interface-stream-muxer from 2.0.2 to 3.0.0 (#9) 259 | * **deps:** bump @libp2p/components from 2.1.1 to 3.0.0 (#7) 260 | 261 | ### Bug Fixes 262 | 263 | * update project config ([#10](https://github.com/ChainSafe/js-libp2p-yamux/issues/10)) ([b752604](https://github.com/ChainSafe/js-libp2p-yamux/commit/b752604f371a51d7efe02fea499a8e8c4f4e435c)) 264 | 265 | 266 | ### Trivial Changes 267 | 268 | * **deps-dev:** bump @libp2p/interface-stream-muxer-compliance-tests from 4.0.0 to 5.0.0 ([#8](https://github.com/ChainSafe/js-libp2p-yamux/issues/8)) ([af8c3ae](https://github.com/ChainSafe/js-libp2p-yamux/commit/af8c3ae6b708ed43b02f7021e19ae10466653a5e)) 269 | * **deps:** bump @libp2p/components from 2.1.1 to 3.0.0 ([#7](https://github.com/ChainSafe/js-libp2p-yamux/issues/7)) ([2c31bce](https://github.com/ChainSafe/js-libp2p-yamux/commit/2c31bceffdb120d044a4bfd612c94f3d28ff8540)) 270 | * **deps:** bump @libp2p/interface-stream-muxer from 2.0.2 to 3.0.0 ([#9](https://github.com/ChainSafe/js-libp2p-yamux/issues/9)) ([3235d5f](https://github.com/ChainSafe/js-libp2p-yamux/commit/3235d5fbf1fe91e0a6ec8d8356c97951d261b931)) 271 | -------------------------------------------------------------------------------- /src/muxer.ts: -------------------------------------------------------------------------------- 1 | import { InvalidParametersError, MuxerClosedError, TooManyOutboundProtocolStreamsError, serviceCapabilities } from '@libp2p/interface' 2 | import { AbstractStreamMuxer, repeatingTask } from '@libp2p/utils' 3 | import { raceSignal } from 'race-signal' 4 | import { Uint8ArrayList } from 'uint8arraylist' 5 | import { defaultConfig, verifyConfig } from './config.js' 6 | import { Decoder } from './decode.js' 7 | import { encodeHeader } from './encode.js' 8 | import { InvalidFrameError, isProtocolError, NotMatchingPingError, UnRequestedPingError } from './errors.js' 9 | import { Flag, FrameType, GoAwayCode } from './frame.js' 10 | import { StreamState, YamuxStream } from './stream.js' 11 | import type { Config } from './config.js' 12 | import type { Frame } from './decode.js' 13 | import type { FrameHeader } from './frame.js' 14 | import type { AbortOptions, MessageStream, StreamMuxerFactory } from '@libp2p/interface' 15 | import type { RepeatingTask } from '@libp2p/utils' 16 | 17 | function debugFrame (header: FrameHeader): any { 18 | return { 19 | type: FrameType[header.type], 20 | flags: [ 21 | (header.flag & Flag.SYN) === Flag.SYN ? 'SYN' : undefined, 22 | (header.flag & Flag.ACK) === Flag.ACK ? 'ACK' : undefined, 23 | (header.flag & Flag.FIN) === Flag.FIN ? 'FIN' : undefined, 24 | (header.flag & Flag.RST) === Flag.RST ? 'RST' : undefined 25 | ].filter(Boolean), 26 | streamID: header.streamID, 27 | length: header.length 28 | } 29 | } 30 | 31 | const YAMUX_PROTOCOL_ID = '/yamux/1.0.0' 32 | 33 | export interface YamuxMuxerInit extends Partial { 34 | } 35 | 36 | export class Yamux implements StreamMuxerFactory { 37 | protocol = YAMUX_PROTOCOL_ID 38 | private readonly _init: Partial 39 | 40 | constructor (init: Partial = {}) { 41 | this._init = init 42 | } 43 | 44 | readonly [Symbol.toStringTag] = '@chainsafe/libp2p-yamux' 45 | 46 | readonly [serviceCapabilities]: string[] = [ 47 | '@libp2p/stream-multiplexing' 48 | ] 49 | 50 | createStreamMuxer (maConn: MessageStream): YamuxMuxer { 51 | return new YamuxMuxer(maConn, { 52 | ...this._init 53 | }) 54 | } 55 | } 56 | 57 | export interface CloseOptions extends AbortOptions { 58 | reason?: GoAwayCode 59 | } 60 | 61 | export interface ActivePing extends PromiseWithResolvers { 62 | id: number 63 | start: number 64 | } 65 | 66 | export class YamuxMuxer extends AbstractStreamMuxer { 67 | /** The next stream id to be used when initiating a new stream */ 68 | private nextStreamID: number 69 | 70 | /** The next ping id to be used when pinging */ 71 | private nextPingID: number 72 | /** Tracking info for the currently active ping */ 73 | private activePing?: ActivePing 74 | /** Round trip time */ 75 | private rtt: number 76 | 77 | /** True if client, false if server */ 78 | private client: boolean 79 | 80 | private localGoAway?: GoAwayCode 81 | private remoteGoAway?: GoAwayCode 82 | 83 | /** Number of tracked inbound streams */ 84 | private numInboundStreams: number 85 | /** Number of tracked outbound streams */ 86 | private numOutboundStreams: number 87 | 88 | private decoder: Decoder 89 | private keepAlive?: RepeatingTask 90 | 91 | private enableKeepAlive: boolean 92 | private keepAliveInterval: number 93 | private maxInboundStreams: number 94 | private maxOutboundStreams: number 95 | 96 | constructor (maConn: MessageStream, init: YamuxMuxerInit = {}) { 97 | super(maConn, { 98 | ...init, 99 | protocol: YAMUX_PROTOCOL_ID, 100 | name: 'yamux' 101 | }) 102 | 103 | this.client = maConn.direction === 'outbound' 104 | verifyConfig(init) 105 | 106 | this.enableKeepAlive = init.enableKeepAlive ?? defaultConfig.enableKeepAlive 107 | this.keepAliveInterval = init.keepAliveInterval ?? defaultConfig.keepAliveInterval 108 | this.maxInboundStreams = init.maxInboundStreams ?? defaultConfig.maxInboundStreams 109 | this.maxOutboundStreams = init.maxOutboundStreams ?? defaultConfig.maxOutboundStreams 110 | 111 | this.decoder = new Decoder() 112 | 113 | this.numInboundStreams = 0 114 | this.numOutboundStreams = 0 115 | 116 | // client uses odd streamIDs, server uses even streamIDs 117 | this.nextStreamID = this.client ? 1 : 2 118 | 119 | this.nextPingID = 0 120 | this.rtt = -1 121 | 122 | this.log.trace('muxer created') 123 | 124 | if (this.enableKeepAlive) { 125 | this.log.trace('muxer keepalive enabled interval=%s', this.keepAliveInterval) 126 | this.keepAlive = repeatingTask(async (options) => { 127 | try { 128 | await this.ping(options) 129 | } catch (err: any) { 130 | // TODO: should abort here? 131 | this.log.error('ping error: %s', err) 132 | } 133 | }, this.keepAliveInterval, { 134 | // send an initial ping to establish RTT 135 | runImmediately: true 136 | }) 137 | this.keepAlive.start() 138 | } 139 | } 140 | 141 | onData (buf: Uint8Array | Uint8ArrayList): void { 142 | for (const frame of this.decoder.emitFrames(buf)) { 143 | this.handleFrame(frame) 144 | } 145 | } 146 | 147 | onCreateStream (): YamuxStream { 148 | if (this.remoteGoAway !== undefined) { 149 | throw new MuxerClosedError('Muxer closed remotely') 150 | } 151 | 152 | if (this.localGoAway !== undefined) { 153 | throw new MuxerClosedError('Muxer closed locally') 154 | } 155 | 156 | const id = this.nextStreamID 157 | this.nextStreamID += 2 158 | 159 | // check against our configured maximum number of outbound streams 160 | if (this.numOutboundStreams >= this.maxOutboundStreams) { 161 | throw new TooManyOutboundProtocolStreamsError('max outbound streams exceeded') 162 | } 163 | 164 | this.log.trace('new outgoing stream id=%s', id) 165 | 166 | const stream = this._newStream(id, StreamState.Init, 'outbound') 167 | 168 | this.numOutboundStreams++ 169 | 170 | // send a window update to open the stream on the receiver end. do this in a 171 | // microtask so the stream gets added to the streams array by the superclass 172 | // before we send the SYN flag, otherwise we create a race condition whereby 173 | // we can receive the ACK before the stream is added to the streams list 174 | queueMicrotask(() => { 175 | stream.sendWindowUpdate() 176 | }) 177 | 178 | return stream 179 | } 180 | 181 | /** 182 | * Initiate a ping and wait for a response 183 | * 184 | * Note: only a single ping will be initiated at a time. 185 | * If a ping is already in progress, a new ping will not be initiated. 186 | * 187 | * @returns the round-trip-time in milliseconds 188 | */ 189 | async ping (options?: AbortOptions): Promise { 190 | if (this.remoteGoAway !== undefined) { 191 | throw new MuxerClosedError('Muxer closed remotely') 192 | } 193 | if (this.localGoAway !== undefined) { 194 | throw new MuxerClosedError('Muxer closed locally') 195 | } 196 | 197 | if (this.activePing != null) { 198 | // an active ping is already in progress, piggyback off that 199 | return raceSignal(this.activePing.promise, options?.signal) 200 | } 201 | 202 | // An active ping does not yet exist, handle the process here 203 | // create active ping 204 | this.activePing = Object.assign(Promise.withResolvers(), { 205 | id: this.nextPingID++, 206 | start: Date.now() 207 | }) 208 | // send ping 209 | this.sendPing(this.activePing.id) 210 | // await pong 211 | try { 212 | this.rtt = await raceSignal(this.activePing.promise, options?.signal) 213 | } finally { 214 | // clean-up active ping 215 | this.activePing = undefined 216 | } 217 | 218 | return this.rtt 219 | } 220 | 221 | /** 222 | * Get the ping round trip time 223 | * 224 | * Note: Will return 0 if no successful ping has yet been completed 225 | * 226 | * @returns the round-trip-time in milliseconds 227 | */ 228 | getRTT (): number { 229 | return this.rtt 230 | } 231 | 232 | /** 233 | * Close the muxer 234 | */ 235 | async close (options: CloseOptions = {}): Promise { 236 | if (this.status !== 'open') { 237 | // already closed 238 | return 239 | } 240 | 241 | try { 242 | const reason = options?.reason ?? GoAwayCode.NormalTermination 243 | 244 | this.log.trace('muxer close reason=%s', GoAwayCode[reason]) 245 | 246 | await super.close(options) 247 | 248 | // send reason to the other side, allow the other side to close gracefully 249 | this.sendGoAway(reason) 250 | } finally { 251 | this.keepAlive?.stop() 252 | } 253 | } 254 | 255 | abort (err: Error): void { 256 | if (this.status !== 'open') { 257 | // already closed 258 | return 259 | } 260 | 261 | try { 262 | super.abort(err) 263 | 264 | let reason = GoAwayCode.InternalError 265 | 266 | if (isProtocolError(err)) { 267 | reason = err.reason 268 | } 269 | 270 | // If reason was provided, use that, otherwise use the presence of `err` to determine the reason 271 | this.log.error('muxer abort reason=%s error=%s', reason, err) 272 | 273 | // send reason to the other side, allow the other side to close gracefully 274 | this.sendGoAway(reason) 275 | } finally { 276 | this.keepAlive?.stop() 277 | } 278 | } 279 | 280 | onTransportClosed (): void { 281 | try { 282 | super.onTransportClosed() 283 | } finally { 284 | this.keepAlive?.stop() 285 | } 286 | } 287 | 288 | /** Create a new stream */ 289 | private _newStream (streamId: number, state: StreamState, direction: 'inbound' | 'outbound'): YamuxStream { 290 | if (this.streams.find(s => s.streamId === streamId) != null) { 291 | throw new InvalidParametersError('Stream already exists with that id') 292 | } 293 | 294 | const stream = new YamuxStream({ 295 | ...this.streamOptions, 296 | id: `${streamId}`, 297 | streamId, 298 | state, 299 | direction, 300 | sendFrame: this.sendFrame.bind(this), 301 | log: this.log.newScope(`${direction}:${streamId}`), 302 | getRTT: this.getRTT.bind(this) 303 | }) 304 | 305 | stream.addEventListener('close', () => { 306 | this.closeStream(streamId) 307 | }, { 308 | once: true 309 | }) 310 | 311 | return stream 312 | } 313 | 314 | /** 315 | * closeStream is used to close a stream once both sides have 316 | * issued a close. 317 | */ 318 | private closeStream (id: number): void { 319 | if (this.client === (id % 2 === 0)) { 320 | this.numInboundStreams-- 321 | } else { 322 | this.numOutboundStreams-- 323 | } 324 | } 325 | 326 | private handleFrame (frame: Frame): void { 327 | const { 328 | streamID, 329 | type, 330 | length 331 | } = frame.header 332 | 333 | this.log.trace('received frame %o', debugFrame(frame.header)) 334 | 335 | if (streamID === 0) { 336 | switch (type) { 337 | case FrameType.Ping: 338 | { this.handlePing(frame.header); return } 339 | case FrameType.GoAway: 340 | { this.handleGoAway(length); return } 341 | default: 342 | // Invalid state 343 | throw new InvalidFrameError('Invalid frame type') 344 | } 345 | } else { 346 | switch (frame.header.type) { 347 | case FrameType.Data: 348 | case FrameType.WindowUpdate: 349 | { this.handleStreamMessage(frame); return } 350 | default: 351 | // Invalid state 352 | throw new InvalidFrameError('Invalid frame type') 353 | } 354 | } 355 | } 356 | 357 | private handlePing (header: FrameHeader): void { 358 | // If the ping is initiated by the sender, send a response 359 | if (header.flag === Flag.SYN) { 360 | this.log.trace('received ping request pingId=%s', header.length) 361 | this.sendPing(header.length, Flag.ACK) 362 | } else if (header.flag === Flag.ACK) { 363 | this.log.trace('received ping response pingId=%s', header.length) 364 | this.handlePingResponse(header.length) 365 | } else { 366 | // Invalid state 367 | throw new InvalidFrameError('Invalid frame flag') 368 | } 369 | } 370 | 371 | private handlePingResponse (pingId: number): void { 372 | if (this.activePing === undefined) { 373 | // this ping was not requested 374 | throw new UnRequestedPingError('ping not requested') 375 | } 376 | if (this.activePing.id !== pingId) { 377 | // this ping doesn't match our active ping request 378 | throw new NotMatchingPingError('ping doesn\'t match our id') 379 | } 380 | 381 | // valid ping response 382 | this.activePing.resolve(Date.now() - this.activePing.start) 383 | } 384 | 385 | private handleGoAway (reason: GoAwayCode): void { 386 | this.log.trace('received GoAway reason=%s', GoAwayCode[reason] ?? 'unknown') 387 | this.remoteGoAway = reason 388 | 389 | if (reason === GoAwayCode.NormalTermination) { 390 | this.onTransportClosed() 391 | } else { 392 | // reset any streams that are still open and close the muxer 393 | this.abort(new Error('Remote sent GoAway')) 394 | } 395 | } 396 | 397 | private handleStreamMessage (frame: Frame): void { 398 | const { streamID, flag, type } = frame.header 399 | 400 | if ((flag & Flag.SYN) === Flag.SYN) { 401 | this.incomingStream(streamID) 402 | } 403 | 404 | const stream = this.streams.find(s => s.streamId === streamID) 405 | if (stream === undefined) { 406 | this.log.trace('frame for missing stream id=%s', streamID) 407 | 408 | return 409 | } 410 | 411 | switch (type) { 412 | case FrameType.WindowUpdate: { 413 | stream.handleWindowUpdate(frame); return 414 | } 415 | case FrameType.Data: { 416 | stream.handleData(frame); return 417 | } 418 | default: 419 | throw new Error('unreachable') 420 | } 421 | } 422 | 423 | private incomingStream (id: number): void { 424 | if (this.client !== (id % 2 === 0)) { 425 | throw new InvalidParametersError('Both endpoints are clients') 426 | } 427 | if (this.streams.find(s => s.streamId === id)) { 428 | return 429 | } 430 | 431 | this.log.trace('new incoming stream id=%s', id) 432 | 433 | if (this.localGoAway !== undefined) { 434 | // reject (reset) immediately if we are doing a go away 435 | this.sendFrame({ 436 | type: FrameType.WindowUpdate, 437 | flag: Flag.RST, 438 | streamID: id, 439 | length: 0 440 | }) 441 | return 442 | } 443 | 444 | // check against our configured maximum number of inbound streams 445 | if (this.numInboundStreams >= this.maxInboundStreams) { 446 | this.log('maxIncomingStreams exceeded, forcing stream reset') 447 | this.sendFrame({ 448 | type: FrameType.WindowUpdate, 449 | flag: Flag.RST, 450 | streamID: id, 451 | length: 0 452 | }) 453 | return 454 | } 455 | 456 | // allocate a new stream 457 | const stream = this._newStream(id, StreamState.SYNReceived, 'inbound') 458 | 459 | this.numInboundStreams++ 460 | 461 | // the stream should now be tracked 462 | this.onRemoteStream(stream) 463 | } 464 | 465 | private sendFrame (header: FrameHeader, data?: Uint8ArrayList): boolean { 466 | let encoded: Uint8Array | Uint8ArrayList 467 | 468 | if (header.type === FrameType.Data) { 469 | if (data == null) { 470 | throw new InvalidFrameError('Invalid frame') 471 | } 472 | 473 | encoded = new Uint8ArrayList(encodeHeader(header), data) 474 | } else { 475 | encoded = encodeHeader(header) 476 | } 477 | 478 | this.log.trace('sending frame %o', debugFrame(header)) 479 | 480 | return this.send(encoded) 481 | } 482 | 483 | private sendPing (pingId: number, flag: Flag = Flag.SYN): void { 484 | if (flag === Flag.SYN) { 485 | this.log.trace('sending ping request pingId=%s', pingId) 486 | } else { 487 | this.log.trace('sending ping response pingId=%s', pingId) 488 | } 489 | this.sendFrame({ 490 | type: FrameType.Ping, 491 | flag, 492 | streamID: 0, 493 | length: pingId 494 | }) 495 | } 496 | 497 | private sendGoAway (reason: GoAwayCode = GoAwayCode.NormalTermination): void { 498 | this.log('sending GoAway reason=%s', GoAwayCode[reason]) 499 | this.localGoAway = reason 500 | this.sendFrame({ 501 | type: FrameType.GoAway, 502 | flag: 0, 503 | streamID: 0, 504 | length: reason 505 | }) 506 | } 507 | } 508 | --------------------------------------------------------------------------------