Skip to content

feat: opt-in WebSocket subprotocol negotiation#203

Merged
pi0 merged 2 commits into
mainfrom
feat/opt-handle-protocols
Jul 3, 2026
Merged

feat: opt-in WebSocket subprotocol negotiation#203
pi0 merged 2 commits into
mainfrom
feat/opt-handle-protocols

Conversation

@pi0x

@pi0x pi0x commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Closes #176.

Problem

Browsers open connections with new WebSocket(url, protocols) and send the offer in the Sec-WebSocket-Protocol request header. They reject the connection unless the server's 101 echoes one of the offered subprotocols back. crossws negotiated none by default (Node hardcoded handleProtocols: () => false, Deno/others didn't echo either), so browser clients using standard protocols like graphql-transport-ws / graphql-ws (GraphQL Yoga, Apollo, …) failed to connect.

The existing workaround — setting the header yourself in the upgrade hook — worked for ws clients but was undiscoverable boilerplate.

Change

Two opt-in ways to accept a subprotocol:

// Global default (AdapterOptions) — mirrors ws's selector signature
crossws({
  handleProtocols: (protocols, request) =>
    protocols.has("graphql-transport-ws") ? "graphql-transport-ws" : false,
});

// Per-connection override (upgrade hook), takes precedence
defineHooks({
  upgrade: (req) => ({ protocol: "graphql-transport-ws" }),
});

The choice is resolved centrally in hooks.upgrade() and folded into the upgrade headers. Every adapter already turns a sec-websocket-protocol entry in the upgrade headers into the accepted subprotocol (Node re-emits it via ws's headers event, Deno reads it into its native protocol, Bun forwards it to server.upgrade), so Node, Bun and Deno stay consistent with no per-adapter logic.

Resolution precedence: upgrade() { protocol } → a sec-websocket-protocol header the hook set directly (pre-existing way, kept working) → handleProtocols option → none.

Default stays strict (accept none) so a server never claims a subprotocol the app didn't opt into.

Verified on the wire (Node)

upgrade() {protocol} field  -> sec-websocket-protocol: graphql-transport-ws
handleProtocols option      -> sec-websocket-protocol: chat        (picked from "chat, superchat")
not selected (strict)       -> (none)

Note: Bun additionally echoes the first offered subprotocol even when none is selected (oven-sh/bun#18243) — documented, not fixable from our side.

Tests / docs

  • Shared fixture exercises all three paths ({ protocol }, header, handleProtocols); new tests gated to node/bun/deno.
  • 254 tests pass (all suites incl. spawned Bun/Deno), typecheck + lint clean.
  • Docs: new "Subprotocol negotiation" guide section + handleProtocols in shared adapter options.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for WebSocket subprotocol negotiation, including a global default option and per-connection selection.
    • Documented how accepted subprotocols are chosen during the handshake across supported adapters.
  • Bug Fixes

    • Improved handshake handling so negotiated subprotocols are consistently applied to the connection.

Browsers reject a WebSocket connection unless the handshake response echoes
one of the subprotocols they offered in `Sec-WebSocket-Protocol`. crossws
negotiated none by default, breaking browser clients using protocols like
`graphql-transport-ws` (#176).

Adds two opt-in ways to accept a subprotocol, resolved centrally in
`hooks.upgrade()` and folded into the upgrade headers — every adapter already
turns that into the accepted subprotocol, so Node, Bun and Deno stay
consistent with no per-adapter logic:

- `handleProtocols(protocols, request)` adapter option — global default,
  mirroring ws's selector signature.
- `{ protocol }` returned from the `upgrade` hook — per-connection override.

Default stays strict (accept none) so a server never claims a protocol the
app didn't opt into.

Closes #176

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds WebSocket subprotocol negotiation support via a new handleProtocols adapter option and a protocol return field from the upgrade hook. Resolution logic in AdapterHookable prioritizes the per-connection protocol before falling back to the global option. Documentation and tests are updated accordingly.

Changes

Subprotocol negotiation

Layer / File(s) Summary
AdapterOptions handleProtocols contract
src/adapter.ts
Adds MaybePromise import and a new optional handleProtocols callback type for selecting an accepted subprotocol during handshake.
Upgrade hook protocol resolution logic
src/hooks.ts
Refactors upgrade handling to capture protocolFromHook/upgradeHeaders, adds _resolveProtocol with precedence order (hook protocol → header → handleProtocols), merges the chosen protocol into response headers, and extends Hooks.upgrade return type with protocol?: string.
Fixture and test coverage
test/fixture/_shared.ts, test/tests.ts
Updates the demo fixture's handleProtocols hook and adapter option to negotiate subprotocols, and adds tests verifying negotiated headers from both the upgrade hook and handleProtocols option.
Documentation
docs/1.guide/2.hooks.md, docs/2.adapters/1.index.md
Documents subprotocol negotiation, the handleProtocols option, precedence rules, and adapter compatibility notes.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • h3js/crossws#184: Related sec-websocket-protocol forwarding/echoing behavior during upgrade in createWebSocketProxy.
  • h3js/crossws#185: Both PRs modify src/hooks.ts's upgrade hook result shape and AdapterHookable.upgrade() handling.

Suggested labels: enhancement

Suggested reviewers: pi0

Poem

A rabbit hops through handshake gates,
Negotiating protocols at the pearly WebSocket states,
"chat" or "graphql-transport-ws", pick just one,
Headers echoed back before the connection's begun,
Hop hop hooray, the browsers rejoice! 🐰📡

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: opt-in WebSocket subprotocol negotiation.
Linked Issues check ✅ Passed The PR adds handleProtocols, per-connection protocol selection, strict defaults, docs, and tests as requested in #176.
Out of Scope Changes check ✅ Passed The code changes are focused on subprotocol negotiation and supporting docs/tests, with no clear unrelated scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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/opt-handle-protocols

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.

The negotiated subprotocol is folded into `upgradeHeaders`, which every
adapter reads; the extra `protocol` field on the result was never consumed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pi0 pi0 merged commit 3175962 into main Jul 3, 2026
4 of 6 checks passed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
test/tests.ts (1)

90-115: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider extending coverage to uws/bunny.

These new tests are scoped to node|bun|deno, matching the docs' stated compatibility note. However, the provided bunny.ts/uws.ts snippets show both adapters already forward sec-websocket-protocol from upgradeHeaders, so the centralized resolution in hooks.upgrade() may already work for them too — worth confirming whether the omission is a deliberate runtime limitation or just missing test coverage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/tests.ts` around lines 90 - 115, The new sub-protocol negotiation tests
are unnecessarily limited to the node|bun|deno adapters even though the bunny
and uws adapters appear to forward sec-websocket-protocol through upgradeHeaders
too. Update the coverage around wsConnect/getURL and the
upgrade()/handleProtocols paths to include uws and bunny if they support the
same behavior, or otherwise document and encode the runtime limitation
explicitly in the tests so the adapter scope matches the actual implementation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/hooks.ts`:
- Around line 171-198: The _resolveProtocol method in hooks.ts is accepting
protocolFromHook and handleProtocols results without verifying they were
actually offered by the client. Update _resolveProtocol to parse the offered
subprotocols once from the request headers, then validate any selected protocol
from protocolFromHook or handleProtocols against that offered set before
returning it. If the chosen value is not in the offered set, ignore it and fall
back to undefined so the handshake only echoes a client-requested protocol.

---

Nitpick comments:
In `@test/tests.ts`:
- Around line 90-115: The new sub-protocol negotiation tests are unnecessarily
limited to the node|bun|deno adapters even though the bunny and uws adapters
appear to forward sec-websocket-protocol through upgradeHeaders too. Update the
coverage around wsConnect/getURL and the upgrade()/handleProtocols paths to
include uws and bunny if they support the same behavior, or otherwise document
and encode the runtime limitation explicitly in the tests so the adapter scope
matches the actual implementation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cb398c2a-54bd-4eb9-831e-641ca9ec6777

📥 Commits

Reviewing files that changed from the base of the PR and between e1a13f8 and c5e1d50.

📒 Files selected for processing (6)
  • docs/1.guide/2.hooks.md
  • docs/2.adapters/1.index.md
  • src/adapter.ts
  • src/hooks.ts
  • test/fixture/_shared.ts
  • test/tests.ts

Comment thread src/hooks.ts
Comment on lines +171 to +198
async _resolveProtocol(
request: Request,
upgradeHeaders: HeadersInit | undefined,
protocolFromHook: string | undefined,
): Promise<string | undefined> {
if (protocolFromHook) {
return protocolFromHook;
}
if (upgradeHeaders) {
const headers =
upgradeHeaders instanceof Headers ? upgradeHeaders : new Headers(upgradeHeaders);
const fromHeader = headers.get("sec-websocket-protocol");
if (fromHeader) {
return fromHeader;
}
}
const handleProtocols = this.options.handleProtocols;
if (handleProtocols) {
const offered = _parseProtocols(request.headers.get("sec-websocket-protocol"));
if (offered.size > 0) {
const chosen = await handleProtocols(offered, request);
if (chosen) {
return chosen;
}
}
}
return undefined;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Selected protocol isn't validated against the client-offered set.

Neither protocolFromHook nor the value returned by handleProtocols is checked against offered. Per the JSDoc in src/adapter.ts ("must be one of the offered values") and RFC 6455 §4.2.2, a server must not echo a subprotocol the client didn't request. A misconfigured hook/option (e.g. hardcoding a protocol regardless of what was offered) will silently produce a spec-violating handshake — the same class of browser-rejection bug this PR sets out to fix, just from the opposite direction.

🛡️ Proposed fix: validate against offered protocols
   async _resolveProtocol(
     request: Request,
     upgradeHeaders: HeadersInit | undefined,
     protocolFromHook: string | undefined,
   ): Promise<string | undefined> {
+    const offered = _parseProtocols(request.headers.get("sec-websocket-protocol"));
     if (protocolFromHook) {
-      return protocolFromHook;
+      return offered.has(protocolFromHook) ? protocolFromHook : undefined;
     }
     if (upgradeHeaders) {
       const headers =
         upgradeHeaders instanceof Headers ? upgradeHeaders : new Headers(upgradeHeaders);
       const fromHeader = headers.get("sec-websocket-protocol");
       if (fromHeader) {
         return fromHeader;
       }
     }
     const handleProtocols = this.options.handleProtocols;
-    if (handleProtocols) {
-      const offered = _parseProtocols(request.headers.get("sec-websocket-protocol"));
+    if (handleProtocols) {
       if (offered.size > 0) {
         const chosen = await handleProtocols(offered, request);
-        if (chosen) {
+        if (chosen && offered.has(chosen)) {
           return chosen;
         }
       }
     }
     return undefined;
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks.ts` around lines 171 - 198, The _resolveProtocol method in hooks.ts
is accepting protocolFromHook and handleProtocols results without verifying they
were actually offered by the client. Update _resolveProtocol to parse the
offered subprotocols once from the request headers, then validate any selected
protocol from protocolFromHook or handleProtocols against that offered set
before returning it. If the chosen value is not in the offered set, ignore it
and fall back to undefined so the handshake only echoes a client-requested
protocol.

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.

feat: allow configuring handleProtocols or change default behavior

2 participants