Context
Today middleware/websocket returns 426 Upgrade Required for any HTTP/2 client request. RFC 8441 (2018) defines how to speak WebSocket over HTTP/2 by using the CONNECT method with a :protocol pseudo-header. Adoption is patchy but present in modern infra (Envoy, HAProxy H2 upstream mode, curl, and some browsers in specific contexts), and it's the only real spec-compliance gap in the WebSocket middleware.
This is a spike, not a straight-forward enhancement, because RFC 8441 requires changes in the H2 processor (Extended CONNECT negotiation, :protocol pseudo-header acceptance, stream-scoped raw-frame routing) that don't exist today. Realistically sized size/L — may need to slide to v1.5.0 or v1.6.0 once sized more carefully.
What RFC 8441 requires
1. Server advertises support via a SETTINGS frame
SETTINGS_ENABLE_CONNECT_PROTOCOL (0x8) = 1
Sent during the initial H2 handshake. Clients that don't see this SETTING must fall back to H1 Upgrade: websocket.
2. Server accepts Extended CONNECT
A normal H2 request has pseudo-headers :method, :scheme, :authority, :path. An Extended CONNECT adds :protocol and uses :method = CONNECT:
:method = CONNECT
:scheme = https
:authority = example.com
:path = /ws
:protocol = websocket
Plus normal WS negotiation headers (Sec-WebSocket-Version: 13, Sec-WebSocket-Protocol, Sec-WebSocket-Extensions). Note: Sec-WebSocket-Key is omitted over H2 — the handshake is inherent to the stream, there's no HTTP/1-style 101 exchange.
3. Server acknowledges with :status = 200 on that stream
No 101 Switching Protocols — H2 has no such mechanism. The stream then carries WebSocket frames (RFC 6455 framing) as the DATA frame payloads. Each H2 DATA frame carries one or more WS frames; WS fragmentation interacts with H2 flow control in non-obvious ways.
4. Stream lifetime = WebSocket lifetime
When either side sends :status 200 (server) or the first DATA frame, the WS session is live. WS close frame → server sends END_STREAM to finish the H2 stream. A client RST_STREAM translates to a WS abort.
Design considerations
-
H2 processor changes. protocol/h2/stream/processor.go currently rejects :method = CONNECT and any pseudo-header other than the standard four. Needs:
- Advertise
SETTINGS_ENABLE_CONNECT_PROTOCOL=1 when a WS route is registered.
- Accept
:protocol = websocket on CONNECT streams.
- Route these streams to a new dispatch path that hands the raw DATA-frame byte stream to the
middleware/websocket Handler, exactly like H1 hijack does today.
-
Detach model. H1 WS detaches the whole TCP conn. H2 WS detaches a single stream — the connection still multiplexes other streams in parallel. Celeris' Context.Detach today is conn-scoped; we'd need a stream-scoped variant or a new middleware/websocket/h2 path that talks directly to the H2 processor without invoking Detach.
-
Flow-control interaction. WS's own fragmentation + H2 WINDOW_UPDATE frames need careful coordination. A slow WS reader can backpressure the stream (correct) but a bug here can starve the whole connection (other streams' flow control). middleware/websocket's existing backpressure hooks map cleanly to stream-level WINDOW_UPDATE; validate the math.
-
Compression (permessage-deflate). The H2 CONTINUATION story interacts with permessage-deflate's sliding window. Keep it simple: reject permessage-deflate negotiation on WS-over-H2 in the first version (clients will drop back to uncompressed), ship compression support in a follow-up.
-
Autobahn. fuzzingclient doesn't currently test RFC 8441. Need a separate validation path — probably a Go-side integration test using golang.org/x/net/http2 client that speaks Extended CONNECT.
Scope of spike
- Prototype the H2 processor changes (
SETTINGS_ENABLE_CONNECT_PROTOCOL, Extended CONNECT acceptance, stream-scoped DATA routing).
- Prototype a stream-scoped WebSocket adapter that reuses as much of
middleware/websocket's Conn as possible.
- Interop: run against
golang.org/x/net/http2 client speaking Extended CONNECT; decide if browser testing is in scope (most browsers don't expose WS-over-H2 to JS yet — Envoy/HAProxy are the real interop targets).
- Size the full implementation and decide whether it ships in v1.4.2 or slides to v1.5.0 / v1.6.0.
Exit criteria
- Decision doc committed at
middleware/websocket/H2-DESIGN.md describing the chosen detach/dispatch model.
- End-to-end test: x/net/http2 client completes Extended CONNECT to a celeris server, exchanges echo frames, closes cleanly.
- Memory + latency characterization vs the H1 WS path: H2 WS should be within 2× of H1 WS latency (the extra framing is real, but the multiplex win offsets it at multi-stream concurrency).
- Autobahn-analog: a Go-side conformance suite (~30 cases) covering the subset of WS behaviors RFC 8441 changes (handshake, framing over DATA, close → END_STREAM, RST_STREAM abort).
- Decision: size/L realistic? If spike reveals
size/XL, move to v1.5.0 / v1.6.0 and scope the spike down.
Critical files (expected touch points)
protocol/h2/stream/processor.go — Extended CONNECT acceptance, SETTINGS advertisement.
internal/conn/h2.go — server-preface SETTINGS emission when any WS route is registered.
middleware/websocket/upgrade.go — detect H2 + Extended CONNECT, dispatch to H2 adapter.
- New:
middleware/websocket/h2.go — stream-scoped Conn wrapper.
test/conformance/ws_h2_test.go (new) — x/net/http2-driven interop tests.
Related
Context
Today
middleware/websocketreturns 426 Upgrade Required for any HTTP/2 client request. RFC 8441 (2018) defines how to speak WebSocket over HTTP/2 by using theCONNECTmethod with a:protocolpseudo-header. Adoption is patchy but present in modern infra (Envoy, HAProxy H2 upstream mode, curl, and some browsers in specific contexts), and it's the only real spec-compliance gap in the WebSocket middleware.This is a spike, not a straight-forward enhancement, because RFC 8441 requires changes in the H2 processor (Extended CONNECT negotiation,
:protocolpseudo-header acceptance, stream-scoped raw-frame routing) that don't exist today. Realistically sizedsize/L— may need to slide to v1.5.0 or v1.6.0 once sized more carefully.What RFC 8441 requires
1. Server advertises support via a SETTINGS frame
Sent during the initial H2 handshake. Clients that don't see this SETTING must fall back to H1
Upgrade: websocket.2. Server accepts Extended CONNECT
A normal H2 request has pseudo-headers
:method,:scheme,:authority,:path. An Extended CONNECT adds:protocoland uses:method = CONNECT:Plus normal WS negotiation headers (
Sec-WebSocket-Version: 13,Sec-WebSocket-Protocol,Sec-WebSocket-Extensions). Note:Sec-WebSocket-Keyis omitted over H2 — the handshake is inherent to the stream, there's no HTTP/1-style 101 exchange.3. Server acknowledges with
:status = 200on that streamNo
101 Switching Protocols— H2 has no such mechanism. The stream then carries WebSocket frames (RFC 6455 framing) as the DATA frame payloads. Each H2 DATA frame carries one or more WS frames; WS fragmentation interacts with H2 flow control in non-obvious ways.4. Stream lifetime = WebSocket lifetime
When either side sends
:status200 (server) or the first DATA frame, the WS session is live. WS close frame → server sends END_STREAM to finish the H2 stream. A client RST_STREAM translates to a WS abort.Design considerations
H2 processor changes.
protocol/h2/stream/processor.gocurrently rejects:method = CONNECTand any pseudo-header other than the standard four. Needs:SETTINGS_ENABLE_CONNECT_PROTOCOL=1when a WS route is registered.:protocol = websocketon CONNECT streams.middleware/websocketHandler, exactly like H1 hijack does today.Detach model. H1 WS detaches the whole TCP conn. H2 WS detaches a single stream — the connection still multiplexes other streams in parallel. Celeris'
Context.Detachtoday is conn-scoped; we'd need a stream-scoped variant or a newmiddleware/websocket/h2path that talks directly to the H2 processor without invoking Detach.Flow-control interaction. WS's own fragmentation + H2 WINDOW_UPDATE frames need careful coordination. A slow WS reader can backpressure the stream (correct) but a bug here can starve the whole connection (other streams' flow control).
middleware/websocket's existing backpressure hooks map cleanly to stream-level WINDOW_UPDATE; validate the math.Compression (permessage-deflate). The H2 CONTINUATION story interacts with permessage-deflate's sliding window. Keep it simple: reject permessage-deflate negotiation on WS-over-H2 in the first version (clients will drop back to uncompressed), ship compression support in a follow-up.
Autobahn.
fuzzingclientdoesn't currently test RFC 8441. Need a separate validation path — probably a Go-side integration test usinggolang.org/x/net/http2client that speaks Extended CONNECT.Scope of spike
SETTINGS_ENABLE_CONNECT_PROTOCOL, Extended CONNECT acceptance, stream-scoped DATA routing).middleware/websocket'sConnas possible.golang.org/x/net/http2client speaking Extended CONNECT; decide if browser testing is in scope (most browsers don't expose WS-over-H2 to JS yet — Envoy/HAProxy are the real interop targets).Exit criteria
middleware/websocket/H2-DESIGN.mddescribing the chosen detach/dispatch model.size/XL, move to v1.5.0 / v1.6.0 and scope the spike down.Critical files (expected touch points)
protocol/h2/stream/processor.go— Extended CONNECT acceptance, SETTINGS advertisement.internal/conn/h2.go— server-preface SETTINGS emission when any WS route is registered.middleware/websocket/upgrade.go— detect H2 + Extended CONNECT, dispatch to H2 adapter.middleware/websocket/h2.go— stream-scoped Conn wrapper.test/conformance/ws_h2_test.go(new) — x/net/http2-driven interop tests.Related
middleware/websocket/doc.gocurrently documents the H2 426 response — this issue flips that.