@norskvideo/moq-net 0.1.7 → 0.2.0

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 (286) hide show
  1. package/README.md +2 -2
  2. package/announce.d.ts +7 -0
  3. package/announce.d.ts.map +1 -0
  4. package/announce.js +8 -0
  5. package/announce.js.map +1 -0
  6. package/announced.d.ts +49 -91
  7. package/announced.d.ts.map +1 -1
  8. package/announced.js +21 -156
  9. package/announced.js.map +1 -1
  10. package/bandwidth.d.ts +163 -0
  11. package/bandwidth.d.ts.map +1 -0
  12. package/bandwidth.js +304 -0
  13. package/bandwidth.js.map +1 -0
  14. package/bandwidth_api.d.ts +7 -0
  15. package/bandwidth_api.d.ts.map +1 -0
  16. package/bandwidth_api.js +8 -0
  17. package/bandwidth_api.js.map +1 -0
  18. package/broadcast.d.ts +44 -35
  19. package/broadcast.d.ts.map +1 -1
  20. package/broadcast.js +104 -60
  21. package/broadcast.js.map +1 -1
  22. package/connection/accept.d.ts +16 -1
  23. package/connection/accept.d.ts.map +1 -1
  24. package/connection/accept.js +52 -28
  25. package/connection/accept.js.map +1 -1
  26. package/connection/browser.d.ts.map +1 -1
  27. package/connection/browser.js +9 -7
  28. package/connection/browser.js.map +1 -1
  29. package/connection/connect.d.ts +30 -6
  30. package/connection/connect.d.ts.map +1 -1
  31. package/connection/connect.js +110 -54
  32. package/connection/connect.js.map +1 -1
  33. package/connection/established.d.ts +17 -21
  34. package/connection/established.d.ts.map +1 -1
  35. package/connection/established.js.map +1 -1
  36. package/connection/forward.d.ts +2 -0
  37. package/connection/forward.d.ts.map +1 -0
  38. package/connection/forward.js +173 -0
  39. package/connection/forward.js.map +1 -0
  40. package/connection/handshake.d.ts +1 -0
  41. package/connection/handshake.d.ts.map +1 -1
  42. package/connection/handshake.js +5 -2
  43. package/connection/handshake.js.map +1 -1
  44. package/connection/index.d.ts +5 -5
  45. package/connection/index.d.ts.map +1 -1
  46. package/connection/index.js +4 -5
  47. package/connection/index.js.map +1 -1
  48. package/connection/pool.d.ts +186 -0
  49. package/connection/pool.d.ts.map +1 -0
  50. package/connection/pool.js +361 -0
  51. package/connection/pool.js.map +1 -0
  52. package/connection/reload.d.ts +14 -93
  53. package/connection/reload.d.ts.map +1 -1
  54. package/connection/reload.js +217 -81
  55. package/connection/reload.js.map +1 -1
  56. package/connection/stats.d.ts +3 -26
  57. package/connection/stats.d.ts.map +1 -1
  58. package/connection/stats.js.map +1 -1
  59. package/connection/transport.d.ts +0 -7
  60. package/connection/transport.d.ts.map +1 -1
  61. package/consume.d.ts +1 -43
  62. package/consume.d.ts.map +1 -1
  63. package/consume.js +1 -1
  64. package/consume.js.map +1 -1
  65. package/error.d.ts +180 -29
  66. package/error.d.ts.map +1 -1
  67. package/error.js +331 -16
  68. package/error.js.map +1 -1
  69. package/errors.d.ts +7 -0
  70. package/errors.d.ts.map +1 -0
  71. package/errors.js +8 -0
  72. package/errors.js.map +1 -0
  73. package/group.d.ts +9 -43
  74. package/group.d.ts.map +1 -1
  75. package/group.js +284 -69
  76. package/group.js.map +1 -1
  77. package/hop.d.ts +115 -0
  78. package/hop.d.ts.map +1 -0
  79. package/hop.js +119 -0
  80. package/hop.js.map +1 -0
  81. package/ietf/adapter.d.ts +5 -1
  82. package/ietf/adapter.d.ts.map +1 -1
  83. package/ietf/adapter.js +105 -60
  84. package/ietf/adapter.js.map +1 -1
  85. package/ietf/aliases.d.ts +1 -78
  86. package/ietf/aliases.d.ts.map +1 -1
  87. package/ietf/cluster.d.ts +9 -123
  88. package/ietf/cluster.d.ts.map +1 -1
  89. package/ietf/cluster.js +84 -44
  90. package/ietf/cluster.js.map +1 -1
  91. package/ietf/connection.d.ts +6 -72
  92. package/ietf/connection.d.ts.map +1 -1
  93. package/ietf/connection.js +62 -56
  94. package/ietf/connection.js.map +1 -1
  95. package/ietf/error.d.ts +11 -0
  96. package/ietf/error.d.ts.map +1 -0
  97. package/ietf/error.js +193 -0
  98. package/ietf/error.js.map +1 -0
  99. package/ietf/fetch.d.ts +7 -20
  100. package/ietf/fetch.d.ts.map +1 -1
  101. package/ietf/fetch.js +52 -22
  102. package/ietf/fetch.js.map +1 -1
  103. package/ietf/filter.d.ts +2 -0
  104. package/ietf/filter.d.ts.map +1 -1
  105. package/ietf/filter.js +10 -1
  106. package/ietf/filter.js.map +1 -1
  107. package/ietf/goaway.d.ts.map +1 -1
  108. package/ietf/goaway.js +22 -5
  109. package/ietf/goaway.js.map +1 -1
  110. package/ietf/hidden.d.ts +2 -0
  111. package/ietf/hidden.d.ts.map +1 -0
  112. package/ietf/hidden.js +30 -0
  113. package/ietf/hidden.js.map +1 -0
  114. package/ietf/index.d.ts +2 -0
  115. package/ietf/index.d.ts.map +1 -1
  116. package/ietf/index.js +2 -0
  117. package/ietf/index.js.map +1 -1
  118. package/ietf/object.d.ts +15 -8
  119. package/ietf/object.d.ts.map +1 -1
  120. package/ietf/object.js +51 -36
  121. package/ietf/object.js.map +1 -1
  122. package/ietf/parameters.d.ts +14 -2
  123. package/ietf/parameters.d.ts.map +1 -1
  124. package/ietf/parameters.js +97 -29
  125. package/ietf/parameters.js.map +1 -1
  126. package/ietf/properties.d.ts +1 -0
  127. package/ietf/properties.d.ts.map +1 -1
  128. package/ietf/properties.js +14 -0
  129. package/ietf/properties.js.map +1 -1
  130. package/ietf/publish.d.ts +19 -2
  131. package/ietf/publish.d.ts.map +1 -1
  132. package/ietf/publish.js +40 -6
  133. package/ietf/publish.js.map +1 -1
  134. package/ietf/publish_namespace.d.ts +25 -0
  135. package/ietf/publish_namespace.d.ts.map +1 -1
  136. package/ietf/publish_namespace.js +63 -0
  137. package/ietf/publish_namespace.js.map +1 -1
  138. package/ietf/publisher.d.ts +1 -82
  139. package/ietf/publisher.d.ts.map +1 -1
  140. package/ietf/publisher.js +483 -235
  141. package/ietf/publisher.js.map +1 -1
  142. package/ietf/solicit.d.ts +1 -40
  143. package/ietf/solicit.d.ts.map +1 -1
  144. package/ietf/subscribe.d.ts +8 -6
  145. package/ietf/subscribe.d.ts.map +1 -1
  146. package/ietf/subscribe.js +33 -27
  147. package/ietf/subscribe.js.map +1 -1
  148. package/ietf/subscribe_namespace.d.ts +8 -2
  149. package/ietf/subscribe_namespace.d.ts.map +1 -1
  150. package/ietf/subscribe_namespace.js +18 -8
  151. package/ietf/subscribe_namespace.js.map +1 -1
  152. package/ietf/subscriber.d.ts +1 -65
  153. package/ietf/subscriber.d.ts.map +1 -1
  154. package/ietf/subscriber.js +337 -123
  155. package/ietf/subscriber.js.map +1 -1
  156. package/ietf/token.d.ts +2 -0
  157. package/ietf/token.d.ts.map +1 -0
  158. package/ietf/token.js +99 -0
  159. package/ietf/token.js.map +1 -0
  160. package/ietf/track.d.ts +4 -0
  161. package/ietf/track.d.ts.map +1 -1
  162. package/ietf/track.js +6 -20
  163. package/ietf/track.js.map +1 -1
  164. package/ietf/version.d.ts +12 -1
  165. package/ietf/version.d.ts.map +1 -1
  166. package/ietf/version.js +13 -0
  167. package/ietf/version.js.map +1 -1
  168. package/index.d.ts +12 -7
  169. package/index.d.ts.map +1 -1
  170. package/index.js +10 -5
  171. package/index.js.map +1 -1
  172. package/internal.d.ts +115 -1
  173. package/internal.d.ts.map +1 -1
  174. package/internal.js +108 -0
  175. package/internal.js.map +1 -1
  176. package/lite/announce.d.ts +60 -10
  177. package/lite/announce.d.ts.map +1 -1
  178. package/lite/announce.js +176 -31
  179. package/lite/announce.js.map +1 -1
  180. package/lite/connection.d.ts +9 -59
  181. package/lite/connection.d.ts.map +1 -1
  182. package/lite/connection.js +34 -35
  183. package/lite/connection.js.map +1 -1
  184. package/lite/datagram.d.ts +3 -2
  185. package/lite/datagram.d.ts.map +1 -1
  186. package/lite/datagram.js +7 -8
  187. package/lite/datagram.js.map +1 -1
  188. package/lite/fetch.d.ts +15 -1
  189. package/lite/fetch.d.ts.map +1 -1
  190. package/lite/fetch.js +39 -7
  191. package/lite/fetch.js.map +1 -1
  192. package/lite/goaway.d.ts.map +1 -1
  193. package/lite/goaway.js +7 -1
  194. package/lite/goaway.js.map +1 -1
  195. package/lite/group.d.ts +30 -12
  196. package/lite/group.d.ts.map +1 -1
  197. package/lite/group.js +68 -26
  198. package/lite/group.js.map +1 -1
  199. package/lite/message.d.ts +2 -2
  200. package/lite/message.d.ts.map +1 -1
  201. package/lite/message.js +14 -5
  202. package/lite/message.js.map +1 -1
  203. package/lite/priority.d.ts +1 -61
  204. package/lite/priority.d.ts.map +1 -1
  205. package/lite/priority.js +4 -5
  206. package/lite/priority.js.map +1 -1
  207. package/lite/publisher.d.ts +1 -69
  208. package/lite/publisher.d.ts.map +1 -1
  209. package/lite/publisher.js +630 -262
  210. package/lite/publisher.js.map +1 -1
  211. package/lite/setup.d.ts +8 -8
  212. package/lite/setup.d.ts.map +1 -1
  213. package/lite/setup.js +33 -30
  214. package/lite/setup.js.map +1 -1
  215. package/lite/subscribe.d.ts +71 -17
  216. package/lite/subscribe.d.ts.map +1 -1
  217. package/lite/subscribe.js +205 -53
  218. package/lite/subscribe.js.map +1 -1
  219. package/lite/subscriber.d.ts +15 -56
  220. package/lite/subscriber.d.ts.map +1 -1
  221. package/lite/subscriber.js +451 -247
  222. package/lite/subscriber.js.map +1 -1
  223. package/lite/track.d.ts +4 -10
  224. package/lite/track.d.ts.map +1 -1
  225. package/lite/track.js +34 -29
  226. package/lite/track.js.map +1 -1
  227. package/lite/version.d.ts +42 -6
  228. package/lite/version.d.ts.map +1 -1
  229. package/lite/version.js +126 -10
  230. package/lite/version.js.map +1 -1
  231. package/origin.d.ts +256 -29
  232. package/origin.d.ts.map +1 -1
  233. package/origin.js +1427 -37
  234. package/origin.js.map +1 -1
  235. package/package.json +8 -3
  236. package/path.d.ts +25 -7
  237. package/path.d.ts.map +1 -1
  238. package/path.js +5 -3
  239. package/path.js.map +1 -1
  240. package/stream.d.ts +73 -14
  241. package/stream.d.ts.map +1 -1
  242. package/stream.js +372 -141
  243. package/stream.js.map +1 -1
  244. package/tail.d.ts +18 -0
  245. package/tail.d.ts.map +1 -0
  246. package/tail.js +167 -0
  247. package/tail.js.map +1 -0
  248. package/time.d.ts +15 -2
  249. package/time.d.ts.map +1 -1
  250. package/time.js +28 -9
  251. package/time.js.map +1 -1
  252. package/track.d.ts +211 -83
  253. package/track.d.ts.map +1 -1
  254. package/track.js +816 -205
  255. package/track.js.map +1 -1
  256. package/util/abort.d.ts +2 -0
  257. package/util/abort.d.ts.map +1 -0
  258. package/util/abort.js +20 -0
  259. package/util/abort.js.map +1 -0
  260. package/util/log.d.ts +5 -0
  261. package/util/log.d.ts.map +1 -0
  262. package/util/log.js +17 -0
  263. package/util/log.js.map +1 -0
  264. package/util/u64.d.ts +39 -0
  265. package/util/u64.d.ts.map +1 -0
  266. package/util/u64.js +83 -0
  267. package/util/u64.js.map +1 -0
  268. package/util/varint.d.ts +29 -0
  269. package/util/varint.d.ts.map +1 -0
  270. package/util/varint.js +198 -0
  271. package/util/varint.js.map +1 -0
  272. package/varint.d.ts +10 -6
  273. package/varint.d.ts.map +1 -1
  274. package/varint.js +40 -237
  275. package/varint.js.map +1 -1
  276. package/wire.d.ts +80 -0
  277. package/wire.d.ts.map +1 -0
  278. package/wire.js +32 -0
  279. package/wire.js.map +1 -0
  280. package/zod.d.ts +1 -1
  281. package/zod.d.ts.map +1 -1
  282. package/zod.js.map +1 -1
  283. package/mock.d.ts +0 -66
  284. package/mock.d.ts.map +0 -1
  285. package/mock.js +0 -243
  286. package/mock.js.map +0 -1
package/lite/publisher.js CHANGED
@@ -1,18 +1,20 @@
1
1
  /* @ts-self-types="./publisher.d.ts" */
2
- import { Signal } from "@norskvideo/moq-signals";
3
- import { error, reason } from "../error.js";
4
- import * as Path from "../path.js";
2
+ import { race, Signal } from "@norskvideo/moq-signals";
3
+ import { error, NotFound, reason, StreamCode, StreamError } from "../error.js";
4
+ import { Cost, routesEqual } from "../hop.js";
5
+ import { hiddenBelow, hooks, presented } from "../internal.js";
5
6
  import { Writer } from "../stream.js";
6
- import { Timescale } from "../time.js";
7
+ import { Milli, Timescale } from "../time.js";
8
+ import { wireOf } from "../wire.js";
7
9
  import { AnnounceInit, AnnounceOk, encodeAnnounceBroadcast } from "./announce.js";
8
10
  import { Datagram as DatagramMessage } from "./datagram.js";
9
11
  import * as DatagramStream from "./datagram_stream.js";
10
12
  import { Group as GroupMessage } from "./group.js";
11
13
  import { Priority, sendOrder } from "./priority.js";
12
14
  import { Probe } from "./probe.js";
13
- import { encodeSubscribeResponse, SubscribeEnd, SubscribeOk, SubscribeStart, SubscribeUpdate, } from "./subscribe.js";
15
+ import { encodeSubscribeResponse, exclusiveGroupEnd, SubscribeEnd, SubscribeOk, SubscribeStart, SubscribeUpdate, } from "./subscribe.js";
14
16
  import { TrackInfo as TrackInfoMessage } from "./track.js";
15
- import { hasAnnounceId, hasAnnounceOk, hasDatagrams, hasProbeRtt, Version } from "./version.js";
17
+ import { hasAnnounceId, hasAnnounceOk, hasDatagrams, hasProbeRtt, hasRouteCost, hasStreamCount, resolvesStart, Version, } from "./version.js";
16
18
  const PROBE_INTERVAL = 100; // ms
17
19
  const PROBE_MAX_AGE = 10_000; // ms
18
20
  const PROBE_MAX_DELTA = 0.25;
@@ -34,6 +36,175 @@ function supportsTrackStream(version) {
34
36
  return true;
35
37
  }
36
38
  }
39
+ /**
40
+ * The frames of `sequence` a subscription asked for, as a start index and an inclusive end.
41
+ *
42
+ * The frame bounds qualify the start and end group only; every other group is served whole.
43
+ * Which groups are served at all is the subscriber's read cursor (`replaceGroups`),
44
+ * applied when a group is popped rather than re-checked here.
45
+ *
46
+ * The serving loop calls this synchronously after the pop, before any SUBSCRIBE_UPDATE can
47
+ * change `bounds`. Nothing downstream trims the frame range: it is a wire request,
48
+ * deliberately decoupled from the receiver's local read cursor.
49
+ */
50
+ function frameRange(bounds, sequence) {
51
+ return {
52
+ start: bounds.startGroup === sequence ? bounds.startFrame : 0,
53
+ end: bounds.endGroup === sequence ? bounds.endFrame : undefined,
54
+ };
55
+ }
56
+ /**
57
+ * The subscribe stream's control half, decoded ahead of the serving loop.
58
+ *
59
+ * Decoding runs on its own and publishes each full subscription update immediately, so a
60
+ * blocked response write cannot delay re-ranking streams already in flight. It separately
61
+ * stores only the latest range state for the serving loop, which owns the local track cursor
62
+ * and frame bounds. That keeps a group pop and its frame-range snapshot one indivisible step.
63
+ *
64
+ * Reading ahead makes control-first ordering hold for a burst. Coalescing bounds memory while
65
+ * preserving the newest state decoded before the next group pop. The Rust publisher gets the
66
+ * same ordering from `poll_decode_maybe`, which decodes straight out of the reader's buffer;
67
+ * nothing here can decode synchronously, so it reads ahead instead.
68
+ */
69
+ class SubscriptionControls {
70
+ #writer;
71
+ #update;
72
+ // Sticky, first one wins: null once the stream is over, an Error once it failed.
73
+ #end;
74
+ #ended;
75
+ #resolveEnd;
76
+ #changed = new Signal(0);
77
+ /** Settles once decoding stops, so teardown can wait for it rather than leaving it running. */
78
+ decoding;
79
+ constructor({ reader, writer, version, apply }) {
80
+ this.#writer = writer;
81
+ this.#ended = new Promise((resolve) => {
82
+ this.#resolveEnd = resolve;
83
+ });
84
+ this.decoding = this.#decode(reader, version, apply);
85
+ // Our own half going away ends the loop too, and has to reach it the same way: the
86
+ // loop looks at nothing else.
87
+ void writer.closed.then(() => this.#finish(null), (err) => this.#finish(error(err)));
88
+ }
89
+ /** The next control to apply, or undefined while the peer is quiet. */
90
+ take() {
91
+ const update = this.#update;
92
+ this.#update = undefined;
93
+ // An update decoded before the stream ended still applies before the sticky end.
94
+ if (update)
95
+ return { kind: "update", update };
96
+ if (this.#end === undefined)
97
+ return undefined;
98
+ return this.#end === null ? { kind: "done" } : { kind: "error", error: this.#end };
99
+ }
100
+ /** Calls `fn` once {@link take} may answer differently. */
101
+ changed(fn) {
102
+ return this.#changed.changed(fn);
103
+ }
104
+ /** Returns false when peer departure supersedes a blocked response write. */
105
+ async response(pending) {
106
+ // `#ended` lives as long as the stream, so it is raced as-is rather than mapped per call.
107
+ const result = await race([
108
+ pending.then(() => ({ kind: "sent" }), (err) => ({ kind: "error", error: error(err) })),
109
+ this.#ended,
110
+ ]);
111
+ if (result !== null && !(result instanceof Error)) {
112
+ if (result.kind === "sent")
113
+ return true;
114
+ throw result.error;
115
+ }
116
+ // The race leaves the blocked encode running, so reset the writable half too.
117
+ this.#writer.reset(result ?? new StreamError(StreamCode.Cancel, { message: "cancel" }));
118
+ if (result)
119
+ throw result;
120
+ return false;
121
+ }
122
+ /** Settles once the stream is over: `null` when it ended cleanly, or the failure. */
123
+ get ended() {
124
+ return this.#ended;
125
+ }
126
+ #finish(end) {
127
+ if (this.#end !== undefined)
128
+ return;
129
+ this.#end = end;
130
+ this.#resolveEnd(end);
131
+ this.#changed.update((value) => value + 1);
132
+ }
133
+ async #decode(reader, version, apply) {
134
+ try {
135
+ while (this.#end === undefined) {
136
+ const update = await SubscribeUpdate.decodeMaybe(reader, version);
137
+ if (!update)
138
+ break;
139
+ apply(update);
140
+ this.#update = update;
141
+ this.#changed.update((value) => value + 1);
142
+ }
143
+ }
144
+ catch (err) {
145
+ this.#finish(error(err));
146
+ return;
147
+ }
148
+ this.#finish(null);
149
+ }
150
+ }
151
+ // A microtask is too short: decoding one framed update crosses several awaits, each of which
152
+ // can requeue behind the serving continuation. A task boundary lets the decoder finish whatever
153
+ // the transport already delivered before the next group pop. Updates are rare, so groups do not
154
+ // pay this scheduling cost on the normal path.
155
+ const yieldToControls = () => new Promise((resolve) => setTimeout(resolve, 0));
156
+ // Register both readiness sources in the same turn after the caller observed neither ready.
157
+ // The winner disposes both registrations, so an idle subscription accumulates nothing no
158
+ // matter how many times it wakes.
159
+ function waitForSubscription(controls, subscriber) {
160
+ return new Promise((resolve) => {
161
+ let settled = false;
162
+ const dispose = [];
163
+ const wake = () => {
164
+ if (settled)
165
+ return;
166
+ settled = true;
167
+ for (const close of dispose)
168
+ close();
169
+ resolve();
170
+ };
171
+ dispose.push(controls.changed(wake), hooks.groupChanged(subscriber, wake));
172
+ });
173
+ }
174
+ /**
175
+ * The budget to serve a peer with, given what its wire could tell us.
176
+ *
177
+ * A version without the field decodes as `0`, which is indistinguishable from a peer
178
+ * genuinely asking for the live edge. Serving that as real time would discard backlog
179
+ * a legacy subscriber never declined, so fall back to a window wide enough not to drop
180
+ * and leave enforcement to the receiver, as the IETF path does for the same reason.
181
+ */
182
+ function servingMaxAge(version, requested) {
183
+ return carriesMaxAge(version) ? (requested ?? 0) : Number.MAX_SAFE_INTEGER;
184
+ }
185
+ /** Whether this version's SUBSCRIBE carries Subscriber Max Age at all. */
186
+ function carriesMaxAge(version) {
187
+ return version !== Version.DRAFT_01 && version !== Version.DRAFT_02;
188
+ }
189
+ /**
190
+ * Position a subscription's read cursor for the wire serving it.
191
+ *
192
+ * On lite-06 there is nothing to do: the cursor is floored at the group the subscription
193
+ * named (or 0), and its Max Age decides what above the floor is worth delivering.
194
+ *
195
+ * Pre-06 wires are the exception: their drafts define an absent `Group Start` as the
196
+ * latest group, so say so explicitly rather than letting the budget reach back. Lite-03/04/05
197
+ * carry a Max Age, but there it is a staleness tolerance only; lite-01/02 additionally get
198
+ * an unbounded budget so nothing is dropped under them (see {@link servingMaxAge}), which
199
+ * must not read as a request to replay the whole cache on join.
200
+ */
201
+ function positionCursor(track, version, startGroup) {
202
+ if (resolvesStart(version) || startGroup !== undefined)
203
+ return;
204
+ const latest = track.latest();
205
+ if (latest !== undefined)
206
+ hooks.replaceGroups(track, { start: { included: latest } });
207
+ }
37
208
  /**
38
209
  * Handles publishing broadcasts and managing their lifecycle.
39
210
  *
@@ -46,58 +217,45 @@ export class Publisher {
46
217
  // can detect loops and prefer shorter paths. Created by Connection and
47
218
  // shared with Subscriber, which can optionally use it to filter out its
48
219
  // own announcements.
49
- origin;
220
+ hop;
50
221
  #quic;
51
222
  // The one writer for the outbound datagram stream (getWriter locks it), acquired once at
52
223
  // construction when this version + transport carry datagrams, released in close(). Its
53
224
  // presence is the gate: undefined means datagrams aren't served on this connection. All
54
225
  // subscriptions share it, since a second getWriter on the same stream would throw.
55
226
  #datagramWriter;
56
- // Our published broadcasts.
57
- // It's a signal so we can live update any announce streams.
58
- #broadcasts = new Signal(new Map());
227
+ // Originated advertisements this session forwards.
228
+ #advertised;
229
+ #publish;
59
230
  // TRACK_INFO is immutable per track, so resolve it from the application once
60
231
  // (via a throwaway subscribe whose info() resolves when the app calls accept)
61
- // and reuse it for every later TRACK request of the same track. Keyed by
62
- // `broadcast\0track`. A rejected lookup is evicted so a retry can re-probe.
63
- #trackInfo = new Map();
232
+ // and reuse it for every later TRACK request of the same track. Keyed by the
233
+ // routing front rather than the path: immutability holds for one broadcast, and a
234
+ // republish puts a different one on the path, so its entries must not be reused.
235
+ // A rejected lookup is evicted so a retry can re-probe.
236
+ #trackInfo = new WeakMap();
64
237
  /**
65
238
  * Creates a new Publisher instance.
66
239
  * @param quic - The WebTransport session to use
67
240
  * @param version - Negotiated protocol version
68
- * @param origin - Origin id shared with the Subscriber
241
+ * @param origin - Hop id shared with the Subscriber
242
+ * @param publish - The origin whose broadcasts this session serves; omit to publish nothing
69
243
  *
70
244
  * @internal
71
245
  */
72
- constructor(quic, version, origin) {
246
+ constructor(quic, version, hop, publish) {
73
247
  this.#quic = quic;
74
248
  this.version = version;
75
- this.origin = origin;
249
+ this.hop = hop;
250
+ const origin = publish && wireOf(publish);
251
+ this.#advertised = origin?.advertised ?? new Signal(new Map());
252
+ this.#publish = publish;
76
253
  // Grab the datagram writer up front when the transport carries datagrams (no group
77
254
  // fallback, so it stays undefined otherwise). One writer for all subscriptions.
78
255
  if (hasDatagrams(version)) {
79
256
  this.#datagramWriter = DatagramStream.datagramWriter(quic);
80
257
  }
81
258
  }
82
- /**
83
- * Publishes a broadcast with any associated tracks.
84
- * @param name - The broadcast to publish
85
- */
86
- publish(path, broadcast) {
87
- this.#broadcasts.mutate((broadcasts) => {
88
- if (!broadcasts)
89
- throw new Error("closed");
90
- broadcasts.set(path, broadcast);
91
- });
92
- // Remove the broadcast from the lookup when it's closed, unless the path was republished.
93
- void broadcast.closed.then(() => {
94
- this.#broadcasts.mutate((broadcasts) => {
95
- if (broadcasts?.get(path) === broadcast) {
96
- broadcasts.delete(path);
97
- }
98
- });
99
- });
100
- }
101
259
  /**
102
260
  * Handles an announce interest message.
103
261
  * @param msg - The announce interest message
@@ -107,99 +265,133 @@ export class Publisher {
107
265
  */
108
266
  async runAnnounce(msg, stream) {
109
267
  console.debug(`announce: prefix=${msg.prefix}`);
110
- // Send initial announcements
111
- let active = new Set();
112
- const broadcasts = this.#broadcasts.peek();
113
- if (!broadcasts)
114
- return; // closed
115
- for (const name of broadcasts.keys()) {
116
- const suffix = Path.stripPrefix(msg.prefix, name);
117
- if (suffix === null)
118
- continue;
119
- console.debug(`announce: broadcast=${name} active=true`);
120
- active.add(suffix);
121
- }
268
+ // Keyed by suffix, valued by identity plus route, so a republish diffs as
269
+ // ended-then-active and a re-price as a restart.
270
+ let active = new Map();
122
271
  // Lite06+: announce ids. Every active we send implicitly assigns the next
123
- // per-stream ordinal; ended references the id instead of repeating the path.
272
+ // per-stream ordinal; ended/restart reference the id instead of repeating the path.
124
273
  let nextAnnounceId = 0n;
125
274
  const announceIds = new Map();
126
- switch (this.version) {
127
- case Version.DRAFT_01:
128
- case Version.DRAFT_02: {
129
- const init = new AnnounceInit([...active]);
130
- await init.encode(stream.writer, this.version);
131
- break;
275
+ const wireHops = (route) => {
276
+ if (hasAnnounceOk(this.version))
277
+ return route.hops;
278
+ return [...route.hops, this.hop];
279
+ };
280
+ // What the peer decodes for a route: pre-lite-06 wires carry no cost, so a re-price
281
+ // there must not restart.
282
+ const onWire = (route) => (hasRouteCost(this.version) ? route : { ...route, cost: Cost.zero });
283
+ const announce = async (suffix, route) => {
284
+ console.debug(`announce: broadcast=${suffix} active=true`);
285
+ if (hasAnnounceId(this.version))
286
+ announceIds.set(suffix, nextAnnounceId++);
287
+ await encodeAnnounceBroadcast(stream.writer, { status: "active", suffix, hops: wireHops(route), cost: route.cost }, this.version);
288
+ };
289
+ const restart = async (suffix, route) => {
290
+ if (!hasAnnounceId(this.version)) {
291
+ await retract(suffix);
292
+ await announce(suffix, route);
293
+ return;
294
+ }
295
+ const id = announceIds.get(suffix);
296
+ if (id === undefined) {
297
+ await announce(suffix, route);
298
+ return;
132
299
  }
133
- default: {
134
- if (!hasAnnounceOk(this.version)) {
135
- // Draft03/04: send individual Announce messages, stamping our origin as a hop.
136
- for (const suffix of active) {
137
- await encodeAnnounceBroadcast(stream.writer, { status: "active", suffix, hops: [this.origin] }, this.version);
300
+ console.debug(`announce: broadcast=${suffix} restart=true`);
301
+ await encodeAnnounceBroadcast(stream.writer, { status: "restart", id, hops: wireHops(route), cost: route.cost }, this.version);
302
+ };
303
+ // Lite06+ retracts by announce id; older versions repeat the path (ended announces
304
+ // don't need hops).
305
+ const retract = async (suffix) => {
306
+ console.debug(`announce: broadcast=${suffix} active=false`);
307
+ if (!hasAnnounceId(this.version)) {
308
+ await encodeAnnounceBroadcast(stream.writer, { status: "ended", suffix }, this.version);
309
+ return;
310
+ }
311
+ const id = announceIds.get(suffix);
312
+ announceIds.delete(suffix);
313
+ if (id === undefined)
314
+ return; // never announced
315
+ await encodeAnnounceBroadcast(stream.writer, { status: "endedId", id }, this.version);
316
+ };
317
+ // Subscribe BEFORE writing anything: every encode below awaits the wire, and a publish
318
+ // landing in that window only notifies the listeners already registered. One created
319
+ // afterwards would sleep through it, leaving the change unannounced until something
320
+ // unrelated moved.
321
+ // TODO Make a better helper within Signals.
322
+ let dispose;
323
+ let changed = new Promise((resolve) => {
324
+ dispose = this.#advertised.changed(resolve);
325
+ });
326
+ // A hidden route stays off the wire unless the request opted in.
327
+ const carries = (covered) => msg.hidden || !hiddenBelow(msg.prefix, covered);
328
+ try {
329
+ const initial = this.#advertised.peek();
330
+ if (!initial)
331
+ return; // closed
332
+ for (const [name, snap] of presented(msg.prefix, initial, carries)) {
333
+ active.set(name, snap);
334
+ }
335
+ switch (this.version) {
336
+ case Version.DRAFT_01:
337
+ case Version.DRAFT_02: {
338
+ for (const suffix of active.keys()) {
339
+ console.debug(`announce: broadcast=${suffix} active=true`);
138
340
  }
341
+ const init = new AnnounceInit([...active.keys()]);
342
+ await init.encode(stream.writer, this.version);
139
343
  break;
140
344
  }
141
- // Report our origin id once via AnnounceOk and the count of initial announces
142
- // that follow; the subscriber stamps our origin onto each hop chain, so we omit it.
143
- const ok = new AnnounceOk(this.origin, active.size);
144
- await ok.encode(stream.writer, this.version);
145
- for (const suffix of active) {
146
- if (hasAnnounceId(this.version)) {
147
- announceIds.set(suffix, nextAnnounceId++);
345
+ default: {
346
+ if (!hasAnnounceOk(this.version)) {
347
+ for (const [suffix, snap] of active) {
348
+ await announce(suffix, snap.route);
349
+ }
350
+ break;
351
+ }
352
+ const ok = new AnnounceOk(this.hop, active.size);
353
+ await ok.encode(stream.writer, this.version);
354
+ for (const [suffix, snap] of active) {
355
+ await announce(suffix, snap.route);
148
356
  }
149
- await encodeAnnounceBroadcast(stream.writer, { status: "active", suffix, hops: [] }, this.version);
357
+ break;
150
358
  }
151
- break;
152
359
  }
153
- }
154
- // Wait for updates to the broadcasts.
155
- for (;;) {
156
- // TODO Make a better helper within Signals.
157
- let dispose;
158
- const changed = new Promise((resolve) => {
159
- dispose = this.#broadcasts.changed(resolve);
160
- });
161
- // Wait until the map of broadcasts changes.
162
- const broadcasts = await Promise.race([changed, stream.reader.closed]);
163
- dispose();
164
- if (!broadcasts)
165
- break;
166
- // Create a new set of active broadcasts.
167
- // This is SLOW, but it's not worth optimizing because we often have just 1 broadcast anyway.
168
- const newActive = new Set();
169
- for (const name of broadcasts.keys()) {
170
- const suffix = Path.stripPrefix(msg.prefix, name);
171
- if (suffix === null)
172
- continue; // Not our prefix.
173
- newActive.add(suffix);
174
- }
175
- // Announce any new broadcasts. Lite05+ reports our origin once via AnnounceOk, so
176
- // the subscriber stamps it onto each hop chain; older versions stamp it here.
177
- for (const added of newActive.difference(active)) {
178
- console.debug(`announce: broadcast=${added} active=true`);
179
- const hops = hasAnnounceOk(this.version) ? [] : [this.origin];
180
- if (hasAnnounceId(this.version)) {
181
- announceIds.set(added, nextAnnounceId++);
360
+ for (;;) {
361
+ const advertised = await race([changed, stream.reader.closed]);
362
+ dispose();
363
+ if (!advertised)
364
+ break;
365
+ // Re-arm before reading, so an advertise that lands while we write is not lost.
366
+ changed = new Promise((resolve) => {
367
+ dispose = this.#advertised.changed(resolve);
368
+ });
369
+ const latest = this.#advertised.peek();
370
+ if (!latest)
371
+ break;
372
+ const updated = new Map();
373
+ for (const [name, snap] of presented(msg.prefix, latest, carries)) {
374
+ updated.set(name, snap);
182
375
  }
183
- await encodeAnnounceBroadcast(stream.writer, { status: "active", suffix: added, hops }, this.version);
184
- }
185
- // Announce any removed broadcasts. Lite06+ retracts by announce id;
186
- // older versions repeat the path (ended announces don't need hops).
187
- for (const removed of active.difference(newActive)) {
188
- console.debug(`announce: broadcast=${removed} active=false`);
189
- if (hasAnnounceId(this.version)) {
190
- const id = announceIds.get(removed);
191
- announceIds.delete(removed);
192
- if (id === undefined)
193
- continue; // never announced
194
- await encodeAnnounceBroadcast(stream.writer, { status: "endedId", id }, this.version);
376
+ for (const [suffix, snap] of active) {
377
+ const cur = updated.get(suffix);
378
+ if (!cur || cur.identity !== snap.identity)
379
+ await retract(suffix);
195
380
  }
196
- else {
197
- await encodeAnnounceBroadcast(stream.writer, { status: "ended", suffix: removed }, this.version);
381
+ for (const [suffix, snap] of updated) {
382
+ const prev = active.get(suffix);
383
+ if (!prev || prev.identity !== snap.identity) {
384
+ await announce(suffix, snap.route);
385
+ }
386
+ else if (!routesEqual(onWire(prev.route), onWire(snap.route))) {
387
+ await restart(suffix, snap.route);
388
+ }
198
389
  }
390
+ active = updated;
199
391
  }
200
- // NOTE: This is kind of a hack that won't work with a rapid UNANNOUNCE/ANNOUNCE cycle.
201
- // However, our client doesn't do that anyway.
202
- active = newActive;
392
+ }
393
+ finally {
394
+ dispose();
203
395
  }
204
396
  }
205
397
  /**
@@ -210,27 +402,37 @@ export class Publisher {
210
402
  * @internal
211
403
  */
212
404
  async runSubscribe(msg, stream) {
213
- const broadcast = this.#broadcasts.peek()?.get(msg.broadcast);
214
- if (!broadcast) {
405
+ let front;
406
+ try {
407
+ front =
408
+ this.#publish &&
409
+ (wireOf(this.#publish).local(msg.broadcast) ?? (await wireOf(this.#publish).demand(msg.broadcast)));
410
+ }
411
+ catch (err) {
412
+ stream.writer.reset(error(err));
413
+ return;
414
+ }
415
+ if (!front) {
215
416
  console.debug(`publish unknown: broadcast=${msg.broadcast}`);
216
- stream.writer.reset(new Error("not found"));
417
+ stream.writer.reset(new NotFound(`broadcast ${msg.broadcast}`));
217
418
  return;
218
419
  }
219
- const track = broadcast.subscribe(msg.track, {
420
+ const endGroup = exclusiveGroupEnd(msg.endGroup);
421
+ const track = wireOf(front).subscribe(msg.track, {
220
422
  priority: msg.priority,
221
- ordered: msg.ordered,
222
- latencyMax: msg.maxLatency,
223
- startGroup: msg.startGroup,
224
- endGroup: msg.endGroup,
423
+ maxAge: Milli(servingMaxAge(this.version, msg.maxAge)),
424
+ groups: {
425
+ start: msg.startGroup === undefined ? undefined : { included: msg.startGroup },
426
+ end: endGroup === undefined ? undefined : { excluded: endGroup },
427
+ },
225
428
  });
226
- const startGroup = msg.startGroup ?? track.latest();
227
- if (startGroup !== undefined)
228
- track.startAt(startGroup);
229
- track.endAt(msg.endGroup);
429
+ positionCursor(track, this.version, msg.startGroup);
430
+ hooks.replaceGroups(track, { end: endGroup === undefined ? undefined : { excluded: endGroup } });
230
431
  // The best-effort datagram loop, started once serving begins. It parks when the
231
432
  // track finishes (recvDatagram returns undefined), so #runTrack alone ends the
232
433
  // subscription; awaited during teardown so it doesn't outlive the subscription.
233
434
  let datagrams = Promise.resolve();
435
+ let controls;
234
436
  try {
235
437
  let timescale = Timescale.MILLI;
236
438
  if (supportsTrackStream(this.version)) {
@@ -250,51 +452,57 @@ export class Publisher {
250
452
  // Older drafts acknowledge with SUBSCRIBE_OK and stream frames verbatim.
251
453
  const ok = new SubscribeOk({
252
454
  priority: msg.priority,
253
- ordered: msg.ordered,
254
- maxLatency: msg.maxLatency,
455
+ maxAge: msg.maxAge,
255
456
  startGroup: msg.startGroup,
256
457
  endGroup: msg.endGroup,
257
458
  });
258
459
  await encodeSubscribeResponse(stream.writer, { ok }, this.version);
259
460
  }
260
461
  console.debug(`publish ok: broadcast=${msg.broadcast} track=${track.name}`);
261
- const serving = this.#runTrack(msg.id, msg.broadcast, track, stream.writer, timescale);
262
462
  // Serve datagrams concurrently with groups whenever the transport carries them
263
463
  // (the writer exists iff so). No group fallback: otherwise they simply aren't sent.
264
464
  if (this.#datagramWriter) {
265
465
  datagrams = this.#runDatagrams(msg.id, track, timescale);
266
466
  }
267
- for (;;) {
268
- const decode = SubscribeUpdate.decodeMaybe(stream.reader, this.version);
269
- const result = await Promise.any([serving, decode]);
270
- if (!result)
271
- break;
272
- if (result instanceof SubscribeUpdate) {
273
- console.debug(`subscribe update: broadcast=${msg.broadcast} track=${track.name}`);
467
+ controls = new SubscriptionControls({
468
+ reader: stream.reader,
469
+ writer: stream.writer,
470
+ version: this.version,
471
+ apply: (update) => {
472
+ const end = exclusiveGroupEnd(update.endGroup);
274
473
  track.update({
275
- priority: result.priority,
276
- ordered: result.ordered,
277
- latencyMax: result.maxLatency,
278
- startGroup: result.startGroup,
279
- endGroup: result.endGroup,
474
+ priority: update.priority,
475
+ maxAge: Milli(servingMaxAge(this.version, update.maxAge)),
476
+ groups: {
477
+ start: update.startGroup === undefined ? undefined : { included: update.startGroup },
478
+ end: end === undefined ? undefined : { excluded: end },
479
+ },
280
480
  });
281
- if (result.startGroup !== undefined)
282
- track.startAt(result.startGroup);
283
- track.endAt(result.endGroup);
284
- }
285
- }
481
+ },
482
+ });
483
+ await this.#runTrack(track, stream.writer, controls, {
484
+ sub: msg.id,
485
+ broadcast: msg.broadcast,
486
+ timescale,
487
+ bounds: {
488
+ startGroup: msg.startGroup,
489
+ startFrame: msg.startFrame,
490
+ endGroup: msg.endGroup,
491
+ endFrame: msg.endFrame,
492
+ },
493
+ });
286
494
  console.debug(`publish done: broadcast=${msg.broadcast} track=${track.name}`);
287
495
  stream.close();
288
496
  track.close();
289
- // track.close ends the datagram loop; wait so it doesn't leak past teardown.
290
- await datagrams;
497
+ // Closing the stream ends the decoder and track.close ends the datagram loop.
498
+ await Promise.all([datagrams, controls.decoding]);
291
499
  }
292
500
  catch (err) {
293
501
  const e = error(err);
294
502
  console.warn(`publish error: broadcast=${msg.broadcast} track=${track.name} error=${reason(e)}`);
295
503
  track.close(e);
296
504
  stream.abort(e);
297
- await datagrams;
505
+ await Promise.all([datagrams, controls?.decoding]);
298
506
  }
299
507
  }
300
508
  /**
@@ -307,10 +515,19 @@ export class Publisher {
307
515
  stream.writer.reset(new Error("fetch requires moq-lite-05 or newer"));
308
516
  return;
309
517
  }
310
- const broadcast = this.#broadcasts.peek()?.get(msg.broadcast);
311
- if (!broadcast) {
518
+ let front;
519
+ try {
520
+ front =
521
+ this.#publish &&
522
+ (wireOf(this.#publish).local(msg.broadcast) ?? (await wireOf(this.#publish).demand(msg.broadcast)));
523
+ }
524
+ catch (err) {
525
+ stream.writer.reset(error(err));
526
+ return;
527
+ }
528
+ if (!front) {
312
529
  console.debug(`fetch unknown: broadcast=${msg.broadcast}`);
313
- stream.writer.reset(new Error("not found"));
530
+ stream.writer.reset(new NotFound(`broadcast ${msg.broadcast}`));
314
531
  return;
315
532
  }
316
533
  // The subscriber opened this stream, so its send order only ranked the request. Rank the
@@ -318,10 +535,15 @@ export class Publisher {
318
535
  stream.writer.setPriority(sendOrder({ priority: msg.priority }));
319
536
  let group;
320
537
  try {
321
- // The timescale is immutable, so serve exactly what TRACK_INFO advertised.
322
- const info = await this.#resolveTrackInfo(msg.broadcast, msg.track);
323
- group = await broadcast.track(msg.track).fetchGroup(msg.group, { priority: msg.priority });
324
- await this.#runFetchGroup(group, stream.writer, Timescale(info.timescale));
538
+ // The timescale is immutable, so serve exactly what TRACK_INFO advertised. Both
539
+ // come off the same front, so the metadata and the frames are one generation.
540
+ const info = await this.#resolveTrackInfo(front, msg.track);
541
+ group = await wireOf(front).fetchGroup(msg.track, msg.group, { priority: msg.priority });
542
+ await this.#runFetchGroup(group, stream.writer, {
543
+ timescale: Timescale(info.timescale),
544
+ start: msg.startFrame,
545
+ end: msg.endFrame,
546
+ });
325
547
  console.debug(`fetch done: broadcast=${msg.broadcast} track=${msg.track} group=${msg.group}`);
326
548
  stream.close();
327
549
  group.close();
@@ -342,70 +564,172 @@ export class Publisher {
342
564
  *
343
565
  * @internal
344
566
  */
345
- async #runTrack(sub, broadcast, track, stream, timescale) {
567
+ async #runTrack(track, stream, controls, serving) {
568
+ const { sub, broadcast, timescale, bounds } = serving;
346
569
  // Lite-05+ resolves the range on the subscribe stream: SUBSCRIBE_START once the
347
570
  // first group is known, SUBSCRIBE_END when the track finishes.
348
571
  const emitRange = supportsTrackStream(this.version);
349
572
  let startSent = false;
350
- // The exclusive end of the delivered range. recvGroup is arrival-ordered rather than
351
- // sequence-ordered, so this tracks the max and not the last group seen. 0 is already
352
- // the encoding for a track that produced no groups.
353
- let end = 0;
573
+ let endSent = false;
574
+ // Lite-07+ counts the group streams in SUBSCRIBE_END, so it goes out only once every
575
+ // served group has opened its stream or given up, and a cap holding groups back
576
+ // delays it until they are released.
577
+ const countStreams = hasStreamCount(this.version);
578
+ let streams = 0;
579
+ const opening = new Set();
580
+ // The track's exclusive final boundary. A Rust subscriber feeds SUBSCRIBE_END
581
+ // straight into finish_at, so it must name the track's boundary (which counts
582
+ // datagram sequences too), not the delivered range: a subscription cap can hold
583
+ // produced groups back. The latest() fallback covers a subscription torn down
584
+ // before the producer declared it.
585
+ const boundary = () => track.final() ?? (track.latest() ?? -1) + 1;
586
+ // Before lite-07, SUBSCRIBE_END names that boundary, which a cap can hold groups back
587
+ // from, so it goes out as soon as the producer finishes and the subscription keeps
588
+ // serving whatever a later cap raise releases (see the Rust publisher's Recv::Boundary).
589
+ const sendEnd = async () => {
590
+ endSent = true;
591
+ if (!emitRange)
592
+ return true;
593
+ return controls.response((async () => {
594
+ // A group that gives up before its stream opens is never counted.
595
+ if (countStreams)
596
+ while (opening.size > 0)
597
+ await Promise.all(opening);
598
+ const end = new SubscribeEnd(boundary(), streams);
599
+ await encodeSubscribeResponse(stream, { end }, this.version);
600
+ })());
601
+ };
354
602
  // One ranking for the whole subscription, shared by every group it serves.
355
603
  const priority = new Priority(track);
604
+ // Every group this subscription started serving, until its stream finishes or resets.
605
+ const groups = new Set();
356
606
  // Cancels groups still queued for a stream slot. Only the subscriber leaving counts:
357
- // a track that ran out of groups still has to flush the ones already queued, and we
358
- // FIN the subscribe stream ourselves below to say so.
607
+ // a track that ran out of groups still has to flush the ones already queued, and the
608
+ // caller FINs the subscribe stream to say so.
359
609
  let finished = false;
360
610
  let unsubscribe;
361
611
  const unsubscribed = new Promise((resolve) => {
362
612
  unsubscribe = resolve;
363
613
  });
364
- void stream.closed.then(() => {
365
- if (!finished)
366
- unsubscribe();
367
- },
368
- // A reset is always the peer.
369
- () => unsubscribe());
370
614
  try {
371
615
  for (;;) {
372
- // Exactly-once serving, not the sequence cursor: on a relay, a burst can be
373
- // ingested micro-reordered by the upstream leg, and a sequence cursor would
374
- // permanently skip the older group even though it is cached and in demand.
375
- // Staleness is the latency window's job (cache expiry), not arrival order's.
376
- const next = track.recvGroup();
377
- const group = await Promise.race([next, stream.closed]);
378
- if (!group) {
379
- next.then((group) => group?.close()).catch(() => { });
380
- break;
616
+ // Control before data, matching the Rust publisher: every control decoded while
617
+ // the loop was parked applies to the next pop, never to one already made. This
618
+ // drain is synchronous, so nothing the decoder holds can land between the pop
619
+ // below and its frame range.
620
+ const control = controls.take();
621
+ if (control) {
622
+ switch (control.kind) {
623
+ case "done":
624
+ // The subscriber left. Its queued groups are pointless now, which
625
+ // the finally below acts on since `finished` stays false.
626
+ return;
627
+ case "error":
628
+ throw control.error;
629
+ case "update": {
630
+ const update = control.update;
631
+ console.debug(`subscribe update: broadcast=${broadcast} track=${track.name}`);
632
+ hooks.replaceGroups(track, {
633
+ start: update.startGroup === undefined ? undefined : { included: update.startGroup },
634
+ end: update.endGroup === undefined ? undefined : { included: update.endGroup },
635
+ });
636
+ bounds.startGroup = update.startGroup;
637
+ bounds.startFrame = update.startFrame;
638
+ bounds.endGroup = update.endGroup;
639
+ bounds.endFrame = update.endFrame;
640
+ await yieldToControls();
641
+ continue;
642
+ }
643
+ }
644
+ }
645
+ // Exactly-once arrival-order serving. This synchronous package-internal pop
646
+ // and frameRange call are the operation's linearization point.
647
+ const recv = hooks.tryRecvGroup(track);
648
+ switch (recv.kind) {
649
+ case "error":
650
+ throw recv.error;
651
+ case "idle":
652
+ // Before lite-07, an end declared ahead of the live edge goes out as
653
+ // soon as it is known, while the remaining groups are still being
654
+ // produced. The lite-07 count is not final until those groups open.
655
+ if (!endSent && !countStreams && track.final() !== undefined) {
656
+ if (!(await sendEnd()))
657
+ return;
658
+ continue;
659
+ }
660
+ await waitForSubscription(controls, track);
661
+ continue;
662
+ case "boundary":
663
+ // The producer finished but is still holding groups above the cap.
664
+ // Declare the boundary, then wait for an update to release them.
665
+ if (!endSent && !countStreams) {
666
+ if (!(await sendEnd()))
667
+ return;
668
+ continue;
669
+ }
670
+ await waitForSubscription(controls, track);
671
+ continue;
672
+ case "done": {
673
+ if (!endSent) {
674
+ if (!(await sendEnd()))
675
+ return;
676
+ continue;
677
+ }
678
+ // The FIN tells the subscriber every group is accounted for, so it waits
679
+ // until each group stream finished or reset. The subscriber leaving
680
+ // instead cancels whatever is still queued.
681
+ const drained = Symbol("drained");
682
+ const end = await Promise.race([Promise.all(groups).then(() => drained), controls.ended]);
683
+ if (end instanceof Error)
684
+ throw end;
685
+ if (end !== drained)
686
+ return;
687
+ finished = true;
688
+ return;
689
+ }
381
690
  }
691
+ const group = recv.group;
692
+ const range = frameRange(bounds, group.sequence);
382
693
  if (emitRange && !startSent) {
383
694
  startSent = true;
384
695
  // SUBSCRIBE_START promises nothing below this sequence will be delivered.
385
696
  // Arrival-order serving could later surface a straggler below the first
386
697
  // group, so pin the floor to what was announced.
387
- track.startAt(group.sequence);
388
- await encodeSubscribeResponse(stream, { start: new SubscribeStart(group.sequence) }, this.version);
698
+ hooks.replaceGroups(track, {
699
+ start: { included: group.sequence },
700
+ end: bounds.endGroup === undefined ? undefined : { included: bounds.endGroup },
701
+ });
702
+ if (!(await controls.response(encodeSubscribeResponse(stream, { start: new SubscribeStart(group.sequence) }, this.version))))
703
+ return;
389
704
  }
390
- end = Math.max(end, group.sequence + 1);
391
- void this.#runGroup({ sub, group, timescale, priority, unsubscribed });
392
- }
393
- if (emitRange) {
394
- await encodeSubscribeResponse(stream, { end: new SubscribeEnd(end) }, this.version);
705
+ const options = {
706
+ sub,
707
+ group,
708
+ timescale,
709
+ priority,
710
+ unsubscribed,
711
+ start: range.start,
712
+ end: range.end,
713
+ };
714
+ // `opening` settles when the stream opens so the lite-07 count can be sent.
715
+ // `groups` covers the serve too, so the FIN still waits for every stream to
716
+ // finish or reset, including one that has not opened yet.
717
+ const opened = this.#openGroup(options);
718
+ const task = opened.then(async (writer) => {
719
+ if (!writer)
720
+ return;
721
+ streams += 1;
722
+ await this.#serveGroup(writer, options);
723
+ });
724
+ groups.add(task);
725
+ void task.finally(() => groups.delete(task));
726
+ opening.add(opened);
727
+ void opened.finally(() => opening.delete(opened));
395
728
  }
396
- console.debug(`publish close: broadcast=${broadcast} track=${track.name}`);
397
- finished = true;
398
- track.close();
399
- stream.close();
400
- }
401
- catch (err) {
402
- const e = error(err);
403
- console.warn(`publish error: broadcast=${broadcast} track=${track.name} error=${reason(e)}`);
404
- unsubscribe();
405
- track.close(e);
406
- stream.reset(e);
407
729
  }
408
730
  finally {
731
+ if (!finished)
732
+ unsubscribe();
409
733
  priority.close();
410
734
  }
411
735
  }
@@ -416,7 +740,11 @@ export class Publisher {
416
740
  */
417
741
  async runTrackInfo(msg, stream) {
418
742
  try {
419
- const info = await this.#resolveTrackInfo(msg.broadcast, msg.track);
743
+ const front = this.#publish &&
744
+ (wireOf(this.#publish).local(msg.broadcast) ?? (await wireOf(this.#publish).demand(msg.broadcast)));
745
+ if (!front)
746
+ throw new NotFound(`broadcast ${msg.broadcast}`);
747
+ const info = await this.#resolveTrackInfo(front, msg.track);
420
748
  await info.encode(stream.writer, this.version);
421
749
  console.debug(`track info: broadcast=${msg.broadcast} track=${msg.track}`);
422
750
  stream.close();
@@ -427,34 +755,33 @@ export class Publisher {
427
755
  }
428
756
  }
429
757
  // Resolve (and cache) a track's immutable TRACK_INFO by asking the application.
430
- // `broadcast.track(name).info()` triggers a TrackRequest the app answers with
431
- // accept(TrackInfo); only the immutable properties are needed (not the groups).
432
- // Cached because they're fixed for the track's lifetime. Rejects if the broadcast
433
- // or track is unavailable.
434
- #resolveTrackInfo(broadcast, track) {
435
- const key = `${broadcast}\0${track}`;
436
- const cached = this.#trackInfo.get(key);
437
- if (cached)
758
+ // `resolveTrackInfo` triggers a TrackRequest the app answers with accept(TrackInfo);
759
+ // only the immutable properties are needed (not the groups). Cached because they're
760
+ // fixed for the track's lifetime. Rejects if the track is unavailable.
761
+ #resolveTrackInfo(front, track) {
762
+ let tracks = this.#trackInfo.get(front);
763
+ if (!tracks) {
764
+ tracks = new Map();
765
+ this.#trackInfo.set(front, tracks);
766
+ }
767
+ const cached = tracks.get(track);
768
+ if (cached !== undefined)
438
769
  return cached;
439
770
  const pending = (async () => {
440
- const published = this.#broadcasts.peek()?.get(broadcast);
441
- if (!published)
442
- throw new Error("not found");
443
- const info = await published.track(track).info();
771
+ const info = await wireOf(front).resolveTrackInfo(track);
444
772
  return new TrackInfoMessage({
445
773
  priority: info.priority,
446
- ordered: info.ordered,
447
- // Publisher Max Latency: the publisher's retention bound, advertised so
774
+ // Publisher Max Age: the publisher's retention bound, advertised so
448
775
  // relays re-serve with the same window.
449
- latencyMax: info.latencyMax,
776
+ maxAge: info.maxAge,
450
777
  // Lite05 mandates per-frame timestamps. Advertise the track's timescale;
451
- // `#runGroup` emits each frame converted to it.
778
+ // `#serveGroup` emits each frame converted to it.
452
779
  timescale: info.timescale,
453
780
  });
454
781
  })();
455
782
  // Don't poison the cache on failure: a later request may succeed.
456
- pending.catch(() => this.#trackInfo.delete(key));
457
- this.#trackInfo.set(key, pending);
783
+ pending.catch(() => tracks.delete(track));
784
+ tracks.set(track, pending);
458
785
  return pending;
459
786
  }
460
787
  /**
@@ -475,9 +802,9 @@ export class Publisher {
475
802
  const datagram = await track.recvDatagram();
476
803
  if (!datagram)
477
804
  return; // Track finished; #runTrack tears the subscription down.
478
- // Convert the timestamp to the track's advertised timescale, matching #runGroup.
805
+ // Convert the timestamp to the track's advertised timescale, matching #serveGroup.
479
806
  const ts = Math.round(datagram.timestamp.as(timescale));
480
- const body = new DatagramMessage(sub, datagram.sequence, ts, datagram.payload).encode();
807
+ const body = new DatagramMessage(sub, datagram.sequence, ts, datagram.payload).encode(this.version);
481
808
  // No group fallback: drop anything that doesn't fit a single datagram.
482
809
  if (body.byteLength > maxSize) {
483
810
  console.debug(`dropping oversize datagram: sub=${sub} size=${body.byteLength} max=${maxSize}`);
@@ -494,10 +821,18 @@ export class Publisher {
494
821
  }
495
822
  // Serialize a fetched group's frames onto the FETCH stream as bare records: each a
496
823
  // zigzag-delta timestamp (at the track's advertised timescale) followed by size + bytes.
497
- async #runFetchGroup(group, stream, timescale) {
824
+ async #runFetchGroup(group, stream, { timescale, start: startFrame, end: endFrame }) {
825
+ // The response carries no header, so the receiver numbers the first frame it gets
826
+ // as `startFrame`. Skipping the head here is the only thing keeping those numbers
827
+ // honest; a group that ends before we reach it can't be served at all.
828
+ for (let i = 0; i < startFrame; i++) {
829
+ if (!(await race([group.readFrame(), stream.closed]))) {
830
+ throw new Error(`fetch group ended at frame ${i}, before the requested start ${startFrame}`);
831
+ }
832
+ }
498
833
  let prevTs = 0n;
499
- for (;;) {
500
- const frame = await Promise.race([group.readFrame(), stream.closed]);
834
+ for (let index = startFrame; endFrame === undefined || index <= endFrame; index++) {
835
+ const frame = await race([group.readFrame(), stream.closed]);
501
836
  if (!frame)
502
837
  break;
503
838
  const ts = BigInt(Math.round(frame.timestamp.as(timescale)));
@@ -508,13 +843,13 @@ export class Publisher {
508
843
  }
509
844
  }
510
845
  /**
511
- * Serves one group on its own unidirectional stream.
846
+ * Opens the unidirectional stream for one group, or closes the group and resolves
847
+ * `undefined` when it cannot get one.
512
848
  *
513
849
  * @internal
514
850
  */
515
- async #runGroup(options) {
516
- const { sub, group, timescale, priority, unsubscribed } = options;
517
- const msg = new GroupMessage(sub, group.sequence);
851
+ async #openGroup(options) {
852
+ const { group, priority, unsubscribed } = options;
518
853
  try {
519
854
  // The transport drains streams by send order, so this is what makes a high-priority
520
855
  // track (and a newer group within it) win the link when there isn't room for both.
@@ -524,55 +859,92 @@ export class Publisher {
524
859
  // in the order we asked, which is oldest-first, exactly backwards for live media.
525
860
  // Failing here drops the group and lets the next one compete for the next slot.
526
861
  const stream = await Writer.tryOpen(this.#quic, {
862
+ version: this.version,
527
863
  sendOrder: priority.rank(group.sequence),
528
864
  cancel: unsubscribed,
529
865
  waitUntilAvailable: false,
530
866
  });
531
- if (!stream) {
867
+ if (!stream)
532
868
  group.close(new Error("no stream slot"));
533
- return;
534
- }
535
- // Everything past this point runs inside the cleanup scope, so a failure never leaves
536
- // a finished group's stream being ranked.
537
- try {
538
- // A SUBSCRIBE_UPDATE re-ranks the subscription, so a group already on the wire
539
- // follows it too rather than keeping a stale rank until it finishes.
540
- priority.add(stream, group.sequence);
869
+ return stream;
870
+ }
871
+ catch (err) {
872
+ group.close(error(err));
873
+ return undefined;
874
+ }
875
+ }
876
+ /**
877
+ * Serves one group on the stream {@link #openGroup} opened for it.
878
+ *
879
+ * @internal
880
+ */
881
+ async #serveGroup(stream, options) {
882
+ const { sub, group, timescale, priority, start: startFrame, end: endFrame } = options;
883
+ // This model holds whole groups, so frame `startFrame` is always reachable unless
884
+ // the group ends first. Declaring it up front keeps the stream self-describing.
885
+ const msg = new GroupMessage({ subscribe: sub, sequence: group.sequence, frameStart: startFrame });
886
+ // Everything past this point runs inside the cleanup scope, so a failure never leaves
887
+ // a finished group's stream being ranked.
888
+ try {
889
+ // A SUBSCRIBE_UPDATE re-ranks the subscription, so a group already on the wire
890
+ // follows it too rather than keeping a stale rank until it finishes.
891
+ priority.add(stream, group.sequence);
892
+ await hooks.guardGroup(group, async () => {
541
893
  await stream.u53(0); // stream type
542
- await msg.encode(stream);
543
- // Lite05+ prefixes every frame with a zigzag-delta timestamp at the track's
544
- // advertised timescale; older drafts omit it.
545
- const timestamps = supportsTrackStream(this.version);
546
- let prevTs = 0n;
547
- for (;;) {
548
- const frame = await Promise.race([group.readFrame(), stream.closed]);
549
- if (!frame)
894
+ await msg.encode(stream, this.version);
895
+ });
896
+ // Lite05+ prefixes every frame with a zigzag-delta timestamp at the track's
897
+ // advertised timescale; older drafts omit it.
898
+ const timestamps = supportsTrackStream(this.version);
899
+ let prevTs = 0n;
900
+ // Whether the cursor ever reached the requested start, which decides how the
901
+ // end of the group is read below.
902
+ let reached = startFrame === 0;
903
+ for (;;) {
904
+ const read = await race([hooks.readGroupFrame(group), stream.closed]);
905
+ if (!read) {
906
+ // The group ended before the frame the subscriber asked to start
907
+ // at, so this publisher can't serve the range at all. FINning here
908
+ // would claim an empty group under that index; reset so it reads
909
+ // as the gap it is.
910
+ if (!reached)
911
+ throw new Error(`group ended before frame ${startFrame}`);
912
+ break;
913
+ }
914
+ try {
915
+ // A group that ends exactly at the start is a valid, empty range.
916
+ if (read.sequence + 1 >= startFrame)
917
+ reached = true;
918
+ // Frames below the requested start were excluded, and the receiver
919
+ // numbers what it gets from `startFrame`.
920
+ if (read.sequence < startFrame)
921
+ continue;
922
+ if (endFrame !== undefined && read.sequence > endFrame)
550
923
  break;
551
924
  if (timestamps) {
552
925
  // Convert each frame to the track's advertised timescale.
553
- const ts = BigInt(Math.round(frame.timestamp.as(timescale)));
554
- await stream.u62(zigzag(ts - prevTs));
926
+ const ts = BigInt(Math.round(read.frame.timestamp.as(timescale)));
927
+ await hooks.guardGroup(group, () => stream.u62(zigzag(ts - prevTs)));
555
928
  prevTs = ts;
556
929
  }
557
- await stream.u53(frame.payload.byteLength);
558
- await stream.write(frame.payload);
930
+ await hooks.guardGroup(group, () => stream.u53(read.frame.payload.byteLength));
931
+ await hooks.guardGroup(group, () => stream.write(read.frame.payload));
932
+ }
933
+ finally {
934
+ read.complete();
559
935
  }
560
- stream.close();
561
- group.close();
562
- }
563
- catch (err) {
564
- const e = error(err);
565
- stream.reset(e);
566
- group.close(e);
567
- }
568
- finally {
569
- priority.remove(stream);
570
936
  }
937
+ stream.close();
938
+ group.close();
571
939
  }
572
940
  catch (err) {
573
941
  const e = error(err);
942
+ stream.reset(e);
574
943
  group.close(e);
575
944
  }
945
+ finally {
946
+ priority.remove(stream);
947
+ }
576
948
  }
577
949
  /**
578
950
  * Handles a probe stream by periodically reporting estimated bitrate.
@@ -605,7 +977,7 @@ export class Publisher {
605
977
  try {
606
978
  for (;;) {
607
979
  const timeout = new Promise((resolve) => setTimeout(() => resolve("timeout"), PROBE_INTERVAL));
608
- const result = await Promise.race([timeout, stream.reader.closed]);
980
+ const result = await race([timeout, stream.reader.closed]);
609
981
  if (result !== "timeout")
610
982
  break;
611
983
  // The two fields are independent on the wire, each using 0 for
@@ -658,12 +1030,8 @@ export class Publisher {
658
1030
  }
659
1031
  }
660
1032
  close() {
661
- this.#broadcasts.update((broadcasts) => {
662
- for (const broadcast of broadcasts?.values() ?? []) {
663
- broadcast.close();
664
- }
665
- return undefined;
666
- });
1033
+ // The broadcasts belong to the origin, which outlives this session; closing here
1034
+ // only drops the borrow. The peer sees the unannounce when the streams die.
667
1035
  // Release the datagram writer's lock so the stream can be torn down.
668
1036
  this.#datagramWriter?.releaseLock();
669
1037
  this.#datagramWriter = undefined;