Bun.serve: add HTTP/2 server support via ALPN on the TLS listener

Closed
RO
3mo ago
+4828-383

Bun.serve({ tls, http2: true }) 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 and served over RFC 9113 framing with HPACK via lshpack. Everything else continues to speak HTTP/1.1 on the same port. Defaults to off. WebSocket over h2 is not supported.

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 Rust's AnyResponse/AnyRequest dispatch works unchanged and server/mod.rs 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>. H2App::create() installs an ALPN select cb on the parent SSLApp's root sslCtx and each per-SNI domainCtx (addServerName propagates it; sni_cb swaps ssl->ctx before BoringSSL reads alpn_select_cb, so every ctx needs a copy).
  • HttpContext<true>::onData<IsNodeHttp> 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, teardown deferred to sweep() so callers can touch the handle after end()/tryEnd() returns (matching H3's lsquic process_conns() deferral).
  • 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).

C ABI (src/uws_sys/libuwsockets_h2.cpp): uws_h2_* mirrors uws_h3_* 1:1.

Rust (src/uws_sys/h2.rs): mirrors h3.rs minus ListenSocket/listen (H2 shares the TCP listener). AnyResponse/AnyRequest/ResponseKind/UWSResponseKind/SinkID/StartTag/CtxTag/NativePromiseContext::Tag each gain an H2 arm.

MUX const-generic: the last const-generic on RequestContext / HTTPServerWritable / AnyRequestContext / NativePromiseContext changes from const HTTP3: bool to const MUX: u8 (MUX_H1=0, MUX_H2=1, MUX_H3=2). H2 and H3 share every stream-multiplexed semantic branch (connection-specific header filter, no transfer-encoding, end_without_body close flag, onAborted post-completion, TE→400) via IS_MUX = MUX != MUX_H1. Only concrete-type selection (AnyResponse/AnyRequest variant, FetchHeaders::create_from_*, callRoute*, sink name) branches on IS_H2 vs IS_H3.

server/mod.rs wires h2_app alongside h3_app: created against the parent SSLApp in listen() (no separate listen() call), routes mirrored in set_routes(), GOAWAY on graceful stop, force-close on abrupt stop via TemplatedApp::close() walking the H2 group like it walks WS groups, destroyed in deinit.

Tests

test/js/bun/http/serve-http2.test.ts (20 tests): basic GET, POST body + response headers echoed, HPACK header decode, user/static route mirroring, multi-frame >16KiB body with flow control, streamed ReadableStream response, multiplexed requests on one connection, server.requestIP(), HTTP/1.1 fallback on the same listener, trailers, SETTINGS/RST_STREAM/WINDOW_UPDATE protocol-error handling, stop(true) force-close, graceful stop, per-SNI ALPN, and option validation.

serve-http3.test.ts (48 tests) and node-http2.test.js (307 tests) still pass.

Relationship to #29962 and #33487

This supersedes closed PR #29962. The Http2*.h layer is ported forward from that PR with the onData<IsNodeHttp> template rebase and 11-field us_socket_vtable_t; the Rust side is new (that PR was Zig-era).

Open PR #33487 also installs SSL_CTX_set_alpn_select_cb (in us_ssl_ctx_build_raw). This PR's installH2Alpn installs on the already-built SSL_CTX; when #33487 lands, installH2Alpn wins because it runs second, and can be folded into the user-supplied protocol list.

Fixes #14672


[review] gate passed · iteration 2 · 44 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/bun/http/serve-http2.test.ts"
ninja: Entering directory `/workspace/bun/build/debug'
[1/167] gen JSSink.{cpp,h,lut.h,rs}
generated_jssink.rs: 6 sinks, 72 exported symbols
Generating /workspace/bun/build/debug/codegen/JSSink.lut.h from /workspace/bun/build/debug/codegen/JSSink.lut.txt
[2/167] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[3/167] gen cpp.rs (cppbind)
[4/167] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (13 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts

... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (4c11ece8f)

test/js/bun/http/serve-http2.test.ts:
(pass) Bun.serve http2: true > request headers decode via HPACK and reach the handler [45.26ms]
(pass) Bun.serve http2: true > HTTP/1.1 still works on the same listener when h2 is enabled [43.06ms]
(pass) Bun.serve http2: true > simple GET over ALPN h2 [48.07ms]
(pass) Bun.serve http2: true > multi-frame response body (>16KiB) with flow control [45.92ms]
(pass) Bun.serve http2: true > http2: true without tls throws [0.13ms]
(pass) Bun.serve http2: true > http2: true with http1: false throws [0.07ms]
(pass) Bun.serve http2: true > multiple requests multiplex on one TLS connection [45.19ms]
(pass) Bun.serve http2: true > server.requestIP(req) returns the peer's textual address [44.67ms]
(pass) Bun.serve http2: true > POST body + response headers echoed [47.49ms]
(pass) Bun.serve http2: true > streamed ReadableStream response arrives intact [51.31ms]
(pass) Bun.serve http2: true > user routes and static routes are mirrored onto the H2 router [52.14ms]
(pass) Bun.serve http2: true > request terminated by a trailer section delivers body to the handler [48.94ms]
(pass) Bun.serve http2: true > a fi
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/bun/http/serve-http2.test.ts"
bun test v1.4.0 (13805dd3b)

test/js/bun/http/serve-http2.test.ts:
(pass) Bun.serve http2: true > multi-frame response body (>16KiB) with flow control [559.72ms]
(pass) Bun.serve http2: true > request headers decode via HPACK and reach the handler [581.69ms]
(pass) Bun.serve http2: true > simple GET over ALPN h2 [632.90ms]
(pass) Bun.serve http2: true > POST body + response headers echoed [601.56ms]
(pass) Bun.serve http2: true > user routes and static routes are mirrored onto the H2 router [594.77ms]
(pass) Bun.serve http2: true > streamed ReadableStream response arrives intact [1004.25ms]
(pass) Bun.serve http2: true > multiple requests multiplex on one TLS connection [1013.63ms]
(pass) Bun.serve http2: true > HTTP/1.1 still works on the same listener when h2 is enabled [1008.44ms]
(pass) Bun.serve http2: true > server.requestIP(req) returns the peer's textual address [1021.48ms]
(pass) Bun.serve http2: true > request terminated by a trailer section delivers body to the handler [1033.26ms]
(pass
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 639ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/125] gen cpp.rs (cppbind)
[2/125] gen JSSink.{cpp,h,lut.h,rs}
generated_jssink.rs: 7 sinks, 84 exported symbols
Generating /workspace/bun/build/release/codegen/JSSink.lut.h from /workspace/bun/build/release/codegen/JSSink.lut.txt
[3/125] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[4/125] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (13 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRoute
... (truncated)
diff hotspot
packages/bun-types/serve.d.ts                      |   12 +
 packages/bun-uws/src/App.h                         |   26 +
 packages/bun-uws/src/Http2App.h                    |  349 ++++++
 packages/bun-uws/src/Http2Context.h                | 1118 ++++++++++++++++++++
 packages/bun-uws/src/Http2ContextData.h            |   23 +
 packages/bun-uws/src/Http2Request.h                |  121 +++
 packages/bun-uws/src/Http2Response.h               |  160 +++
 packages/bun-uws/src/Http2ResponseData.h           |  103 ++
 packages/bun-uws/src/HttpContext.h                 |   81 ++
 packages/bun-uws/src/HttpContextData.h             |   10 +
 src/codegen/generate-jssink.ts                     |    2 +
 src/jsc/FetchHeaders.rs                            |    6 +
 src/jsc/bindings/CookieMap.cpp                     |    4 +
 src/jsc/bindings/NativePromiseContext.h            |    2 +
 src/jsc/bindings/NodeHTTP.cpp                      |   57 +
 src/jsc/bindings/ServerRouteList.cpp               |   15 +
 src/jsc/bindings/Sink.h                            |    3 +-
 src/jsc/bindings/ZigGlobalObject.cpp               |   26 +
 src/jsc/bindings/ZigGlobalObject.h                 |   15 +-
 src/jsc/bindings/bindings.cpp                      |   26 +
 src/jsc/bindings/headers-handwritten.h             |    1 +
 src/jsc/bindings/headers.h                         |   30 +
 .../bindings/webcore/streams/BunStreamSource.cpp   |    1 +
 src/runtime/api/NativePromiseContext.rs            |  104 +-
 src/runtime/bake/DevServer.rs                      |    4 +-
 src/runtime/server/AnyRequestContext.rs            |   74 +-
 src/runtime/server/FileResponseStream.rs           |    4 +-
 src/runtime/server/FileRoute.rs                    |   17 +
 src/runtime/server/HTMLBundle.rs                   |    2 +-
 src/runtime/server/NodeHTTPResponse.rs             |    3 +
 src/runtime/server/RequestContext.rs               |  356 ++++---
 src/runtime/server/ServerConfig.rs                
... (truncated)

gate history · 3 passed · 0 rejected · iteration 2

evidence per changed file
file                                      reads  edits  tests
packages/bun-types/serve.d.ts                 0      0      0
packages/bun-uws/src/App.h                    0      0      0
packages/bun-uws/src/Http2App.h               3      4      0
packages/bun-uws/src/Http2Context.h           9     11      0
packages/bun-uws/src/Http2ContextData.h       0      0      0
packages/bun-uws/src/Http2Request.h           1      2      0
packages/bun-uws/src/Http2Response.h          2      2      0
packages/bun-uws/src/Http2ResponseData.h      1      1      0
packages/bun-uws/src/HttpContext.h            0      0      0
packages/bun-uws/src/HttpContextData.h        0      0      0
src/codegen/generate-jssink.ts                0      0      0
src/jsc/FetchHeaders.rs                       0      0      0
src/jsc/bindings/CookieMap.cpp                0      0      0
src/jsc/bindings/NativePromiseContext.h       0      0      0
src/jsc/bindings/NodeHTTP.cpp                 0      0      0
src/jsc/bindings/ServerRouteList.cpp          0      0      0
(+ 28 more files)