@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.
Files changed (310) hide show
  1. package/README.md +1 -0
  2. package/announced.d.ts +111 -21
  3. package/announced.d.ts.map +1 -1
  4. package/announced.js +213 -40
  5. package/announced.js.map +1 -1
  6. package/broadcast.d.ts +80 -25
  7. package/broadcast.d.ts.map +1 -1
  8. package/broadcast.js +270 -49
  9. package/broadcast.js.map +1 -1
  10. package/connection/accept.d.ts +6 -0
  11. package/connection/accept.d.ts.map +1 -1
  12. package/connection/accept.js +42 -13
  13. package/connection/accept.js.map +1 -1
  14. package/connection/browser.d.ts +5 -0
  15. package/connection/browser.d.ts.map +1 -0
  16. package/connection/browser.js +31 -0
  17. package/connection/browser.js.map +1 -0
  18. package/connection/connect.d.ts +43 -1
  19. package/connection/connect.d.ts.map +1 -1
  20. package/connection/connect.js +118 -123
  21. package/connection/connect.js.map +1 -1
  22. package/connection/established.d.ts +43 -16
  23. package/connection/established.d.ts.map +1 -1
  24. package/connection/established.js.map +1 -1
  25. package/connection/handshake.d.ts +12 -1
  26. package/connection/handshake.d.ts.map +1 -1
  27. package/connection/handshake.js +30 -14
  28. package/connection/handshake.js.map +1 -1
  29. package/connection/index.d.ts +8 -0
  30. package/connection/index.d.ts.map +1 -1
  31. package/connection/index.js +6 -0
  32. package/connection/index.js.map +1 -1
  33. package/connection/reload.d.ts +66 -9
  34. package/connection/reload.d.ts.map +1 -1
  35. package/connection/reload.js +192 -66
  36. package/connection/reload.js.map +1 -1
  37. package/connection/stats.d.ts +92 -0
  38. package/connection/stats.d.ts.map +1 -0
  39. package/connection/stats.js +35 -0
  40. package/connection/stats.js.map +1 -0
  41. package/connection/transport.d.ts +10 -0
  42. package/connection/transport.d.ts.map +1 -0
  43. package/connection/transport.js +12 -0
  44. package/connection/transport.js.map +1 -0
  45. package/consume.d.ts +44 -0
  46. package/consume.d.ts.map +1 -0
  47. package/consume.js +60 -0
  48. package/consume.js.map +1 -0
  49. package/datagram.d.ts +23 -0
  50. package/datagram.d.ts.map +1 -0
  51. package/datagram.js +3 -0
  52. package/datagram.js.map +1 -0
  53. package/error.d.ts +76 -0
  54. package/error.d.ts.map +1 -0
  55. package/error.js +107 -0
  56. package/error.js.map +1 -0
  57. package/group.d.ts +143 -38
  58. package/group.d.ts.map +1 -1
  59. package/group.js +270 -68
  60. package/group.js.map +1 -1
  61. package/ietf/adapter.d.ts +2 -2
  62. package/ietf/adapter.d.ts.map +1 -1
  63. package/ietf/adapter.js +20 -10
  64. package/ietf/adapter.js.map +1 -1
  65. package/ietf/aliases.d.ts +73 -5
  66. package/ietf/aliases.d.ts.map +1 -1
  67. package/ietf/aliases.js +121 -14
  68. package/ietf/aliases.js.map +1 -1
  69. package/ietf/cluster.d.ts +141 -0
  70. package/ietf/cluster.d.ts.map +1 -0
  71. package/ietf/cluster.js +204 -0
  72. package/ietf/cluster.js.map +1 -0
  73. package/ietf/connection.d.ts +39 -6
  74. package/ietf/connection.d.ts.map +1 -1
  75. package/ietf/connection.js +73 -12
  76. package/ietf/connection.js.map +1 -1
  77. package/ietf/fetch.d.ts +17 -0
  78. package/ietf/fetch.d.ts.map +1 -1
  79. package/ietf/fetch.js +21 -0
  80. package/ietf/fetch.js.map +1 -1
  81. package/ietf/filter.d.ts +87 -0
  82. package/ietf/filter.d.ts.map +1 -0
  83. package/ietf/filter.js +292 -0
  84. package/ietf/filter.js.map +1 -0
  85. package/ietf/index.d.ts +2 -0
  86. package/ietf/index.d.ts.map +1 -1
  87. package/ietf/index.js +3 -0
  88. package/ietf/index.js.map +1 -1
  89. package/ietf/namespace.d.ts +6 -0
  90. package/ietf/namespace.d.ts.map +1 -1
  91. package/ietf/namespace.js +27 -2
  92. package/ietf/namespace.js.map +1 -1
  93. package/ietf/object.d.ts +54 -6
  94. package/ietf/object.d.ts.map +1 -1
  95. package/ietf/object.js +178 -25
  96. package/ietf/object.js.map +1 -1
  97. package/ietf/parameters.d.ts +35 -10
  98. package/ietf/parameters.d.ts.map +1 -1
  99. package/ietf/parameters.js +236 -45
  100. package/ietf/parameters.js.map +1 -1
  101. package/ietf/priority.d.ts +5 -0
  102. package/ietf/priority.d.ts.map +1 -0
  103. package/ietf/priority.js +10 -0
  104. package/ietf/priority.js.map +1 -0
  105. package/ietf/properties.d.ts +13 -2
  106. package/ietf/properties.d.ts.map +1 -1
  107. package/ietf/properties.js +65 -8
  108. package/ietf/properties.js.map +1 -1
  109. package/ietf/publish.d.ts.map +1 -1
  110. package/ietf/publish.js +13 -3
  111. package/ietf/publish.js.map +1 -1
  112. package/ietf/publish_namespace.d.ts +16 -2
  113. package/ietf/publish_namespace.d.ts.map +1 -1
  114. package/ietf/publish_namespace.js +25 -6
  115. package/ietf/publish_namespace.js.map +1 -1
  116. package/ietf/publisher.d.ts +40 -7
  117. package/ietf/publisher.d.ts.map +1 -1
  118. package/ietf/publisher.js +751 -134
  119. package/ietf/publisher.js.map +1 -1
  120. package/ietf/request.js +1 -1
  121. package/ietf/request.js.map +1 -1
  122. package/ietf/solicit.d.ts +41 -0
  123. package/ietf/solicit.d.ts.map +1 -0
  124. package/ietf/solicit.js +47 -0
  125. package/ietf/solicit.js.map +1 -0
  126. package/ietf/subscribe.d.ts +41 -2
  127. package/ietf/subscribe.d.ts.map +1 -1
  128. package/ietf/subscribe.js +103 -22
  129. package/ietf/subscribe.js.map +1 -1
  130. package/ietf/subscribe_namespace.d.ts +21 -7
  131. package/ietf/subscribe_namespace.d.ts.map +1 -1
  132. package/ietf/subscribe_namespace.js +31 -14
  133. package/ietf/subscribe_namespace.js.map +1 -1
  134. package/ietf/subscriber.d.ts +20 -7
  135. package/ietf/subscriber.d.ts.map +1 -1
  136. package/ietf/subscriber.js +512 -142
  137. package/ietf/subscriber.js.map +1 -1
  138. package/ietf/version.d.ts +7 -1
  139. package/ietf/version.d.ts.map +1 -1
  140. package/ietf/version.js +7 -0
  141. package/ietf/version.js.map +1 -1
  142. package/index.d.ts +10 -5
  143. package/index.d.ts.map +1 -1
  144. package/index.js +10 -5
  145. package/index.js.map +1 -1
  146. package/internal.d.ts +29 -0
  147. package/internal.d.ts.map +1 -0
  148. package/internal.js +8 -0
  149. package/internal.js.map +1 -0
  150. package/lite/announce.d.ts +62 -15
  151. package/lite/announce.d.ts.map +1 -1
  152. package/lite/announce.js +230 -94
  153. package/lite/announce.js.map +1 -1
  154. package/lite/connection.d.ts +69 -20
  155. package/lite/connection.d.ts.map +1 -1
  156. package/lite/connection.js +128 -81
  157. package/lite/connection.js.map +1 -1
  158. package/lite/datagram.d.ts +23 -0
  159. package/lite/datagram.d.ts.map +1 -0
  160. package/lite/datagram.js +57 -0
  161. package/lite/datagram.js.map +1 -0
  162. package/lite/datagram_stream.d.ts +8 -0
  163. package/lite/datagram_stream.d.ts.map +1 -0
  164. package/lite/datagram_stream.js +44 -0
  165. package/lite/datagram_stream.js.map +1 -0
  166. package/lite/fetch.d.ts.map +1 -1
  167. package/lite/fetch.js +2 -2
  168. package/lite/fetch.js.map +1 -1
  169. package/lite/index.d.ts +3 -0
  170. package/lite/index.d.ts.map +1 -1
  171. package/lite/index.js +3 -0
  172. package/lite/index.js.map +1 -1
  173. package/lite/priority.d.ts +67 -0
  174. package/lite/priority.d.ts.map +1 -0
  175. package/lite/priority.js +114 -0
  176. package/lite/priority.js.map +1 -0
  177. package/lite/probe.d.ts +18 -2
  178. package/lite/probe.d.ts.map +1 -1
  179. package/lite/probe.js +24 -22
  180. package/lite/probe.js.map +1 -1
  181. package/lite/publisher.d.ts +14 -7
  182. package/lite/publisher.d.ts.map +1 -1
  183. package/lite/publisher.js +406 -84
  184. package/lite/publisher.js.map +1 -1
  185. package/lite/setup.d.ts +83 -6
  186. package/lite/setup.d.ts.map +1 -1
  187. package/lite/setup.js +203 -31
  188. package/lite/setup.js.map +1 -1
  189. package/lite/stream.d.ts +12 -3
  190. package/lite/stream.d.ts.map +1 -1
  191. package/lite/stream.js +7 -4
  192. package/lite/stream.js.map +1 -1
  193. package/lite/subscribe.d.ts +37 -23
  194. package/lite/subscribe.d.ts.map +1 -1
  195. package/lite/subscribe.js +70 -55
  196. package/lite/subscribe.js.map +1 -1
  197. package/lite/subscriber.d.ts +29 -23
  198. package/lite/subscriber.d.ts.map +1 -1
  199. package/lite/subscriber.js +689 -112
  200. package/lite/subscriber.js.map +1 -1
  201. package/lite/track.d.ts +25 -13
  202. package/lite/track.d.ts.map +1 -1
  203. package/lite/track.js +60 -35
  204. package/lite/track.js.map +1 -1
  205. package/lite/version.d.ts +39 -4
  206. package/lite/version.d.ts.map +1 -1
  207. package/lite/version.js +139 -17
  208. package/lite/version.js.map +1 -1
  209. package/mock.d.ts +53 -3
  210. package/mock.d.ts.map +1 -1
  211. package/mock.js +110 -21
  212. package/mock.js.map +1 -1
  213. package/{lite/origin.d.ts → origin.d.ts} +25 -1
  214. package/origin.d.ts.map +1 -0
  215. package/{lite/origin.js → origin.js} +25 -1
  216. package/origin.js.map +1 -0
  217. package/package.json +6 -4
  218. package/path.d.ts +67 -0
  219. package/path.d.ts.map +1 -1
  220. package/path.js +128 -0
  221. package/path.js.map +1 -1
  222. package/stream.d.ts +94 -4
  223. package/stream.d.ts.map +1 -1
  224. package/stream.js +128 -17
  225. package/stream.js.map +1 -1
  226. package/time.d.ts +109 -86
  227. package/time.d.ts.map +1 -1
  228. package/time.js +80 -30
  229. package/time.js.map +1 -1
  230. package/track.d.ts +298 -47
  231. package/track.d.ts.map +1 -1
  232. package/track.js +721 -109
  233. package/track.js.map +1 -1
  234. package/util/timeout.d.ts +5 -0
  235. package/util/timeout.d.ts.map +1 -0
  236. package/util/timeout.js +18 -0
  237. package/util/timeout.js.map +1 -0
  238. package/util/utf8.d.ts +3 -0
  239. package/util/utf8.d.ts.map +1 -0
  240. package/util/utf8.js +7 -0
  241. package/util/utf8.js.map +1 -0
  242. package/varint.d.ts +14 -3
  243. package/varint.d.ts.map +1 -1
  244. package/varint.js +23 -12
  245. package/varint.js.map +1 -1
  246. package/zod.d.ts +5 -5
  247. package/zod.d.ts.map +1 -1
  248. package/zod.js.map +1 -1
  249. package/bandwidth.d.ts +0 -11
  250. package/bandwidth.d.ts.map +0 -1
  251. package/bandwidth.js +0 -7
  252. package/bandwidth.js.map +0 -1
  253. package/connection/cert.test.d.ts +0 -2
  254. package/connection/cert.test.d.ts.map +0 -1
  255. package/connection/cert.test.js +0 -26
  256. package/connection/cert.test.js.map +0 -1
  257. package/connection/reload.test.d.ts +0 -2
  258. package/connection/reload.test.d.ts.map +0 -1
  259. package/connection/reload.test.js +0 -39
  260. package/connection/reload.test.js.map +0 -1
  261. package/group.test.d.ts +0 -2
  262. package/group.test.d.ts.map +0 -1
  263. package/group.test.js +0 -66
  264. package/group.test.js.map +0 -1
  265. package/ietf/aliases.test.d.ts +0 -2
  266. package/ietf/aliases.test.d.ts.map +0 -1
  267. package/ietf/aliases.test.js +0 -30
  268. package/ietf/aliases.test.js.map +0 -1
  269. package/ietf/ietf.test.d.ts +0 -2
  270. package/ietf/ietf.test.d.ts.map +0 -1
  271. package/ietf/ietf.test.js +0 -873
  272. package/ietf/ietf.test.js.map +0 -1
  273. package/integration.test.d.ts +0 -2
  274. package/integration.test.d.ts.map +0 -1
  275. package/integration.test.js +0 -90
  276. package/integration.test.js.map +0 -1
  277. package/lite/origin.d.ts.map +0 -1
  278. package/lite/origin.js.map +0 -1
  279. package/lite/subscriber.test.d.ts +0 -2
  280. package/lite/subscriber.test.d.ts.map +0 -1
  281. package/lite/subscriber.test.js +0 -30
  282. package/lite/subscriber.test.js.map +0 -1
  283. package/lite/track.test.d.ts +0 -2
  284. package/lite/track.test.d.ts.map +0 -1
  285. package/lite/track.test.js +0 -53
  286. package/lite/track.test.js.map +0 -1
  287. package/path.test.d.ts +0 -2
  288. package/path.test.d.ts.map +0 -1
  289. package/path.test.js +0 -169
  290. package/path.test.js.map +0 -1
  291. package/stream.test.d.ts +0 -2
  292. package/stream.test.d.ts.map +0 -1
  293. package/stream.test.js +0 -210
  294. package/stream.test.js.map +0 -1
  295. package/track.test.d.ts +0 -2
  296. package/track.test.d.ts.map +0 -1
  297. package/track.test.js +0 -56
  298. package/track.test.js.map +0 -1
  299. package/util/error.d.ts +0 -3
  300. package/util/error.d.ts.map +0 -1
  301. package/util/error.js +0 -9
  302. package/util/error.js.map +0 -1
  303. package/util/index.d.ts +0 -2
  304. package/util/index.d.ts.map +0 -1
  305. package/util/index.js +0 -3
  306. package/util/index.js.map +0 -1
  307. package/varint.test.d.ts +0 -2
  308. package/varint.test.d.ts.map +0 -1
  309. package/varint.test.js +0 -106
  310. package/varint.test.js.map +0 -1
@@ -1,20 +1,70 @@
1
1
  /* @ts-self-types="./subscriber.d.ts" */
2
- import { Announced } from "../announced.js";
3
- import { Broadcast } from "../broadcast.js";
4
- import { Group } from "../group.js";
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 { error } from "../util/error.js";
8
- import { Announce, AnnounceInit, AnnounceInterest } from "./announce.js";
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 { hasTrackStream, Version } from "./version.js";
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
- // Recv bandwidth producer (Lite03+ only).
29
- #recvBandwidth;
30
- // RTT producer (Lite04+ only).
31
- #rtt;
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 recvBandwidth - Optional bandwidth producer for PROBE
40
- * @param rtt - Optional RTT signal for PROBE
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, recvBandwidth, rtt) {
103
+ constructor(quic, version, origin, probe, peerSetup) {
45
104
  this.#quic = quic;
46
105
  this.version = version;
47
106
  this.origin = origin;
48
- this.#recvBandwidth = recvBandwidth;
49
- this.#rtt = rtt;
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 Announced();
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
- // Send our own session-level origin id so the peer can skip announces
65
- // whose hop chain already passed through us. Matches the Rust subscriber's
66
- // `exclude_hop: self.self_origin.id` in `run_announce_prefix`.
67
- const msg = new AnnounceInterest(prefix, this.origin);
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
- // Open a stream and send the announce interest.
70
- const stream = await Stream.open(this.#quic);
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
- Announce.decodeMaybe(stream.reader, this.version),
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
- // Optionally drop reflected announces so callers asking for
101
- // "someone else's broadcasts" don't re-see their own publishes.
102
- if (options.ignoreSelf && announce.hops.includes(this.origin)) {
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
- const path = Path.join(prefix, announce.suffix);
106
- console.debug(`announced: broadcast=${path} active=${announce.active}`);
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
- announced.close(error(err));
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
- const broadcast = new Broadcast();
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 broadcast.requested();
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 broadcast;
363
+ return consumer;
132
364
  }
133
365
  async #runSubscribe(broadcast, request) {
134
366
  const id = this.#subscribeNext++;
135
- // Save the writer so we can append groups to it.
136
- this.#subscribes.set(id, request.track);
137
- console.debug(`subscribe start: id=${id} broadcast=${broadcast} track=${request.track.name}`);
138
- const msg = new Subscribe({ id, broadcast, track: request.track.name, priority: request.priority });
139
- const stream = await Stream.open(this.#quic);
140
- await stream.writer.u53(StreamId.Subscribe);
141
- await msg.encode(stream.writer, this.version);
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
- // The first response MUST be a SUBSCRIBE_OK.
144
- const resp = await decodeSubscribeResponse(stream.reader, this.version);
145
- if (!("ok" in resp)) {
146
- throw new Error("first subscribe response must be SUBSCRIBE_OK");
147
- }
148
- console.debug(`subscribe ok: id=${id} broadcast=${broadcast} track=${request.track.name}`);
149
- // Watch for priority changes and send SUBSCRIBE_UPDATE. Lite01/Lite02
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
- // Draining responses also consumes any SUBSCRIBE_END / SUBSCRIBE_DROP and
153
- // resolves when the publisher FINs.
154
- const waits = [this.#drainResponses(stream), request.track.closed];
155
- switch (this.version) {
156
- case Version.DRAFT_01:
157
- case Version.DRAFT_02:
158
- break;
159
- default:
160
- waits.push(this.#runPriorityUpdates(id, broadcast, request.track, msg, stream));
161
- break;
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
- await Promise.race(waits);
164
- request.track.close();
431
+ producer.close();
165
432
  stream.close();
166
- console.debug(`subscribe close: id=${id} broadcast=${broadcast} track=${request.track.name}`);
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
- request.track.close(e);
171
- console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${request.track.name} error=${e.message}`);
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
- * Drain responses on the subscribe stream until the publisher FINs.
180
- *
181
- * The first SUBSCRIBE_OK is consumed by the caller; this reads any further
182
- * SUBSCRIBE_END / SUBSCRIBE_DROP. They aren't acted on yet (groups arrive on
183
- * their own streams regardless), but consuming them keeps the stream aligned.
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
- for (;;) {
187
- const resp = await decodeSubscribeResponseMaybe(stream.reader, this.version);
188
- if (!resp)
189
- break;
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 priority signal changes.
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 updatePriority after close.
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 #runPriorityUpdates(id, broadcast, track, msg, stream) {
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.state.priority.peek();
208
- if (current === undefined || current === lastSent) {
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.state.priority.next(), stopped]);
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: msg.ordered,
220
- maxLatency: msg.maxLatency,
221
- startGroup: msg.startGroup,
222
- endGroup: msg.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} priority=${current}`);
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 subscribe = this.#subscribes.get(group.subscribe);
238
- if (!subscribe) {
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 producer = new Group(group.sequence);
245
- subscribe.writeGroup(producer);
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(), subscribe.closed, producer.closed]);
709
+ const done = await Promise.race([stream.done(), track.closed, producer.closed]);
249
710
  if (done !== false)
250
711
  break;
251
- if (hasTrackStream(this.version)) {
252
- // moq-lite-05+ prefixes each frame with a zigzag timestamp delta. Decode it
253
- // to stay aligned with the wire, but don't surface it yet.
254
- await stream.u62();
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.#recvBandwidth)
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
- // bandwidth/RTT estimates so consumers know they're stale.
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
- this.#recvBandwidth.set(probe.bitrate ?? undefined);
298
- if (this.#rtt && probe.rtt !== undefined) {
299
- this.#rtt.set(probe.rtt);
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.#recvBandwidth.set(undefined);
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