Add HTTP/2 server support to Bun.serve()
Bun.serve({ http2: true, tls: ... }) now offers h2,http/1.1 via ALPN on the existing TLS listener. Connections that negotiate h2 are adopted from the HTTP/1 socket group into an HTTP/2 group; everything else continues to speak HTTP/1.1 on the same port.
Bun.serve({
port: 0,
tls: { cert, key },
http2: true,
fetch: req => new Response("hello"),
});
How it's wired
The interface mirrors what http3: true already does, so Zig's AnyResponse/AnyRequest inline else dispatch works unchanged and server.zig doesn't need protocol-specific branches beyond construction.
uWebSockets (packages/bun-uws/src/Http2*.h)
H2App/Http2Context/Http2Response/Http2RequestmirrorHttp3*.Http2Contextis a heap struct with an embeddedus_socket_group_t+ statich2VTable, same shape asHttpContext<SSL>post-#29932.H2App::create()installs an ALPN select cb on the parent SSLApp's rootsslCtxand each per-SNIdomainCtx(addServerNamepropagates it to new ones —sni_cbswapsssl->ctxbefore BoringSSL readsalpn_select_cb, so every ctx needs a copy).HttpContext<true>::onDatachecks ALPN at the top of each read; onh2it destructs the H1 per-socket state,us_socket_adopts the socket into the H2 group (kind.dynamic→h2VTable), and dispatches the initial bytes (preface + client SETTINGS). TheSSL*stays on the socket so TLS decryption continues transparently.- Per-connection state (lshpack enc/dec, stream map, flow-control windows, frame-parse accumulator) lives in the adopted socket's ext.
Http2Responseis heap-allocated per stream. - Frame parser handles DATA / HEADERS / CONTINUATION / SETTINGS / WINDOW_UPDATE / PING / RST_STREAM / GOAWAY / PRIORITY per RFC 9113. HPACK via
lshpack(same library the fetch client uses). - Stream teardown is deferred to the connection's next event (matching how H3 defers to lsquic's
process_conns()), so Zig callers can touch the response handle afterend()/tryEnd()returns.
C ABI (src/uws_sys/libuwsockets_h2.cpp): uws_h2_* mirrors uws_h3_* 1:1.
Zig (src/uws_sys/h2.zig): mirrors h3.zig. AnyResponse/AnyRequest/ResponseKind/UWSResponseKind gain .H2/.h2 arms. RequestContext's transport parameter becomes an enum {h1,h2,h3}; the shared stream-multiplexed branches (body terminated by END_STREAM, connection-specific headers stripped, onAborted post-completion) are keyed on transport != .h1 so H2 and H3 share them.
server.zig wires h2_app alongside h3_app: created against the parent SSLApp (no separate listen()), routes mirrored in setRoutes, GOAWAY on graceful stop, force-close on abrupt stop via TemplatedApp::close() walking the H2 group like it walks WS groups, freed in deinit.
Rebases
onto #29932 (us_socket_context_t → embedded us_socket_group_t + static vtable + kind-byte dispatch):
Http2Contextwent from a reinterpret_cast overus_socket_context_tto a real heap struct withgroup{}+datamembers and staticonOpen/onData/… handlers inh2VTable— direct mirror ofHttpContext<SSL>'s new shape.- Adoption:
us_socket_context_adopt_socket→us_socket_adopt(s, &h2ctx->group, US_SOCKET_KIND_DYNAMIC, …).dynamicroutes throughgroup->vtable; the SSL* carried over ons->sslkeeps TLS decryption working. - Per-socket calls dropped the leading
sslarg (us_socket_is_closed(s),us_socket_ext(s), …). - ALPN/SNI: the SNI tree moved from the SSL socket context to the listen socket, and per-SNI
SSL_CTXis now created insideTemplatedApp::addServerName; theinstallH2Alpncall moved there. TemplatedApp::close():us_socket_context_close(h2ChildContext)→us_socket_group_close_all(&h2ctx->group).
onto the src/ restructure: src/deps/libuwsockets_h2.cpp → src/uws_sys/libuwsockets_h2.cpp, src/deps/uws/h2.zig → src/uws_sys/h2.zig, src/bun.js/api/server/ → src/runtime/server/, etc. No logic changes.
onto #30583 (h3/h1 → http3/http1): the option key and ServerConfig struct field are now http2 to match; internal transport tags (.h1/.h2/.h3) and ALPN wire strings stay as-is per #30583's convention.
All HTTP/2 protocol logic (framing, HPACK, flow control, stream lifecycle, JS-reentrancy hardening) carried over unchanged.
Verification
test/js/bun/http/serve-http2.test.ts exercises Bun's own HTTP/2 fetch client (protocol: "h2") and a raw-TLS wire-level client against Bun.serve({ http2: true }):
- simple GET over ALPN h2
- POST with 9 KB body echoed back (request DATA framing +
:authority→hostsynthesis) - request headers decoded via HPACK reach the handler
- user routes and static routes are mirrored onto the H2 router
- 256 KB response body (multi-frame + flow control)
- streamed
ReadableStreamresponse - 8 concurrent requests multiplexed on one TLS connection
server.requestIP(req)returns a textual address- HTTP/1.1 still works on the same listener (ALPN fallback)
- request terminated by a trailer section (gRPC-style) delivers body
- §6.9.2 window-delta overflow →
GOAWAY(FLOW_CONTROL_ERROR) - §6.5.2
SETTINGS_ENABLE_PUSHvalue ≠ 0/1 →GOAWAY(PROTOCOL_ERROR) - §5.1/§6.4 RST_STREAM / WINDOW_UPDATE on idle stream →
GOAWAY(PROTOCOL_ERROR); on closed stream → ignored - raised
SETTINGS_INITIAL_WINDOW_SIZEdrains backpressured streams - DATA after END_STREAM →
RST_STREAM(STREAM_CLOSED) server.stop(true)force-closes live H2 connections- graceful stop: in-flight response completes and the connection FINs
- ALPN offers h2 on per-SNI
SSL_CTX(tls.serverName) http2: truewithouttls/ withhttp1: falsethrows