ADR-028: WebSocket Binary Frame Integrity and Socket Parser Detachment
Status
Accepted
Date
2026-08-12
Context
The @nogoo9/no-crd gateway provides a reverse proxy for HTTP and WebSocket connections to dynamically spawned Kubernetes workspace pods (/route/:workspaceId/*). This powers interactive web apps, terminal sandboxes (ttyd), and remote desktop GUI sessions (VNC).
During testing and deployment with ttyd terminal containers, WebSocket connections consistently failed or dropped immediately with TCP close code 1006 Connection ended. Investigation revealed two critical issues:
- Binary Frame Corruption: The WebSocket proxy previously converted raw binary incoming TCP data chunks to UTF-8 strings (
chunk.toString("utf8")) before writing them back to client or upstream sockets. Non-ASCII binary payload bytes (such as ANSI escape sequences,ttydbinary terminal frames, or VNC graphics data) were mangled into UTF-8 replacement characters (\uFFFD), corrupting the WebSocket protocol frame headers and payloads. - HTTP Parser Interference: When intercepting HTTP upgrade requests on Node's
http.Server, Node's internal HTTP request parser (HTTPParser) remains attached to the upgradednet.Socket. When raw WebSocket frame data or 101 Switching Protocols response bytes were piped back through the socket, Node's HTTP parser attempted to parse incoming binary frames as standard HTTP request headers, throwingHPE_INVALID_METHODparser errors and destroying the socket connection. - Async Pod Resolution Race Condition: Resolving target pod IP and port via the Kubernetes API is asynchronous (
listNamespacedPod). Any client WebSocket handshake bytes sent immediately after the HTTP Upgrade header were lost if client data listeners were attached only after pod resolution completed.
Decision
We implemented a robust socket management and binary frame proxying strategy in src/server/ws-proxy.ts:
- Pure Binary Buffer Piping: Treat all incoming data chunks on both client and upstream sockets strictly as raw
Bufferobjects (socket.write(chunk)). Never convert WebSocket bytes to UTF-8 strings during frame forwarding. - Synchronous Data Listener & Data Buffering: Immediately and synchronously attach client
socket.on("data", onClientData)at the start ofhandleUpgradeRequest. Buffer any client data arriving during async Kubernetes pod resolution into an in-memory array (clientBuffer), flushing buffered chunks toupstreamSocketas soon as the upstream TCP connection opens. - HTTP Parser Detachment: Explicitly detach Node's internal HTTP parser upon upgrade interception by clearing
socket.parser = null, clearingreq.socket.parser = null, and callingsocket.removeAllListeners("data")andsocket.removeAllListeners("timeout"). - Upstream Host Header Normalization: Rewrite the
Hostheader sent to upstream pods to match target pod IP and container port (${podIP}:${port}), ensuring sandbox apps expecting exactHostheader alignment (e.g.ttyd) accept the proxied WebSocket connection.
Alternatives Considered
Relying on @fastify/http-proxy for WebSockets
- Pros: Declarative Fastify plugin configuration.
- Cons:
@fastify/http-proxyhandles WebSocket upgrades viawslibrary wrappers that conflict with Fastify's encapsulated route prefixing, override custom OIDC auth header injection (x-user-sub), and create competing upgrade listeners onapp.server. - Rejected: Custom socket-level TCP proxying gives full control over authentication header injection and path rewriting.
String-Encoding with Base64
- Pros: Avoids UTF-8 character conversion issues.
- Cons: Adds CPU overhead and breaks raw WebSocket TCP stream pass-through.
- Rejected: Unnecessary; raw
Bufferpiping requires zero encoding/decoding overhead.
Consequences
ttydweb terminal sandboxes, VNC desktop streaming, and custom binary WebSocket web apps operate with 100% data frame integrity.- Client handshake data sent during pod resolution is preserved with zero frame loss.
- Node's HTTP parser no longer crashes sockets when binary WebSocket frames arrive.
- High performance raw TCP data piping without string conversion overhead.
