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
What it is
Section titled “What it is”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.
Install
Section titled “Install”npm install @go-ricochet/udx # 0.1.0, Node >= 20, ESM onlynpm 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 onlyThe packages used to be @stephanfeb/udx and @stephanfeb/libp2p-udx; those
are deprecated, same code.
Core workflow
Section titled “Core workflow”Echo server and client
Section titled “Echo server and client”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 answersconst stream = conn.openStream() // SYN goes out at oncestream.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() madeawait server.close() // closes every connection, then the socketOne socket that accepts and dials
Section titled “One socket that accepts and dials”const mux = await UdxMultiplexer.create({ host: '0.0.0.0', port: 4001 })const conn = mux.dial(peerPort, peerHost) // synchronousawait conn.established // optional: the peer answeredconst inbound = await mux.accept() // or mux.on('connection', ...)const s = await inbound.acceptStream() // or inbound.on('stream', ...)Writing with back-pressure
Section titled “Writing with back-pressure”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()}Closing without losing data
Section titled “Closing without losing data”// 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()Keep-alive and liveness
Section titled “Keep-alive and liveness”const mux = await UdxMultiplexer.create({ port: 0, keepAliveInterval: 10_000 })const ok = await conn.ping() // true when ACKed, false after 5000 msconsole.log(conn.smoothedRtt, conn.cwnd, conn.datagramSize)js-libp2p node
Section titled “js-libp2p node”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 })Key concepts / API map
Section titled “Key concepts / API map”listen(opts):Promise<UdxMultiplexer>bound tohost/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 NodeEventEmitter, async iterable ofUint8Array):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_SIZE1200,MAX_IDLE_TIMEOUT30 000,MAX_INCOMING_STREAMS1024. - Testing:
MemoryNetwork(clock, { delay, loss, reorder, reorderDelay, mtu }, seed),ManualClock(start)withadvance/runUntil/nextDeadline,seededRandom. - libp2p:
udx(init),UdxTransport,UdxListener,UdxMultiaddrConnection,UDXmatcher,parseUdxMultiaddr,toUdxMultiaddr,isUdxMultiaddr,registerUdxProtocol,UDX_PROTOCOL_CODE(0x0300),UDX_WIRE_VERSION.
Gotchas
Section titled “Gotchas”writereturning 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.- Streams start paused. Nothing is emitted until a
'data'listener is added, the stream is iterated, orresume()is called. Bytes count as consumed only when emitted, so an unread stream closes the peer’s window. 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 forflushed()either.- 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. - Idle connections close after 30 s with
ErrorCode.ConnectionTimeout. UDX sends nothing by itself;keepAliveIntervaldefaults to 0 in@go-ricochet/udxand to 10 000 in the libp2p transport. Values under 15 000 survive one lost PING. - Chunk boundaries are not preserved. A stream is a byte stream; frame your messages.
'close'fires once per connection or stream. An unset argument on a connection means you closed it with code 0. Code 0 witherr.remoteis 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.- 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.
- 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. - 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
pmtudon rather than sending large datagrams. - Stream limits. The peer allows 100 concurrent streams until it says
otherwise (js-udx peers allow 1024); past it
openStream()throws aUdxErrorwithErrorCode.StreamLimitError. go-udx counts every stream a connection ever had, so against go-udx a connection caps at 100 for life. - Flow-control violation resets. A peer that sends more than 8 MiB ahead
of in-order data gets its stream reset with
ErrorCode.FlowControlError. - Stateless reset only works between js-udx peers. Enabling
statelessResetSecretis harmless with go-udx and dart_udx, which ignore the token. The secret must be 32 bytes or more and stable across restarts. Protocol 768 was unknown/Protocol udx was unknown. Theudxcodec (0x0300) was not registered in the copy of@multiformats/multiaddrthat js-libp2p uses. Import the package (or/multiaddrin 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.- The package root does not bundle for the browser. It imports
node:dgram. Browser code may import only@go-ricochet/libp2p-udx/multiaddr. - No DNS dialling. The transport dials
/ip4and/ip6only, not/dns4,/dns6or/dnsaddr. Each listen address gets its own socket; all dials share one socket per address family. - 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.
- Do not pause libp2p streams you read to the end. In
@libp2p/utils7.4.1 a paused stream’s iterator can end at the remote FIN with data still buffered (libp2p/js-libp2p#3646), including theecho()helper. Read withfor awaitwithout pausing. - Node reports IPv4 peers on a dual-stack socket as
::ffff:a.b.c.d.toUdxMultiaddrmaps them back to/ip4; do the same in your own code.
Testing
Section titled “Testing”- 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 = falsevoid 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 })plusdial; close both inafterEach. - 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-udxruns go-udx’s bulk suites against js-udx;UDX_SLOW_TESTS=1adds 45 s idle keep-alive tests. - Codec changes must still match
packages/udx/test/vectors/go-v3.json(regenerate withnpm run vectorsafter a go-udx wire change). packages/libp2p-udx/test/compliance.test.tsruns js-libp2p’s transport compliance suite.
Red lines
Section titled “Red lines”- 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:
udxis 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.
Reference
Section titled “Reference”- Docs: https://cryptopeer.net/udx/js/ (getting-started, streams, connections, libp2p, interop pages)
- Protocol overview: https://cryptopeer.net/udx/
- Repo: https://github.com/stephanfeb/js-udx (PLAN.md has the wire rules and deviations)
- npm: https://www.npmjs.com/package/@go-ricochet/udx, https://www.npmjs.com/package/@go-ricochet/libp2p-udx
- Related skills: udx-go, udx-dart, go-libp2p-udx