Skip to content

Commit 4c50080

Browse files
authored
Add support for passing IO objects to Websockets.open() (#1251)
* Add support for passing IO objects to Websockets.open() This is handy if the caller already has the required stream/socket open. * fixup! Add support for passing IO objects to Websockets.open()
1 parent 6a19dbf commit 4c50080

6 files changed

Lines changed: 284 additions & 13 deletions

File tree

docs/src/guides/protocols.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@ proxy-aware clients without forcing you through internal parser state. The
5050
[WebSockets API reference](../api/websockets.md) is the canonical home for the
5151
public docstrings.
5252

53+
`HTTP.WebSockets.open` can also handshake over an already-connected `IO` instead
54+
of a URL. Pass any byte stream (a raw `TCPSocket`, a TLS stream, etc.) as the
55+
first argument, and optionally override the request-line `target`/`Host` header.
56+
This is handy when the transport is established out-of-band or for tunnelling
57+
WebSockets over a custom stream. The caller retains ownership of the `IO`, `open`
58+
never closes it.
59+
60+
```julia
61+
ws = HTTP.WebSockets.open(io; target = "/echo", host = "example.com:80")
62+
```
63+
5364
`HTTP.WebSockets.open` also accepts the client-side handshake timeout controls:
5465

5566
- `connect_timeout`

src/http_core.jl

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,6 +1056,27 @@ function defaultheader!(headers::Headers, item::Pair)
10561056
return setheader(headers, item)
10571057
end
10581058

1059+
"""
1060+
Read at least one byte into `b` (up to `nb`), blocking until data is available
1061+
or EOF, and return the number of bytes read (`0` only at EOF).
1062+
1063+
The WebSocket read loop relies on Reseau's "block until at least one byte or
1064+
EOF" semantics, which `readbytes!(conn; all=false)` provides for Reseau conns.
1065+
A stdlib `TCPSocket` with `all=false` instead returns `0` whenever no bytes are
1066+
buffered *yet*, so for any other `IO` we reimplement the blocking contract on
1067+
top of `eof`/`bytesavailable`. This lets `WebSockets.open(io)` accept an
1068+
arbitrary caller-provided stream that bypasses the connection pool.
1069+
"""
1070+
function _blocking_readbytes!(conn::Union{TCP.Conn,TLS.Conn}, b::AbstractVector{UInt8}, nb::Integer=length(b))
1071+
readbytes!(conn, b, nb; all=false)
1072+
end
1073+
1074+
function _blocking_readbytes!(io::IO, b::AbstractVector{UInt8}, nb::Integer=length(b))
1075+
eof(io) && return 0
1076+
n = min(bytesavailable(io), Int(nb))
1077+
return readbytes!(io, b, n)
1078+
end
1079+
10591080
"""
10601081
Internal buffered reader that first drains already-read bytes before continuing
10611082
from the underlying TCP or TLS connection.

src/http_websockets.jl

Lines changed: 136 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import ..CookieJar
3131
import ..COOKIEJAR
3232
import ..Cookies
3333
import .._ConnReader
34+
import .._blocking_readbytes!
3435
import .._USE_TRANSPORT_PROXY
3536
import .._close_conn!
3637
import .._client_for_request
@@ -442,8 +443,8 @@ function _ws_read_loop!(ws::WebSocket, buffer_bytes::Int=DEFAULT_READ_BUFFER_BYT
442443
# Read directly into the reusable `buf`. `readavailable` would
443444
# allocate a fresh Base.SZ_UNBUFFERED_IO (64KB) buffer on every
444445
# frame read (16× the bytes/RTT vs HTTP 1.x), driving GC pressure;
445-
# `readbytes!(...; all=false)` does one socket read into `buf`.
446-
n = readbytes!(ws.stream, buf, length(buf); all=false)
446+
# `_blocking_readbytes!` does one blocking socket read into `buf`.
447+
n = _blocking_readbytes!(ws.stream, buf, length(buf))
447448
n == 0 && break
448449
ws_on_incoming_data!(on_frame, ws.codec, buf, n)
449450
_flush_ws_output!(ws)
@@ -495,6 +496,42 @@ function _start_read_task!(ws::WebSocket, buffer_bytes::Int=DEFAULT_READ_BUFFER_
495496
return nothing
496497
end
497498

499+
# Shared client-side handshake completion for both `open` forms: build the
500+
# client `WebSocket` over an already-established `stream`, record the handshake
501+
# messages, replay any early frames the server already sent (`buffered`), and
502+
# start the read task.
503+
function _finish_client_websocket(
504+
stream,
505+
close_transport!,
506+
request::Request,
507+
response::Response,
508+
negotiated::Union{Nothing,String};
509+
maxframesize::Integer,
510+
maxfragmentation::Integer,
511+
read_idle_timeout_ns::Integer=0,
512+
buffered::Vector{UInt8}=UInt8[],
513+
pmce::Union{Nothing,PMCEContext}=nothing,
514+
)::WebSocket
515+
ws = WebSocket(
516+
stream,
517+
close_transport!;
518+
subprotocol=negotiated,
519+
maxframesize=maxframesize,
520+
maxfragmentation=maxfragmentation,
521+
read_idle_timeout_ns=read_idle_timeout_ns,
522+
is_client=true,
523+
pmce=pmce,
524+
)
525+
ws.handshake_request = request
526+
ws.handshake_response = response
527+
if !isempty(buffered)
528+
ws_on_incoming_data!(frame -> _process_incoming_frame!(ws, frame), ws.codec, buffered)
529+
_flush_ws_output!(ws)
530+
end
531+
_start_read_task!(ws)
532+
return ws
533+
end
534+
498535
"""
499536
send(ws, message) -> Int
500537
@@ -885,24 +922,18 @@ function _open_client_websocket(
885922
return nothing
886923
end
887924
end
888-
ws = WebSocket(
925+
return _finish_client_websocket(
889926
_conn_stream(conn),
890927
close_transport!,
891-
subprotocol=negotiated,
928+
send_request,
929+
response,
930+
negotiated;
892931
maxframesize=maxframesize,
893932
maxfragmentation=maxfragmentation,
894933
read_idle_timeout_ns=_request_read_idle_timeout_ns(send_request),
895-
is_client=true,
934+
buffered=attempt.buffered,
896935
pmce=pmce_ctx,
897936
)
898-
ws.handshake_request = send_request
899-
ws.handshake_response = response
900-
if !isempty(attempt.buffered)
901-
ws_on_incoming_data!(frame -> _process_incoming_frame!(ws, frame), ws.codec, attempt.buffered)
902-
_flush_ws_output!(ws)
903-
end
904-
_start_read_task!(ws)
905-
return ws
906937
end
907938
if !_is_redirect_status(response.status) || redirect_policy.max_redirects == 0
908939
owns_client && close(req_client)
@@ -1044,6 +1075,98 @@ function open(
10441075
end
10451076
end
10461077

1078+
# Perform the websocket client handshake directly over a caller-provided `io`,
1079+
# bypassing the connection pool and TLS/proxy machinery. The handshake request is
1080+
# serialized and written to `io`, then the 101 response is parsed back off it. The
1081+
# returned WebSocket's `close_transport!` is a no-op: the caller retains ownership
1082+
# of `io` and is responsible for closing it.
1083+
function _open_client_websocket_io(
1084+
io::IO;
1085+
target::AbstractString="/",
1086+
host::AbstractString="",
1087+
headers=Pair{String,String}[],
1088+
maxframesize::Integer=typemax(Int),
1089+
maxfragmentation::Integer=DEFAULT_MAX_FRAG,
1090+
subprotocols::AbstractVector{<:AbstractString}=String[],
1091+
)::WebSocket
1092+
req_headers = _normalize_headers_input(headers)
1093+
isempty(host) || setheader(req_headers, "Host", host)
1094+
key = ws_random_handshake_key()
1095+
_apply_websocket_request_headers!(req_headers, key, subprotocols)
1096+
request = Request("GET", String(target); headers=req_headers, host=String(host), body=EmptyBody(), content_length=0)
1097+
expected_accept = ws_compute_accept_key(header(request.headers, "Sec-WebSocket-Key")::String)
1098+
1099+
# Serialize and write the handshake request to the caller's transport.
1100+
request_buf = IOBuffer()
1101+
write_request!(request_buf, request)
1102+
write(io, take!(request_buf))
1103+
1104+
incoming = _read_incoming_response(io, request)
1105+
@try_ignore body_close!(incoming.rawbody)
1106+
response = _streaming_response(incoming)
1107+
negotiated = _validate_websocket_upgrade!(response, expected_accept, subprotocols)
1108+
1109+
# No-op `close_transport!`: the caller retains ownership of `io`.
1110+
return _finish_client_websocket(
1111+
io,
1112+
() -> nothing,
1113+
request,
1114+
response,
1115+
negotiated;
1116+
maxframesize=maxframesize,
1117+
maxfragmentation=maxfragmentation,
1118+
)
1119+
end
1120+
1121+
"""
1122+
open(io::IO; target="/", host="", kwargs...) -> WebSocket
1123+
open(f, io::IO; target="/", host="", kwargs...) -> Any
1124+
1125+
Perform the WebSocket client handshake directly over an already-connected `io`
1126+
(a raw `TCPSocket`, a TLS stream, or any other byte `IO`) instead of dialing a
1127+
new connection from a URL. This is useful when the transport is established
1128+
out-of-band, for testing, or for tunnelling WebSockets over a custom stream.
1129+
1130+
Because there is no URL to derive them from, the request-line `target` and `Host`
1131+
header default to `"/"` and unset; override them with the `target` and `host`
1132+
keyword arguments. The remaining `headers`, `subprotocols`, `maxframesize`, and
1133+
`maxfragmentation` keywords match the URL-based [`open`](@ref); pool, TLS, proxy,
1134+
redirect, cookie, and timeout options do not apply.
1135+
1136+
`io` is **not** closed by `open` — the caller retains ownership of the underlying
1137+
stream's lifetime. As with the URL form, calling `open` with a function closes
1138+
the WebSocket with status code `1000` when `f` returns (the close frame is sent
1139+
over `io`, but `io` itself stays open).
1140+
"""
1141+
open(io::IO; kwargs...) = _open_client_websocket_io(io; kwargs...)
1142+
1143+
function open(
1144+
f::Function,
1145+
io::IO;
1146+
suppress_close_error::Bool=false,
1147+
kwargs...,
1148+
)
1149+
ws = open(io; kwargs...)
1150+
try
1151+
return f(ws)
1152+
catch err
1153+
if err isa WebSocketError && isok(err)
1154+
return nothing
1155+
end
1156+
rethrow(err)
1157+
finally
1158+
if !isclosed(ws)
1159+
try
1160+
close(ws, CloseFrameBody(1000, ""))
1161+
catch err
1162+
if !(suppress_close_error && err isa WebSocketError)
1163+
rethrow(err)
1164+
end
1165+
end
1166+
end
1167+
end
1168+
end
1169+
10471170
"""
10481171
Server(; network="tcp", address="127.0.0.1:0", handler, tls_config=nothing, ...)
10491172

test/Project.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
55
JuliaC = "acedd4c2-ced6-4a15-accc-2607eb759ba2"
66
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
77
Reseau = "802f3686-a58f-41ce-bb0c-3c43c75bba36"
8+
Sockets = "6462fe0b-24de-5631-8697-dd941f90decc"
89
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
using Test
2+
using HTTP
3+
using Reseau
4+
using Sockets
5+
6+
const W = HTTP.WebSockets
7+
8+
@testset "HTTP.WebSockets.open over a raw IO" begin
9+
# Echo server that also surfaces the request target the client handshook with.
10+
server = W.listen!("127.0.0.1", 0) do ws
11+
W.send(ws, "target:" * ws.handshake_request.target)
12+
for msg in ws
13+
W.send(ws, "echo: " * (msg isa String ? msg : String(msg)))
14+
end
15+
end
16+
17+
try
18+
host, port = Reseau.HostResolvers.split_host_port(W.server_addr(server))
19+
port = parse(Int, port)
20+
21+
@testset "echo round-trip over a connected TCPSocket" begin
22+
sock = Sockets.connect(host, port)
23+
got = String[]
24+
W.open(sock; host="$host:$port") do ws
25+
push!(got, W.receive(ws))
26+
W.send(ws, "hello")
27+
push!(got, W.receive(ws))
28+
W.send(ws, "world")
29+
push!(got, W.receive(ws))
30+
end
31+
@test got == ["target:/", "echo: hello", "echo: world"]
32+
# open() must not close the caller-owned transport: only the caller
33+
# may close `sock`, so it is still locally open here.
34+
@test isopen(sock)
35+
close(sock)
36+
end
37+
38+
@testset "custom target keyword is sent in the request line" begin
39+
sock = Sockets.connect(host, port)
40+
target = nothing
41+
W.open(sock; target="/ws/v1", host="$host:$port") do ws
42+
target = W.receive(ws)
43+
end
44+
@test target == "target:/ws/v1"
45+
close(sock)
46+
end
47+
48+
@testset "non-function form returns a usable WebSocket" begin
49+
sock = Sockets.connect(host, port)
50+
ws = W.open(sock; host="$host:$port")
51+
try
52+
@test W.receive(ws) == "target:/"
53+
W.send(ws, "hi")
54+
@test W.receive(ws) == "echo: hi"
55+
finally
56+
close(ws)
57+
end
58+
@test isopen(sock)
59+
close(sock)
60+
end
61+
62+
@testset "round-trip over a Base.Pipe forwarded to the server" begin
63+
# `open` runs the handshake over a duplex `Base.PipeEndpoint` (a
64+
# non-socket `Base.IO` whose `all=false` reads return 0 before data
65+
# arrives) rather than over the TCP socket directly. A pair of tasks
66+
# forwards bytes between the pipe's far end and a normal TCP
67+
# connection to the echo server above, so the whole exchange is
68+
# driven over a pipe.
69+
# A named pipe gives us a full-duplex `Base.PipeEndpoint`. The
70+
# address format differs by platform: Windows uses a `\\.\pipe\...`
71+
# name (not a filesystem path), other platforms a Unix-domain socket.
72+
pipename = "http_jl_ws_pipe_$(getpid())"
73+
sockpath = Sys.iswindows() ? "\\\\.\\pipe\\$pipename" : joinpath(mktempdir(), "$pipename.sock")
74+
relay = Sockets.listen(sockpath)
75+
client_pipe = Sockets.connect(sockpath) # handed to open()
76+
bridge = Sockets.accept(relay) # forwarded to the server
77+
close(relay)
78+
tcp = Sockets.connect(host, port)
79+
80+
fwd_up = Threads.@spawn try
81+
while !eof(bridge)
82+
write(tcp, readavailable(bridge))
83+
end
84+
finally
85+
close(tcp)
86+
end
87+
fwd_down = Threads.@spawn try
88+
while !eof(tcp)
89+
write(bridge, readavailable(tcp))
90+
end
91+
finally
92+
close(bridge)
93+
end
94+
95+
try
96+
@test client_pipe isa Base.PipeEndpoint
97+
got = String[]
98+
W.open(client_pipe) do ws
99+
push!(got, W.receive(ws))
100+
W.send(ws, "hello")
101+
push!(got, W.receive(ws))
102+
end
103+
104+
@test got == ["target:/", "echo: hello"]
105+
finally
106+
close(client_pipe)
107+
wait(fwd_up)
108+
wait(fwd_down)
109+
end
110+
end
111+
finally
112+
close(server)
113+
end
114+
end

test/runtests.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ test_files = [
106106
"http_websocket_client_tests.jl",
107107
"http_websocket_server_tests.jl",
108108
"http_websocket_integration_tests.jl",
109+
"http_websocket_raw_io_tests.jl",
109110
"http_retry_tests.jl",
110111
"http_client_transport_tests.jl",
111112
"http_client_proxy_tests.jl",

0 commit comments

Comments
 (0)