Description
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.
❤ ❤ ❤