Split HTTP/3 module out from QUIC#63995
Conversation
Signed-off-by: Tim Perry <pimterry@gmail.com>
|
Review requested:
|
This ensures we don't fire session events for totally invalid TLS handshakes - fundamental errors, bad SNI/ALPN values, or anything else that our TLS config would reject. Instead, it means servers can access servername & alpnProtocol synchronously as soon as the event is fired - all key session data is available and it's immediately usable. We don't want to defer further to handshake completed, since that'd be an extra RT, and defeat 0RTT benefits entirely. ClientHello processed without errors is sufficient for now. This isn't a security mechanism. Existing structures will defer actually sending & receiving anything that's not marked explicitly as early data until the handshake completes.
node:quic no longer requires an Application. When none is set, the session schedules streams on its own send queue and pulls/commits their data directly. This lets us drop DefaultApplication, to support the imminent HTTP 3 app changes coming. The Session also gains the data-plane dispatchers (GetStreamData/StreamCommit/ReceiveStream*/ScheduleStream) that route to an installed application of present or a native path otherwise, with the ngtcp2 callbacks guarding on whether an application is installed. HTTP/3 is unchanged (for now).
Move nghttp3 integration, and header/priority/settings/datagram-framing logic out of node:quic core into node:http3, leaving node:quic as a pure transport-only layer.
jasnell
left a comment
There was a problem hiding this comment.
I'm firmly -1 on this for the time being. When designig the API explicitly did NOT want Yet Another Top Level HTTP module to add to our already overly complicated node:http and node:http2 split.
What we've discussed in the past is a new, version-agnostic, unified API model that allows for all three in a single interface. Let's call it node:httpx for the sake of discussion.
This node:httpx would sit on top of the node:quic when http3 is used rather than replacing it or fully splitting it out. It really should not need to extend to the C++ layer at all and it should be possible to layer on the quic JS API.
That is entirely possible with the implementation here - it's just slower. We could drop the HTTP/3 module completely from this PR, and userspace could implement it instead. It's not possible however to do anything like this with the implementation on
For the client side, this is Undici I think, we agreed at the Paris summit to aim for that to eventually become the primary Node HTTP client API. For the server side, I know @marco-ippolito has a rough outline of an I can see a world where we expose That said, we still need per-protocol APIs. There are many low-level cases where this is very useful, there are substantial differences and sometimes you care about them. And the QUIC module here does already expose HTTP/3, and I agree it should.
Would you prefer this if it was inside the Design details aside, dropping the top-level module part is fine by me if that's what people prefer. I had thought separating them would be better, but that's not the core goal: from my POV the key point is just that QuicSession & QuicStream should be fully separate from Http3Session & Http3Stream, for all the reasons in the description here. As long as that happens, I'm happy, I don't much care where they live. |
This PR creates a new
node:http3module, extracting the HTTP/3 parts ofnode:quicthere, and making a few surgical additional changes to the APIs around that to make that effective. This exposes an API very similar to the current one for HTTP/3, but makes pure QUIC much simpler, and offers more flexibility to separate the two.It's a large change! But the substantial majority is just moving code around (5.6k additions, 4.7k deletions). This doesn't drastically change the internal implementation details, it's mostly about modifying the structure to significantly change the user-facing API & behaviour on top.
I've tried to break it down by commits relatively cleanly, so that reviewing commit-by-commit might be helpful, up to you. There's been a fair bit of Claude's hands in here to cover this much ground, but I've been through every line myself quite a few times as well.
Background
QUIC & HTTP/3 are obviously linked but separate protocols. The current API mixes them together:
h3ALPN is ever negotiated within QUIC, we automatically do the whole HTTP/3 dance for you and return an HTTP/3 session that behaves quite differently to pure QUIC, with no configuration or opt-out.quicStream.sendHeaders()orsetPriority()- both HTTP/3 only features.h3Stream.sendDatagram()sent QUIC datagrams, which aren't valid HTTP/3 datagrams, and you can't just read & write arbitrary data to an HTTP/3 stream - you need headers etc.This doesn't match the rest of our API (TLSSockets do not automatically turn into HTTP requests if they match the right ALPN). It is a fairly convenient API for purely HTTP/3 use, but it's awkward for many edge cases and for low-level control, and it's going to be increasingly limiting, as we do future development and want to add more QUIC-only or HTTP/3-only options & features, since all QUIC or HTTP/3 changes will directly affect the API used by the other.
I think we can fix it fairly cleanly so I've taken a shot at it. Demo (bring your own key/cert):
Core Changes
To do this, I've made a few core changes:
node:http3module, behind the same--experimental-quicflag.session.alpnProtocoland act appropriately, or you just set a single ALPN value in your options and then you always know what you're getting).Http3SessionandHttp3Streamclasses, which wrap QUIC streams and expose HTTP/3 APIs, with no direct dependency on ngtcp2.createBidirectionalStream()is nowrequest()(much clearer imo) and for servers you now setonheaderson the stream, not the entire session (to simplify the API and match HTTP/2).options.settingsandonsettings(extractingonapplicationfrom QUIC etc).listen()andconnect()fromnode:http3directly - very similar API to today, but using sensible HTTP/3 defaults, and producing Http3Sessions (and Http3Streams) not QuicSessions with a pure HTTP API.new Http3Session(quicSession). This is only allowed before the session is 'active' - on the server, that means synchronously in the tick whensessionis emitted, and on the client it means beforeopenedresolves. This is intended to allow dynamic application configuration in any way you like - by ALPN, SNI, remote address, you name it. The client use case here is more limited than the server one, but that API only supports single-ALPN right now anyway, and I have a follow-up plan to expand that side later.new Http3Session(quicSession)useful, since right now when the session event fires the handshake hasn't been processed at all, so you can't readalpnProtocolorservernamewhich are clearly useful.alpnProtocol/servernameand all other details immediately (you could read those before by waiting foropened, but that does mean waiting a full round-trip for the handshake to complete - in practice, adding this delay actually reduces time to access a usable session).sessionevent.Bonus improvements
There's a few other nice changes that fall out of this en route:
Custom app ticket data, for 0-RTT without HTTP/3. If you set the
appTicketDataoption to a fixed value (HTTP/3 settings equivalent) it will be included in server tickets, validated in resumption (exact match for now) and matching sessions can do 0RTT just like HTTP/3, if enabled. Generic version of HTTP/3's settings tokens & validation. Mutually exclusive withHttp3Sessionetc - only for other QUIC protocols. Previous 0RTT was only usable if you have no server settings to validate before resume.Dropping datagrams from the HTTP/3 API. The existing functionality was pure QUIC and would not work as expected. Now we have separate APIs it's easy to not expose this for HTTP/3 yet. We'll add this back with proper HTTP/3 support shortly, once some new changes land in nghttp3: quic: http/3 datagrams partially diverge from spec #63891.
HTTP/3 priority didn't work correctly:
request({ priority })applied priority withnghttp3_conn_set_client_stream_prioritybefore submitting the request. This was silently returning anNGHTTP3_ERR_STREAM_NOT_FOUNDerror, and the priority was ignored. This nghttp3 API is intended for sending PRIORITY_UPDATE frames, not setting the initial priority of a stream. We now do that correctly instead (sending it in the headers).SendPendingDatain thenwrite == 0branch (congestion or nothing to send) would never reachTryWritePendingDatagram, so on idle connections datagrams would be queued but never sent. We now check for these specifically.ondatagramset before the stream had an id was never successfully registeredHeader validation in
createBidirectionalStream(nowrequest()) ran after the stream was created, so invalid headers leaked the stream. We now validate up front.Assorted small performance improvements (caching to avoid repeated FindStream lookups, using a single timestamp for each batch) that popped up en route, such that request-per-second for HTTP/3 here is actually fractionally faster than before in my benchmarks.
Deferrals
There's plenty of other things I'd like to do here, but this is already quite large, I'm trying to pull other changes into separate PRs for easier management & review.
As noted above, HTTP/3 datagrams aren't yet in and late-attach for clients is a bit limited, also it'd be nice to have separate HTTP/3 server/client session & stream APIs (like HTTP/3, there's various other small details of the HTTP/3 API that definitely could be refined, appTicketData could be more flexible, there's assorted small doc nits and bugs I've found but I don't want to bundle into this, etc.
I'd really like to keep this focused and break as much of that out into separate future PRs. Once this big refactor is in, a lot of that work can happen in parallel! Definitely happy to debate the core change & shape of this (it is a bit bold, let's discuss) but for nits & feedback on smaller details or possible extras, I'd like to lean towards building out future PRs on top if that's possible.