@norskvideo/moq-net 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/announced.d.ts +111 -21
- package/announced.d.ts.map +1 -1
- package/announced.js +213 -40
- package/announced.js.map +1 -1
- package/broadcast.d.ts +80 -25
- package/broadcast.d.ts.map +1 -1
- package/broadcast.js +270 -49
- package/broadcast.js.map +1 -1
- package/connection/accept.d.ts +6 -0
- package/connection/accept.d.ts.map +1 -1
- package/connection/accept.js +42 -13
- package/connection/accept.js.map +1 -1
- package/connection/browser.d.ts +5 -0
- package/connection/browser.d.ts.map +1 -0
- package/connection/browser.js +31 -0
- package/connection/browser.js.map +1 -0
- package/connection/connect.d.ts +43 -1
- package/connection/connect.d.ts.map +1 -1
- package/connection/connect.js +118 -123
- package/connection/connect.js.map +1 -1
- package/connection/established.d.ts +43 -16
- package/connection/established.d.ts.map +1 -1
- package/connection/established.js.map +1 -1
- package/connection/handshake.d.ts +12 -1
- package/connection/handshake.d.ts.map +1 -1
- package/connection/handshake.js +30 -14
- package/connection/handshake.js.map +1 -1
- package/connection/index.d.ts +8 -0
- package/connection/index.d.ts.map +1 -1
- package/connection/index.js +6 -0
- package/connection/index.js.map +1 -1
- package/connection/reload.d.ts +66 -9
- package/connection/reload.d.ts.map +1 -1
- package/connection/reload.js +192 -66
- package/connection/reload.js.map +1 -1
- package/connection/stats.d.ts +92 -0
- package/connection/stats.d.ts.map +1 -0
- package/connection/stats.js +35 -0
- package/connection/stats.js.map +1 -0
- package/connection/transport.d.ts +10 -0
- package/connection/transport.d.ts.map +1 -0
- package/connection/transport.js +12 -0
- package/connection/transport.js.map +1 -0
- package/consume.d.ts +44 -0
- package/consume.d.ts.map +1 -0
- package/consume.js +60 -0
- package/consume.js.map +1 -0
- package/datagram.d.ts +23 -0
- package/datagram.d.ts.map +1 -0
- package/datagram.js +3 -0
- package/datagram.js.map +1 -0
- package/error.d.ts +76 -0
- package/error.d.ts.map +1 -0
- package/error.js +107 -0
- package/error.js.map +1 -0
- package/group.d.ts +143 -38
- package/group.d.ts.map +1 -1
- package/group.js +270 -68
- package/group.js.map +1 -1
- package/ietf/adapter.d.ts +2 -2
- package/ietf/adapter.d.ts.map +1 -1
- package/ietf/adapter.js +20 -10
- package/ietf/adapter.js.map +1 -1
- package/ietf/aliases.d.ts +73 -5
- package/ietf/aliases.d.ts.map +1 -1
- package/ietf/aliases.js +121 -14
- package/ietf/aliases.js.map +1 -1
- package/ietf/cluster.d.ts +141 -0
- package/ietf/cluster.d.ts.map +1 -0
- package/ietf/cluster.js +204 -0
- package/ietf/cluster.js.map +1 -0
- package/ietf/connection.d.ts +39 -6
- package/ietf/connection.d.ts.map +1 -1
- package/ietf/connection.js +73 -12
- package/ietf/connection.js.map +1 -1
- package/ietf/fetch.d.ts +17 -0
- package/ietf/fetch.d.ts.map +1 -1
- package/ietf/fetch.js +21 -0
- package/ietf/fetch.js.map +1 -1
- package/ietf/filter.d.ts +87 -0
- package/ietf/filter.d.ts.map +1 -0
- package/ietf/filter.js +292 -0
- package/ietf/filter.js.map +1 -0
- package/ietf/index.d.ts +2 -0
- package/ietf/index.d.ts.map +1 -1
- package/ietf/index.js +3 -0
- package/ietf/index.js.map +1 -1
- package/ietf/namespace.d.ts +6 -0
- package/ietf/namespace.d.ts.map +1 -1
- package/ietf/namespace.js +27 -2
- package/ietf/namespace.js.map +1 -1
- package/ietf/object.d.ts +54 -6
- package/ietf/object.d.ts.map +1 -1
- package/ietf/object.js +178 -25
- package/ietf/object.js.map +1 -1
- package/ietf/parameters.d.ts +35 -10
- package/ietf/parameters.d.ts.map +1 -1
- package/ietf/parameters.js +236 -45
- package/ietf/parameters.js.map +1 -1
- package/ietf/priority.d.ts +5 -0
- package/ietf/priority.d.ts.map +1 -0
- package/ietf/priority.js +10 -0
- package/ietf/priority.js.map +1 -0
- package/ietf/properties.d.ts +13 -2
- package/ietf/properties.d.ts.map +1 -1
- package/ietf/properties.js +65 -8
- package/ietf/properties.js.map +1 -1
- package/ietf/publish.d.ts.map +1 -1
- package/ietf/publish.js +13 -3
- package/ietf/publish.js.map +1 -1
- package/ietf/publish_namespace.d.ts +16 -2
- package/ietf/publish_namespace.d.ts.map +1 -1
- package/ietf/publish_namespace.js +25 -6
- package/ietf/publish_namespace.js.map +1 -1
- package/ietf/publisher.d.ts +40 -7
- package/ietf/publisher.d.ts.map +1 -1
- package/ietf/publisher.js +751 -134
- package/ietf/publisher.js.map +1 -1
- package/ietf/request.js +1 -1
- package/ietf/request.js.map +1 -1
- package/ietf/solicit.d.ts +41 -0
- package/ietf/solicit.d.ts.map +1 -0
- package/ietf/solicit.js +47 -0
- package/ietf/solicit.js.map +1 -0
- package/ietf/subscribe.d.ts +41 -2
- package/ietf/subscribe.d.ts.map +1 -1
- package/ietf/subscribe.js +103 -22
- package/ietf/subscribe.js.map +1 -1
- package/ietf/subscribe_namespace.d.ts +21 -7
- package/ietf/subscribe_namespace.d.ts.map +1 -1
- package/ietf/subscribe_namespace.js +31 -14
- package/ietf/subscribe_namespace.js.map +1 -1
- package/ietf/subscriber.d.ts +20 -7
- package/ietf/subscriber.d.ts.map +1 -1
- package/ietf/subscriber.js +512 -142
- package/ietf/subscriber.js.map +1 -1
- package/ietf/version.d.ts +7 -1
- package/ietf/version.d.ts.map +1 -1
- package/ietf/version.js +7 -0
- package/ietf/version.js.map +1 -1
- package/index.d.ts +10 -5
- package/index.d.ts.map +1 -1
- package/index.js +10 -5
- package/index.js.map +1 -1
- package/internal.d.ts +29 -0
- package/internal.d.ts.map +1 -0
- package/internal.js +8 -0
- package/internal.js.map +1 -0
- package/lite/announce.d.ts +62 -15
- package/lite/announce.d.ts.map +1 -1
- package/lite/announce.js +230 -94
- package/lite/announce.js.map +1 -1
- package/lite/connection.d.ts +69 -20
- package/lite/connection.d.ts.map +1 -1
- package/lite/connection.js +128 -81
- package/lite/connection.js.map +1 -1
- package/lite/datagram.d.ts +23 -0
- package/lite/datagram.d.ts.map +1 -0
- package/lite/datagram.js +57 -0
- package/lite/datagram.js.map +1 -0
- package/lite/datagram_stream.d.ts +8 -0
- package/lite/datagram_stream.d.ts.map +1 -0
- package/lite/datagram_stream.js +44 -0
- package/lite/datagram_stream.js.map +1 -0
- package/lite/fetch.d.ts.map +1 -1
- package/lite/fetch.js +2 -2
- package/lite/fetch.js.map +1 -1
- package/lite/index.d.ts +3 -0
- package/lite/index.d.ts.map +1 -1
- package/lite/index.js +3 -0
- package/lite/index.js.map +1 -1
- package/lite/priority.d.ts +67 -0
- package/lite/priority.d.ts.map +1 -0
- package/lite/priority.js +114 -0
- package/lite/priority.js.map +1 -0
- package/lite/probe.d.ts +18 -2
- package/lite/probe.d.ts.map +1 -1
- package/lite/probe.js +24 -22
- package/lite/probe.js.map +1 -1
- package/lite/publisher.d.ts +14 -7
- package/lite/publisher.d.ts.map +1 -1
- package/lite/publisher.js +406 -84
- package/lite/publisher.js.map +1 -1
- package/lite/setup.d.ts +83 -6
- package/lite/setup.d.ts.map +1 -1
- package/lite/setup.js +203 -31
- package/lite/setup.js.map +1 -1
- package/lite/stream.d.ts +12 -3
- package/lite/stream.d.ts.map +1 -1
- package/lite/stream.js +7 -4
- package/lite/stream.js.map +1 -1
- package/lite/subscribe.d.ts +37 -23
- package/lite/subscribe.d.ts.map +1 -1
- package/lite/subscribe.js +70 -55
- package/lite/subscribe.js.map +1 -1
- package/lite/subscriber.d.ts +29 -23
- package/lite/subscriber.d.ts.map +1 -1
- package/lite/subscriber.js +689 -112
- package/lite/subscriber.js.map +1 -1
- package/lite/track.d.ts +25 -13
- package/lite/track.d.ts.map +1 -1
- package/lite/track.js +60 -35
- package/lite/track.js.map +1 -1
- package/lite/version.d.ts +39 -4
- package/lite/version.d.ts.map +1 -1
- package/lite/version.js +139 -17
- package/lite/version.js.map +1 -1
- package/mock.d.ts +53 -3
- package/mock.d.ts.map +1 -1
- package/mock.js +110 -21
- package/mock.js.map +1 -1
- package/{lite/origin.d.ts → origin.d.ts} +25 -1
- package/origin.d.ts.map +1 -0
- package/{lite/origin.js → origin.js} +25 -1
- package/origin.js.map +1 -0
- package/package.json +6 -4
- package/path.d.ts +67 -0
- package/path.d.ts.map +1 -1
- package/path.js +128 -0
- package/path.js.map +1 -1
- package/stream.d.ts +94 -4
- package/stream.d.ts.map +1 -1
- package/stream.js +128 -17
- package/stream.js.map +1 -1
- package/time.d.ts +109 -86
- package/time.d.ts.map +1 -1
- package/time.js +80 -30
- package/time.js.map +1 -1
- package/track.d.ts +298 -47
- package/track.d.ts.map +1 -1
- package/track.js +721 -109
- package/track.js.map +1 -1
- package/util/timeout.d.ts +5 -0
- package/util/timeout.d.ts.map +1 -0
- package/util/timeout.js +18 -0
- package/util/timeout.js.map +1 -0
- package/util/utf8.d.ts +3 -0
- package/util/utf8.d.ts.map +1 -0
- package/util/utf8.js +7 -0
- package/util/utf8.js.map +1 -0
- package/varint.d.ts +14 -3
- package/varint.d.ts.map +1 -1
- package/varint.js +23 -12
- package/varint.js.map +1 -1
- package/zod.d.ts +5 -5
- package/zod.d.ts.map +1 -1
- package/zod.js.map +1 -1
- package/bandwidth.d.ts +0 -11
- package/bandwidth.d.ts.map +0 -1
- package/bandwidth.js +0 -7
- package/bandwidth.js.map +0 -1
- package/connection/cert.test.d.ts +0 -2
- package/connection/cert.test.d.ts.map +0 -1
- package/connection/cert.test.js +0 -26
- package/connection/cert.test.js.map +0 -1
- package/connection/reload.test.d.ts +0 -2
- package/connection/reload.test.d.ts.map +0 -1
- package/connection/reload.test.js +0 -39
- package/connection/reload.test.js.map +0 -1
- package/group.test.d.ts +0 -2
- package/group.test.d.ts.map +0 -1
- package/group.test.js +0 -66
- package/group.test.js.map +0 -1
- package/ietf/aliases.test.d.ts +0 -2
- package/ietf/aliases.test.d.ts.map +0 -1
- package/ietf/aliases.test.js +0 -30
- package/ietf/aliases.test.js.map +0 -1
- package/ietf/ietf.test.d.ts +0 -2
- package/ietf/ietf.test.d.ts.map +0 -1
- package/ietf/ietf.test.js +0 -873
- package/ietf/ietf.test.js.map +0 -1
- package/integration.test.d.ts +0 -2
- package/integration.test.d.ts.map +0 -1
- package/integration.test.js +0 -90
- package/integration.test.js.map +0 -1
- package/lite/origin.d.ts.map +0 -1
- package/lite/origin.js.map +0 -1
- package/lite/subscriber.test.d.ts +0 -2
- package/lite/subscriber.test.d.ts.map +0 -1
- package/lite/subscriber.test.js +0 -30
- package/lite/subscriber.test.js.map +0 -1
- package/lite/track.test.d.ts +0 -2
- package/lite/track.test.d.ts.map +0 -1
- package/lite/track.test.js +0 -53
- package/lite/track.test.js.map +0 -1
- package/path.test.d.ts +0 -2
- package/path.test.d.ts.map +0 -1
- package/path.test.js +0 -169
- package/path.test.js.map +0 -1
- package/stream.test.d.ts +0 -2
- package/stream.test.d.ts.map +0 -1
- package/stream.test.js +0 -210
- package/stream.test.js.map +0 -1
- package/track.test.d.ts +0 -2
- package/track.test.d.ts.map +0 -1
- package/track.test.js +0 -56
- package/track.test.js.map +0 -1
- package/util/error.d.ts +0 -3
- package/util/error.d.ts.map +0 -1
- package/util/error.js +0 -9
- package/util/error.js.map +0 -1
- package/util/index.d.ts +0 -2
- package/util/index.d.ts.map +0 -1
- package/util/index.js +0 -3
- package/util/index.js.map +0 -1
- package/varint.test.d.ts +0 -2
- package/varint.test.d.ts.map +0 -1
- package/varint.test.js +0 -106
- package/varint.test.js.map +0 -1
package/lite/subscriber.js
CHANGED
|
@@ -1,20 +1,70 @@
|
|
|
1
1
|
/* @ts-self-types="./subscriber.d.ts" */
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import
|
|
2
|
+
import { Signal } from "@norskvideo/moq-signals";
|
|
3
|
+
import * as announce from "../announced.js";
|
|
4
|
+
import * as broadcast from "../broadcast.js";
|
|
5
|
+
import { BroadcastCache } from "../consume.js";
|
|
6
|
+
import { error, ProtocolViolation, reason } from "../error.js";
|
|
7
|
+
import * as netGroup from "../group.js";
|
|
8
|
+
import { UNKNOWN_ORIGIN } from "../origin.js";
|
|
5
9
|
import * as Path from "../path.js";
|
|
6
10
|
import { Stream } from "../stream.js";
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
11
|
+
import * as Time from "../time.js";
|
|
12
|
+
import { withTimeout } from "../util/timeout.js";
|
|
13
|
+
import { AnnounceInit, AnnounceOk, AnnounceRequest, decodeAnnounceBroadcastMaybe } from "./announce.js";
|
|
14
|
+
import { Datagram as DatagramMessage } from "./datagram.js";
|
|
15
|
+
import * as DatagramStream from "./datagram_stream.js";
|
|
16
|
+
import { Fetch as FetchMessage } from "./fetch.js";
|
|
17
|
+
import { sendOrder } from "./priority.js";
|
|
9
18
|
import { Probe } from "./probe.js";
|
|
19
|
+
import { ProbeLevel } from "./setup.js";
|
|
10
20
|
import { StreamId } from "./stream.js";
|
|
11
21
|
import { decodeSubscribeResponse, decodeSubscribeResponseMaybe, Subscribe, SubscribeUpdate } from "./subscribe.js";
|
|
12
|
-
import {
|
|
22
|
+
import { TrackInfo, Track as TrackMessage } from "./track.js";
|
|
23
|
+
import { hasAnnounceId, hasAnnounceOk, hasDatagrams, hasExcludeHop, hasProbeRtt, restartSupported, Version, } from "./version.js";
|
|
24
|
+
// Bound on how long stream-open plus the first response (SUBSCRIBE_OK on older
|
|
25
|
+
// drafts, or TRACK_INFO on lite-05+) may take. Browsers cap concurrent QUIC streams
|
|
26
|
+
// (Chrome ~100) and we open with waitUntilAvailable, so past the cap the open blocks
|
|
27
|
+
// until the peer frees a slot. The timeout turns a stall into a clear error.
|
|
28
|
+
const SUBSCRIBE_SETUP_TIMEOUT_MS = 10_000;
|
|
29
|
+
/** Decode an unsigned zigzag varint back to a signed delta (mirrors Rust `VarInt::to_zigzag`). */
|
|
30
|
+
function unzigzag(v) {
|
|
31
|
+
return (v >> 1n) ^ -(v & 1n);
|
|
32
|
+
}
|
|
33
|
+
// The TRACK stream and implicit SUBSCRIBE acceptance are lite-05+.
|
|
34
|
+
function supportsTrackStream(version) {
|
|
35
|
+
switch (version) {
|
|
36
|
+
case Version.DRAFT_01:
|
|
37
|
+
case Version.DRAFT_02:
|
|
38
|
+
case Version.DRAFT_03:
|
|
39
|
+
case Version.DRAFT_04:
|
|
40
|
+
return false;
|
|
41
|
+
default:
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
13
45
|
/**
|
|
14
46
|
* Handles subscribing to broadcasts and managing their lifecycle.
|
|
15
47
|
*
|
|
16
48
|
* @internal
|
|
17
49
|
*/
|
|
50
|
+
// What we close a session with on a protocol violation.
|
|
51
|
+
//
|
|
52
|
+
// The draft names the condition but assigns no numbers, so this is the Rust
|
|
53
|
+
// implementation's code for `Error::ProtocolViolation`: matching it is what makes the
|
|
54
|
+
// two report the same thing, where the default 0 would tell the peer it closed cleanly.
|
|
55
|
+
const PROTOCOL_VIOLATION_CODE = 15;
|
|
56
|
+
// WebTransport rejects a close reason over 1024 bytes of UTF-8 by throwing, so a reason
|
|
57
|
+
// built from peer-supplied data has to be bounded before it gets there. A broadcast path
|
|
58
|
+
// is peer-supplied and long enough to reach this on its own.
|
|
59
|
+
const MAX_CLOSE_REASON = 1024;
|
|
60
|
+
// The longest prefix of `text` that fits a close reason. `encodeInto` stops on a whole
|
|
61
|
+
// code point, so `read` never lands mid-character the way slicing bytes would.
|
|
62
|
+
function closeReason(text) {
|
|
63
|
+
const encoder = new TextEncoder();
|
|
64
|
+
const buf = new Uint8Array(MAX_CLOSE_REASON);
|
|
65
|
+
const { read } = encoder.encodeInto(text, buf);
|
|
66
|
+
return text.slice(0, read);
|
|
67
|
+
}
|
|
18
68
|
export class Subscriber {
|
|
19
69
|
#quic;
|
|
20
70
|
// The version of the connection.
|
|
@@ -22,13 +72,22 @@ export class Subscriber {
|
|
|
22
72
|
// Shared with the Publisher so callers can optionally filter out their
|
|
23
73
|
// own announcements on a per-call basis (see {@link AnnouncedOptions}).
|
|
24
74
|
origin;
|
|
25
|
-
// Our subscribed tracks.
|
|
75
|
+
// Our subscribed tracks. `timescale` resolves once known (from TRACK_INFO on
|
|
76
|
+
// lite-05+, or implicit defaults on older drafts); group streams block on it
|
|
77
|
+
// before decoding any frame, since a group's QUIC stream can race ahead.
|
|
26
78
|
#subscribes = new Map();
|
|
27
79
|
#subscribeNext = 0n;
|
|
28
|
-
//
|
|
29
|
-
#
|
|
30
|
-
//
|
|
31
|
-
|
|
80
|
+
// Dedup consumed broadcasts per path: repeat consume() calls share one subscription.
|
|
81
|
+
#consumes = new BroadcastCache();
|
|
82
|
+
// Dedup in-flight one-shot fetches, keyed by [broadcast, track, sequence]. Concurrent (or
|
|
83
|
+
// repeat, while still open) fetchGroup() calls for the same group share one FETCH stream and
|
|
84
|
+
// each get an independent mirror; the entry is evicted once the group closes.
|
|
85
|
+
#fetches = new Map();
|
|
86
|
+
// The peer's PROBE estimates, written as they arrive (Lite03+ only).
|
|
87
|
+
#probe;
|
|
88
|
+
// The peer's SETUP (lite-05+), undefined until it arrives. Gates opening the PROBE
|
|
89
|
+
// stream on the peer having advertised Probe >= Report.
|
|
90
|
+
#peerSetup;
|
|
32
91
|
// Distinguishes failures from streams torn down by Subscriber.close().
|
|
33
92
|
#closed = new AbortController();
|
|
34
93
|
/**
|
|
@@ -36,17 +95,17 @@ export class Subscriber {
|
|
|
36
95
|
* @param quic - The WebTransport session to use
|
|
37
96
|
* @param version - The protocol version
|
|
38
97
|
* @param origin - Origin id shared with the Publisher
|
|
39
|
-
* @param
|
|
40
|
-
* @param
|
|
98
|
+
* @param probe - Optional sink for the peer's PROBE estimates
|
|
99
|
+
* @param peerSetup - Optional peer SETUP slot for capability gating (lite-05+)
|
|
41
100
|
*
|
|
42
101
|
* @internal
|
|
43
102
|
*/
|
|
44
|
-
constructor(quic, version, origin,
|
|
103
|
+
constructor(quic, version, origin, probe, peerSetup) {
|
|
45
104
|
this.#quic = quic;
|
|
46
105
|
this.version = version;
|
|
47
106
|
this.origin = origin;
|
|
48
|
-
this.#
|
|
49
|
-
this.#
|
|
107
|
+
this.#probe = probe;
|
|
108
|
+
this.#peerSetup = peerSetup;
|
|
50
109
|
}
|
|
51
110
|
/**
|
|
52
111
|
* Subscribe to broadcast announcements under `prefix`.
|
|
@@ -55,31 +114,68 @@ export class Subscriber {
|
|
|
55
114
|
* this connection's {@link origin}.
|
|
56
115
|
*/
|
|
57
116
|
announced(prefix = Path.empty(), options = {}) {
|
|
58
|
-
const announced = new
|
|
117
|
+
const announced = new announce.Producer(prefix);
|
|
59
118
|
void this.#runAnnounced(announced, prefix, options);
|
|
60
|
-
return announced;
|
|
119
|
+
return announced.consume();
|
|
61
120
|
}
|
|
62
121
|
async #runAnnounced(announced, prefix, options) {
|
|
63
122
|
console.debug(`announced: prefix=${prefix}`);
|
|
64
|
-
//
|
|
65
|
-
// whose hop chain already passed through us.
|
|
66
|
-
//
|
|
67
|
-
|
|
123
|
+
// Lite04/05: send our own session-level origin id so the peer can skip announces
|
|
124
|
+
// whose hop chain already passed through us. Encoding drops it on every other
|
|
125
|
+
// version, where we drop the reflected announce on receipt instead. Matches the
|
|
126
|
+
// Rust subscriber's `exclude_hop: self.self_origin.id` in `run_announce_prefix`.
|
|
127
|
+
const msg = new AnnounceRequest(prefix, this.origin);
|
|
128
|
+
// Drop reflected announces so callers asking for "someone else's broadcasts"
|
|
129
|
+
// don't re-see their own publishes. A caller can always ask for this, and it is
|
|
130
|
+
// required on versions that don't carry excludeHop above: there the peer isn't
|
|
131
|
+
// filtering them out for us, so filtering here keeps what the app sees the same
|
|
132
|
+
// as lite-05. Lite01-03 carry no real hop ids, so the check never matches there.
|
|
133
|
+
const dropReflected = options.ignoreSelf || !hasExcludeHop(this.version);
|
|
134
|
+
// Opened outside the try so the catch can reach it: a protocol violation below has
|
|
135
|
+
// to reset the stream, not just close our side of it.
|
|
136
|
+
let stream;
|
|
68
137
|
try {
|
|
69
|
-
|
|
70
|
-
|
|
138
|
+
stream = await Stream.open(this.#quic);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
announced.close(error(err));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
// Send the announce interest.
|
|
71
146
|
await stream.writer.u53(StreamId.Announce);
|
|
72
147
|
await msg.encode(stream.writer, this.version);
|
|
148
|
+
// Lite05+: the publisher reports its own origin id before any announces.
|
|
149
|
+
// It no longer stamps itself onto each hop chain, so we append it here to
|
|
150
|
+
// keep the ignoreSelf loop check seeing the full chain.
|
|
151
|
+
let responderOrigin;
|
|
152
|
+
if (hasAnnounceOk(this.version)) {
|
|
153
|
+
const ok = await AnnounceOk.decode(stream.reader, this.version);
|
|
154
|
+
// A responder that withholds its identity sends the reserved 0. It names
|
|
155
|
+
// nobody, so folding it into a chain would stamp a placeholder that cannot
|
|
156
|
+
// close a loop or tell two publishers apart. Treat it as absent instead,
|
|
157
|
+
// which is the loop-blind route the draft describes.
|
|
158
|
+
responderOrigin = ok.origin === UNKNOWN_ORIGIN ? undefined : ok.origin;
|
|
159
|
+
}
|
|
160
|
+
const advertised = new Map();
|
|
73
161
|
switch (this.version) {
|
|
74
162
|
case Version.DRAFT_01:
|
|
75
163
|
case Version.DRAFT_02: {
|
|
76
164
|
// Receive ANNOUNCE_INIT first
|
|
77
165
|
const init = await AnnounceInit.decode(stream.reader, this.version);
|
|
78
|
-
// Process initial announcements
|
|
166
|
+
// Process initial announcements. These are advertisements like any other, so
|
|
167
|
+
// they go on record and obey the same one-per-path rule: the initial set
|
|
168
|
+
// naming a path twice is the same violation as two ANNOUNCE_STARTs for it,
|
|
169
|
+
// and the record is what catches either. Draft01/02 carry no hop ids and no
|
|
170
|
+
// ANNOUNCE_OK, so nothing names the publisher.
|
|
79
171
|
for (const suffix of init.suffixes) {
|
|
80
172
|
const path = Path.join(prefix, suffix);
|
|
173
|
+
if (advertised.has(suffix)) {
|
|
174
|
+
throw new ProtocolViolation(`duplicate announce for ${path}`);
|
|
175
|
+
}
|
|
176
|
+
advertised.set(suffix, { publisher: undefined, live: true });
|
|
81
177
|
console.debug(`announced: broadcast=${path} active=true`);
|
|
82
|
-
announced.append({ path, active: true });
|
|
178
|
+
announced.append({ path: suffix, active: true });
|
|
83
179
|
}
|
|
84
180
|
break;
|
|
85
181
|
}
|
|
@@ -87,145 +183,492 @@ export class Subscriber {
|
|
|
87
183
|
// Draft03+: no AnnounceInit, initial state comes via Announce messages.
|
|
88
184
|
break;
|
|
89
185
|
}
|
|
186
|
+
// Lite06+: announce ids. Each received `active` implicitly assigns the next
|
|
187
|
+
// per-stream ordinal; `endedId`/`restart` reference it. Tracked even for
|
|
188
|
+
// announces we skip via ignoreSelf, since the sender doesn't know we skipped.
|
|
189
|
+
let nextAnnounceId = 0n;
|
|
190
|
+
const announcedById = new Map();
|
|
90
191
|
// Receive announce updates (for Draft03, this includes initial state)
|
|
91
192
|
for (;;) {
|
|
92
193
|
const announce = await Promise.race([
|
|
93
|
-
|
|
194
|
+
decodeAnnounceBroadcastMaybe(stream.reader, this.version),
|
|
94
195
|
announced.closed,
|
|
95
196
|
]);
|
|
197
|
+
// undefined: the stream ended. null: the consumer closed cleanly.
|
|
96
198
|
if (!announce)
|
|
97
199
|
break;
|
|
98
200
|
if (announce instanceof Error)
|
|
99
201
|
throw announce;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
202
|
+
let suffix;
|
|
203
|
+
let active;
|
|
204
|
+
// Present on active/restart; ended messages never carry hops worth checking.
|
|
205
|
+
let hops;
|
|
206
|
+
switch (announce.status) {
|
|
207
|
+
case "active":
|
|
208
|
+
suffix = announce.suffix;
|
|
209
|
+
active = true;
|
|
210
|
+
hops = announce.hops;
|
|
211
|
+
if (hasAnnounceId(this.version)) {
|
|
212
|
+
announcedById.set(nextAnnounceId++, announce.suffix);
|
|
213
|
+
}
|
|
214
|
+
break;
|
|
215
|
+
case "ended":
|
|
216
|
+
suffix = announce.suffix;
|
|
217
|
+
active = false;
|
|
218
|
+
break;
|
|
219
|
+
case "endedId": {
|
|
220
|
+
// Resolve and retire the id; an unknown or retired id is a protocol violation.
|
|
221
|
+
const path = announcedById.get(announce.id);
|
|
222
|
+
if (path === undefined)
|
|
223
|
+
throw new ProtocolViolation(`unknown announce id: ${announce.id}`);
|
|
224
|
+
announcedById.delete(announce.id);
|
|
225
|
+
suffix = path;
|
|
226
|
+
active = false;
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
case "restart": {
|
|
230
|
+
// Resolve the id; it stays live (the replacement reuses it).
|
|
231
|
+
const path = announcedById.get(announce.id);
|
|
232
|
+
if (path === undefined)
|
|
233
|
+
throw new ProtocolViolation(`unknown announce id: ${announce.id}`);
|
|
234
|
+
suffix = path;
|
|
235
|
+
active = true;
|
|
236
|
+
hops = announce.hops;
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const path = Path.join(prefix, suffix);
|
|
241
|
+
// One current advertisement per path per stream, decided before anything below
|
|
242
|
+
// can skip this announcement. A second ANNOUNCE_START for a path the peer
|
|
243
|
+
// already advertised is a violation whether or not its route would be usable
|
|
244
|
+
// here, and whether or not we kept the first; letting a skip pre-empt it would
|
|
245
|
+
// retract the live route and leave the stream open on a peer already out of
|
|
246
|
+
// spec.
|
|
247
|
+
//
|
|
248
|
+
// lite-05 alone is exempt, where a duplicate ANNOUNCE *is* the replacement
|
|
249
|
+
// idiom. lite-06 gave that its own message and older versions never had one, so
|
|
250
|
+
// a duplicate means the same thing on both sides of it. Mirrors the branch the
|
|
251
|
+
// Rust announce loop takes before `start_announce`.
|
|
252
|
+
const duplicateIsRestart = restartSupported(this.version) && !hasAnnounceId(this.version);
|
|
253
|
+
if (announce.status === "active" && !duplicateIsRestart && advertised.has(suffix)) {
|
|
254
|
+
throw new ProtocolViolation(`duplicate announce for ${path}`);
|
|
255
|
+
}
|
|
256
|
+
// Retract the path: forget the advertisement, drop the shared consume entry so a
|
|
257
|
+
// later announce subscribes fresh rather than cloning the dead generation's tracks,
|
|
258
|
+
// and tell the consumer. A no-op for an advertisement never surfaced, which is
|
|
259
|
+
// what an id retiring a skipped announce resolves to.
|
|
260
|
+
const retract = () => {
|
|
261
|
+
const previous = advertised.get(suffix);
|
|
262
|
+
advertised.delete(suffix);
|
|
263
|
+
if (!previous?.live)
|
|
264
|
+
return;
|
|
265
|
+
this.#consumes.evict(path);
|
|
266
|
+
console.debug(`announced: broadcast=${path} active=false`);
|
|
267
|
+
announced.append({ path: suffix, active: false });
|
|
268
|
+
};
|
|
269
|
+
// In Lite05+ the sender's origin arrives via AnnounceOk, not in each hop
|
|
270
|
+
// list, so fold it back in before checking.
|
|
271
|
+
if (hops !== undefined && dropReflected) {
|
|
272
|
+
const full = responderOrigin !== undefined ? [...hops, responderOrigin] : hops;
|
|
273
|
+
if (full.includes(this.origin)) {
|
|
274
|
+
// A reflected restart means the peer's remaining route loops back through
|
|
275
|
+
// us, so the route is gone even though the message says active. The
|
|
276
|
+
// advertisement stays live: the peer still holds the path and its id still
|
|
277
|
+
// resolves here.
|
|
278
|
+
retract();
|
|
279
|
+
advertised.set(suffix, { publisher: undefined, live: false });
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (active) {
|
|
284
|
+
// The first hop identifies the original publisher; an empty chain means the
|
|
285
|
+
// peer itself originated it. See `restart_announce` in the Rust subscriber.
|
|
286
|
+
const publisher = hops?.[0] ?? responderOrigin;
|
|
287
|
+
// A publisher with no identity (an empty chain from a peer that withheld its
|
|
288
|
+
// own id, or a lite-03 UNKNOWN placeholder) never proves continuity: two such
|
|
289
|
+
// advertisements can be unrelated publishers. Mirrors the
|
|
290
|
+
// `publisher == Origin::UNKNOWN` arm of the Rust `restart_announce`.
|
|
291
|
+
const identified = publisher !== undefined && publisher !== UNKNOWN_ORIGIN;
|
|
292
|
+
// A second advertisement for a path we already carry is a restart: either an
|
|
293
|
+
// explicit ANNOUNCE_UPDATE, or (lite-05) a duplicate ANNOUNCE.
|
|
294
|
+
const previous = advertised.get(suffix);
|
|
295
|
+
if (previous?.live) {
|
|
296
|
+
if (identified && previous.publisher === publisher) {
|
|
297
|
+
// Same publisher, new route. In-flight subscriptions resume across it,
|
|
298
|
+
// so there is nothing for a consumer to react to. An unidentified
|
|
299
|
+
// publisher falls through to the replacement path below instead.
|
|
300
|
+
console.debug(`announced: broadcast=${path} rerouted`);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
// A different publisher took the path, so cached track info and existing
|
|
304
|
+
// subscriptions must not carry over. Surface a real end before the start.
|
|
305
|
+
retract();
|
|
306
|
+
}
|
|
307
|
+
// After `retract()`, which clears the entry: the path is advertised again, by
|
|
308
|
+
// whoever just took it over. Recording it before would leave nothing behind, so
|
|
309
|
+
// the *next* takeover would read as a first announcement and skip its own end.
|
|
310
|
+
advertised.set(suffix, { publisher, live: true });
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
retract();
|
|
103
314
|
continue;
|
|
104
315
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
announced.append({ path, active: announce.active });
|
|
316
|
+
console.debug(`announced: broadcast=${path} active=true`);
|
|
317
|
+
announced.append({ path: suffix, active: true });
|
|
108
318
|
}
|
|
109
319
|
announced.close();
|
|
110
320
|
}
|
|
111
321
|
catch (err) {
|
|
112
|
-
|
|
322
|
+
const e = error(err);
|
|
323
|
+
// Reaches here on a protocol violation the peer committed (a second
|
|
324
|
+
// advertisement for a live path, an unknown announce id) as well as on a
|
|
325
|
+
// transport failure. Either way the peer has to be told: closing only our side
|
|
326
|
+
// would leave it announcing into a stream nobody reads.
|
|
327
|
+
stream.abort(e);
|
|
328
|
+
announced.close(e);
|
|
329
|
+
// A violation ends the session, not just this stream, so a nonconforming peer
|
|
330
|
+
// cannot repeat it on the next one. Matches `ietf::Subscriber` and the Rust
|
|
331
|
+
// lite subscriber, where the announce half only ever ends the session on error.
|
|
332
|
+
if (e instanceof ProtocolViolation) {
|
|
333
|
+
this.#quic.close({ closeCode: PROTOCOL_VIOLATION_CODE, reason: closeReason(reason(e)) });
|
|
334
|
+
}
|
|
113
335
|
}
|
|
114
336
|
}
|
|
115
337
|
/**
|
|
116
338
|
* Consumes a broadcast from the connection.
|
|
117
339
|
*
|
|
340
|
+
* Deduplicated per path: repeat calls for the same still-live path share one reference-counted
|
|
341
|
+
* broadcast (and one upstream subscription). The shared broadcast closes once every caller has
|
|
342
|
+
* closed its handle, so callers close normally.
|
|
343
|
+
*
|
|
118
344
|
* @param name - The name of the broadcast to consume
|
|
119
345
|
* @returns A Broadcast instance
|
|
120
346
|
*/
|
|
121
347
|
consume(path) {
|
|
122
|
-
|
|
348
|
+
return this.#consumes.get(path) ?? this.#consumes.insert(path, this.#createConsume(path));
|
|
349
|
+
}
|
|
350
|
+
#createConsume(path) {
|
|
351
|
+
// A consumed broadcast resolves info() and fetchGroup() over the wire by reaching
|
|
352
|
+
// back into this Subscriber (see ConsumeBroadcast below), rather than the wire
|
|
353
|
+
// installing callbacks on the broadcast.
|
|
354
|
+
const consumer = new ConsumeBroadcast(this, path);
|
|
123
355
|
void (async () => {
|
|
124
356
|
for (;;) {
|
|
125
|
-
const request = await
|
|
357
|
+
const request = await consumer.requested();
|
|
126
358
|
if (!request)
|
|
127
359
|
break;
|
|
128
360
|
void this.#runSubscribe(path, request);
|
|
129
361
|
}
|
|
130
362
|
})();
|
|
131
|
-
return
|
|
363
|
+
return consumer;
|
|
132
364
|
}
|
|
133
365
|
async #runSubscribe(broadcast, request) {
|
|
134
366
|
const id = this.#subscribeNext++;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
367
|
+
const subscription = request.subscription;
|
|
368
|
+
// `timescale` stays undefined until TRACK_INFO (or, on older drafts,
|
|
369
|
+
// implicit defaults) resolves it; runGroup blocks on it before decoding.
|
|
370
|
+
const timescale = new Signal(undefined);
|
|
371
|
+
console.debug(`subscribe start: id=${id} broadcast=${broadcast} track=${request.name}`);
|
|
372
|
+
const msg = new Subscribe({
|
|
373
|
+
id,
|
|
374
|
+
broadcast,
|
|
375
|
+
track: request.name,
|
|
376
|
+
priority: subscription.priority ?? 0,
|
|
377
|
+
ordered: subscription.ordered,
|
|
378
|
+
maxLatency: subscription.latencyMax,
|
|
379
|
+
startGroup: subscription.startGroup,
|
|
380
|
+
endGroup: subscription.endGroup,
|
|
381
|
+
});
|
|
382
|
+
// Open the stream under a timeout. The stream handle flows back via `state`
|
|
383
|
+
// so the timeout path can abort it if it finishes opening after the deadline.
|
|
384
|
+
const state = {};
|
|
385
|
+
const setup = this.#openSubscribe(state, msg, request, id, timescale);
|
|
386
|
+
let opened;
|
|
142
387
|
try {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
388
|
+
opened = await withTimeout(setup, SUBSCRIBE_SETUP_TIMEOUT_MS, `subscribe timed out after ${SUBSCRIBE_SETUP_TIMEOUT_MS}ms waiting for the first response (browser stream limit reached?)`);
|
|
389
|
+
console.debug(`subscribe ok: id=${id} broadcast=${broadcast} track=${request.name}`);
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
const e = error(err);
|
|
393
|
+
request.reject(e);
|
|
394
|
+
this.#subscribes.delete(id);
|
|
395
|
+
console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${request.name} error=${reason(e)}`);
|
|
396
|
+
// If the stream eventually opens after the timeout, abort it so we
|
|
397
|
+
// don't leak it. Cover both branches: setup may resolve late, or it
|
|
398
|
+
// may reject (e.g. encode/decode failure) after the stream is open.
|
|
399
|
+
setup.then(() => state.stream?.abort(e), () => state.stream?.abort(e));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const { stream, producer } = opened;
|
|
403
|
+
try {
|
|
404
|
+
// Watch for subscription changes and send SUBSCRIBE_UPDATE. Lite01/Lite02
|
|
150
405
|
// don't carry SUBSCRIBE_UPDATE on the wire, so skip the watcher there
|
|
151
406
|
// and just wait on the stream/track like before.
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
407
|
+
//
|
|
408
|
+
// On lite-05+ the publisher sends SUBSCRIBE_START/END/DROP on this stream;
|
|
409
|
+
// drain them (we don't drive delivery off the resolved range) so the FIN is
|
|
410
|
+
// observed. Older drafts just wait for the stream to close.
|
|
411
|
+
const closed = supportsTrackStream(this.version) ? this.#drainResponses(stream) : stream.reader.closed;
|
|
412
|
+
const subscriptionUpdates = this.version === Version.DRAFT_01 || this.version === Version.DRAFT_02
|
|
413
|
+
? undefined
|
|
414
|
+
: this.#runSubscriptionUpdates(id, broadcast, producer, msg, stream);
|
|
415
|
+
// Terminal conditions (stream end, track close, a failed subscription update) settle at most
|
|
416
|
+
// once; race them into one stable promise so the demand loop doesn't re-subscribe each pass.
|
|
417
|
+
const terminal = [closed, producer.closed];
|
|
418
|
+
if (subscriptionUpdates !== undefined)
|
|
419
|
+
terminal.push(subscriptionUpdates);
|
|
420
|
+
const done = Promise.race(terminal);
|
|
421
|
+
// Serve until a terminal condition fires or the last local subscriber leaves. The unused
|
|
422
|
+
// wake is level-triggered: re-check demand so a subscriber that returns before we tear
|
|
423
|
+
// down (e.g. a quickly unmuted tile) resumes on the same subscription.
|
|
424
|
+
const idle = Symbol("idle");
|
|
425
|
+
for (;;) {
|
|
426
|
+
const reason = await Promise.race([done, producer.unused().then(() => idle)]);
|
|
427
|
+
if (reason === idle && producer.closed.peek() === undefined && producer.used.peek())
|
|
428
|
+
continue;
|
|
429
|
+
break;
|
|
162
430
|
}
|
|
163
|
-
|
|
164
|
-
request.track.close();
|
|
431
|
+
producer.close();
|
|
165
432
|
stream.close();
|
|
166
|
-
console.debug(`subscribe close: id=${id} broadcast=${broadcast} track=${request.
|
|
433
|
+
console.debug(`subscribe close: id=${id} broadcast=${broadcast} track=${request.name}`);
|
|
167
434
|
}
|
|
168
435
|
catch (err) {
|
|
169
436
|
const e = error(err);
|
|
170
|
-
|
|
171
|
-
console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${request.
|
|
437
|
+
producer.close(e);
|
|
438
|
+
console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${request.name} error=${reason(e)}`);
|
|
172
439
|
stream.abort(e);
|
|
173
440
|
}
|
|
174
441
|
finally {
|
|
175
442
|
this.#subscribes.delete(id);
|
|
176
443
|
}
|
|
177
444
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
445
|
+
// Determine the track's immutable properties, accept the request (so the
|
|
446
|
+
// application's track.Subscriber resolves and incoming groups have a producer to
|
|
447
|
+
// write into), register it, then open the subscribe stream. `state.stream` is
|
|
448
|
+
// populated as soon as the subscribe stream opens so the caller can clean it up
|
|
449
|
+
// on timeout even before this promise settles.
|
|
450
|
+
//
|
|
451
|
+
// On lite-05+ the properties come from a TRACK stream opened first, and the
|
|
452
|
+
// SUBSCRIBE is accepted implicitly (no SUBSCRIBE_OK). Older drafts carry no
|
|
453
|
+
// per-track properties, so they resolve to defaults and just drain SUBSCRIBE_OK.
|
|
454
|
+
async #openSubscribe(state, msg, request, id, timescale) {
|
|
455
|
+
let producer;
|
|
456
|
+
let drainOk = false;
|
|
457
|
+
if (supportsTrackStream(this.version)) {
|
|
458
|
+
// Fetch the immutable properties once via the TRACK stream.
|
|
459
|
+
const info = await this.#trackInfo(msg.broadcast, msg.track);
|
|
460
|
+
producer = request.accept(this.#toModelInfo(info));
|
|
461
|
+
timescale.set(info.timescale);
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
// Older drafts negotiate nothing per-track: verbatim frames, no timescale.
|
|
465
|
+
producer = request.accept();
|
|
466
|
+
timescale.set(0);
|
|
467
|
+
drainOk = true;
|
|
468
|
+
}
|
|
469
|
+
// Register before opening SUBSCRIBE so a racing GROUP stream finds the entry.
|
|
470
|
+
this.#subscribes.set(id, { track: producer, timescale });
|
|
471
|
+
state.stream = await Stream.open(this.#quic);
|
|
472
|
+
await state.stream.writer.u53(StreamId.Subscribe);
|
|
473
|
+
await msg.encode(state.stream.writer, this.version);
|
|
474
|
+
if (drainOk) {
|
|
475
|
+
// The first response MUST be a SUBSCRIBE_OK (older drafts only).
|
|
476
|
+
const resp = await decodeSubscribeResponse(state.stream.reader, this.version);
|
|
477
|
+
if (!("ok" in resp)) {
|
|
478
|
+
throw new Error("first subscribe response must be SUBSCRIBE_OK");
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return { stream: state.stream, producer };
|
|
482
|
+
}
|
|
483
|
+
// Opens a TRACK stream, reads the single TRACK_INFO, and FINs. Lite-05+ only.
|
|
484
|
+
async #trackInfo(broadcast, track) {
|
|
485
|
+
const stream = await Stream.open(this.#quic);
|
|
486
|
+
try {
|
|
487
|
+
await stream.writer.u53(StreamId.Track);
|
|
488
|
+
await new TrackMessage(broadcast, track).encode(stream.writer, this.version);
|
|
489
|
+
const info = await TrackInfo.decode(stream.reader, this.version);
|
|
490
|
+
// The publisher FINs after TRACK_INFO; FIN our side too.
|
|
491
|
+
stream.close();
|
|
492
|
+
return info;
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
stream.abort(error(err));
|
|
496
|
+
throw err;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// Map the wire TRACK_INFO onto the model track.Info a producer/consumer holds.
|
|
500
|
+
#toModelInfo(info) {
|
|
501
|
+
return {
|
|
502
|
+
timescale: Time.Timescale(info.timescale),
|
|
503
|
+
// Publisher Max Latency rides on the wire, so the local retention window
|
|
504
|
+
// matches what the upstream advertises (relays re-serve with the same bound).
|
|
505
|
+
latencyMax: info.latencyMax,
|
|
506
|
+
priority: info.priority,
|
|
507
|
+
ordered: info.ordered,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
// Resolve a track's immutable model info via a TRACK stream (lite-05+), for the
|
|
511
|
+
// ConsumeBroadcast backing track.Consumer.info(). On older drafts there's no TRACK
|
|
512
|
+
// stream, so this rejects rather than fabricating defaults.
|
|
513
|
+
async resolveTrackInfo(broadcast, track) {
|
|
514
|
+
if (!supportsTrackStream(this.version)) {
|
|
515
|
+
throw new Error("track info requires moq-lite-05 or newer");
|
|
516
|
+
}
|
|
517
|
+
return this.#toModelInfo(await this.#trackInfo(broadcast, track));
|
|
518
|
+
}
|
|
519
|
+
// Open a FETCH stream for one group and stream its bare frames into a group, for the
|
|
520
|
+
// ConsumeBroadcast backing track.Consumer.fetchGroup() (lite-05+).
|
|
521
|
+
fetchGroup(broadcast, track, sequence, options = {}) {
|
|
522
|
+
// Coalesce onto a still-open fetch of the same group so we don't open a second FETCH
|
|
523
|
+
// stream (and re-download it); each caller reads an independent mirror.
|
|
524
|
+
const key = JSON.stringify([broadcast, track, sequence]);
|
|
525
|
+
const existing = this.#fetches.get(key);
|
|
526
|
+
if (existing && !existing.isClosed)
|
|
527
|
+
return Promise.resolve(existing.mirror());
|
|
528
|
+
// Create and cache the group synchronously (before any await) so a concurrent fetch for
|
|
529
|
+
// the same group finds it and coalesces rather than racing to open its own stream.
|
|
530
|
+
const group = new netGroup.Producer(sequence);
|
|
531
|
+
this.#fetches.set(key, group);
|
|
532
|
+
void group.closed.then(() => {
|
|
533
|
+
if (this.#fetches.get(key) === group)
|
|
534
|
+
this.#fetches.delete(key);
|
|
535
|
+
});
|
|
536
|
+
return this.#runFetch(broadcast, track, sequence, options, group);
|
|
537
|
+
}
|
|
538
|
+
// Open the FETCH stream and pump the response into the shared group. Setup errors close the
|
|
539
|
+
// group (so coalesced mirrors observe them and the entry evicts) and reject this caller.
|
|
540
|
+
async #runFetch(broadcast, track, sequence, options, group) {
|
|
541
|
+
try {
|
|
542
|
+
if (!supportsTrackStream(this.version)) {
|
|
543
|
+
throw new Error("fetch group requires moq-lite-05 or newer");
|
|
544
|
+
}
|
|
545
|
+
const info = await this.#trackInfo(broadcast, track);
|
|
546
|
+
const priority = options.priority ?? 0;
|
|
547
|
+
const stream = await Stream.open(this.#quic, { sendOrder: sendOrder({ priority }) });
|
|
548
|
+
try {
|
|
549
|
+
await stream.writer.u53(StreamId.Fetch);
|
|
550
|
+
await new FetchMessage(broadcast, track, priority, sequence).encode(stream.writer, this.version);
|
|
551
|
+
}
|
|
552
|
+
catch (err) {
|
|
553
|
+
stream.abort(error(err));
|
|
554
|
+
throw err;
|
|
555
|
+
}
|
|
556
|
+
// Mint this caller's reader before starting the pump, so the group has demand when the
|
|
557
|
+
// pump begins watching it (an abandoned fetch cancels once every reader has left).
|
|
558
|
+
const consumer = group.mirror();
|
|
559
|
+
void this.#runFetchResponse(stream, group, Time.Timescale(info.timescale));
|
|
560
|
+
return consumer;
|
|
561
|
+
}
|
|
562
|
+
catch (err) {
|
|
563
|
+
group.close(error(err));
|
|
564
|
+
throw err;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// Read the FETCH response (bare zigzag-delta-timestamped frames) into the group, then
|
|
568
|
+
// FIN. A stream-level failure aborts the group so its reader observes the gap.
|
|
569
|
+
async #runFetchResponse(stream, group, timescale) {
|
|
570
|
+
try {
|
|
571
|
+
let prevTs = 0n;
|
|
572
|
+
// Serve until the stream FINs, the group closes, or every reader leaves. A group can
|
|
573
|
+
// stay open indefinitely (a catalog or JSON stream), so an abandoned fetch is stopped by
|
|
574
|
+
// demand, not by the stream ending. `closed` and `unused` are watched across frames as
|
|
575
|
+
// stable promises (not re-subscribed to the signals each frame); the unused check is
|
|
576
|
+
// level-triggered, so a coalesced fetch that arrives before we cancel re-arms and resumes.
|
|
577
|
+
const idle = Symbol("idle");
|
|
578
|
+
const closed = Promise.resolve(group.closed);
|
|
579
|
+
let unused = group.unused().then(() => idle);
|
|
580
|
+
for (;;) {
|
|
581
|
+
const done = await Promise.race([stream.reader.done(), closed, unused]);
|
|
582
|
+
if (done === idle) {
|
|
583
|
+
if (!group.isClosed && group.used.peek()) {
|
|
584
|
+
unused = group.unused().then(() => idle);
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
if (done !== false)
|
|
590
|
+
break;
|
|
591
|
+
prevTs += unzigzag(await stream.reader.u62());
|
|
592
|
+
const timestamp = new Time.Timestamp(Number(prevTs), timescale);
|
|
593
|
+
const size = await stream.reader.u53();
|
|
594
|
+
const payload = await stream.reader.read(size);
|
|
595
|
+
if (!payload)
|
|
596
|
+
break;
|
|
597
|
+
group.writeFrame({ payload, timestamp });
|
|
598
|
+
}
|
|
599
|
+
group.close();
|
|
600
|
+
stream.close();
|
|
601
|
+
}
|
|
602
|
+
catch (err) {
|
|
603
|
+
const e = error(err);
|
|
604
|
+
group.close(e);
|
|
605
|
+
stream.abort(e);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// Drains SUBSCRIBE_START/END/DROP on the subscribe stream until FIN (lite-05+).
|
|
609
|
+
// The resolved range is informational here; the producer already orders groups.
|
|
610
|
+
// Resolves (never rejects) on FIN or on the stream being reset out from under it,
|
|
611
|
+
// so it's safe to drop from a Promise.race without an unhandled rejection.
|
|
185
612
|
async #drainResponses(stream) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
613
|
+
try {
|
|
614
|
+
for (;;) {
|
|
615
|
+
const resp = await decodeSubscribeResponseMaybe(stream.reader, this.version);
|
|
616
|
+
if (!resp)
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
// Stream closed or reset; nothing more to drain.
|
|
190
622
|
}
|
|
191
623
|
}
|
|
192
624
|
/**
|
|
193
|
-
* Send SUBSCRIBE_UPDATE messages whenever the track's
|
|
625
|
+
* Send SUBSCRIBE_UPDATE messages whenever the track's aggregate subscription changes.
|
|
194
626
|
*
|
|
195
627
|
* Resolves cleanly when the stream or track closes, so the caller can include
|
|
196
628
|
* this in Promise.race without leaving a dangling pending write that would
|
|
197
|
-
* become an unhandled rejection if the user calls
|
|
629
|
+
* become an unhandled rejection if the user calls update after close.
|
|
198
630
|
*
|
|
199
631
|
* Peeks the signal at the top of every iteration so that updates which landed
|
|
200
632
|
* before SubscribeOk arrived (or between iterations, before .next() registered
|
|
201
633
|
* its listener) aren't lost.
|
|
202
634
|
*/
|
|
203
|
-
async #
|
|
635
|
+
async #runSubscriptionUpdates(id, broadcast, track, msg, stream) {
|
|
204
636
|
const stopped = Promise.race([track.closed, stream.reader.closed]).then(() => null);
|
|
205
|
-
let lastSent
|
|
637
|
+
let lastSent = {
|
|
638
|
+
priority: msg.priority,
|
|
639
|
+
ordered: msg.ordered,
|
|
640
|
+
latencyMax: msg.maxLatency,
|
|
641
|
+
startGroup: msg.startGroup,
|
|
642
|
+
endGroup: msg.endGroup,
|
|
643
|
+
};
|
|
206
644
|
for (;;) {
|
|
207
|
-
const current = track.
|
|
208
|
-
if (current === undefined || current
|
|
645
|
+
const current = track.subscription.peek();
|
|
646
|
+
if (current === undefined || this.#sameSubscription(current, lastSent)) {
|
|
209
647
|
// Nothing new to send; wait for a change or termination.
|
|
210
|
-
const next = await Promise.race([track.
|
|
648
|
+
const next = await Promise.race([track.subscription.changed(), stopped]);
|
|
211
649
|
if (next === null)
|
|
212
650
|
return;
|
|
213
651
|
continue;
|
|
214
652
|
}
|
|
215
|
-
// Round-trip the other Subscribe parameters so the publisher doesn't
|
|
216
|
-
// interpret SUBSCRIBE_UPDATE as a reset of ordered/maxLatency/etc.
|
|
217
653
|
const update = new SubscribeUpdate({
|
|
218
|
-
priority: current,
|
|
219
|
-
ordered:
|
|
220
|
-
maxLatency:
|
|
221
|
-
startGroup:
|
|
222
|
-
endGroup:
|
|
654
|
+
priority: current.priority ?? 0,
|
|
655
|
+
ordered: current.ordered,
|
|
656
|
+
maxLatency: current.latencyMax,
|
|
657
|
+
startGroup: current.startGroup,
|
|
658
|
+
endGroup: current.endGroup,
|
|
223
659
|
});
|
|
224
660
|
await update.encode(stream.writer, this.version);
|
|
225
|
-
lastSent = current;
|
|
226
|
-
console.debug(`subscribe update: id=${id} broadcast=${broadcast} track=${track.name}
|
|
661
|
+
lastSent = { ...current };
|
|
662
|
+
console.debug(`subscribe update: id=${id} broadcast=${broadcast} track=${track.name}`);
|
|
227
663
|
}
|
|
228
664
|
}
|
|
665
|
+
#sameSubscription(a, b) {
|
|
666
|
+
return ((a.priority ?? 0) === (b.priority ?? 0) &&
|
|
667
|
+
(a.ordered ?? false) === (b.ordered ?? false) &&
|
|
668
|
+
(a.latencyMax ?? 0) === (b.latencyMax ?? 0) &&
|
|
669
|
+
a.startGroup === b.startGroup &&
|
|
670
|
+
a.endGroup === b.endGroup);
|
|
671
|
+
}
|
|
229
672
|
/**
|
|
230
673
|
* Handles a group message.
|
|
231
674
|
* @param group - The group message
|
|
@@ -234,30 +677,51 @@ export class Subscriber {
|
|
|
234
677
|
* @internal
|
|
235
678
|
*/
|
|
236
679
|
async runGroup(group, stream) {
|
|
237
|
-
const
|
|
238
|
-
if (!
|
|
680
|
+
const entry = this.#subscribes.get(group.subscribe);
|
|
681
|
+
if (!entry) {
|
|
239
682
|
if (group.subscribe >= this.#subscribeNext) {
|
|
240
683
|
throw new Error(`unknown subscription: id=${group.subscribe}`);
|
|
241
684
|
}
|
|
242
685
|
return;
|
|
243
686
|
}
|
|
244
|
-
const
|
|
245
|
-
|
|
687
|
+
const { track, timescale } = entry;
|
|
688
|
+
const producer = new netGroup.Producer(group.sequence);
|
|
689
|
+
track.writeGroup(producer);
|
|
246
690
|
try {
|
|
691
|
+
// Block until the timescale is known; the group's stream can arrive before
|
|
692
|
+
// TRACK_INFO (or implicit defaults) resolves it on the subscribe stream.
|
|
693
|
+
let scale = timescale.peek();
|
|
694
|
+
while (scale === undefined) {
|
|
695
|
+
if (track.closed.peek() !== undefined) {
|
|
696
|
+
// Subscription ended before the scale resolved; nothing to decode.
|
|
697
|
+
producer.close();
|
|
698
|
+
stream.stop(new Error("cancel"));
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
await Signal.race(timescale, track.closed);
|
|
702
|
+
scale = timescale.peek();
|
|
703
|
+
}
|
|
704
|
+
// A non-zero scale means every frame is prefixed with a zigzag-delta timestamp
|
|
705
|
+
// (the lite-05 FRAME format), which we decode into a Timestamp at that scale.
|
|
706
|
+
// Scale 0 (pre-lite-05) carries no timestamp, so we wall-clock-stamp.
|
|
707
|
+
let prevTs = 0n;
|
|
247
708
|
for (;;) {
|
|
248
|
-
const done = await Promise.race([stream.done(),
|
|
709
|
+
const done = await Promise.race([stream.done(), track.closed, producer.closed]);
|
|
249
710
|
if (done !== false)
|
|
250
711
|
break;
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
712
|
+
let timestamp;
|
|
713
|
+
if (scale !== 0) {
|
|
714
|
+
prevTs += unzigzag(await stream.u62());
|
|
715
|
+
timestamp = new Time.Timestamp(Number(prevTs), Time.Timescale(scale));
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
timestamp = Time.Timestamp.now();
|
|
255
719
|
}
|
|
256
720
|
const size = await stream.u53();
|
|
257
721
|
const payload = await stream.read(size);
|
|
258
722
|
if (!payload)
|
|
259
723
|
break;
|
|
260
|
-
producer.writeFrame(payload);
|
|
724
|
+
producer.writeFrame({ payload, timestamp });
|
|
261
725
|
}
|
|
262
726
|
producer.close();
|
|
263
727
|
stream.stop(new Error("cancel"));
|
|
@@ -268,6 +732,69 @@ export class Subscriber {
|
|
|
268
732
|
stream.stop(e);
|
|
269
733
|
}
|
|
270
734
|
}
|
|
735
|
+
/**
|
|
736
|
+
* Receives QUIC datagrams and routes each to its subscription's track producer (lite-05 §6.4).
|
|
737
|
+
*
|
|
738
|
+
* Returns immediately on a non-datagram transport or pre-lite-05 version. A decode error or an
|
|
739
|
+
* unknown subscribe id drops that datagram without tearing down the session (best-effort); the
|
|
740
|
+
* loop ends only when the datagram stream closes.
|
|
741
|
+
*
|
|
742
|
+
* @internal
|
|
743
|
+
*/
|
|
744
|
+
async runDatagrams() {
|
|
745
|
+
if (!hasDatagrams(this.version) || DatagramStream.maxDatagramSize(this.#quic) === 0) {
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
// Never reject: this loop is awaited alongside the connection's other tasks, so a
|
|
749
|
+
// datagram-stream failure must not tear the whole session down (it's best-effort).
|
|
750
|
+
const reader = DatagramStream.datagramReader(this.#quic);
|
|
751
|
+
if (!reader)
|
|
752
|
+
return;
|
|
753
|
+
try {
|
|
754
|
+
try {
|
|
755
|
+
for (;;) {
|
|
756
|
+
const { value, done } = await reader.read();
|
|
757
|
+
if (done)
|
|
758
|
+
break;
|
|
759
|
+
if (!value)
|
|
760
|
+
continue;
|
|
761
|
+
try {
|
|
762
|
+
await this.#routeDatagram(value);
|
|
763
|
+
}
|
|
764
|
+
catch (err) {
|
|
765
|
+
console.debug(`dropping datagram: ${reason(err)}`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
finally {
|
|
770
|
+
reader.releaseLock();
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
catch (err) {
|
|
774
|
+
const e = error(err);
|
|
775
|
+
if (e.message === "The session is closed.") {
|
|
776
|
+
console.debug(`datagram receive stopped: ${e.message}`);
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
console.warn("datagram stream error", err);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
// Decode one datagram body and hand it to the matching subscription's producer. Drops the
|
|
784
|
+
// datagram (best-effort) if the subscription is unknown/closed or its timescale isn't resolved.
|
|
785
|
+
async #routeDatagram(payload) {
|
|
786
|
+
const dg = await DatagramMessage.decode(payload);
|
|
787
|
+
const entry = this.#subscribes.get(dg.subscribe);
|
|
788
|
+
if (!entry)
|
|
789
|
+
return; // Unknown or already-closed subscription.
|
|
790
|
+
// Datagrams are lite-05+, which always negotiates a timescale; if it hasn't resolved
|
|
791
|
+
// yet (the datagram raced ahead of TRACK_INFO), drop rather than guess.
|
|
792
|
+
const scale = entry.timescale.peek();
|
|
793
|
+
if (!scale)
|
|
794
|
+
return;
|
|
795
|
+
const timestamp = new Time.Timestamp(dg.timestamp, Time.Timescale(scale));
|
|
796
|
+
entry.track.writeDatagram({ sequence: dg.sequence, timestamp, payload: dg.payload });
|
|
797
|
+
}
|
|
271
798
|
/**
|
|
272
799
|
* Opens a PROBE bidi stream to receive bandwidth estimates from the publisher.
|
|
273
800
|
* Returns immediately if recv bandwidth is not supported.
|
|
@@ -279,14 +806,31 @@ export class Subscriber {
|
|
|
279
806
|
*
|
|
280
807
|
* @internal
|
|
281
808
|
*/
|
|
809
|
+
// Await the peer's advertised probe level, blocking until its SETUP arrives. The peer
|
|
810
|
+
// MUST send exactly one SETUP, so this resolves once that stream is read.
|
|
811
|
+
async #peerProbeLevel(peerSetup) {
|
|
812
|
+
let setup = peerSetup.peek();
|
|
813
|
+
while (setup === undefined) {
|
|
814
|
+
setup = await peerSetup.changed();
|
|
815
|
+
}
|
|
816
|
+
return setup.probe;
|
|
817
|
+
}
|
|
282
818
|
async runProbe() {
|
|
283
|
-
if (!this.#
|
|
819
|
+
if (!this.#probe)
|
|
284
820
|
return;
|
|
285
821
|
if (this.version === Version.DRAFT_01 || this.version === Version.DRAFT_02)
|
|
286
822
|
return;
|
|
823
|
+
// Lite-05+ gates the PROBE stream on the peer advertising Probe >= Report in its
|
|
824
|
+
// SETUP. Wait for the SETUP, then bail if the peer can't report bitrate. Older
|
|
825
|
+
// drafts have no SETUP, so they keep probing unconditionally.
|
|
826
|
+
if (this.#peerSetup) {
|
|
827
|
+
const probe = await this.#peerProbeLevel(this.#peerSetup);
|
|
828
|
+
if (probe < ProbeLevel.Report)
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
287
831
|
// Probe is best-effort: any failure (stream reset by peer, missing peer support,
|
|
288
832
|
// transport hiccup) MUST NOT tear down the connection. On error, drop the
|
|
289
|
-
//
|
|
833
|
+
// estimates so consumers know they're stale.
|
|
290
834
|
try {
|
|
291
835
|
const stream = await Stream.open(this.#quic);
|
|
292
836
|
await stream.writer.u53(StreamId.Probe);
|
|
@@ -294,10 +838,18 @@ export class Subscriber {
|
|
|
294
838
|
const probe = await Probe.decodeMaybe(stream.reader, this.version);
|
|
295
839
|
if (!probe)
|
|
296
840
|
break;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
841
|
+
// lite-03 carries no RTT field, so an absent value there means "not
|
|
842
|
+
// carried" and the last reading stands. From lite-04 the field is
|
|
843
|
+
// always present and 0 explicitly means unknown, so undefined is the
|
|
844
|
+
// peer retracting a value we would otherwise hold forever.
|
|
845
|
+
const prev = this.#probe.peek();
|
|
846
|
+
const rtt = probe.rtt !== undefined ? Time.Milli(probe.rtt) : undefined;
|
|
847
|
+
this.#probe.set({
|
|
848
|
+
// `undefined` is the peer reporting "unknown", not an estimate of
|
|
849
|
+
// zero; letting it through would become a real 0 bps ABR target.
|
|
850
|
+
estimatedRecvRate: probe.bitrate,
|
|
851
|
+
rtt: hasProbeRtt(this.version) ? rtt : (rtt ?? prev.rtt),
|
|
852
|
+
});
|
|
301
853
|
}
|
|
302
854
|
}
|
|
303
855
|
catch (err) {
|
|
@@ -306,16 +858,41 @@ export class Subscriber {
|
|
|
306
858
|
}
|
|
307
859
|
}
|
|
308
860
|
finally {
|
|
309
|
-
this.#
|
|
310
|
-
this.#rtt?.set(undefined);
|
|
861
|
+
this.#probe.set({});
|
|
311
862
|
}
|
|
312
863
|
}
|
|
313
864
|
close() {
|
|
314
865
|
this.#closed.abort();
|
|
315
|
-
for (const track of this.#subscribes.values()) {
|
|
866
|
+
for (const { track } of this.#subscribes.values()) {
|
|
316
867
|
track.close();
|
|
317
868
|
}
|
|
318
869
|
this.#subscribes.clear();
|
|
319
870
|
}
|
|
320
871
|
}
|
|
872
|
+
/**
|
|
873
|
+
* A broadcast consumed from a lite session. It resolves `track.Consumer.info()` and
|
|
874
|
+
* `.fetchGroup()` over the wire (lite-05+ TRACK / FETCH streams) by reaching into the
|
|
875
|
+
* {@link Subscriber} it was opened from, the way the Rust `BroadcastConsumer` holds its
|
|
876
|
+
* session. Live subscribes still flow through the inherited requested() queue.
|
|
877
|
+
*/
|
|
878
|
+
class ConsumeBroadcast extends broadcast.Consumer {
|
|
879
|
+
#subscriber;
|
|
880
|
+
#path;
|
|
881
|
+
constructor(subscriber, path, state) {
|
|
882
|
+
super(state);
|
|
883
|
+
this.#subscriber = subscriber;
|
|
884
|
+
this.#path = path;
|
|
885
|
+
}
|
|
886
|
+
// Preserve the subclass (and its wire-backed info/fetchGroup) when the consume cache shares
|
|
887
|
+
// this broadcast across callers.
|
|
888
|
+
clone() {
|
|
889
|
+
return new ConsumeBroadcast(this.#subscriber, this.#path, this.shareState());
|
|
890
|
+
}
|
|
891
|
+
resolveTrackInfo(name) {
|
|
892
|
+
return this.#subscriber.resolveTrackInfo(this.#path, name);
|
|
893
|
+
}
|
|
894
|
+
fetchGroup(name, sequence, options) {
|
|
895
|
+
return this.#subscriber.fetchGroup(this.#path, name, sequence, options);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
321
898
|
//# sourceMappingURL=subscriber.js.map
|