Skip to content

Commit eeb77b2

Browse files
quinnjclaude
andauthored
WebSocket permessage-deflate compression (RFC 7692) (#853) (#1308)
Adds opt-in per-message DEFLATE compression for WebSocket connections, negotiated during the handshake. Enable with `compress=true` on the client `open(...)`, the server `listen!(...)`, or `upgrade(...)`; it is negotiated per connection and transparently falls back to uncompressed frames if either side declines. Implementation (src/http_websocket_pmce.jl): - Raw DEFLATE via thin Zlib_jll bindings (negative windowBits), driving Z_SYNC_FLUSH with the 0x00 0x00 0xff 0xff trailer strip/append trick (RFC 7692 §7.2). The CodecZlib transcode API cannot express sync-flush or context takeover, so we go to zlib directly. Adds Zlib_jll as a dep. - Per-connection context-takeover or no_context_takeover, negotiated window bits, the empty-message 0x00 rule (§7.2.3.6), and a decompression-bomb guard bounded by `maxframesize`. - Full Sec-WebSocket-Extensions negotiation: client offer, server accept (declining unknown params / unsupported 8-bit windows), client validation of the server response. Codec integration: RSV1 is accepted only when negotiated and only on the first frame of a data message; the decoder defers UTF-8 validation for compressed text (validated after the whole message is inflated). Outgoing data messages are compressed as one frame; control frames are never compressed. Tests: 45 codec/negotiation/end-to-end cases (no docker needed). Autobahn with compression enabled: 271 cases, zero failures, all 24 permessage-deflate cases (12.*/13.*) OK and core cases 1-11 unaffected. Existing WS suites unchanged: codec 122, client 20, server 35, integration 42. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 89341f0 commit eeb77b2

8 files changed

Lines changed: 637 additions & 29 deletions

File tree

Project.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ Reseau = "802f3686-a58f-41ce-bb0c-3c43c75bba36"
1414
SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce"
1515
URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4"
1616
UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
17+
Zlib_jll = "83775a58-1f1d-513f-b197-d71354ab007a"
1718

1819
[compat]
1920
CodecZlib = "0.7"
2021
EnumX = "1"
2122
PrecompileTools = "1.2.1"
2223
Reseau = "1.3"
2324
URIs = "1.6.1"
25+
Zlib_jll = "1.2.12"
2426
julia = "1.10"
2527

2628
[extras]

docs/src/guides/protocols.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,34 @@ public docstrings.
5858
- `read_idle_timeout`
5959
- `write_idle_timeout`
6060

61+
### Message compression (permessage-deflate)
62+
63+
HTTP.jl supports the WebSocket permessage-deflate extension ([RFC 7692](https://www.rfc-editor.org/rfc/rfc7692)),
64+
which DEFLATE-compresses each message. It is **opt-in on both ends** via
65+
`compress = true` and is negotiated during the handshake — if either side
66+
declines, the connection transparently falls back to uncompressed frames.
67+
68+
```julia
69+
# server advertises permessage-deflate; clients may negotiate it
70+
server = HTTP.WebSockets.listen!("127.0.0.1", 0; listenany = true, compress = true) do ws
71+
for msg in ws
72+
HTTP.WebSockets.send(ws, msg)
73+
end
74+
end
75+
76+
# client offers compression
77+
HTTP.WebSockets.open("ws://" * HTTP.WebSockets.server_addr(server); compress = true) do ws
78+
HTTP.WebSockets.send(ws, repeat("compress me ", 1000)) # sent compressed
79+
HTTP.WebSockets.receive(ws)
80+
end
81+
```
82+
83+
`compress` is also accepted by `HTTP.WebSockets.upgrade` for servers that mix
84+
HTTP and WebSocket routes. Compression is most beneficial for larger, repetitive
85+
text/JSON payloads; tiny or already-compressed (binary/media) messages gain
86+
little. Decompressed message size is bounded by `maxframesize`, guarding against
87+
decompression bombs.
88+
6189
## HTTP/2 Support
6290

6391
`HTTP.jl` supports HTTP/2 through the normal client and server APIs.

src/http_websocket_codec.jl

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,13 @@ mutable struct WsDecoder
201201
expecting_continuation::Bool
202202
fragment_opcode::UInt8
203203
text_fragment_payload::Vector{UInt8}
204+
# permessage-deflate (RFC 7692): `pmce_enabled` is set when the extension was
205+
# negotiated (so RSV1 is allowed); `message_compressed` carries the current
206+
# message's RSV1 across its fragments. When a message is compressed the
207+
# decoder defers UTF-8 validation — the bytes are compressed until the upper
208+
# layer inflates the reassembled message.
209+
pmce_enabled::Bool
210+
message_compressed::Bool
204211
# Reused result vector for ws_decoder_process! — contents are valid only
205212
# until the next process! call on this decoder.
206213
frames_scratch::Vector{WsFrame{Vector{UInt8}}}
@@ -223,6 +230,8 @@ function ws_decoder_new()::WsDecoder
223230
false,
224231
UInt8(0),
225232
UInt8[],
233+
false,
234+
false,
226235
WsFrame{Vector{UInt8}}[],
227236
)
228237
end
@@ -266,9 +275,13 @@ function ws_decoder_process!(f::Union{Nothing,Function}, dec::WsDecoder, data::A
266275
pos += 1
267276
dec.fin = (b & 0x80) != 0
268277
dec.rsv = ((b & 0x40) != 0, (b & 0x20) != 0, (b & 0x10) != 0)
269-
(dec.rsv[1] || dec.rsv[2] || dec.rsv[3]) && _ws_throw_protocol_error("unexpected websocket RSV bits without negotiated extensions")
270278
dec.opcode = b & 0x0f
271279
dec.opcode in (0x00, 0x01, 0x02, 0x08, 0x09, 0x0a) || _ws_throw_protocol_error("invalid websocket opcode")
280+
# RSV2/RSV3 are never valid (no negotiated extension uses them). RSV1
281+
# is the permessage-deflate per-message-compressed bit: valid only
282+
# when negotiated, and only on the first frame of a data message.
283+
(dec.rsv[2] || dec.rsv[3]) && _ws_throw_protocol_error("unexpected websocket RSV bits without negotiated extensions")
284+
dec.rsv[1] && !(dec.pmce_enabled && dec.opcode in (0x01, 0x02)) && _ws_throw_protocol_error("unexpected websocket RSV1 bit")
272285
is_control = ws_is_control_frame(dec.opcode)
273286
is_control && !dec.fin && _ws_throw_protocol_error("control frames must not be fragmented")
274287
if !is_control
@@ -278,6 +291,8 @@ function ws_decoder_process!(f::Union{Nothing,Function}, dec::WsDecoder, data::A
278291
dec.expecting_continuation && _ws_throw_protocol_error("unexpected new data frame while fragmented message is open")
279292
empty!(dec.text_fragment_payload)
280293
dec.fragment_opcode = dec.fin ? UInt8(0) : dec.opcode
294+
# RSV1 on the first data frame marks the whole message compressed.
295+
dec.message_compressed = dec.rsv[1]
281296
end
282297
dec.expecting_continuation = !dec.fin
283298
end
@@ -366,16 +381,19 @@ function ws_decoder_process!(f::Union{Nothing,Function}, dec::WsDecoder, data::A
366381
if dec.opcode == UInt8(WsOpcode.CLOSE)
367382
ws_validate_close_payload(dec.payload_buf)
368383
elseif dec.opcode == UInt8(WsOpcode.TEXT)
369-
if dec.fin
384+
# Compressed text is validated upstream, after decompression.
385+
if dec.message_compressed
386+
# nothing to validate here
387+
elseif dec.fin
370388
isvalid(String, dec.payload_buf) || _ws_throw_invalid_payload("invalid UTF-8 text frame payload")
371389
else
372390
append!(dec.text_fragment_payload, dec.payload_buf)
373391
end
374392
elseif dec.opcode == UInt8(WsOpcode.CONTINUATION)
375393
if dec.fragment_opcode == UInt8(WsOpcode.TEXT)
376-
append!(dec.text_fragment_payload, dec.payload_buf)
394+
dec.message_compressed || append!(dec.text_fragment_payload, dec.payload_buf)
377395
if dec.fin
378-
if !isvalid(String, dec.text_fragment_payload)
396+
if !dec.message_compressed && !isvalid(String, dec.text_fragment_payload)
379397
dec.expecting_continuation = false
380398
dec.fragment_opcode = UInt8(0)
381399
empty!(dec.text_fragment_payload)
@@ -470,13 +488,17 @@ mutable struct WSConn
470488
outgoing_spare::Vector{UInt8}
471489
max_incoming_payload_length::UInt64
472490
incoming_message_payload_total::UInt64
491+
# permessage-deflate state, or nothing when the extension is not negotiated.
492+
pmce::Union{Nothing,PMCEContext}
473493
end
474494

475495
function WSConn(;
476496
is_client::Bool=true,
477497
max_incoming_payload_length::UInt64=UInt64(0),
498+
pmce::Union{Nothing,PMCEContext}=nothing,
478499
)::WSConn
479500
decoder = ws_decoder_new()
501+
decoder.pmce_enabled = pmce !== nothing
480502
return WSConn(
481503
is_client,
482504
true,
@@ -488,6 +510,7 @@ function WSConn(;
488510
UInt8[],
489511
max_incoming_payload_length,
490512
UInt64(0),
513+
pmce,
491514
)
492515
end
493516

@@ -547,7 +570,7 @@ function _ws_append_frame!(out::Vector{UInt8}, frame::WsFrame)::Nothing
547570
return nothing
548571
end
549572

550-
function ws_send_frame!(ws::WSConn, opcode::UInt8, payload::AbstractVector{UInt8}; fin::Bool=true)::Nothing
573+
function ws_send_frame!(ws::WSConn, opcode::UInt8, payload::AbstractVector{UInt8}; fin::Bool=true, rsv1::Bool=false)::Nothing
551574
ws.is_open || throw(ProtocolError("websocket connection is closed"))
552575
if ws_is_control_frame(opcode)
553576
!fin && throw(ArgumentError("control frames must not be fragmented"))
@@ -565,6 +588,7 @@ function ws_send_frame!(ws::WSConn, opcode::UInt8, payload::AbstractVector{UInt8
565588
fin=fin,
566589
masked=ws.is_client,
567590
masking_key=masking_key,
591+
rsv=(rsv1, false, false),
568592
)
569593
@lock ws.out_lock begin
570594
_ws_append_frame!(ws.outgoing, frame)

0 commit comments

Comments
 (0)