Skip to content

Commit f135d3b

Browse files
committed
quic: add support for HTTP/3 datagrams
Signed-off-by: Tim Perry <pimterry@gmail.com>
1 parent ed33235 commit f135d3b

14 files changed

Lines changed: 625 additions & 85 deletions

File tree

doc/api/quic.md

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,29 @@ delivered out of order. The [`session.ondatagramstatus`][] callback reports
358358
whether each sent datagram was `'acknowledged'`, `'lost'`, or `'abandoned'`
359359
(never sent on the wire).
360360

361+
#### HTTP/3 datagrams
362+
363+
On HTTP/3 sessions, datagrams are associated with an individual request stream
364+
rather than with the session as a whole. Send and receive them per-stream with
365+
[`stream.sendDatagram()`][] and [`stream.ondatagram`][], passing and receiving
366+
your application payload directly; the protocol framing that binds a datagram to
367+
its stream is handled internally and never visible to your code. Both peers must
368+
set [`application.enableDatagrams`][] to `true`.
369+
370+
The session-level datagram API does not apply to HTTP/3 sessions:
371+
[`session.sendDatagram()`][] throws `ERR_INVALID_STATE` and
372+
[`session.ondatagram`][] never fires. (Only non-HTTP/3 ALPNs use the raw,
373+
session-level datagrams described above.)
374+
375+
Because datagrams are unordered with respect to stream data, a datagram is only
376+
delivered while its associated stream still exists on the receiver; one that
377+
arrives for an unknown or already-closed stream is dropped.
378+
379+
Under the hood, each HTTP/3 datagram ([RFC 9297][]) is carried in a QUIC
380+
`DATAGRAM` frame prefixed with a _Quarter Stream ID_ (the request stream's id
381+
divided by four) that binds it to the stream. This prefix is added and stripped
382+
automatically; most applications never need to think about it.
383+
361384
### 0-RTT early data and session resumption
362385

363386
QUIC supports 0-RTT early data, allowing a client that has previously connected
@@ -1114,7 +1137,12 @@ added: v23.8.0
11141137

11151138
* Type: {quic.OnDatagramCallback}
11161139

1117-
The callback to invoke when a new datagram is received from a remote peer. Read/write.
1140+
The callback to invoke when a new raw datagram is received from a remote peer.
1141+
Read/write.
1142+
1143+
This is only used for non-HTTP/3 sessions. On HTTP/3 sessions datagrams are
1144+
bound to a request stream and delivered via [`stream.ondatagram`][] instead;
1145+
this callback never fires. See [Datagrams][].
11181146

11191147
### `session.ondatagramstatus`
11201148

@@ -1384,9 +1412,13 @@ added: v23.8.0
13841412
**Default:** `'utf8'`.
13851413
* Returns: {Promise} for a {bigint} datagram ID.
13861414

1387-
Sends an unreliable datagram to the remote peer, returning a promise for
1415+
Sends an unreliable raw datagram to the remote peer, returning a promise for
13881416
the datagram ID.
13891417

1418+
This method is for non-HTTP/3 sessions only. On HTTP/3 sessions datagrams are
1419+
per-stream, so this method throws `ERR_INVALID_STATE`; use
1420+
[`stream.sendDatagram()`][] instead. See [Datagrams][].
1421+
13901422
If `datagram` is a string, it will be encoded using the specified `encoding`.
13911423

13921424
If `datagram` is an `ArrayBufferView`, the bytes are copied into an
@@ -1403,12 +1435,6 @@ inherently unreliable).
14031435
If the datagram payload is zero-length (empty string after encoding, detached
14041436
buffer, or zero-length view), `0n` is returned and no datagram is sent.
14051437

1406-
For HTTP/3 sessions, the peer must advertise `SETTINGS_H3_DATAGRAM=1`
1407-
(via `application: { enableDatagrams: true }`) for datagrams to be sent.
1408-
If the peer's setting is `0`, `sendDatagram()` returns `0n` (per RFC 9297
1409-
§3, an endpoint MUST NOT send HTTP Datagrams unless the peer indicated
1410-
support).
1411-
14121438
Datagrams cannot be fragmented — each must fit within a single QUIC packet.
14131439
The maximum datagram size is determined by the peer's
14141440
`maxDatagramFrameSize` transport parameter (which the peer advertises during
@@ -2012,6 +2038,20 @@ continue using the still-active direction on a bidirectional stream),
20122038
abort the other direction with [`writer.fail()`][], or tear down the
20132039
whole stream with [`stream.destroy()`][]. Read/write.
20142040

2041+
### `stream.ondatagram`
2042+
2043+
<!-- YAML
2044+
added: REPLACEME
2045+
-->
2046+
2047+
* Type: {quic.OnStreamDatagramCallback}
2048+
2049+
The callback invoked when a datagram associated with this stream is received.
2050+
It receives the datagram payload as a `Uint8Array` and a `boolean` indicating
2051+
whether it arrived as 0-RTT early data. Read/write.
2052+
2053+
Only applies to HTTP/3 sessions. See [Datagrams][].
2054+
20152055
### `stream.headers`
20162056

20172057
<!-- YAML
@@ -2146,6 +2186,29 @@ the [`stream.onwanttrailers`][] callback, or set ahead of time via
21462186
[`stream.pendingTrailers`][]. Throws `ERR_INVALID_STATE` if the session
21472187
does not support headers.
21482188

2189+
### `stream.sendDatagram(datagram[, encoding])`
2190+
2191+
<!-- YAML
2192+
added: REPLACEME
2193+
-->
2194+
2195+
* `datagram` {string|ArrayBufferView}
2196+
* `encoding` {string} The encoding to use if `datagram` is a string.
2197+
**Default:** `'utf8'`.
2198+
* Returns: {bigint} The datagram id, or `0n` if the datagram was not sent.
2199+
2200+
Sends an unreliable datagram associated with this stream to the peer, where it
2201+
is delivered to the corresponding stream's [`stream.ondatagram`][] callback. The
2202+
payload is sent as-is; the framing that binds it to the stream is handled
2203+
internally.
2204+
2205+
Delivery is best-effort: datagrams may be lost, reordered, or dropped. `0n` is
2206+
returned (and the payload silently discarded) if the datagram cannot be sent.
2207+
The delivery status of a sent datagram is reported via the session's
2208+
[`session.ondatagramstatus`][] callback.
2209+
2210+
Only applies to HTTP/3 sessions. See [Datagrams][].
2211+
21492212
### `stream.priority`
21502213

21512214
<!-- YAML
@@ -3745,6 +3808,16 @@ added: v23.8.0
37453808
* `this` {quic.QuicStream}
37463809
* `error` {any}
37473810

3811+
### Callback: `OnStreamDatagramCallback`
3812+
3813+
<!-- YAML
3814+
added: REPLACEME
3815+
-->
3816+
3817+
* `this` {quic.QuicStream}
3818+
* `datagram` {Uint8Array} The datagram payload.
3819+
* `early` {boolean} `true` if the datagram arrived as 0-RTT early data.
3820+
37483821
### Callback: `OnHeadersCallback`
37493822

37503823
<!-- YAML
@@ -4424,6 +4497,7 @@ throughput issues caused by flow control.
44244497
44254498
[Aborting a stream]: #aborting-a-stream
44264499
[Callback error handling]: #callback-error-handling
4500+
[Datagrams]: #datagrams
44274501
[JSON-SEQ]: https://www.rfc-editor.org/rfc/rfc7464
44284502
[NSS Key Log Format]: https://udn.realityripple.com/docs/Mozilla/Projects/NSS/Key_Log_Format
44294503
[Permission Model]: permissions.md#permission-model
@@ -4504,10 +4578,12 @@ throughput issues caused by flow control.
45044578
[`sessionOptions.token`]: #sessionoptionstoken-client-only
45054579
[`stream.destroy()`]: #streamdestroyerror-options
45064580
[`stream.headers`]: #streamheaders
4581+
[`stream.ondatagram`]: #streamondatagram
45074582
[`stream.onerror`]: #streamonerror
45084583
[`stream.onwanttrailers`]: #streamonwanttrailers
45094584
[`stream.pendingTrailers`]: #streampendingtrailers
45104585
[`stream.priority`]: #streampriority
4586+
[`stream.sendDatagram()`]: #streamsenddatagramdatagram-encoding
45114587
[`stream.sendHeaders()`]: #streamsendheadersheaders-options
45124588
[`stream.sendInformationalHeaders()`]: #streamsendinformationalheadersheaders
45134589
[`stream.sendTrailers()`]: #streamsendtrailersheaders

lib/internal/quic/quic.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,8 @@ const {
276276

277277
const kNilDatagramId = 0n;
278278

279+
const kApplicationTypeHttp3 = 2;
280+
279281
// Module-level registry of all live QuicEndpoint instances. Used by
280282
// connect() and listen() to find existing endpoints for reuse instead
281283
// of creating a new one per session.
@@ -945,6 +947,16 @@ setCallbacks({
945947
this[kOwner][kBlocked]();
946948
},
947949

950+
/**
951+
* Called when an HTTP/3 datagram (RFC 9297) is received for this stream.
952+
* @param {Uint8Array} uint8Array The datagram payload
953+
* @param {boolean} early True if received as 0-RTT early data.
954+
*/
955+
onStreamDatagram(uint8Array, early) {
956+
debug('stream datagram callback', this[kOwner]);
957+
this[kOwner][kDatagram](uint8Array, early);
958+
},
959+
948960
onStreamDrain() {
949961
// Called when the stream's outbound buffer has capacity for more data.
950962
debug('stream drain callback', this[kOwner]);
@@ -1571,6 +1583,7 @@ class QuicStream {
15711583
onerror: undefined,
15721584
onblocked: undefined,
15731585
onreset: undefined,
1586+
ondatagram: undefined,
15741587
onheaders: undefined,
15751588
ontrailers: undefined,
15761589
oninfo: undefined,
@@ -1776,6 +1789,29 @@ class QuicStream {
17761789
}
17771790
}
17781791

1792+
/**
1793+
* The callback invoked when an HTTP/3 datagram (RFC 9297) associated with
1794+
* this stream is received. Read/write.
1795+
* @type {OnStreamDatagramCallback}
1796+
*/
1797+
get ondatagram() {
1798+
assertIsQuicStream(this);
1799+
return this.#inner.ondatagram;
1800+
}
1801+
1802+
set ondatagram(fn) {
1803+
assertIsQuicStream(this);
1804+
const inner = this.#inner;
1805+
if (fn === undefined) {
1806+
inner.ondatagram = undefined;
1807+
inner.state.wantsDatagram = false;
1808+
} else {
1809+
validateFunction(fn, 'ondatagram');
1810+
inner.ondatagram = FunctionPrototypeBind(fn, this);
1811+
inner.state.wantsDatagram = true;
1812+
}
1813+
}
1814+
17791815
/** @type {OnHeadersCallback} */
17801816
get onheaders() {
17811817
assertIsQuicStream(this);
@@ -2429,6 +2465,48 @@ class QuicStream {
24292465
this.#handle.resetStream(BigInt(code));
24302466
}
24312467

2468+
/**
2469+
* Sends an HTTP/3 datagram (RFC 9297) associated with this stream. The
2470+
* payload is framed with the stream's Quarter Stream ID by the C++ layer
2471+
* before being queued for transmission.
2472+
*
2473+
* Datagrams are unreliable: they may be lost, reordered, or dropped if too
2474+
* large or if datagrams were not negotiated. In any such case `0n` is
2475+
* returned and the payload is silently discarded. Otherwise the underlying
2476+
* QUIC datagram id is returned.
2477+
* @param {ArrayBufferView|string} datagram The datagram payload.
2478+
* @param {string} [encoding] The encoding to use if datagram is a string.
2479+
* @returns {bigint} The datagram id, or `0n` if it was not sent.
2480+
*/
2481+
sendDatagram(datagram, encoding = 'utf8') {
2482+
assertIsQuicStream(this);
2483+
if (this.destroyed || this.pending) return kNilDatagramId;
2484+
2485+
// Datagrams are gated by the session's negotiated max datagram size.
2486+
// A value of 0 means datagrams are disabled (transport or, for HTTP/3,
2487+
// the peer's SETTINGS_H3_DATAGRAM). The C++ layer enforces the exact
2488+
// limit including the Quarter Stream ID prefix.
2489+
if (getQuicSessionState(this.#inner.session).maxDatagramSize === 0) {
2490+
return kNilDatagramId;
2491+
}
2492+
2493+
if (typeof datagram === 'string') {
2494+
datagram = new Uint8Array(Buffer.from(datagram, encoding));
2495+
} else if (!isArrayBufferView(datagram)) {
2496+
throw new ERR_INVALID_ARG_TYPE('datagram',
2497+
['ArrayBufferView', 'string'],
2498+
datagram);
2499+
}
2500+
2501+
const length = isDataView(datagram) ?
2502+
DataViewPrototypeGetByteLength(datagram) :
2503+
TypedArrayPrototypeGetByteLength(datagram);
2504+
2505+
const id = this.#handle.sendDatagram(datagram);
2506+
debug(`stream datagram ${id} sent with ${length} bytes`);
2507+
return id;
2508+
}
2509+
24322510
/**
24332511
* The priority of the stream. If the stream is destroyed or if
24342512
* the session does not support priority, `null` will be
@@ -2538,6 +2616,7 @@ class QuicStream {
25382616
inner.pendingClose.resolve = undefined;
25392617
inner.onblocked = undefined;
25402618
inner.onreset = undefined;
2619+
inner.ondatagram = undefined;
25412620
inner.onheaders = undefined;
25422621
inner.onerror = undefined;
25432622
inner.ontrailers = undefined;
@@ -2591,6 +2670,14 @@ class QuicStream {
25912670
safeCallbackInvoke(inner.onreset, this, error);
25922671
}
25932672

2673+
[kDatagram](u8, early) {
2674+
const inner = this.#inner;
2675+
assert(typeof inner.ondatagram === 'function',
2676+
'Unexpected stream datagram event');
2677+
if (inner.session === undefined) return;
2678+
safeCallbackInvoke(inner.ondatagram, this, u8, early);
2679+
}
2680+
25942681
[kHeaders](headers, kind) {
25952682
const block = parseHeaderPairs(headers);
25962683
const kindName = kHeadersKindName[kind] ?? kind;
@@ -3359,6 +3446,13 @@ class QuicSession {
33593446
throw new ERR_INVALID_STATE('Session is closed');
33603447
}
33613448

3449+
// HTTP/3 sessions must use HTTP/3 datagrams
3450+
if (this.#inner.state.applicationType === kApplicationTypeHttp3) {
3451+
throw new ERR_INVALID_STATE(
3452+
'Raw datagrams are not supported on HTTP/3 sessions; ' +
3453+
'use stream.sendDatagram() instead');
3454+
}
3455+
33623456
const maxDatagramSize = this.#inner.state.maxDatagramSize;
33633457

33643458
// The peer max datagram size is either unknown or they have explicitly

lib/internal/quic/state.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ const {
102102
IDX_STATE_STREAM_WANTS_HEADERS,
103103
IDX_STATE_STREAM_WANTS_RESET,
104104
IDX_STATE_STREAM_WANTS_TRAILERS,
105+
IDX_STATE_STREAM_WANTS_DATAGRAM,
105106
IDX_STATE_STREAM_RECEIVED_EARLY_DATA,
106107
IDX_STATE_STREAM_WRITE_DESIRED_SIZE,
107108
IDX_STATE_STREAM_HIGH_WATER_MARK,
@@ -143,6 +144,7 @@ assert(IDX_STATE_STREAM_WANTS_BLOCK !== undefined);
143144
assert(IDX_STATE_STREAM_WANTS_HEADERS !== undefined);
144145
assert(IDX_STATE_STREAM_WANTS_RESET !== undefined);
145146
assert(IDX_STATE_STREAM_WANTS_TRAILERS !== undefined);
147+
assert(IDX_STATE_STREAM_WANTS_DATAGRAM !== undefined);
146148
assert(IDX_STATE_STREAM_WRITE_DESIRED_SIZE !== undefined);
147149
assert(IDX_STATE_STREAM_RESET_CODE !== undefined);
148150

@@ -798,6 +800,20 @@ class QuicStreamState {
798800
DataViewPrototypeSetUint8(handle, this.#offset + IDX_STATE_STREAM_WANTS_BLOCK, val ? 1 : 0);
799801
}
800802

803+
/** @type {boolean} */
804+
get wantsDatagram() {
805+
const handle = this.#handle;
806+
if (handle === undefined) return undefined;
807+
return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_STREAM_WANTS_DATAGRAM) !== 0;
808+
}
809+
810+
/** @type {boolean} */
811+
set wantsDatagram(val) {
812+
const handle = this.#handle;
813+
if (handle === undefined) return;
814+
DataViewPrototypeSetUint8(handle, this.#offset + IDX_STATE_STREAM_WANTS_DATAGRAM, val ? 1 : 0);
815+
}
816+
801817
/** @type {boolean} */
802818
get wantsHeaders() {
803819
const handle = this.#handle;
@@ -905,6 +921,7 @@ class QuicStreamState {
905921
wantsReset,
906922
wantsHeaders,
907923
wantsTrailers,
924+
wantsDatagram,
908925
early,
909926
resetCode,
910927
writeDesiredSize,
@@ -925,6 +942,7 @@ class QuicStreamState {
925942
wantsReset,
926943
wantsHeaders,
927944
wantsTrailers,
945+
wantsDatagram,
928946
early,
929947
resetCode,
930948
writeDesiredSize,
@@ -961,6 +979,7 @@ class QuicStreamState {
961979
wantsReset,
962980
wantsHeaders,
963981
wantsTrailers,
982+
wantsDatagram,
964983
early,
965984
resetCode,
966985
writeDesiredSize,
@@ -981,6 +1000,7 @@ class QuicStreamState {
9811000
wantsReset,
9821001
wantsHeaders,
9831002
wantsTrailers,
1003+
wantsDatagram,
9841004
early,
9851005
resetCode,
9861006
writeDesiredSize,

src/quic/application.cc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,15 @@ bool Session::Application::AcknowledgeStreamData(stream_id id, size_t datalen) {
203203
return true;
204204
}
205205

206+
void Session::Application::ReceiveDatagram(
207+
const uint8_t* data,
208+
size_t datalen,
209+
const Session::DatagramReceivedFlags& flags) {
210+
// Raw QUIC applications have no datagram framing: deliver the payload
211+
// verbatim to the session-level ondatagram handler.
212+
session().DeliverRawDatagram(data, datalen, flags);
213+
}
214+
206215
void Session::Application::CollectSessionTicketAppData(
207216
SessionTicket::AppData* app_data) const {
208217
// By default, write just the application type byte.

0 commit comments

Comments
 (0)