Skip to content

Split HTTP/3 module out from QUIC#63995

Open
pimterry wants to merge 15 commits into
nodejs:mainfrom
pimterry:http3
Open

Split HTTP/3 module out from QUIC#63995
pimterry wants to merge 15 commits into
nodejs:mainfrom
pimterry:http3

Conversation

@pimterry

Copy link
Copy Markdown
Member

This PR creates a new node:http3 module, extracting the HTTP/3 parts of node:quic there, 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:

  • All QUIC clients & servers default to HTTP/3 ALPN.
  • If an h3 ALPN 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.
  • Pure QUIC streams have various HTTP/3 behaviours, options & methods that are ignored/unusable/broken for pure QUIC, e.g. quicStream.sendHeaders() or setPriority() - both HTTP/3 only features.
  • HTTP/3 streams have some QUIC behaviours, options & methods that are ignored/unusable/broken for HTTP/3, e.g. 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):

import * as http3 from 'node:http3';
import { bytes } from 'stream/iter';

// Server:
const endpoint = await http3.listen((session) => {
  session.onstream = (stream) => {
    stream.onheaders = (headers) => {
      stream.sendHeaders({ ':status': '200', 'content-type': 'text/plain' });
      stream.writer.writeSync(`hello h3 — you asked for ${headers[':path']}`);
      stream.writer.endSync();
    };
  };
}, { sni: { '*': { keys: [key], certs: [cert] } } });

// Client:
const session = await http3.connect(endpoint.address, {
  servername: 'localhost',
  verifyPeer: 'manual',
});
await session.opened;

const stream = await session.request({
  ':method': 'GET', ':path': '/hello', ':scheme': 'https', ':authority': 'localhost',
}, { onheaders: (h) => console.log('status:', h[':status']) });

console.log('body:', new TextDecoder().decode(await bytes(stream)));

await session.close();
await endpoint.close();

Core Changes

To do this, I've made a few core changes:

  • Created a brand new node:http3 module, behind the same --experimental-quic flag.
  • Removed default ALPN from QUIC, and dropped the automatic ALPN-based behaviour. If you use QUIC directly, you handle ALPN yourself (either read session.alpnProtocol and act appropriately, or you just set a single ALPN value in your options and then you always know what you're getting).
  • Dropped all HTTP/3 options & methods from QUIC servers, sessions & streams.
  • The whole Application structure is shrunk significantly overall. The Application interface is now pure QUIC, not HTTP related, Applications are now optional, and DefaultApplication is gone. Logic has moved into the QUIC Session, Http3Application, a new Http3Binding (JS binding for streams), and the rest of the HTTP/3 API. Applications are now pure integration points for built-in implementations to integrate with QUIC internals.
  • Created new Http3Session and Http3Stream classes, which wrap QUIC streams and expose HTTP/3 APIs, with no direct dependency on ngtcp2.
    • Relatively close to the QUIC equivalent, but some notable changes: for clients, createBidirectionalStream() is now request() (much clearer imo) and for servers you now set onheaders on the stream, not the entire session (to simplify the API and match HTTP/2).
    • HTTP/3 settings are now exposed here explicitly, via options.settings and onsettings (extracting onapplication from QUIC etc).
  • Created two ways to use these HTTP/3 classes:
    • Use listen() and connect() from node:http3 directly - very similar API to today, but using sensible HTTP/3 defaults, and producing Http3Sessions (and Http3Streams) not QuicSessions with a pure HTTP API.
    • Use QUIC directly to get a session, and then call new Http3Session(quicSession). This is only allowed before the session is 'active' - on the server, that means synchronously in the tick when session is emitted, and on the client it means before opened resolves. 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.
  • Changed the server onsession callback to fire at the end of ClientHello parsing, not the start.
    • This is necessary to make the new Http3Session(quicSession) useful, since right now when the session event fires the handshake hasn't been processed at all, so you can't read alpnProtocol or servername which are clearly useful.
    • This is still 0-RTT, but by waiting until slightly later in OpenSSL's process we also avoid exposing invalid sessions (wrong SNI/unacceptable ALPN/completely corrupted entirely) and ensure that servers can always read alpnProtocol/servername and all other details immediately (you could read those before by waiting for opened, 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).
    • To ensure events emitted during ClientHello processing (0RTT streams) aren't missed, we defer emits automatically until immediately after the session event.

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 appTicketData option 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 with Http3Session etc - 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 with nghttp3_conn_set_client_stream_priority before submitting the request. This was silently returning an NGHTTP3_ERR_STREAM_NOT_FOUND error, 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).

  • SendPendingData in the nwrite == 0 branch (congestion or nothing to send) would never reach TryWritePendingDatagram, so on idle connections datagrams would be queued but never sent. We now check for these specifically.

  • ondatagram set before the stream had an id was never successfully registered

  • Header validation in createBidirectionalStream (now request()) 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.

Signed-off-by: Tim Perry <pimterry@gmail.com>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/loaders
  • @nodejs/quic
  • @nodejs/startup

@nodejs-github-bot nodejs-github-bot added lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run. labels Jun 18, 2026
pimterry added 14 commits June 19, 2026 01:16
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 jasnell 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.

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.

@pimterry

Copy link
Copy Markdown
Member Author

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 main today. Because HTTP/3 is welded into the ALPN negotiation, you can't replace it with any other version from JS. That's one of the reasons I'd like to change this.

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.

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 http/web module that's exploring this direction.

I can see a world where we expose http/send (Undici) and http/send (httpx generic server API) modules that do protocol-generic HTTP. That'd be great.

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.

I'm firmly -1 on this for the time being.

Would you prefer this if it was inside the quic module, so there's no top-level http3 module? The PR could be easily changed to instead have node:quic expose listenQuic() and listenHttp3() directly (names to bikeshed, lots of ways to split this) and then everything else here would work as is.

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.

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

Labels

lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants