Skip to content

feat: add experimental http2 support - #539

Open
mafredri wants to merge 19 commits into
masterfrom
mafredri/http2
Open

mafredri wants to merge 19 commits into
masterfrom
mafredri/http2

Conversation

@mafredri

@mafredri mafredri commented Sep 29, 2025 •

Copy link
Copy Markdown
Member

Adds experimental WebSocket over HTTP/2 (RFC 8441 extended CONNECT). It is opt-in on both sides through the new HTTPProtocol field in AcceptOptions and DialOptions. The zero value keeps today's HTTP/1.1 behavior, and on Wasm the field does nothing.

  • Server: Accept with HTTPProtocol2 accepts only extended CONNECT, and with HTTPProtocolAny it accepts both. The process must run with GODEBUG=http2xconnect=1, because Go has disabled extended CONNECT by default since Go 1.24 (net/http: do not enable HTTP/2 extended connect by default golang/go#71128). CL 776300 proposes a per-server option instead (net/http: add support for SETTINGS_ENABLE_CONNECT_PROTOCOL golang/go#53208). Over HTTP/2, Accept does not require Sec-WebSocket-Key, because Chrome and Firefox send none (RFC 8441 §5). It answers a key that is present with Sec-WebSocket-Accept, for HTTP/1.1 clients behind proxies such as Envoy.
  • Client: Dial with HTTPProtocol2 needs an http.Client that uses golang.org/x/net/http2.Transport, because net/http's own client rejects the :protocol pseudo-header. On Go 1.27 the program must also be built with -tags http2legacy, because x/net's transport otherwise hands requests to net/http. Dial cannot detect whether a transport supports HTTP/2, so the caller chooses the protocol. Dial still sends a key over HTTP/2, for HTTP/1.1 servers behind proxies, and checks Sec-WebSocket-Accept only when the server sends one.

The HTTP/2 tests are in internal/thirdparty/http2, because they import x/net. ci/test.sh runs them with GODEBUG=http2xconnect=0 and with =1, and adds their coverage to the main report. A runnable example is in internal/examples/http2.

Dial now also rejects an HTTP/1.1 handshake response with a ProtoMajor other than 0 or 1 (#550), so an HTTP/3 response is not treated as an upgrade. HTTP/2 benchmarks are not included.

Closes #4

Decision log
  • ProtoMajor 0 is accepted: custom RoundTrippers that build the response by hand may leave it unset, and rejecting them would break dials that work today.
  • The example and test servers use net/http (Server.Protocols, Go 1.24+), not x/net's ConfigureServer and h2c, which x/net deprecates. The x/net client stays, with //lint:file-ignore SA1019, because net/http has no way to send :protocol yet.
  • golang.org/x/net (v0.62.0) and golang.org/x/text (v0.43.0) are bumped in the internal modules to clear govulncheck. Those x/net releases require go 1.26.0, so both internal go directives say 1.26.0.
  • Checked on Go 1.27.3: the HTTP/2 suite fails its 3 successful-dial cases without -tags http2legacy and passes with it.

🤖 This PR was created with the help of Coder Agents.

@mafredri
mafredri force-pushed the mafredri/http2 branch 11 times, most recently from 469125f to 9470379 Compare September 29, 2025 18:59
@mafredri mafredri self-assigned this Sep 29, 2025
@mafredri
mafredri marked this pull request as ready for review October 1, 2025 13:09
@mafredri
mafredri requested a review from code-asher October 1, 2025 13:10
@mtojek
mtojek self-requested a review October 1, 2025 14:06
Comment thread .github/workflows/ci.yml Resolved

@code-asher code-asher left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice 🤌 I messed around with the example as well and it all looks good.

Comment thread internal/examples/http2/README.md Resolved
Comment thread internal/examples/http2/server.go Resolved
Comment thread dial.go Resolved

@mtojek mtojek left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amazing skills 👍 👍

Have you tried to establish a connection using other tools? JS possibly?

Comment thread .github/workflows/daily.yml Resolved
Comment thread protocol.go Resolved
Comment thread protocol.go Resolved
Comment thread ci/test.sh Resolved
Comment thread ci/static.sh Resolved
Comment thread autobahn_test.go Resolved
Comment thread internal/examples/http2/main.go Resolved
Comment thread internal/examples/http2/server.go Resolved
Comment thread internal/examples/http2/server.go Resolved
Comment thread accept.go Resolved
@code-asher

code-asher commented Oct 3, 2025 •

Copy link
Copy Markdown
Member

Have you tried to establish a connection using other tools? JS possibly?

While I was testing I experimented with connecting to the server example from a JS (Node) client. Here is what I used for anyone interested (needs ws).

const http2 = require("http2")
const { Receiver, Sender } = require("ws")

const client = http2.connect("http://127.0.0.1:8080")

const req = client.request({
 ":method": "CONNECT",
 ":protocol": "websocket",
 ":scheme": "http",
 ":path": "/",
 ":authority": "127.0.0.1",
 "Sec-WebSocket-Key": "AQIDBAUGBwgJCgsMDQ4PEC==",
 "Sec-WebSocket-Version": "13",
})

req.on("response", (headers) => {
 console.log("status:", headers[":status"])
})

req.on("error", (err) => {
 console.log("error:", err)
})

const receiver = new Receiver()
receiver.on("message", (data) => {
 console.log("got message:", data.toString())
})

req.on("data", (chunk) => {
 receiver.write(chunk)
})

setInterval(() => {
 const sender = new Sender(req)
 sender.send("hey", { binary: false, mask: true, fin: true })
}, 1000)

sample output:

status: 200
got message: hey
got message: hey
got message: hey

mafredri added a commit that referenced this pull request Dec 16, 2025
Remove bench-dev and test-dev jobs from daily workflow since the dev
branch no longer exists.

Add ci/static.sh script to generate coverage badge for GitHub Pages.

Refs #539
mafredri added a commit that referenced this pull request Dec 16, 2025
Add resp.ProtoMajor check in verifyServerResponseH1 for consistency
with verifyServerResponseH2 which already performs this check.

Refs #539
mafredri added a commit that referenced this pull request Dec 17, 2025
Add resp.ProtoMajor check in verifyServerResponseH1 for consistency
with verifyServerResponseH2 which already performs this check.

Refs #539
Comment thread accept.go Resolved
@Nek-12

Nek-12 commented Apr 19, 2026

Copy link
Copy Markdown

Are there plans to ship this?

@mafredri

Copy link
Copy Markdown
Member Author

@Nek-12 yes, absolutely! This fell on the back-burner but I will take another look at it next week.

@ybaldus

ybaldus commented Sep 23, 2026

Copy link
Copy Markdown

@mafredri With go 1.27 x/net/http2 moved into the standard library. It might be worth to relook into the changes. See: https://pkg.go.dev/golang.org/x/net/http2#section-readme

I would be interested in the http2 websocket server. Do you need any help to ship this?

mafredri added a commit that referenced this pull request Oct 23, 2026
Remove bench-dev and test-dev jobs from daily workflow since the dev
branch no longer exists.

Add ci/static.sh script to generate coverage badge for GitHub Pages.

Refs #539
mafredri added a commit that referenced this pull request Oct 23, 2026
Nothing calls ci/static.sh, and it reads ci/out/profile.txt, which
ci/test.sh does not write (it writes ci/out/coverage.prof). The script
only works with the ci/test.sh changes in #539, so it moves back there
and this PR only drops the dev branch jobs.
mafredri added a commit that referenced this pull request Oct 23, 2026
Remove bench-dev and test-dev jobs from daily workflow since the dev
branch no longer exists.

Add ci/static.sh script to generate coverage badge for GitHub Pages.

Refs #539
mafredri added a commit that referenced this pull request Oct 29, 2026
Add resp.ProtoMajor check in verifyServerResponseH1 for consistency
with verifyServerResponseH2 which already performs this check.

Refs #539
- Fix 'exampels' typo in CI workflow comments
- Fix README paths to use 'internal/examples/http2'
- Change visibleAddr to display 0.0.0.0 for all-interface bindings
Rename the Protocol type to HTTPProtocol for consistency with existing
HTTPClient and HTTPHeader field naming in DialOptions.

Constants are also renamed:
- ProtocolHTTP1 → HTTPProtocol1
- ProtocolHTTP2 → HTTPProtocol2
- ProtocolAcceptAny → HTTPProtocolAny
Simplify the TLS example by requiring -cert and -key flags instead of
auto-generating a self-signed certificate. This reduces complexity and
keeps the example focused on HTTP/2 WebSocket usage.
Add resp.ProtoMajor check in verifyServerResponseH1 for consistency
with verifyServerResponseH2 which already performs this check.

Refs #539
verifyServerResponseH1 rejects responses whose ProtoMajor is not 1.
These mock responses left it at 0, so each dial failed before the check
its test targets. Responses from net/http always set ProtoMajor.
Without the check, an HTTP/3 response goes on to the Upgrade header
checks, so Dial could treat it as an HTTP/1.1 upgrade.
http2.Transport.DialTLS is deprecated, and staticcheck fails lint on it.
DialTLSContext also lets the transport cancel a dial when the request's
context ends.
http2.Transport.DialTLS is deprecated, and staticcheck fails lint on it.
Custom RoundTrippers that build the 101 response by hand may leave
ProtoMajor at 0. Rejecting those would break dials that work today,
while any other version except 1 is still rejected.
This reverts 3866540. With an unset ProtoMajor accepted, these tests
again cover the hand-built responses that custom RoundTrippers return.
govulncheck reports 8 vulnerabilities in x/net v0.44.0 and x/text
v0.29.0 that the HTTP/2 example calls. internal/thirdparty uses the same
x/net for the HTTP/2 tests and moves with it. Every x/net release with
the fixes requires go 1.26.0, so both go directives change from 1.26 to
1.26.0.
x/net deprecates http2.ConfigureServer and h2c. Since Go 1.24, net/http
serves HTTP/2 extended CONNECT and cleartext HTTP/2 itself, and it reads
GODEBUG=http2xconnect the same way. The tests now run Accept behind the
server most users run.
x/net deprecates http2.Transport, but Go 1.26's net/http Transport fails
the extended CONNECT request with "invalid header field name
":protocol"", so the example client and the tests still need it.
7f2b1c4 renamed the option from Protocol to HTTPProtocol, but doc.go and
README.md still named Protocol.
On Go 1.27, x/net's http2.Transport passes requests to net/http, which
fails the extended CONNECT request with "invalid header field name
":protocol"". Building with -tags http2legacy restores x/net's own
client, and the example client then connects.
RFC 8441 section 5 drops Sec-WebSocket-Accept, and Kestrel, Jetty, axum,
libwebsockets and nghttpx send none, so Dial failed against them. An
accept that is present still has to match our key. The key is still
sent, because proxies such as Envoy forward it to HTTP/1.1 servers.
Chrome and Firefox send no Sec-WebSocket-Key on an HTTP/2 extended
CONNECT, as RFC 8441 section 5 specifies, so Accept rejected them with
400. A key that is present is still answered with Sec-WebSocket-Accept,
     because proxies such as Envoy forward an HTTP/1.1 client's key
     without generating the accept that client checks.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WebSockets over HTTP/2

7 participants