Skip to content

feat: add createWebSocketProxy util#184

Merged
pi0 merged 7 commits intomainfrom
feat/proxy
Apr 10, 2026
Merged

feat: add createWebSocketProxy util#184
pi0 merged 7 commits intomainfrom
feat/proxy

Conversation

@pi0
Copy link
Copy Markdown
Member

@pi0 pi0 commented Apr 10, 2026

Closes #93.

Adds createWebSocketProxy(target) in src/proxy.ts — returns a standard crossws Partial<Hooks> object that forwards every peer to an upstream ws:// or wss:// target.

  • Static URLs, URL instances, or (peer) => url resolvers
  • Text/binary forwarding in both directions, close/error propagation
  • Buffers client messages until the upstream is open (with maxBufferSize limit)
  • connectTimeout, forwardProtocol, headers, and custom WebSocket constructor options
  • Documented under docs/1.guide/7.proxy.md, including SSRF and open-relay warnings

Note: node adapter behavior change

src/adapters/node.ts now decodes text frames to string before dispatching, matching every other adapter. Previously message.rawData was a Buffer for text frames on Node — anyone branching on typeof message.rawData will see different results. This is a prerequisite for the proxy to forward text frames as text (Node's ws delivers them as Buffer otherwise), but it is a standalone fix worth calling out in the changelog.

Returns standard crossws hooks that forward incoming peers to an
upstream ws:// or wss:// target. Supports static or dynamic targets,
subprotocol forwarding, and message buffering during upstream connect.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 10, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a WebSocket proxy utility createWebSocketProxy() (and WebSocketProxyOptions) implemented in src/proxy.ts, re-exported from src/index.ts; introduces docs docs/1.guide/7.proxy.md, tests test/proxy.test.ts, and updates src/adapters/node.ts to normalize text/binary message payloads.

Changes

Cohort / File(s) Summary
Documentation
docs/1.guide/7.proxy.md
New guide describing createWebSocketProxy() behavior, options (target, forwardProtocol, headers, maxBufferSize, connectTimeout, WebSocket), buffering semantics, subprotocol handling, routing/SSRF guidance, and usage examples.
Public API Exports
src/index.ts
Re-exports createWebSocketProxy and WebSocketProxyOptions from src/proxy.ts.
Proxy Implementation
src/proxy.ts
New module implementing createWebSocketProxy returning Partial<Hooks> with upgrade, open, message, close, error; per-peer upstream state, buffered message queue with maxBufferSize, connect timeout handling, forwarding of text/binary frames, subprotocol forwarding (with RFC7230 token validation), header/dialer customization, close-reason truncation and close-code remapping. Also exports internal helpers _remapIncomingCode and _normalizeOutgoingCode for tests.
Tests
test/proxy.test.ts
New Vitest suite covering end-to-end proxy behavior (static/dynamic targets, subprotocol negotiation, text/binary forwarding, buffering, maxBufferSize, connect timeout, error handling) and unit tests for helper functions and constructor/header behaviors.
Adapter change
src/adapters/node.ts
WebSocket "message" handler now passes an isBinary flag and converts non-binary Buffer payloads to UTF-8 strings before emitting the message hook.
Meta
...
Documentation and tests added; new exports introduced; no removal of public types or breaking API deletions.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐇 I dug a hollow, opened a stream,
Packets hop through me like clovered dream.
I hold your notes till upstream wakes,
Echo the protocol the client makes.
Close codes trimmed, buffers kept light—hop on, I beam.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.36% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: add createWebSocketProxy util' clearly and concisely summarizes the main change—adding a new WebSocket proxy utility function to the library.
Linked Issues check ✅ Passed The PR fully implements the requirements from #93: createWebSocketProxy utility supporting static/dynamic targets, message buffering, protocol forwarding, bidirectional messaging, and lifecycle handling.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing the WebSocket proxy feature: new proxy module, documentation, exports, tests, and a minimal adapter update to support proper binary message handling.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/proxy

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/proxy.ts (2)

143-154: Optional chaining on peer.request is unnecessary.

Based on the Peer class definition, request is a required property getter that always returns a Request object. The optional chaining on line 148 is defensive but technically unnecessary.

♻️ Minor: Remove unnecessary optional chaining
 function _resolveProtocols(
   peer: Peer,
   forwardProtocol: boolean | undefined,
 ): string[] | undefined {
   if (forwardProtocol === false) return;
-  const header = peer.request?.headers.get("sec-websocket-protocol");
+  const header = peer.request.headers.get("sec-websocket-protocol");
   if (!header) return;
   return header
     .split(",")
     .map((p) => p.trim())
     .filter(Boolean);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/proxy.ts` around lines 143 - 154, In _resolveProtocols, remove the
unnecessary optional chaining on peer.request since Peer.request is guaranteed
to return a Request; change any use of peer.request?.headers.get(...) to
peer.request.headers.get(...) in the _resolveProtocols function so it directly
accesses the headers, preserving the existing early returns and behavior.

89-101: Consider adding a buffer size limit to prevent memory exhaustion.

If a client sends many messages before the upstream connection opens (e.g., slow upstream or intentional abuse), the buffer grows unboundedly. While this may be acceptable for typical use cases, a malicious or misbehaving client could exhaust server memory.

💡 Optional: Add buffer size limit
+const MAX_BUFFER_SIZE = 100; // or make configurable via options
+
 message(peer, message) {
   const state = upstreams.get(peer.id);
   if (!state) return;
   const data =
     typeof message.rawData === "string"
       ? message.rawData
       : message.uint8Array();
   if (state.open) {
     state.ws.send(data as Parameters<WebSocket["send"]>[0]);
   } else {
+    if (state.buffer.length >= MAX_BUFFER_SIZE) {
+      _safeClose(peer, 1008, "Buffer overflow");
+      upstreams.delete(peer.id);
+      state.ws.close();
+      return;
+    }
     state.buffer.push(data);
   }
 },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/proxy.ts` around lines 89 - 101, The message(peer, message) handler
currently pushes incoming messages into state.buffer unboundedly while
state.open is false; add a max buffer size constant (e.g., MAX_BUFFER_SIZE) and,
before pushing to state.buffer, check state.buffer.length and enforce the
limit—either drop the oldest item (shift) and push the new item, or log and
close the client/upstream connection (use upstreams, state, and peer.id to
locate code) to prevent memory exhaustion; ensure you also log why the buffer
was dropped/connection closed for observability and update any tests or comments
referencing state.buffer behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/proxy.ts`:
- Around line 143-154: In _resolveProtocols, remove the unnecessary optional
chaining on peer.request since Peer.request is guaranteed to return a Request;
change any use of peer.request?.headers.get(...) to
peer.request.headers.get(...) in the _resolveProtocols function so it directly
accesses the headers, preserving the existing early returns and behavior.
- Around line 89-101: The message(peer, message) handler currently pushes
incoming messages into state.buffer unboundedly while state.open is false; add a
max buffer size constant (e.g., MAX_BUFFER_SIZE) and, before pushing to
state.buffer, check state.buffer.length and enforce the limit—either drop the
oldest item (shift) and push the new item, or log and close the client/upstream
connection (use upstreams, state, and peer.id to locate code) to prevent memory
exhaustion; ensure you also log why the buffer was dropped/connection closed for
observability and update any tests or comments referencing state.buffer
behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e8c5d376-b4a2-4f55-aed0-5c0f71577ee1

📥 Commits

Reviewing files that changed from the base of the PR and between 6291e7c and 353931a.

📒 Files selected for processing (4)
  • docs/1.guide/7.proxy.md
  • src/index.ts
  • src/proxy.ts
  • test/proxy.test.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
test/proxy.test.ts (2)

183-190: Exercise forwardProtocol: false as well.

The suite covers the default subprotocol passthrough, but not the opt-out branch of the new public option. I'd add a second proxy fixture with forwardProtocol: false and assert that no protocol is negotiated.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/proxy.test.ts` around lines 183 - 190, Add a second test-case that
exercises the opt-out branch by creating a proxy fixture configured with
forwardProtocol: false, then open a WebSocket via wsConnect (same as the
existing test) to that proxy URL and assert the proxy does not forward/negotiate
a subprotocol; for example, check ws.inspector.headers does not contain
"sec-websocket-protocol" (or that the negotiated protocol field is
undefined/empty). Update or add a proxy fixture name/reference and use
forwardProtocol: false so the test validates the non-passthrough behavior
alongside the existing forwardProtocol default test.

176-180: Add the missing client → upstream binary case.

This only proves upstream → client binary delivery. It doesn't verify that an inbound binary frame stays binary through src/adapters/node.ts and src/proxy.ts, so a regression in that direction would still pass.

➕ Suggested coverage
+  test("forwards client binary frames as binary", async () => {
+    const ws = await wsConnect(proxyURL, { skip: 1 });
+    await ws.send(new TextEncoder().encode("type"));
+    expect(await ws.next()).toBe("type:binary");
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/proxy.test.ts` around lines 176 - 180, Add a complementary test in
test/proxy.test.ts that exercises the client→upstream binary path: using
wsConnect (same as existing test) send a raw binary payload (e.g., a
Buffer/Uint8Array) instead of UTF-8 text and assert the upstream handler (or
mock) receives raw binary bytes (or that the upstream reply preserves binary
framing back to the client); ensure the test references the same wsConnect and
proxyURL setup and verifies that src/adapters/node.ts / src/proxy.ts do not
convert the outbound binary frame to text by checking the actual byte content or
binary marker on the upstream side.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@test/proxy.test.ts`:
- Around line 183-190: Add a second test-case that exercises the opt-out branch
by creating a proxy fixture configured with forwardProtocol: false, then open a
WebSocket via wsConnect (same as the existing test) to that proxy URL and assert
the proxy does not forward/negotiate a subprotocol; for example, check
ws.inspector.headers does not contain "sec-websocket-protocol" (or that the
negotiated protocol field is undefined/empty). Update or add a proxy fixture
name/reference and use forwardProtocol: false so the test validates the
non-passthrough behavior alongside the existing forwardProtocol default test.
- Around line 176-180: Add a complementary test in test/proxy.test.ts that
exercises the client→upstream binary path: using wsConnect (same as existing
test) send a raw binary payload (e.g., a Buffer/Uint8Array) instead of UTF-8
text and assert the upstream handler (or mock) receives raw binary bytes (or
that the upstream reply preserves binary framing back to the client); ensure the
test references the same wsConnect and proxyURL setup and verifies that
src/adapters/node.ts / src/proxy.ts do not convert the outbound binary frame to
text by checking the actual byte content or binary marker on the upstream side.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 979071b0-4bcc-4af0-ae7e-2afb92c65a71

📥 Commits

Reviewing files that changed from the base of the PR and between 353931a and 744ceba.

📒 Files selected for processing (4)
  • docs/1.guide/7.proxy.md
  • src/adapters/node.ts
  • src/proxy.ts
  • test/proxy.test.ts
✅ Files skipped from review due to trivial changes (1)
  • docs/1.guide/7.proxy.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/proxy.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/proxy.ts (2)

92-95: Handle potential empty subprotocol from malformed headers.

If the sec-websocket-protocol header is malformed (e.g., ", chat" with a leading comma), split(",")[0].trim() yields an empty string, which would set an empty protocol header. Consider filtering out empty values.

Suggested fix
       // Accept the first requested subprotocol so the upgrade handshake
       // echoes a value the client expects. Upstream must support it too.
-      const accepted = reqProtocol.split(",")[0]!.trim();
+      const accepted = reqProtocol
+        .split(",")
+        .map((p) => p.trim())
+        .find(Boolean);
+      if (!accepted) return;
       return { headers: { "sec-websocket-protocol": accepted } };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/proxy.ts` around lines 92 - 95, The current logic picks the first token
from reqProtocol via reqProtocol.split(",")[0]!.trim() which can produce an
empty string for malformed headers (e.g., ", chat"); update the selection to
filter tokens: split reqProtocol by ",", map(trim), filter out empty strings,
then pick the first non-empty token and set it to the "sec-websocket-protocol"
header (variable referenced as accepted); if no non-empty token exists, do not
include the sec-websocket-protocol header in the returned object to avoid
sending an empty protocol value.

168-179: Buffer size calculation may undercount multi-byte characters.

For strings, raw.length returns character count, not byte count. With multi-byte UTF-8 characters (emoji, CJK, etc.), the actual memory usage exceeds the calculated size. This could allow buffers to grow slightly beyond maxBufferSize in bytes.

If strict byte-based enforcement is needed:

Suggested fix using TextEncoder
-      const size = typeof raw === "string" ? raw.length : raw.byteLength;
+      const size =
+        typeof raw === "string"
+          ? new TextEncoder().encode(raw).length
+          : raw.byteLength;

Note: This adds encoding overhead per message. The current approach is a reasonable approximation for most use cases.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/proxy.ts` around lines 168 - 179, The size calculation uses raw.length
for strings which counts UTF-16 code units, not UTF-8 bytes, so compute the true
byte length with a TextEncoder and push the encoded Uint8Array into state.buffer
instead of the original string; update the branch around size = ... and the
subsequent state.buffer.push and state.bufferSize increment so that when typeof
raw === "string" you call TextEncoder().encode(raw) (use its length for size and
push the resulting Uint8Array), otherwise keep the existing Uint8Array.from(raw)
behavior; ensure references: state.bufferSize, options.maxBufferSize,
DEFAULT_MAX_BUFFER_SIZE, _cleanupState, _safeClose, and state.buffer are
adjusted accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/proxy.ts`:
- Around line 92-95: The current logic picks the first token from reqProtocol
via reqProtocol.split(",")[0]!.trim() which can produce an empty string for
malformed headers (e.g., ", chat"); update the selection to filter tokens: split
reqProtocol by ",", map(trim), filter out empty strings, then pick the first
non-empty token and set it to the "sec-websocket-protocol" header (variable
referenced as accepted); if no non-empty token exists, do not include the
sec-websocket-protocol header in the returned object to avoid sending an empty
protocol value.
- Around line 168-179: The size calculation uses raw.length for strings which
counts UTF-16 code units, not UTF-8 bytes, so compute the true byte length with
a TextEncoder and push the encoded Uint8Array into state.buffer instead of the
original string; update the branch around size = ... and the subsequent
state.buffer.push and state.bufferSize increment so that when typeof raw ===
"string" you call TextEncoder().encode(raw) (use its length for size and push
the resulting Uint8Array), otherwise keep the existing Uint8Array.from(raw)
behavior; ensure references: state.bufferSize, options.maxBufferSize,
DEFAULT_MAX_BUFFER_SIZE, _cleanupState, _safeClose, and state.buffer are
adjusted accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 48e85591-b91f-4ac4-a5e4-d723d79ffba3

📥 Commits

Reviewing files that changed from the base of the PR and between 8a0f844 and 66c17b9.

📒 Files selected for processing (3)
  • docs/1.guide/7.proxy.md
  • src/proxy.ts
  • test/proxy.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/1.guide/7.proxy.md

@pi0 pi0 merged commit 983b4bc into main Apr 10, 2026
3 of 4 checks passed
@codecov
Copy link
Copy Markdown

codecov bot commented Apr 10, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Utility to create proxy hooks

1 participant