Add HTTP/2 server support to Bun.serve()

Closed
RO
4mo ago
+3705-78

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 / Http2Request mirror Http3*.
  • Http2Context is a heap struct with an embedded us_socket_group_t + static h2VTable, same shape as HttpContext<SSL> post-#29932. H2App::create() installs an ALPN select cb on the parent SSLApp's root sslCtx and each per-SNI domainCtx (addServerName propagates it to new ones — sni_cb swaps ssl->ctx before BoringSSL reads alpn_select_cb, so every ctx needs a copy).
  • HttpContext<true>::onData checks ALPN at the top of each read; on h2 it 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). The SSL* 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. Http2Response is 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 after end()/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):

  • Http2Context went from a reinterpret_cast over us_socket_context_t to a real heap struct with group{} + data members and static onOpen/onData/… handlers in h2VTable — direct mirror of HttpContext<SSL>'s new shape.
  • Adoption: us_socket_context_adopt_socket → us_socket_adopt(s, &h2ctx->group, US_SOCKET_KIND_DYNAMIC, …). dynamic routes through group->vtable; the SSL* carried over on s->ssl keeps TLS decryption working.
  • Per-socket calls dropped the leading ssl arg (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_CTX is now created inside TemplatedApp::addServerName; the installH2Alpn call 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 → host synthesis)
  • 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 ReadableStream response
  • 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_PUSH value ≠ 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_SIZE drains 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: true without tls / with http1: false throws