Skip to content

udx-js

Skill udx-js. Use when writing TypeScript/Node.js code on js-udx: @go-ricochet/udx (listen, dial, UdxMultiplexer, UdxConnection, UdxStream) or the js-libp2p transport @go-ricochet/libp2p-udx (udx(), /udp//udx addresses, the ./multiaddr browser entry), including back-pressure, closing, MemoryNetwork tests and interop with go-udx and dart_udx.

SKILL.md (Claude Code, Codex, Cursor) · udx-js.md (Boubou) · install: curl -fsSL https://cryptopeer.net/skills/install.sh | sh -s -- udx-js

js-udx is the TypeScript implementation of UDX (wire v3) for Node.js, a QUIC-inspired reliable transport over UDP, in two npm packages: @go-ricochet/udx (the protocol, no libp2p dependency) and @go-ricochet/libp2p-udx (a js-libp2p v3 transport for /udp/<port>/udx). It interoperates with go-udx and dart_udx, and the transport with go-libp2p-udx-transport and dart-libp2p. Browsers have no raw UDP: in a browser you can only register the udx multiaddr codec (see below). Use the WebSockets or WebRTC transports to reach peers from a browser.

Terminal window
npm install @go-ricochet/udx # 0.1.0, Node >= 20, ESM only
npm install @go-ricochet/libp2p-udx libp2p @chainsafe/libp2p-noise @chainsafe/libp2p-yamux
# 0.1.1, Node >= 22 (js-libp2p v3)
import { listen, dial, UdxMultiplexer, ErrorCode, ConnectionClosedError, StreamResetError } from '@go-ricochet/udx'
import { udx, UDX, parseUdxMultiaddr, toUdxMultiaddr } from '@go-ricochet/libp2p-udx'
import '@go-ricochet/libp2p-udx/multiaddr' // browser-safe: registers the codec only

The packages used to be @stephanfeb/udx and @stephanfeb/libp2p-udx; those are deprecated, same code.

import { dial, listen } from '@go-ricochet/udx'
const server = await listen({ host: '127.0.0.1', port: 0 })
server.on('connection', conn => {
conn.on('stream', stream => {
stream.on('data', chunk => stream.write(chunk))
stream.on('end', () => stream.end())
})
})
const { port } = server.address()
const conn = await dial(port, '127.0.0.1') // returns before the peer answers
const stream = conn.openStream() // SYN goes out at once
stream.write(new TextEncoder().encode('hello, udx'))
stream.end() // half-close; we can still read
let reply = ''
for await (const chunk of stream) reply += new TextDecoder().decode(chunk, { stream: true })
conn.close() // also closes the socket dial() made
await server.close() // closes every connection, then the socket
const mux = await UdxMultiplexer.create({ host: '0.0.0.0', port: 4001 })
const conn = mux.dial(peerPort, peerHost) // synchronous
await conn.established // optional: the peer answered
const inbound = await mux.accept() // or mux.on('connection', ...)
const s = await inbound.acceptStream() // or inbound.on('stream', ...)
import type { UdxStream } from '@go-ricochet/udx'
async function send (stream: UdxStream, data: Uint8Array[]): Promise<void> {
for (const chunk of data) {
if (!stream.write(chunk)) { // false at 256 KiB queued
await new Promise<void>((resolve, reject) => {
const onDrain = () => { stream.off('close', onClose); resolve() }
const onClose = (err?: Error) => { stream.off('drain', onDrain); reject(err ?? new Error('closed')) }
stream.once('drain', onDrain)
stream.once('close', onClose)
})
}
}
stream.end()
}
// 1. Wait until the receiver says it has everything (e.g. it ends its side).
for await (const _ of stream) {}
// 2. Wait until the peer has ACKed everything we sent, FINs included.
await conn.flushed()
// 3. Only now close: CONNECTION_CLOSE discards anything in flight or unread.
conn.close()
const mux = await UdxMultiplexer.create({ port: 0, keepAliveInterval: 10_000 })
const ok = await conn.ping() // true when ACKed, false after 5000 ms
console.log(conn.smoothedRtt, conn.cwnd, conn.datagramSize)
import { createLibp2p } from 'libp2p'
import { noise } from '@chainsafe/libp2p-noise'
import { yamux } from '@chainsafe/libp2p-yamux'
import { udx, UDX } from '@go-ricochet/libp2p-udx'
const node = await createLibp2p({
addresses: { listen: ['/ip4/0.0.0.0/udp/0/udx'] },
transports: [udx()], // udx({ keepAliveInterval, closeTimeout, inactivityTimeout, udx: {...} })
connectionEncrypters: [noise()],
streamMuxers: [yamux()]
})
const addr = node.getMultiaddrs().find(ma => UDX.exactMatch(ma))

Browser: accept relay reservations that list /udx addresses

Section titled “Browser: accept relay reservations that list /udx addresses”
import '@go-ricochet/libp2p-udx/multiaddr' // before createLibp2p
// or, without the dependency:
import { registry } from '@multiformats/multiaddr'
registry.addProtocol({ code: 0x0300, name: 'udx', size: 0 })
  • listen(opts): Promise<UdxMultiplexer> bound to host/port (0 picks one).
  • dial(port, host, opts?): Promise<UdxConnection> on its own socket, closed with the connection.
  • UdxMultiplexer.create(opts), new UdxMultiplexer(socket, opts): one socket, routes by CID.
  • mux.dial(port, host), accept(), close(), address(), connectionCount, closed, droppedDatagrams; events 'connection', 'close'.
  • Options: type, ipv6Only, recvBufferSize, sendBufferSize, streamOptions.writeHighWaterMark, keepAliveInterval, pmtud, antiAmplification, migration, versionNegotiation, statelessResetSecret, clock.
  • UdxConnection: openStream(), acceptStream(), ping(ms?), flushed(), close(code?, reason?), established; smoothedRtt, cwnd, inflight, datagramSize, peerMaxData, streamCount, remoteAddress, remotePort, addressValidated, closed; events 'stream', 'migrate', 'close'.
  • UdxStream (a Node EventEmitter, async iterable of Uint8Array): write, end, reset(code), pause, resume; writableLength, bufferedBytes, recvWindow, bytesWritten, bytesRead, writableEnded, readableEnded, isPaused, destroyed, error; events 'data', 'end', 'drain', 'close'.
  • Errors: ConnectionClosedError (code, reason, remote), StreamResetError (code, remote), WriteAfterEndError, UdxError, CodecError; ErrorCode.NoError … ErrorCode.ConnectionTimeout (0-6).
  • Constants: VERSION_CURRENT (3), SAFE_DATAGRAM_SIZE 1200, MAX_IDLE_TIMEOUT 30 000, MAX_INCOMING_STREAMS 1024.
  • Testing: MemoryNetwork(clock, { delay, loss, reorder, reorderDelay, mtu }, seed), ManualClock(start) with advance/runUntil/nextDeadline, seededRandom.
  • libp2p: udx(init), UdxTransport, UdxListener, UdxMultiaddrConnection, UDX matcher, parseUdxMultiaddr, toUdxMultiaddr, isUdxMultiaddr, registerUdxProtocol, UDX_PROTOCOL_CODE (0x0300), UDX_WIRE_VERSION.
  1. write returning false is a signal, not an error. Ignore it and the queue grows without bound. Wait for 'drain', and also listen for 'close' or the wait can hang forever.
  2. Streams start paused. Nothing is emitted until a 'data' listener is added, the stream is iterated, or resume() is called. Bytes count as consumed only when emitted, so an unread stream closes the peer’s window.
  3. conn.close() throws data away. CONNECTION_CLOSE drops data in flight and data the peer has but has not read. Use the end, flushed(), close() sequence above. mux.close() does not wait for flushed() either.
  4. A FIN does not mean “read”. end() says you finished sending. If you need to know the peer processed everything, have it reply or end its side.
  5. Idle connections close after 30 s with ErrorCode.ConnectionTimeout. UDX sends nothing by itself; keepAliveInterval defaults to 0 in @go-ricochet/udx and to 10 000 in the libp2p transport. Values under 15 000 survive one lost PING.
  6. Chunk boundaries are not preserved. A stream is a byte stream; frame your messages.
  7. 'close' fires once per connection or stream. An unset argument on a connection means you closed it with code 0. Code 0 with err.remote is a normal remote close; code 6 is our idle timeout; code 1 with reason 'stateless reset' means the peer lost the connection; code 4 can mean a version mismatch.
  8. Wire v3 only. v2 and earlier never interoperate. js-udx answers an unknown version with a version negotiation packet, and a dialer that gets one before the peer answered closes at once instead of timing out.
  9. Interop minimums: go-udx v0.1.3+ (older ones never ACK PINGs, so ping() resolves false), dart_udx 3.1.0+ (older ones merge a second stream into the first), go-libp2p-udx-transport v0.1.4+, dart-libp2p 3.0.0+. Prefer go-udx v0.3.0 and dart_udx 4.3.0, which share the 1200-byte start and RFC 9002 recovery.
  10. Datagrams start at 1200 bytes and PMTUD probes up to 1472 (1452 on IPv6), falling back on a black hole. Mobile carrier tunnels drop larger datagrams; keep pmtud on rather than sending large datagrams.
  11. Stream limits. The peer allows 100 concurrent streams until it says otherwise (js-udx peers allow 1024); past it openStream() throws a UdxError with ErrorCode.StreamLimitError. go-udx counts every stream a connection ever had, so against go-udx a connection caps at 100 for life.
  12. Flow-control violation resets. A peer that sends more than 8 MiB ahead of in-order data gets its stream reset with ErrorCode.FlowControlError.
  13. Stateless reset only works between js-udx peers. Enabling statelessResetSecret is harmless with go-udx and dart_udx, which ignore the token. The secret must be 32 bytes or more and stable across restarts.
  14. Protocol 768 was unknown / Protocol udx was unknown. The udx codec (0x0300) was not registered in the copy of @multiformats/multiaddr that js-libp2p uses. Import the package (or /multiaddr in a browser) before creating the node, and make sure only one copy of @multiformats/multiaddr (v13) is installed (npm ls @multiformats/multiaddr). Install packed tarballs, not linked directories, when testing from source.
  15. The package root does not bundle for the browser. It imports node:dgram. Browser code may import only @go-ricochet/libp2p-udx/multiaddr.
  16. No DNS dialling. The transport dials /ip4 and /ip6 only, not /dns4, /dns6 or /dnsaddr. Each listen address gets its own socket; all dials share one socket per address family.
  17. Libp2p layering. Each libp2p connection is one UDX connection whose first UDX stream is upgraded like TCP (multistream-select, Noise, Yamux), as go-libp2p-udx-transport and dart-libp2p do. Configure Noise and Yamux or you cannot reach those nodes.
  18. Do not pause libp2p streams you read to the end. In @libp2p/utils 7.4.1 a paused stream’s iterator can end at the remote FIN with data still buffered (libp2p/js-libp2p#3646), including the echo() helper. Read with for await without pausing.
  19. Node reports IPv4 peers on a dual-stack socket as ::ffff:a.b.c.d. toUdxMultiaddr maps them back to /ip4; do the same in your own code.
  • Deterministic in-process network:
import { ManualClock, MemoryNetwork, UdxMultiplexer } from '@go-ricochet/udx'
const clock = new ManualClock(1_000_000)
const net = new MemoryNetwork(clock, { delay: 5, loss: 0.01, mtu: 1350 }, 1)
const server = new UdxMultiplexer(net.createSocket('10.0.0.1', 9000), { clock })
const client = new UdxMultiplexer(net.createSocket('10.0.0.2', 9000), { clock })
const conn = client.dial(9000, '10.0.0.1')
let established = false
void conn.established.then(() => { established = true })
const done = (): boolean => established // your own completion check
// Drive time: fire due timers, then let promises settle, until done.
while (!done()) {
const next = clock.nextDeadline()
if (next !== undefined) clock.runUntil(next)
await new Promise(resolve => setImmediate(resolve))
}

packages/udx/test/helpers/net.ts has memoryPair, runUntil, advance and collect helpers built this way; net.rebind(...) simulates a NAT moving a peer.

  • Real UDP: listen({ host: '127.0.0.1', port: 0 }) plus dial; close both in afterEach.
  • Repo commands: npm test (vitest), npm run ci (typecheck, build, lint, test).
  • Interop suites run when sibling checkouts exist (../go-udx, ../dart-udx, ../go-libp2p-udx-transport, ../dart-libp2p) and skip otherwise; npm run interop:go-udx runs go-udx’s bulk suites against js-udx; UDX_SLOW_TESTS=1 adds 45 s idle keep-alive tests.
  • Codec changes must still match packages/udx/test/vectors/go-v3.json (regenerate with npm run vectors after a go-udx wire change).
  • packages/libp2p-udx/test/compliance.test.ts runs js-libp2p’s transport compliance suite.
  • go-udx is the wire authority. Never change encodings, frame codes or the version in js-udx alone; codec output must stay byte-identical to the go-udx vectors.
  • Never change the multiaddr code: udx is 0x0300 with size 0 in all three stacks.
  • Never import the package root into browser bundles.
  • Never call conn.close() right after writing data you need delivered; await conn.flushed() first.
  • Close every multiplexer you create (await mux.close()); each owns a UDP socket and timers that keep Node running.
  • UDX itself is unencrypted. Outside libp2p (which adds Noise), add your own security layer before sending anything sensitive.

Built by Stephan Februarygithub.com/stephanfebLinkedIn