WebSocket Proxy

Forward incoming WebSocket connections to an upstream ws:// or wss:// target.

crossws ships a small helper that returns a set of ready-made hooks which proxy every peer to an upstream WebSocket server. Use it to put crossws in front of an existing backend, split traffic across services, or bridge protocols between runtimes.

The proxy uses the global WebSocket constructor to dial the upstream, which is available on Node.js ≥ 22, Bun, Deno, Cloudflare Workers, and browsers. On older Node versions, pass a custom constructor via the WebSocket option or install a polyfill on globalThis.

Usage

createWebSocketProxy() returns a Partial<Hooks> object that you can pass straight to any crossws adapter.

// https://crossws.h3.dev/adapters
import crossws from "crossws/adapters/<adapter>";
import { createWebSocketProxy } from "crossws";

const websocket = crossws({
  hooks: createWebSocketProxy("wss://echo.websocket.org"),
});

Every incoming peer opens a matching upstream connection. Text and binary messages are forwarded in both directions, and close/error events are propagated to the client.

Messages sent by the client before the upstream connection is ready are buffered and flushed as soon as the upstream is open.
The default proxy is an open relay. It accepts every incoming connection and forwards it to the configured upstream without any authorization check. Always combine it with an upgrade hook when the upstream is not itself publicly accessible — otherwise anyone who can reach the proxy can reach the upstream.

Authentication

createWebSocketProxy() returns a plain hooks object, so you can spread it and override individual hooks. Authenticate the upgrade request before proxying by wrapping the proxy's upgrade hook:

import { createWebSocketProxy } from "crossws";

const proxyHooks = createWebSocketProxy("wss://backend.example.com");

const hooks = {
  ...proxyHooks,
  async upgrade(req) {
    const token = req.headers.get("authorization");
    if (!(await isValidToken(token))) {
      return new Response("Unauthorized", { status: 401 });
    }
    // Delegate to the proxy's own `upgrade` so subprotocol echoing still works.
    return proxyHooks.upgrade?.(req);
  },
};
The WHATWG WebSocket constructor cannot forward cookies, Authorization, or Origin to the upstream, so upstream identity checks relying on those headers will silently fail. Authenticate at the proxy, or pass a custom WebSocket client and use the headers option to propagate identity.

Dynamic target

Pass a function to resolve the upstream URL from the incoming Peer — useful for routing based on request URL, headers, or authenticated context.

import { createWebSocketProxy } from "crossws";

const hooks = createWebSocketProxy({
  target: (peer) => {
    const { pathname } = new URL(peer.request.url);
    return pathname.startsWith("/admin") ? "wss://admin.internal/ws" : "wss://public.internal/ws";
  },
});
SSRF risk. A dynamic target resolver is a trust boundary. Never interpolate untrusted input (query strings, headers, path segments a client controls) directly into the returned URL — a naive resolver turns the proxy into an SSRF primitive that can dial ws://127.0.0.1, ws://169.254.169.254, or any reachable internal service. Always resolve against a hard-coded allowlist of hosts you control.

The resolver may be async — return a promise when the upstream address isn't known yet at connect time (a worker that's still booting, a backend being hot-reloaded, a lookup against a registry). The proxy buffers any client frames that arrive while it resolves (bounded by maxBufferSize) and a non-zero connectTimeout also covers the resolution, so a resolver that never settles closes the peer with 1011 instead of hanging. With connectTimeout: 0 (timeout disabled) a never-settling resolver instead keeps the peer open until maxBufferSize is reached (1009), so give your resolver its own deadline in that case.

const hooks = createWebSocketProxy({
  // Wait for the upstream worker to report its address before proxying.
  target: async () => {
    const addr = await worker.waitForAddress();
    return `ws://${addr.host}:${addr.port}/`;
  },
});

Subprotocol negotiation

By default, the proxy forwards the client's sec-websocket-protocol header to the upstream and echoes the first requested subprotocol back in the upgrade response so the client handshake succeeds. Disable this if you want to negotiate subprotocols yourself:

createWebSocketProxy({
  target: "wss://backend.example.com",
  forwardProtocol: false,
});
The proxy commits to a subprotocol in the upgrade response before the upstream connection is established. If the upstream ultimately picks a different subprotocol (or rejects), the client will still see the one the proxy promised. Only keep forwardProtocol enabled when the upstream is known to accept the same subprotocols the client negotiates.

Rewriting the upstream subprotocol

forwardProtocol can present a different subprotocol to the upstream than the client offered — useful when a client re-labels a token to pass an intermediary and the proxy must restore the real token when dialing the origin. Pick the simplest form that fits:

// 1. Fixed value — the upstream always expects one known subprotocol.
createWebSocketProxy({
  target: "wss://backend.example.com",
  forwardProtocol: "vite-hmr",
});

// 2. Rewrite map — swap specific client tokens, pass the rest through.
createWebSocketProxy({
  target: "wss://backend.example.com",
  forwardProtocol: { "proxied-vite-hmr": "vite-hmr" },
});

// 3. Function — when the rewrite depends on more than the token value.
createWebSocketProxy({
  target: "wss://backend.example.com",
  forwardProtocol: (peer) =>
    (peer.request.headers.get("sec-websocket-protocol") ?? "")
      .split(",")
      .map((p) => p.trim())
      .filter(Boolean)
      .map((p) => (p.startsWith("proxied-") ? p.slice("proxied-".length) : p)),
});

The function receives the Peer and returns a string, an array of strings, or undefined to offer none.

These forms control only what is offered to the upstream. The subprotocol echoed back to the client stays the first token the client offered — per RFC 6455 the selected subprotocol must be one the client proposed, so echoing a rewritten value (which the client may not have offered) would make browsers fail the connection.

Custom WebSocket constructor

Pass a WebSocket constructor via options to override the global — useful on Node.js < 22, to plug in a different client implementation, or to stub the upstream in tests.

import { WebSocket } from "ws";
import { createWebSocketProxy } from "crossws";

const hooks = createWebSocketProxy({
  target: "wss://backend.example.com",
  WebSocket: WebSocket as unknown as typeof globalThis.WebSocket,
});

Unix domain sockets

Proxy to an upstream listening on a Unix domain socket by returning a ws+unix://<socketPath>:<pathname> target. This works out of the box on Node.js, Bun, and Deno — no custom WebSocket constructor required:

import { createWebSocketProxy } from "crossws";

const hooks = createWebSocketProxy({
  target: "ws+unix:///var/run/backend.sock:/chat",
});

The path before the : is the socket file (/var/run/backend.sock); the path after it is the request path forwarded to the upstream (/chat). A dynamic resolver can build it per peer:

const hooks = createWebSocketProxy({
  target: (peer) => `ws+unix:///run/backend.sock:${new URL(peer.request.url).pathname}`,
});

Under the hood the target is dialed through the crossws/websocket client for the current runtime, which picks the right strategy automatically:

  • Bun — its global WebSocket dials ws+unix: natively.
  • Node.js — its global WebSocket rejects the scheme, so crossws dials with the ws client instead (bundled with crossws, no extra dependency to install).
  • Deno — its global WebSocket rejects the scheme, so crossws routes the transport through Deno.createHttpClient's unix client.

Passing an explicit WebSocket constructor opts out of this per-runtime handling — the target is dialed with your constructor verbatim (e.g. ws, which accepts ws+unix: directly).

On Deno, the unix transport uses Deno.createHttpClient, an unstable API — run Deno with the --unstable-net flag to enable it.

Per-peer constructor options

For transports that can't be expressed in the target URL, webSocketOptions passes extra dialing options — a static object or a per-peer resolver — to the upstream WebSocket client, alongside any headers. Use it for runtime- or client-specific options the WHATWG signature doesn't cover — e.g. pinning the connection to a network interface, or supplying a custom transport client. (Unix sockets are dialed automatically, so you don't need this for them.)

For example, pinning the upstream connection to a specific network interface via ws's createConnection, or undici's dispatcher:

import { WebSocket } from "ws";
import { connect } from "node:net";
import { createWebSocketProxy } from "crossws";

const hooks = createWebSocketProxy({
  target: "ws://backend.internal/chat",
  WebSocket: WebSocket as unknown as typeof globalThis.WebSocket,
  webSocketOptions: () => ({
    createConnection: () => connect({ host: "10.0.0.5", port: 80 }),
  }),
});

On Deno, webSocketOptions also carries a custom Deno.createHttpClient client — for example to route the upstream through an HTTP proxy (a plain unix socket is handled automatically by a ws+unix: target):

const hooks = createWebSocketProxy({
  target: "wss://backend.internal/chat",
  webSocketOptions: () => ({
    client: Deno.createHttpClient({ proxy: { url: "http://proxy.internal:8080" } }),
  }),
});
webSocketOptions is only honored by WebSocket constructors that accept a dialing-options argument (the ws/undici clients, or Deno's global — crossws passes the options in the position each expects). The WHATWG browser global ignores them. Keys are merged with headers, with the dedicated headers option taking precedence over a headers key returned here.

Forwarding headers

Passing a headers option attaches extra headers to the upstream handshake. This is the usual way to forward identity (cookie, authorization, origin) or inject a shared secret to the upstream.

import { WebSocket } from "ws";
import { createWebSocketProxy } from "crossws";

const hooks = createWebSocketProxy({
  target: "wss://backend.example.com",
  WebSocket: WebSocket as unknown as typeof globalThis.WebSocket,
  headers: (peer) => ({
    cookie: peer.request.headers.get("cookie") ?? "",
    "x-forwarded-for": peer.remoteAddress ?? "",
  }),
});
The WHATWG global WebSocket constructor does not accept custom headers. headers is only honored when a WebSocket constructor that takes a third options argument is passed via the WebSocket option — e.g. ws or undici. With the global constructor the option is silently ignored.

Client idle timeout

Sometimes a client disappears without closing its connection — the network drops, the device sleeps, or a proxy or tunnel between the client and your server quietly stops passing traffic. The upstream connection stays open, tying up resources for a client that will never return.

Normally WebSocket ping/pong keepalive would notice a dead connection, but it isn't always enough: when there's a proxy or tunnel in the middle, that middlebox can answer the pings itself, so the connection keeps looking healthy long after the real client is gone.

clientIdleTimeout catches these by watching for actual messages from the client. Each message the client sends resets the timer; traffic the server pushes to the client does not. If nothing arrives from the client within the window, the proxy closes both the peer (code 1001) and the upstream connection.

createWebSocketProxy({
  target: "wss://backend.example.com",
  // Close a client that sends nothing for 60s. Assumes the protocol has the
  // client send periodic traffic (e.g. a heartbeat / keepalive message).
  clientIdleTimeout: 60_000,
});
Only enable this for protocols where the client is expected to send traffic periodically. For server-push-only protocols where the client may be legitimately silent, a clientIdleTimeout will close idle-but-live connections — leave it disabled (the default) in that case.

API

createWebSocketProxy(target)

Accepts either a target URL (string or URL), a resolver function, or an options object:

  • targetstring | URL | (peer: Peer) => string | URL | Promise<string | URL>. The upstream WebSocket URL, or a function (optionally async) that resolves it per peer. Accepts ws:/wss:, and ws+unix://<socketPath>:<pathname> for a Unix-socket upstream (dialed out of the box on Node, Bun, and Deno). The proxy does not otherwise enforce a scheme allowlist — any URL a custom WebSocket constructor accepts works. See the SSRF warning before using a dynamic resolver.
  • forwardProtocolboolean | string | string[] | Record<string, string> | (peer: Peer) => string | string[] | undefined (default true). Controls the subprotocol(s) offered to the upstream:
    • true forwards the client's sec-websocket-protocol header verbatim; false offers none.
    • a string/string[] offers a fixed value upstream regardless of the client.
    • a Record<string, string> rewrites matching client tokens to their mapped values, passing unmapped tokens through.
    • a function resolves the value per peer.

    See rewriting the upstream subprotocol. In all cases the value echoed back to the client is the first client-offered token (RFC 6455), and values that are not valid RFC 7230 tokens are dropped from the echo.
  • headersHeadersInit | (peer: Peer) => HeadersInit. Extra headers to send on the upstream handshake. Only honored when a custom WebSocket constructor that accepts a third options argument is supplied — the WHATWG global ignores it.
  • webSocketOptionsRecord<string, unknown> | (peer: Peer) => Record<string, unknown>. Extra dialing options passed to the upstream WebSocket client, statically or per peer. The escape hatch for transport options the URL can't express — e.g. the ws/undici createConnection/dispatcher/agent, or a custom Deno.createHttpClient client. Merged with headers (the headers option wins over a headers key returned here). See per-peer constructor options. Only honored by clients that accept dialing options (ws/undici, or Deno's global); the WHATWG browser global ignores them.
  • maxBufferSizenumber (default 1048576, i.e. 1 MiB). Maximum number of bytes buffered per peer while the upstream is still connecting. String frames are accounted at their UTF-8 worst case (3 bytes per UTF-16 code unit) to avoid undercounting multi-byte content. When exceeded, the peer is closed with code 1009 (Message Too Big). Set to 0 to disable.
  • connectTimeoutnumber (default 10000). Milliseconds to wait for the upstream WebSocket handshake to complete — and, for an async target resolver, for the resolver to settle. If exceeded, the peer is closed with code 1011. Set to 0 to disable.
  • clientIdleTimeoutnumber (default 0, disabled). Milliseconds of client→proxy inactivity after which the peer and upstream are closed with code 1001. Reset by every inbound client frame; unaffected by upstream→client traffic. Only suitable for protocols where the client sends traffic periodically. See client idle timeout. Set to 0 to disable.
  • WebSockettypeof WebSocket (default globalThis.WebSocket). Custom WebSocket constructor used to dial the upstream. Falls back to the global when omitted; throws at setup time if neither is available.

Returns a Partial<Hooks> object containing upgrade, open, message, close, and error hooks.