Skip to content

Commit 66c17b9

Browse files
committed
up
1 parent 8a0f844 commit 66c17b9

3 files changed

Lines changed: 93 additions & 12 deletions

File tree

docs/1.guide/7.proxy.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ icon: tabler:arrows-exchange
99
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.
1010

1111
> [!NOTE]
12-
> The proxy uses the global [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) constructor to dial the upstream, which is available on Node.js ≥ 22, Bun, Deno, Cloudflare Workers, and browsers. On older Node versions, provide a `WebSocket` polyfill on `globalThis`.
12+
> The proxy uses the global [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/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](#custom-websocket-constructor) or install a polyfill on `globalThis`.
1313
1414
## Usage
1515

@@ -61,6 +61,20 @@ createWebSocketProxy({
6161
> [!WARNING]
6262
> 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.
6363
64+
## Custom WebSocket constructor
65+
66+
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.
67+
68+
```ts
69+
import { WebSocket } from "ws";
70+
import { createWebSocketProxy } from "crossws";
71+
72+
const hooks = createWebSocketProxy({
73+
target: "wss://backend.example.com",
74+
WebSocket: WebSocket as unknown as typeof globalThis.WebSocket,
75+
});
76+
```
77+
6478
## Combining with custom hooks
6579

6680
`createWebSocketProxy()` returns a plain hooks object, so you can spread it and override individual hooks — for example, to authenticate the upgrade request before proxying:
@@ -91,5 +105,6 @@ Accepts either a target URL (`string` or `URL`), a resolver function, or an opti
91105
- **`forwardProtocol`**`boolean` (default `true`). When enabled, the client's `sec-websocket-protocol` header is forwarded to the upstream and echoed back in the upgrade response.
92106
- **`maxBufferSize`**`number` (default `1048576`, i.e. 1 MiB). Maximum number of bytes buffered per peer while the upstream is still connecting. When exceeded, the peer is closed with code `1009` (Message Too Big). Set to `0` to disable.
93107
- **`connectTimeout`**`number` (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.
108+
- **`WebSocket`**`typeof 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.
94109

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

src/proxy.ts

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ export interface WebSocketProxyOptions {
4141
* @default 10000
4242
*/
4343
connectTimeout?: number;
44+
45+
/**
46+
* Custom `WebSocket` constructor used to dial the upstream. Useful when
47+
* the runtime does not expose a global `WebSocket` (Node.js < 22) or
48+
* when you want to use a different client implementation (e.g. `ws`,
49+
* `undici`, a mock for tests).
50+
*
51+
* @default globalThis.WebSocket
52+
*/
53+
WebSocket?: typeof WebSocket;
4454
}
4555

4656
/**
@@ -64,6 +74,13 @@ export function createWebSocketProxy(
6474
? { target }
6575
: target;
6676

77+
const WebSocketCtor = options.WebSocket ?? globalThis.WebSocket;
78+
if (typeof WebSocketCtor !== "function") {
79+
throw new TypeError(
80+
"createWebSocketProxy requires a `WebSocket` constructor. Pass one via the `WebSocket` option, or use a runtime that provides a global `WebSocket` (Node.js >= 22, Bun, Deno, Cloudflare Workers, browsers).",
81+
);
82+
}
83+
6784
const upstreams = new Map<string, UpstreamState>();
6885

6986
return {
@@ -82,7 +99,7 @@ export function createWebSocketProxy(
8299
const url = _resolveTarget(options.target, peer);
83100
const protocols = _resolveProtocols(peer, options.forwardProtocol);
84101

85-
const ws = new WebSocket(url, protocols);
102+
const ws = new WebSocketCtor(url, protocols);
86103
ws.binaryType = "arraybuffer";
87104

88105
const state: UpstreamState = {
@@ -123,7 +140,7 @@ export function createWebSocketProxy(
123140
// closures to the client.
124141
if (upstreams.get(peer.id) !== state) return;
125142
_cleanupState(upstreams, peer.id, state);
126-
_safeClose(peer, _remapCloseCode(event.code), event.reason);
143+
_safeClose(peer, _remapIncomingCode(event.code), event.reason);
127144
});
128145

129146
ws.addEventListener("error", () => {
@@ -136,22 +153,29 @@ export function createWebSocketProxy(
136153
message(peer, message) {
137154
const state = upstreams.get(peer.id);
138155
if (!state) return;
139-
const data =
156+
const raw =
140157
typeof message.rawData === "string"
141158
? message.rawData
142159
: message.uint8Array();
143160
if (state.open) {
144-
state.ws.send(data);
161+
try {
162+
state.ws.send(raw);
163+
} catch {
164+
// upstream may have transitioned to CLOSING between the check and send
165+
}
145166
return;
146167
}
147-
const size = typeof data === "string" ? data.length : data.byteLength;
168+
const size = typeof raw === "string" ? raw.length : raw.byteLength;
148169
const limit = options.maxBufferSize ?? DEFAULT_MAX_BUFFER_SIZE;
149170
if (limit > 0 && state.bufferSize + size > limit) {
150171
_cleanupState(upstreams, peer.id, state);
151172
_safeClose(peer, 1009, "Proxy buffer limit exceeded");
152173
return;
153174
}
154-
state.buffer.push(data);
175+
// Copy binary views before buffering: the adapter may own the backing
176+
// memory (e.g. Node's `ws` reuses Buffers in some paths) and the buffer
177+
// may be flushed asynchronously once the upstream is open.
178+
state.buffer.push(typeof raw === "string" ? raw : Uint8Array.from(raw));
155179
state.bufferSize += size;
156180
},
157181

@@ -162,7 +186,7 @@ export function createWebSocketProxy(
162186
upstreams.delete(peer.id);
163187
try {
164188
state.ws.close(
165-
_remapCloseCode(details.code),
189+
_normalizeOutgoingCode(details.code),
166190
_truncateReason(details.reason),
167191
);
168192
} catch {
@@ -262,11 +286,25 @@ function _truncateReason(reason?: string): string | undefined {
262286
);
263287
}
264288

265-
// Reserved codes must never appear in an outbound close frame.
266-
// 1005 (no status), 1006 (abnormal), 1015 (TLS failure) get remapped.
267-
function _remapCloseCode(code?: number): number | undefined {
289+
// Upstream close event → peer.close. Reserved pseudo-codes (1005/1006/1015)
290+
// must never appear on the wire, so they are rewritten. Everything else is
291+
// forwarded as-is; server-side peers can use the full 1000-4999 range.
292+
/** @internal exported for tests */
293+
export function _remapIncomingCode(code?: number): number | undefined {
268294
if (code === undefined) return undefined;
269295
if (code === 1005) return 1000;
270296
if (code === 1006 || code === 1015) return 1011;
271297
return code;
272298
}
299+
300+
// Peer close → upstream `state.ws.close`. The upstream is a client-side
301+
// WebSocket, and WHATWG restricts close() to 1000 or 3000-4999 — anything
302+
// else (1001 going-away, 1008 policy, etc.) throws InvalidAccessError.
303+
// Normalize to 1000 so we don't silently fail to close the upstream.
304+
/** @internal exported for tests */
305+
export function _normalizeOutgoingCode(code?: number): number | undefined {
306+
if (code === undefined) return undefined;
307+
if (code === 1000) return 1000;
308+
if (code >= 3000 && code <= 4999) return code;
309+
return 1000;
310+
}

test/proxy.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createServer, Server } from "node:http";
33
import { getRandomPort, waitForPort } from "get-port-please";
44
import nodeAdapter from "../src/adapters/node.ts";
55
import { createWebSocketProxy, defineHooks } from "../src/index.ts";
6+
import { _normalizeOutgoingCode, _remapIncomingCode } from "../src/proxy.ts";
67
import { wsConnect } from "./_utils.ts";
78

89
describe("createWebSocketProxy", () => {
@@ -18,7 +19,6 @@ describe("createWebSocketProxy", () => {
1819
let limitedProxyURL: string;
1920
let badProxyURL: string;
2021
let timeoutProxyURL: string;
21-
2222
beforeAll(async () => {
2323
// Upstream echo server (crossws node adapter)
2424
const upstream = nodeAdapter({
@@ -244,3 +244,31 @@ describe("createWebSocketProxy", () => {
244244
expect(event.code).toBe(1009);
245245
});
246246
});
247+
248+
describe("createWebSocketProxy internals", () => {
249+
test("_normalizeOutgoingCode allows 1000 and 3000-4999 range", () => {
250+
// state.ws is a client-side WebSocket; WHATWG forbids anything else.
251+
expect(_normalizeOutgoingCode(undefined)).toBeUndefined();
252+
expect(_normalizeOutgoingCode(1000)).toBe(1000);
253+
expect(_normalizeOutgoingCode(3000)).toBe(3000);
254+
expect(_normalizeOutgoingCode(4999)).toBe(4999);
255+
});
256+
257+
test("_normalizeOutgoingCode rewrites reserved and disallowed codes to 1000", () => {
258+
// Regression: state.ws.close(1005) would throw InvalidAccessError,
259+
// leaking the upstream socket. 1001/1008 are valid server-side codes
260+
// but still forbidden for client-side close(), so they also normalize.
261+
for (const code of [1001, 1005, 1006, 1008, 1011, 1015, 2999, 5000]) {
262+
expect(_normalizeOutgoingCode(code)).toBe(1000);
263+
}
264+
});
265+
266+
test("_remapIncomingCode rewrites reserved pseudo-codes before peer close", () => {
267+
expect(_remapIncomingCode(undefined)).toBeUndefined();
268+
expect(_remapIncomingCode(1000)).toBe(1000);
269+
expect(_remapIncomingCode(1005)).toBe(1000);
270+
expect(_remapIncomingCode(1006)).toBe(1011);
271+
expect(_remapIncomingCode(1015)).toBe(1011);
272+
expect(_remapIncomingCode(4321)).toBe(4321);
273+
});
274+
});

0 commit comments

Comments
 (0)