feat: initial implementation of libxumux — xumux v0.1.0-draft TypeScript library#1
Conversation
- package.json: ESM+CJS dual package, exports map, sideEffects:false - tsconfig.json: strict TS, ES2022, DOM+DOM.Iterable for browser APIs - tsup.config.ts: 6 entry points (index + 5 transport adapters), ESM+CJS+.d.ts - vitest.config.ts: v8 coverage, 95% line/stmt/func thresholds, 80% branch - vitest.e2e.config.ts: separate E2E config (30s timeout) - eslint.config.js: ESLint 9 flat config, typescript-eslint, no-any enforced - .prettierrc, Taskfile.yml (build/test/check/e2e tasks)
- encodeFrame: standard (6-byte) and extended (8-byte) frame encoder - decodeFrame: single-frame parser with EXTENDED_LENGTH stripping - FrameDecoder: streaming state machine for TCP/stdio byte streams - fragment(): splits large messages into FRAGMENT/FRAGMENT_END frames - Reassembler: per-channel reassembly with protocol error detection - getMagicBytes/validateMagicBytes: OMUX (0x4F4D5558) for stream transports - XUMUX_VERSION, OMUX_MAGIC, FrameFlags, ControlType, CloseCode constants Implements: FR-1, FR-2, FR-3, FR-4, FR-5, FR-12
- Typed interfaces for all 10 message types (HELLO, WELCOME, OPEN_CHANNEL, CHANNEL_ACK, CLOSE_CHANNEL, CHANNEL_REJECT, PING, PONG, CLOSE, ERROR) - PING/PONG: 4-byte and 8-byte big-endian binary payloads (spec test vectors) - JSON codec for all other control messages (UTF-8) - ControlHandler: HELLO/WELCOME handshake, dynamic channel lifecycle, keepalive PING/PONG with configurable interval/timeout, graceful CLOSE - Buffered message delivery for synchronous in-memory transports - Property-based fuzz tests: PING/PONG round-trips for all uint32 values, HELLO/WELCOME/OPEN_CHANNEL/CLOSE/ERROR JSON round-trips Implements: FR-6, FR-7, FR-8, FR-9, FR-10, FR-11, FR-19
- TransportAdapter interface: { send(Uint8Array), receive(): AsyncIterable, close() }
- WebSocketAdapter: browser + Node.js, duck-typed WebSocketLike interface
- WebRTCAdapter: RTCDataChannel, duck-typed DataChannelLike interface
- TcpAdapter: Node.js net.Socket, OMUX magic + FrameDecoder streaming
- StdioAdapter: process.stdin/stdout, OMUX magic + FrameDecoder streaming
- QuicAdapter: WebTransport ReadableStream/WritableStream pair
- createInMemoryPair(): zero-network transport for unit + integration tests
- All adapters are tree-shakeable separate entry points (no browser-unsafe
code in WebSocket/WebRTC/QUIC; TCP/stdio are Node.js only)
Implements: FR-13, FR-14, FR-15, FR-16, FR-17, NFR-1, NFR-4, NFR-5
- XumuxPeer base (EventTarget): wires codec, control handler, channel map
- Receive loop: decodes frames, demuxes to control handler or per-channel Reassembler
- Events: channel-open, channel-close, error, close
- XumuxClient: initiates HELLO, registers channels from WELCOME
- XumuxServer: awaits HELLO, sends WELCOME, assigns channel IDs 1-254
- XumuxChannel: { id, name, readable: ReadableStream, writable: WritableStream, state }
- WritableStream write handler: fragments large payloads automatically
- ReadableStream: enqueued by receive loop on reassembled payload arrival
- Public API re-exported from src/index.ts with full JSDoc + zero any types
Implements: FR-7, FR-8, FR-18, NFR-1, NFR-2, NFR-7
E2E tests exercising the full xumux stack over real I/O — no in-memory mocks. Each transport runs 5 protocol scenarios: handshake, bidirectional data exchange, 70KB fragmented message (extended-header path), dynamic OPEN_CHANNEL, graceful CLOSE. Transports: - WebSocket (ws package): real server on random port, WebSocketAdapter client - TCP (net.Socket): net.createServer, TcpAdapter + OMUX magic on wire - stdio: spawned child process (child-server.mjs), StdioAdapter on parent side - WebRTC loopback: in-memory DataChannelLike pair, no native deps, queueMicrotask delivery to avoid listener-registration race and CLOSE_ack drop after closeAll() - WebRTC node-datachannel: real DTLS/SCTP via libdatachannel, ICE wired in-process, NodeDCShim bridges callback API to DataChannelLike, shim created after DC open task e2e / task e2e:ws / task e2e:tcp / task e2e:stdio task e2e:webrtc:loopback / task e2e:webrtc:native
|
You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool. What Enabling Code Scanning Means:
For more information about GitHub Code Scanning, check out the documentation. |
|
| Filename | Overview |
|---|---|
| src/control/handler.ts | Lifecycle state machine and keepalive logic; two defects: destroy() never resolves closeResolver (hanging peer.close() promise on unexpected transport EOF), and the PONG deadline timer handle is overwritten without cancelling the prior timer. |
| src/connection.ts | XumuxPeer, XumuxClient, XumuxServer wiring; allocateChannelId() correctly bounds-checks, but the separate nextChannelId counter in accept() has no upper-bound guard (flagged in prior threads). |
| src/codec/decode-frame.ts | Single-frame decoder and streaming FrameDecoder; payload length cap issue flagged in prior threads, otherwise logic is correct. |
| src/codec/fragment.ts | Fragment/reassembly logic; accumulator memory-exhaustion issue flagged in prior threads; fragmentation logic itself is correct. |
| src/transports/websocket-adapter.ts | WebSocket transport adapter; missing error-event listener flagged in prior threads; queue/waiter pattern is otherwise sound. |
| src/transports/webrtc-adapter.ts | WebRTC DataChannel adapter; same message/close queue pattern as WebSocketAdapter with an unregistered error listener. |
| src/transports/tcp-adapter.ts | TCP transport adapter with magic-byte framing; logic is correct, FrameDecoder reuse is sound. |
| src/transports/quic-adapter.ts | QUIC/WebTransport adapter; writer acquired at construction time, released on close; minor concern about releaseLock during an in-flight close, but not a functional defect. |
| src/channel.ts | Channel abstraction wrapping ReadableStream/WritableStream; state machine is straightforward, fragmentation delegation is correct. |
| src/control/codec.ts | Control message codec; PING/PONG binary encoding and JSON codecs for all other message types are correctly implemented. |
Sequence Diagram
sequenceDiagram
participant App
participant XumuxClient
participant ControlHandler
participant Transport
participant XumuxServer
App->>XumuxClient: connect(hello?)
XumuxClient->>ControlHandler: sendHello(msg)
ControlHandler->>Transport: send(encodeHello)
Transport-->>XumuxServer: HELLO frame
XumuxServer->>ControlHandler: awaitHello() resolves
XumuxServer->>ControlHandler: sendWelcome(msg)
ControlHandler->>Transport: send(encodeWelcome)
Transport-->>XumuxClient: WELCOME frame
XumuxClient-->>App: Map<name, XumuxChannel>
loop Keepalive
ControlHandler->>Transport: PING (every pingInterval)
Transport-->>ControlHandler: PONG
Note over ControlHandler: clears pongDeadline
end
App->>XumuxClient: close()
XumuxClient->>ControlHandler: close(code)
ControlHandler->>Transport: send(CLOSE)
Transport-->>XumuxServer: CLOSE frame
XumuxServer->>Transport: send(CLOSE echo)
Transport-->>XumuxClient: CLOSE echo
ControlHandler-->>App: close() resolves
Comments Outside Diff (2)
-
src/control/handler.ts, line 910-918 (link)destroy()leavescloseResolverdangling —peer.close()promise hangs foreverdestroy()rejects pendingopenChannelpromises but never resolves or rejectscloseResolver. If a caller doesawait peer.close()and the remote peer disconnects before the CLOSE echo arrives,receiveLoopdetects the EOF and callsdestroy(). State transitions to'closed', butcloseResolveris never called, so the promise returned bypeer.close()is permanently suspended. Addthis.closeResolver?.(); this.closeResolver = null;(or a rejection) insidedestroy()to unblock callers.Prompt To Fix With AI
This is a comment left during a code review. Path: src/control/handler.ts Line: 910-918 Comment: **`destroy()` leaves `closeResolver` dangling — `peer.close()` promise hangs forever** `destroy()` rejects pending `openChannel` promises but never resolves or rejects `closeResolver`. If a caller does `await peer.close()` and the remote peer disconnects before the CLOSE echo arrives, `receiveLoop` detects the EOF and calls `destroy()`. State transitions to `'closed'`, but `closeResolver` is never called, so the promise returned by `peer.close()` is permanently suspended. Add `this.closeResolver?.(); this.closeResolver = null;` (or a rejection) inside `destroy()` to unblock callers. How can I resolve this? If you propose a fix, please make it concise.
-
src/control/handler.ts, line 886-895 (link)pongDeadlinetimer handle overwritten without clearing the previous oneEach interval tick unconditionally overwrites
this.pongDeadlinewith a newsetTimeouthandle, discarding the reference to any prior timer. IfpingInterval < pingTimeout(a valid non-default configuration), the second PING fires while the first PONG deadline is still running. The first timeout reference is lost, sostopKeepalive()andonPong()can no longer cancel it. When it fires it callsthis.close()even if the PONG for the second PING already arrived and cleared the second deadline, triggering a spurious disconnect. CallclearTimeout(this.pongDeadline)before reassigning the handle.Prompt To Fix With AI
This is a comment left during a code review. Path: src/control/handler.ts Line: 886-895 Comment: **`pongDeadline` timer handle overwritten without clearing the previous one** Each interval tick unconditionally overwrites `this.pongDeadline` with a new `setTimeout` handle, discarding the reference to any prior timer. If `pingInterval < pingTimeout` (a valid non-default configuration), the second PING fires while the first PONG deadline is still running. The first timeout reference is lost, so `stopKeepalive()` and `onPong()` can no longer cancel it. When it fires it calls `this.close()` even if the PONG for the second PING already arrived and cleared the second deadline, triggering a spurious disconnect. Call `clearTimeout(this.pongDeadline)` before reassigning the handle. How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
src/control/handler.ts:910-918
**`destroy()` leaves `closeResolver` dangling — `peer.close()` promise hangs forever**
`destroy()` rejects pending `openChannel` promises but never resolves or rejects `closeResolver`. If a caller does `await peer.close()` and the remote peer disconnects before the CLOSE echo arrives, `receiveLoop` detects the EOF and calls `destroy()`. State transitions to `'closed'`, but `closeResolver` is never called, so the promise returned by `peer.close()` is permanently suspended. Add `this.closeResolver?.(); this.closeResolver = null;` (or a rejection) inside `destroy()` to unblock callers.
### Issue 2 of 2
src/control/handler.ts:886-895
**`pongDeadline` timer handle overwritten without clearing the previous one**
Each interval tick unconditionally overwrites `this.pongDeadline` with a new `setTimeout` handle, discarding the reference to any prior timer. If `pingInterval < pingTimeout` (a valid non-default configuration), the second PING fires while the first PONG deadline is still running. The first timeout reference is lost, so `stopKeepalive()` and `onPong()` can no longer cancel it. When it fires it calls `this.close()` even if the PONG for the second PING already arrived and cleared the second deadline, triggering a spurious disconnect. Call `clearTimeout(this.pongDeadline)` before reassigning the handle.
Reviews (3): Last reviewed commit: "docs: update README for xumux v0.2 8-byt..." | Re-trigger Greptile
| this.accumulator.push(frame.payload); | ||
|
|
||
| if (isFragmentEnd) { | ||
| return this.flush(); | ||
| } |
There was a problem hiding this comment.
Reassembler accumulates fragments without a total-size limit
The accumulator array grows indefinitely as long as frames with the FRAGMENT bit set keep arriving without FRAGMENT_END. A malicious peer can send a continuous stream of small, individually-valid fragment frames (e.g. 64 KB each) that individually satisfy any per-frame checks yet build up hundreds of megabytes in the reassembler per channel before the connection is closed. There is no check against DEFAULT_MAX_MESSAGE_SIZE or any configurable cap, so this is a distinct memory-exhaustion path from the FrameDecoder size issue already filed.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/codec/fragment.ts
Line: 111-115
Comment:
**Reassembler accumulates fragments without a total-size limit**
The `accumulator` array grows indefinitely as long as frames with the `FRAGMENT` bit set keep arriving without `FRAGMENT_END`. A malicious peer can send a continuous stream of small, individually-valid fragment frames (e.g. 64 KB each) that individually satisfy any per-frame checks yet build up hundreds of megabytes in the reassembler per channel before the connection is closed. There is no check against `DEFAULT_MAX_MESSAGE_SIZE` or any configurable cap, so this is a distinct memory-exhaustion path from the `FrameDecoder` size issue already filed.
How can I resolve this? If you propose a fix, please make it concise.
Summary
Full TypeScript implementation of the xumux v0.1.0-draft transport-agnostic channel multiplexing protocol. Isomorphic — targets both Node.js and web browsers with no environment-specific APIs in shared code.
Changes
FrameDecoder, OMUX magic, fragmentation/reassembly (spec test vectors verified)XumuxClient,XumuxServer,XumuxChannel(ReadableStream/WritableStream)vBRIEF References
vbrief/completed/2026-05-04-project-scaffold.vbrief.jsonvbrief/completed/2026-05-04-codec-framing.vbrief.jsonvbrief/completed/2026-05-04-control-channel.vbrief.jsonvbrief/completed/2026-05-04-transport-adapters.vbrief.jsonvbrief/completed/2026-05-04-connection-api.vbrief.jsonvbrief/completed/2026-05-04-unit-coverage-100.vbrief.jsonvbrief/completed/2026-05-04-fuzz-tests.vbrief.jsonvbrief/completed/2026-05-04-e2e-tests.vbrief.jsonvbrief/completed/2026-05-05-e2e-webrtc-loopback.vbrief.jsonvbrief/completed/2026-05-05-e2e-webrtc-node-datachannel.vbrief.jsonChecklist
task checkpasses (lint + typecheck + 145 unit tests, 96.7% line coverage)task e2epasses (WebSocket, TCP, stdio, WebRTC loopback + node-datachannel).envcontent in the diffTesting