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.

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.

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

The proxy does not enforce any scheme allowlist — whatever the configured WebSocket constructor accepts is accepted. For example, the ws package supports Unix domain sockets via its ws+unix: scheme:

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

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

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.

API

createWebSocketProxy(target)

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

  • targetstring | URL | (peer: Peer) => string | URL. The upstream WebSocket URL, or a function that resolves it per peer. The proxy does not enforce a scheme allowlist; any URL the configured WebSocket constructor accepts (including ws+unix: with ws) works. See the SSRF warning before using a dynamic resolver.
  • forwardProtocolboolean (default true). When enabled, the client's sec-websocket-protocol header is forwarded to the upstream and echoed back in the upgrade response. Values that are not valid RFC 7230 tokens are dropped.
  • 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.
  • 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. If exceeded, the peer is closed with code 1011. 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.