Skip to content

HTTP::WebSocketHandler does not support/manages WebSocket sub-protocols #15547

Open
@luislavena

Description

@luislavena

Hello folks,

While attempting to reverse proxy Vite's WebSocket usage (HMR or Hot Module Reload), I found that my naïve attempt was getting the JS client (browser) automatically disconnected and couldn't figure out why.

After some research, I've managed to identify the source of it, and reduced it to two scripts, a mock server that sends a message once connected, and a TS client that will attempt to connect to it:

mock.cr:

require "http/server"
require "http/web_socket"
require "log"

class LogWSProtocol
  include HTTP::Handler

  def call(context : HTTP::Server::Context)
    Log.debug &.emit(
      "request info",
      sec_websocket_protocol: context.request.headers["sec-websocket-protocol"]?,
    )
    return call_next(context)
  end
end

ws_handler = HTTP::WebSocketHandler.new do |ws, ctx|
  Log.debug { "websocket_handler" }

  ws.on_message do |payload|
    Log.debug &.emit("ws.on_message", payload: payload)
  end

  # fake connected payload
  ws.send(%q({"type":"connected"}))
end

Log.setup(:debug)

server = HTTP::Server.new([
  LogWSProtocol.new,
  ws_handler,
])

Process.on_terminate do
  puts "Shutdown requested."
  server.close
end

ipaddr = server.bind_tcp("0.0.0.0", 5050)
puts "Listening on http://#{ipaddr.address}:#{ipaddr.port}/"
server.listen

test-mock.ts:

#!/usr/bin/env bun

const serverUrl = "ws://localhost:5050";

console.log(`Connecting to ${serverUrl}/...`);

// Set up WebSocket connection
const socket = new WebSocket(`${serverUrl}/`, "vite-hmr");

// Handle connection open
socket.addEventListener("open", () => {
  console.log("✓ Connected to server");
  console.log("Waiting 5 seconds for messages...");

  // Set timeout to disconnect after 5 seconds
  setTimeout(() => {
    console.log("⏱️ 5 seconds elapsed, disconnecting");
    socket.close();
  }, 5000);
});

// Handle receiving messages
socket.addEventListener("message", (event) => {
  console.log(`< Received: ${event.data}`);
});

// Handle connection close
socket.addEventListener("close", () => {
  console.log("✗ Disconnected from server");
  process.exit(0);
});

// Handle errors
socket.addEventListener("error", (error) => {
  console.error("✗ WebSocket Error:", error);
  process.exit(1);
});

If you remove the protocol from the WebSocket constructor (MDN docs here), you will see something like the following in the output of both mock and test client:

2025-03-09T11:26:40.343350Z  DEBUG - request info -- sec_websocket_protocol: nil
2025-03-09T11:26:40.343539Z  DEBUG - websocket_handler
Connecting to ws://localhost:5050/...
✓ Connected to server
Waiting 5 seconds for messages...
< Received: {"type":"connected"}
⏱️ 5 seconds elapsed, disconnecting
✗ Disconnected from server

However, once vite-hmr protocol is added back, this is what you see:

Connecting to ws://localhost:5050/...
✗ Disconnected from server

And the mock server:

2025-03-09T11:31:01.421718Z  DEBUG - request info -- sec_websocket_protocol: "vite-hmr"
2025-03-09T11:31:01.422199Z  DEBUG - websocket_handler

I was able to trace this back to an abnormal closing in this rescue:

https://github.com/crystal-lang/crystal/blob/master/src/http/web_socket.cr#L154-L157

Which is caused as the client already closed the connection, as the protocols returned by the server did not match the supported ones from the client.

From the WebSocket documentation on protocols:

https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket#protocols

The connection is not established until the sub-protocol is negotiated with the server.

From the WebSocket specification (Section 11.3.4)

The |Sec-WebSocket-Protocol| header field is used in the WebSocket opening handshake. It is sent from the client to the server and back from the server to the client to confirm the subprotocol of the connection. This enables scripts to both select a subprotocol and be sure that the server agreed to serve that subprotocol.

Additionally, WebSocket specs, 2.2 Opening handshake, point 11:

If protocols is not the empty list and extracting header list values given Sec-WebSocket-Protocol and response’s header list results in null, failure, or the empty byte sequence, then fail the WebSocket connection.

In order to address this, HTTP::WebSocketHandler needs to be aware of Sec-WebSocket-Protocol header and respond accordingly, but this also means changing the initialization signature.

This cannot be handled by the WebSocket, Server::Context proc, as is already too late: the headers were sent and the socket has been upgraded.

I made a proof of concept of this change and made it work, however as it changes the signature, I'm not sure is a good candidate for inclusion.

ws_handler = HTTP::WebSocketHandlerEx.new(
  supported_protocols: ["vite-hmr", "vite-ping"],
) do |ws, ctx|
  # ...
end

I know there are other issues open like #13239 or #8435 and a few others, but none of those covered this issue.

Thank you.
❤ ❤ ❤

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions