multiplex-proxy
v2.1.0
Published
A Commercial-Grade, High-Performance Node.js proxy solution that multiplexes HTTP and SOCKS5 protocols onto a single TCP port.
Maintainers
Readme
Multiplex Proxy
A Commercial-Grade, High-Performance Node.js proxy solution that multiplexes HTTP and SOCKS5 protocols onto a single TCP port.
Two engines ship in this package, built for different jobs — pick the one that matches what you're building, not just what protocol you need:
multiplex-proxy(in-built, this section) — full protocol coverage (HTTP/HTTPS + SOCKS5 CONNECT/BIND/UDP ASSOCIATE) on one port. This is the engine for commercial and development use: reverse-proxying, scraping infrastructure, residential/datacenter proxy products, local dev tooling — anywhere protocol breadth and a scriptable JS middleware chain matter more than shaving off the last millisecond.multiplex-proxy/native— a narrower, native C++ data plane: SOCKS5 CONNECT + UDP ASSOCIATE only, no HTTP. This is the engine for gaming and other jitter-intensive traffic (voice, real-time UDP game state, latency-sensitive tunnels) — anywhere consistent low ping and minimal jitter matter more than protocol breadth.
Features
- Protocol Multiplexing: Automatically detects and routes SOCKS5 and HTTP/HTTPS traffic on the same port.
- Middleware Architecture: Sequential
authHandlerandconnectionHandlerchains for complex logic. - Runs on Node or Bun — automatically uses Bun's native TCP sockets when available.
- Optional upstream proxy chaining, per-connection timeouts, and bandwidth/duration metrics.
- Full SOCKS5 command coverage — CONNECT always on, BIND and UDP ASSOCIATE opt-in (see Options) — alongside HTTP/HTTPS, all on one port.
- A separate native engine (
multiplex-proxy/native) with a C++ reactor data plane — SOCKS5 only (CONNECT + UDP ASSOCIATE), tuned for low ping/jitter; no HTTP/HTTPS. Purpose-built for gaming/real-time traffic, not general-purpose proxying — see Native engine.
Installation
npm install multiplex-proxyQuick Start
import { ProxyEngine } from "multiplex-proxy";
const Server = new ProxyEngine();
Server.listen(8080, () => {
console.log("Multiplex Proxy running on port 8080");
});By default, no authHandler is required to use the proxy: HTTP requests are accepted without credentials, and SOCKS5 auth negotiation is skipped entirely. Registering at least one authHandler (see below) switches both protocols into requiring credentials.
Listening on multiple ports
listen() can be called more than once, with a different port each time, to serve the same proxy on multiple ports concurrently:
Server.listen(8080);
Server.listen(8081);
Server.listen(9000, "127.0.0.1"); // bound to one interface onlyEvery listener shares the same authHandler/connectionHandler chains, DNS resolver/cache, and (in-built engine) HTTP/SOCKS5 parsing — only the lightweight OS-level listening socket is duplicated per port, so adding a port doesn't duplicate any of the proxy's actual state or logic.
Options
new ProxyEngine({
// Runs before protocol detection, keyed only on the client's IP (no credentials yet).
// Returning false (or the promise rejecting) closes the connection immediately.
ipAuthorization: async (ip) => ip !== null,
// SOCKS5 only accepts CONNECT by default. Opt in to BIND and/or UDP ASSOCIATE here --
// each exposes a relaying primitive (a listening TCP socket, or a UDP relay socket per
// request) that most deployments don't need.
socksCommands: ["bind", "udp"],
// How destination hostnames are resolved to an IP before dialing out. Defaults to the OS resolver.
dns: {
// Either specific nameservers to resolve through instead of the OS default...
servers: ["1.1.1.1", "8.8.8.8"],
// ...or take full control yourself (DNS-over-HTTPS, a cache, split-horizon rules). Takes precedence
// over `servers` if both are set. IPs are always used as-is without a lookup either way.
resolve: async (hostname) => "203.0.113.10"
},
// Binds with SO_REUSEPORT so multiple processes (e.g. one per CPU core) can each call `listen()` on
// the same port and have the kernel load-balance connections between them — for clustering/multi-
// threaded deployments without any fd/state sharing between processes. Linux only: this is a Linux
// kernel feature, so on other platforms it either fails the listen with `ENOTSUP` (Node — surfaced via
// the `"error"` event below, not a crash) or silently binds without actually distributing connections
// (Bun). Don't rely on it outside Linux.
reusePort: true
});ipis normalized (an IPv4-mapped IPv6 address like::ffff:127.0.0.1is reported as plain127.0.0.1), but can still benullif the underlying socket has no remote address.- The server is an
EventEmitter; listen for"error"to catch failures that aren't tied to a single connection (e.g. the listen socket itself failing to bind).
Documentation
Authentication Middleware (authHandler)
Server.authHandler(async (options, next) => {
const { ip, username, password } = options;
if (ip === "127.0.0.1") {
// Pass data to the next handler or metrics
return next({ plan: "premium", userId: "admin" });
}
return username === "user" && password === "pass" ? next() : false;
});Registering one or more authHandlers makes credentials mandatory: HTTP clients get 407 Proxy Authentication Required until a valid Proxy-Authorization: Basic ... header is supplied, and SOCKS5 clients are required to negotiate username/password auth.
Connection Middleware (connectionHandler)
import net from "net";
Server.connectionHandler(async (options, submit, next) => {
const { protocol, destAddress, destPort } = options;
const upstream = net.createConnection({ host: "upstream.com", port: 9000 }, () => {
submit("GRANTED");
options.socket.pipe(upstream).pipe(options.socket);
});
await next();
});submit accepts one of the following Status values, which are translated into the appropriate response for the client's protocol (HTTP status code, or SOCKS5 reply code):
| Status | HTTP response | SOCKS5 reply |
|-----------------|---------------|----------------------------|
| GRANTED | 200 (CONNECT writes headers immediately; other methods proceed) | REQUEST_GRANTED |
| FAILURE | 502 | GENERAL_FAILURE |
| NOT_ALLOWED | 403 | CONNECTION_NOT_ALLOWED |
| UNREACHABLE | 504 | HOST_UNREACHABLE |
| NOT_SUPPORTED | 501 | ADDRESS_TYPE_NOT_SUPPORTED |
If no connectionHandler is registered, the server connects to destAddress:destPort itself and relays traffic directly, mapping common connection errors (ETIMEDOUT, ENOTFOUND, ENETUNREACH, ECONNREFUSED) to the equivalent status/response automatically.
Forwarding a plain HTTP request yourself
The example above (piping options.socket directly to your upstream) only works for CONNECT (HTTPS tunnels) and SOCKS5, where socket is a clean raw duplex from the start. For a plain HTTP request, Node's HTTP parser has already consumed the request line/headers off socket before your handler runs, and the body no longer flows through raw socket reads either — so options.socket alone can't be forwarded as-is. In that case (options.protocol === "HTTP" && options.method !== "CONNECT"), two extra fields are populated instead:
options.head— aBufferwith the reconstructed request line + headers (Connection: closeis forced, since only one request is ever sent per upstream connection).options.body— the request body stream, to pipe in place ofsocket.
Server.connectionHandler(async (options, submit) => {
const upstream = net.createConnection({ host: options.destAddress, port: options.destPort }, () => {
submit("GRANTED");
if (options.method === "CONNECT") {
options.socket.pipe(upstream).pipe(options.socket);
} else {
upstream.write(options.head);
// { end: false } matters: piping the body to completion must not close the upstream
// connection's write side before a response arrives — some servers drop the connection
// on an early half-close instead of still responding.
options.body!.pipe(upstream, { end: false });
upstream.pipe(options.socket);
}
});
});SOCKS5 BIND
options.method is "connect", "bind", or "udp" for a SOCKS5 connection ("bind"/"udp" each require the matching entry in socksCommands — see Options; otherwise the client is rejected before your handler ever runs). Granting a bind request requires telling the client a real bound address/port — the placeholder submit("GRANTED") from the examples above only writes 0.0.0.0:0, which is meaningless here. Pass it via bind on submit's options instead:
submit("GRANTED", { bind: { address: "203.0.113.10", port: 51820 } });If you don't register a connectionHandler at all, bind connections that get through socksCommands still work out of the box — the server provides its own default implementation (a one-shot listener), the same way it does for connect. You only need to handle options.method === "bind" yourself if you want custom behavior (e.g. proxying out through your own upstream SOCKS/relay, applying quotas, or logging).
SOCKS5 UDP ASSOCIATE
Same opt-in mechanism as BIND (socksCommands: ["udp"]), and the same "works out of the box with no connectionHandler registered, or bring your own" shape. Granting one binds a UDP relay socket and replies with its real address (bind on submit's options, same field BIND uses) — the client then sends SOCKS5-framed UDP datagrams to that address for as long as the originating TCP control connection stays open; closing that connection tears the relay down. Like BIND, this only has a default implementation when no connectionHandler is registered — a registered handler that grants options.method === "udp" is responsible for standing up the relay itself via options.socket, the same way it would own BIND. Datagrams are routed strictly by valid SOCKS5 framing, not by source IP/port (so roaming client ports don't break the association), matching the native engine's UDP ASSOCIATE behavior.
Routing through an upstream proxy
submit's options accept upstream, timeout, and onMetrics — set any of these and the library takes over the connection entirely (dialing, relaying, and replying to the client), so you don't touch sockets at all. This works the same way for CONNECT, plain HTTP requests, and SOCKS5 connect:
Server.connectionHandler(async (options, submit) => {
submit("GRANTED", {
// Route through another SOCKS5 proxy instead of dialing options.destAddress/destPort directly.
// Omit this to have the library connect directly but still get a timeout/metrics.
upstream: { host: "upstream.example.com", port: 1080, username: "user", password: "pass" },
// Force-close the connection after this many ms, whichever protocol/direction is in flight.
timeout: 60_000,
// Called once when the connection ends.
onMetrics: (metrics) => console.log(metrics)
});
});onMetrics receives:
{
protocol: "HTTP" | "SOCKS5",
domain: string, // options.destAddress
port: number, // options.destPort
bytesSent: number, // client → destination
bytesReceived: number, // destination → client
durationMs: number // time the connection was open
}Whether a connection goes direct or through upstream is entirely your call at the point you call submit — nothing else about your connectionHandler needs to change. This only applies to a GRANTED connect-style request; it's not meaningful for BIND (bind on submit's options is unrelated and still works as described above) or for a non-GRANTED status.
Bun
No configuration needed — the library detects Bun at runtime and automatically uses Bun.connect/Bun.listen for outbound dialing and SOCKS5 BIND's peer-acceptance listener, instead of Node's net. The shared incoming listener (the one thing every connection, HTTP or SOCKS5, passes through) stays on Node/Bun's net module either way, since it needs to interoperate with — or, under Bun, replace — the runtime's own HTTP parsing; either way this is transparent and requires nothing from you. Falls back to plain Node APIs automatically when not running under Bun. DNS resolution (see below) similarly prefers Bun's own resolver when available.
DNS resolution and caching
Every hostname lookup in this library — this engine's dns option, the native engine, and internally for both CONNECT/plain-HTTP requests — goes through dns.ts's shared resolver: a single-flight + TTL cache (so N concurrent requests for the same hostname share one lookup, and repeats within the TTL skip DNS entirely), keyed per-resolver so a custom dns.resolve/servers config gets its own isolated cache. When no custom resolver is configured, the OS-default path is runtime-aware — it uses Bun's own Bun.dns.lookup(..., {ttl:true}) (which reports each record's real TTL) under Bun, and dns.promises.lookup (a fixed 30s TTL, since Node doesn't expose real record TTLs) under Node.
Errors
ProxyEngine extends EventEmitter and emits an "error" event for connection-level failures (e.g. an authHandler/connectionHandler throwing, or a socket-level error that isn't a routine ECONNRESET). Per Node's EventEmitter semantics, an "error" event with no listener attached will throw — always register one:
Server.on("error", (err) => console.error("proxy error:", err));Native engine
npm install multiplex-proxy # loads a prebuilt addon for your platform, no C++ toolchain neededimport { ProxyEngine } from "multiplex-proxy/native";
const Server = new ProxyEngine();
Server.listen(8080, () => console.log("Native multiplex proxy running on port 8080"));A second, independent implementation of the same idea, deliberately narrower in scope and built for throughput and low latency instead of protocol breadth: a native C++ data plane (native/*.cc, built via cmake-js) does all the socket I/O — accept, connect, read, write, and UDP — on one dedicated reactor thread (a hand-rolled epoll/kqueue/IOCP event loop per platform, selected at compile time), completely independent of Node's or Bun's own event loop. Bytes relayed between a client and its target never cross into V8/JS at all; the only traffic that ever reaches the JS thread is SOCKS5 handshake bytes and control-plane decisions (grant()/deny()), via a napi_threadsafe_function bridge.
This is the gaming / jitter-intensive-traffic engine, not a general-purpose proxy. It exists for exactly one job: consistent low ping and minimal jitter on latency-sensitive SOCKS5 TCP + UDP tunnels — game traffic, voice, real-time UDP state — where every millisecond and every byte of JS-side overhead on the hot path is a liability. It is not meant to compete with the in-built engine on protocol breadth, middleware ergonomics, or feature completeness; reach for it specifically when jitter and tail latency are the metric you're optimizing, not as a faster drop-in replacement for the in-built engine elsewhere.
This engine speaks SOCKS5 only — no HTTP or HTTPS in any form, not even CONNECT-tunneled HTTPS. It exists specifically for latency/jitter-sensitive TCP + UDP traffic through SOCKS5 (CONNECT and UDP ASSOCIATE); every byte of protocol surface beyond that (HTTP parsing/rewriting, a second sniff branch) is deliberately left out to keep the hot path minimal. If you need HTTP/HTTPS proxying, or the full SOCKS5 command set including BIND, use the in-built engine — the one built for commercial/development proxying — it isn't going away, and it isn't a fallback, it's the engine for that job.
ProxyEngine here has the same authHandler/connectionHandler/listen() shape as the in-built engine (see Documentation above), including multi-port listen() — every listener a given ProxyEngine opens is just another socket on the same single reactor thread and shared SlabPool (see native/reactor.h in the file map), so extra ports cost one more OS-level listening socket, not a second reactor or a second buffer pool. The difference between the two engines is what's underneath and what's currently supported:
| | In-built (multiplex-proxy) | Native (multiplex-proxy/native) |
|---|---|---|
| Relay path | JS Socket.pipe-style, per-chunk | Native, zero-copy across the JS boundary |
| HTTP / HTTPS | CONNECT + plain requests | Not supported — by design, see below |
| SOCKS5 | CONNECT, BIND (opt-in), UDP ASSOCIATE (opt-in) | CONNECT, UDP ASSOCIATE |
| SOCKS5 BIND | Yes (opt-in) | Not implemented (not planned — see below) |
| Intended use | Commercial / development proxying — protocol breadth, JS middleware | Gaming / jitter-intensive traffic — low ping, minimal jitter |
| TCP_NODELAY / TCP_QUICKACK | Off by default, opt-in per connection | On by default on every relay socket (see SockOptions below) |
| onMetrics | Yes | Not implemented yet (accepted nowhere in this engine's types, to avoid promising a callback that never fires) |
| Requires a C++ toolchain | No | Only if you're building from a git clone instead of the prebuilt binaries npm ships |
Why no HTTP/HTTPS (by design, not planned): this engine's reason to exist is low ping/jitter on SOCKS5 TCP+UDP, not protocol coverage — HTTP request-line/header parsing and rewriting (needed even just to tunnel CONNECT correctly) is exactly the kind of per-connection JS-side work this engine is built to avoid on its hot path. grant()'s native relay is also a raw, unmodified byte pipe in both directions with no hook to rewrite bytes before relay begins, so even re-adding HTTP CONNECT alone would mean building that hook back in. Use the in-built engine for HTTP/HTTPS — it isn't a stopgap, it's the intended engine for that traffic.
Why no SOCKS5 BIND (not planned): BIND needs a "listen for one inbound peer, then relay" primitive the native reactor doesn't expose (only outbound ConnectAsync and one shared multiplexed ListenAsync). The in-built engine already covers BIND for the (uncommon) deployments that need it, and building a one-shot native accept primitive just for BIND isn't worth the added reactor complexity. Use the in-built engine if you need it.
UDP ASSOCIATE's BND.ADDR is a real, routable address, not 0.0.0.0: the reply to a UDP ASSOCIATE request tells the client where to send its datagrams. Not every client falls back to the TCP control connection's own address if BND.ADDR comes back as the wildcard 0.0.0.0 (RFC 1928 allows a server to do this, but plenty of real clients just try to send to it literally and fail, or reject the reply outright) — so this engine reports the accepted connection's actual local address instead (ConnectionMeta.localIp, populated per-connection at accept time via getsockname()/GetAcceptExSockaddrs, not the listen socket's own possibly-wildcard bind address).
connectionHandler does not see UDP ASSOCIATE traffic: unlike SOCKS5 CONNECT above, a SOCKS5 UDP ASSOCIATE request is granted or denied internally (bind a relay port, or fail) without ever calling a registered connectionHandler — there's no per-destination hook for individual datagrams once the association is up, only the initial SOCKS5 username/password authHandler check at greeting time applies. If you need per-target authorization or metrics on UDP traffic, this engine doesn't support it yet; it isn't on the near-term plan either, since it would need handleUdpAssociate/onUdpMessage in engine.ts to gain a full NativeConnectionOptions/submit round trip per datagram (or per newly-seen destination) instead of the current bind-once-and-forward model.
SockOptions (noDelay, quickAck, sndBuf, rcvBuf) map directly to TCP_NODELAY/TCP_QUICKACK (Linux only)/SO_SNDBUF/SO_RCVBUF on the underlying native socket. noDelay and quickAck both default to true on this engine (set in native/reactor.h's SockOpts struct, applied to every listen/grant socket unless explicitly overridden) — Nagle's algorithm and delayed ACKs both work against the low-latency goal this engine exists for, so they're off by default here (unlike the in-built engine, where they're opt-in). On the documented ProxyEngine class, SockOptions fields are accepted as flat fields on a connectionHandler's submit(status, options) (forwarded straight through to the target/upstream socket grant() dials) — EngineOptions (the constructor) and the listen(port, hostname, cb) helper don't expose them for the listening socket (it still gets the native struct defaults above). The low-level NativeEngine (ListenOptions/GrantOptions) does accept them directly on both listen and grant, if you're driving that API directly.
"error" events: like the in-built engine (see Errors above), this engine's "error" is for genuine failures — an authHandler/connectionHandler throwing, or something unexpected. A client that opens a connection and disconnects before finishing its SOCKS5 handshake (a port scanner, a health check, a flaky network) is routine, the same way an early ECONNRESET is on the in-built engine, and does not emit "error".
Prebuilt native binaries
The published npm package does not include the C++ source (native/*.cc/*.h, CMakeLists.txt are in the git repo but excluded from what npm publish ships — see package.json's files) — it ships prebuilt .node binaries for win32-x64 and linux-x64 directly under native/ instead (native/mpx_native-win32-x64.node, native/mpx_native-linux-x64.node). engine.ts's loadNativeBinding() picks the one matching process.platform-process.arch at require time. Because the addon targets N-API v8 (a stable ABI across Node ≥18 — see CMakeLists.txt), one binary per (OS, arch) covers every supported Node version.
These binaries are produced by .github/workflows/build.yml's publish job: on every GitHub Release, it reuses that same run's build-matrix artifacts (rather than rebuilding), stages them into native/, and runs npm publish — so the exact binary that passed CI on the release's commit is what gets published, never a separately-built one.
If you're on a platform without a prebuilt binary (anything other than win32-x64/linux-x64, e.g. macOS or arm64), npm install from the registry won't work as-is: clone the repo instead, run npm run build:native (requires a C++ toolchain — VS Build Tools + CMake on Windows, build-essential+cmake on Linux) to produce build/Release/mpx_native.node, which loadNativeBinding() falls back to when no matching file exists under native/.
