@norskvideo/moq-net 0.1.8 → 0.2.1

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
@@ -1,18 +1,24 @@
1
1
  /* @ts-self-types="./subscriber.d.ts" */
2
+ import { race, Signal } from "@norskvideo/moq-signals";
2
3
  import * as announce from "../announced.js";
3
4
  import * as broadcast from "../broadcast.js";
4
5
  import { BroadcastCache } from "../consume.js";
5
- import { error, ProtocolViolation, reason } from "../error.js";
6
+ import { closeError, controlTimeout, error, ProtocolViolation, reason, sessionCause } from "../error.js";
6
7
  import * as netGroup from "../group.js";
8
+ import { Cost, randomHop, routesEqual, stampHops, UNKNOWN_HOP } from "../hop.js";
9
+ import { hiddenBelow, hooks, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.js";
7
10
  import * as Path from "../path.js";
11
+ import { Tail } from "../tail.js";
8
12
  import { Timestamp } from "../time.js";
9
- import { withTimeout } from "../util/timeout.js";
13
+ import { TimeoutError, withTimeout } from "../util/timeout.js";
14
+ import { overrideBroadcastWire, wireOf } from "../wire.js";
10
15
  import { DuplicateTrackAlias, RetiredTrackAlias, TrackAliases } from "./aliases.js";
11
16
  import * as Cluster from "./cluster.js";
17
+ import { requestReason, toRequestCode } from "./error.js";
12
18
  import { Frame } from "./object.js";
13
- import { toWire } from "./priority.js";
14
- import { PublishError } from "./publish.js";
15
- import { PublishNamespace, PublishNamespaceDone, PublishNamespaceError, PublishNamespaceOk, } from "./publish_namespace.js";
19
+ import { fromWire, toWire } from "./priority.js";
20
+ import { PublishDone, PublishError, publishDoneClean } from "./publish.js";
21
+ import { PublishNamespaceDone, PublishNamespaceError, PublishNamespaceOk, PublishNamespaceUpdate, } from "./publish_namespace.js";
16
22
  import { RequestError, RequestOk } from "./request.js";
17
23
  import { joinFilter, Subscribe, SubscribeError, SubscribeOk, Unsubscribe } from "./subscribe.js";
18
24
  import { PublishBlocked, SubscribeNamespace, SubscribeNamespaceEntry, SubscribeNamespaceEntryDone, SubscribeNamespaceLegacy, SubscribeNamespaceOk, UnsubscribeNamespace, } from "./subscribe_namespace.js";
@@ -21,6 +27,10 @@ import { Version } from "./version.js";
21
27
  // concurrent QUIC streams (Chrome ~100); past the cap openBi() silently
22
28
  // blocks. The timeout turns that into a clear error.
23
29
  const SUBSCRIBE_OK_TIMEOUT_MS = 10_000;
30
+ /** Whether a reader with `filter` sees an announcement at `path`. */
31
+ function sees(filter, path) {
32
+ return scopeOverlaps(filter.scope, path) && (filter.hidden || !hiddenBelow(filter.prefix, path));
33
+ }
24
34
  /**
25
35
  * Handles subscribing to broadcasts using moq-transport protocol.
26
36
  * Uses the stream-per-request pattern (real bidi streams for v17, virtual for v14-v16).
@@ -29,6 +39,9 @@ const SUBSCRIBE_OK_TIMEOUT_MS = 10_000;
29
39
  */
30
40
  export class Subscriber {
31
41
  #session;
42
+ // The transport, so a request cut off by the session's close ends with the session's
43
+ // error. Optional for tests that drive a bare session.
44
+ #quic;
32
45
  // The Hop IDs this session declared; see {@link Cluster}. What the peer declared is what
33
46
  // says whether an advertisement carries a hop path, and ours is what a path looping back
34
47
  // to us contains.
@@ -41,6 +54,9 @@ export class Subscriber {
41
54
  #timescales = new Map();
42
55
  // Dedup consumed broadcasts per path: repeat consume() calls share one subscription.
43
56
  #consumes = new BroadcastCache();
57
+ // A random Hop ID of this connection's own, written as the first hop of any path that
58
+ // names no publisher, so a publisher that reconnects reads as a new one.
59
+ #stamp = randomHop();
44
60
  // Paths with a legacy PUBLISH_NAMESPACE request in flight, reserved synchronously.
45
61
  // The count below is only taken once the OK is written, and two requests that both
46
62
  // got past the duplicate check before either attached would both take one.
@@ -53,16 +69,20 @@ export class Subscriber {
53
69
  // Counting them is what keeps the second from duplicating the announce and the first
54
70
  // to end from retracting what the other still holds.
55
71
  #announced = new Map();
56
- // Any consumers that want each new announcement.
57
- #announcedConsumers = new Set();
72
+ // Any consumers that want each new announcement, keyed by their local filter.
73
+ #announcedConsumers = new Map();
74
+ // Whether the peer understands the HIDDEN parameter (MoQ Hidden).
75
+ #hidden;
58
76
  /**
59
77
  * Creates a new Subscriber instance.
60
78
  *
61
79
  * @internal
62
80
  */
63
- constructor({ session, cluster, }) {
81
+ constructor({ session, quic, cluster, hidden = false, }) {
64
82
  this.#session = session;
83
+ this.#quic = quic;
65
84
  this.#cluster = cluster;
85
+ this.#hidden = hidden;
66
86
  }
67
87
  /**
68
88
  * Whether an advertisement is ours coming back: its hop path already ran through us, so
@@ -76,22 +96,47 @@ export class Subscriber {
76
96
  return advert !== undefined && this.#cluster !== undefined && Cluster.loops(advert, this.#cluster.self);
77
97
  }
78
98
  /**
79
- * Gets an announced reader for the specified prefix.
99
+ * The route an advertisement carries; one without a path is free. A path that names no
100
+ * publisher, or none at all, gets this connection's stamp in front of a 0.
101
+ */
102
+ #route(advert) {
103
+ if (advert === undefined)
104
+ return { hops: [this.#stamp, UNKNOWN_HOP], cost: Cost.zero };
105
+ // A full chain, or a stamp colliding with an entry (a 1-in-2^53 draw), keeps the
106
+ // path as sent.
107
+ const hops = stampHops(advert.hops, this.#stamp) ?? [...advert.hops];
108
+ return { hops, cost: { warm: advert.cost, cold: advert.cost } };
109
+ }
110
+ /**
111
+ * Gets an announced reader matching `scope`. Paths are relative to the session,
112
+ * not the scope.
80
113
  *
81
114
  * The peer is asked with SUBSCRIBE_NAMESPACE regardless of what it declared, and an
82
115
  * unsolicited PUBLISH_NAMESPACE lands here too, so a peer that only tells and one
83
116
  * that only answers are both discovered.
117
+ *
118
+ * Hidden routes (a `.`-prefixed segment below the scope's head) are left out unless
119
+ * `options.hidden` opts in. The opt-in rides the SUBSCRIBE_NAMESPACE when the peer
120
+ * understands it (MoQ Hidden); the rule is also applied here, since an unsolicited
121
+ * PUBLISH_NAMESPACE or a peer that never heard of it hides nothing.
84
122
  */
85
- announced(prefix = Path.empty()) {
86
- const announced = new announce.Producer(prefix);
87
- for (const active of this.#announced.keys()) {
88
- const suffix = Path.stripPrefix(prefix, active);
89
- if (suffix === null)
123
+ announced(scope = Path.Pattern.all(), options) {
124
+ // The wire speaks announce interest by prefix.
125
+ const prefix = scopeHead(scope);
126
+ const filter = { scope, prefix, hidden: options?.hidden ?? false };
127
+ const announced = new announce.Producer();
128
+ for (const [active, info] of this.#announced) {
129
+ if (!sees(filter, active))
90
130
  continue;
91
- announced.append({ path: suffix, active: true });
131
+ announced.append({
132
+ prefix: active,
133
+ captures: scopeCaptures(scope, active),
134
+ kind: "announced",
135
+ route: info.route,
136
+ });
92
137
  }
93
- this.#announcedConsumers.add(announced);
94
- void this.#runAnnounced(announced, prefix).finally(() => {
138
+ this.#announcedConsumers.set(announced, filter);
139
+ void this.#runAnnounced(announced, prefix, filter.hidden && this.#hidden).finally(() => {
95
140
  this.#announcedConsumers.delete(announced);
96
141
  announced.close();
97
142
  });
@@ -101,28 +146,53 @@ export class Subscriber {
101
146
  * Record one more advertisement for a path, telling consumers only when it is the
102
147
  * first. A second one is the same namespace said twice, not news.
103
148
  */
104
- #attachAnnounce(path) {
105
- const count = this.#announced.get(path) ?? 0;
106
- this.#announced.set(path, count + 1);
107
- if (count > 0)
149
+ #attachAnnounce(path, route) {
150
+ const existing = this.#announced.get(path);
151
+ if (existing) {
152
+ existing.count += 1;
108
153
  return;
154
+ }
155
+ this.#announced.set(path, { count: 1, route });
109
156
  console.debug(`announced: broadcast=${path} active=true`);
110
- for (const consumer of this.#announcedConsumers) {
111
- const suffix = Path.stripPrefix(consumer.prefix, path);
112
- if (suffix === null)
157
+ for (const [consumer, filter] of this.#announcedConsumers) {
158
+ if (!sees(filter, path))
113
159
  continue;
114
- consumer.append({ path: suffix, active: true });
160
+ const scope = filter.scope;
161
+ consumer.append({ prefix: path, captures: scopeCaptures(scope, path), kind: "announced", route });
162
+ }
163
+ }
164
+ /**
165
+ * Replace the stored route for a path that is already announced. A no-op when the
166
+ * hops and cost did not change; otherwise consumers hear `updated` so a forwarder
167
+ * can reprice without retracting.
168
+ *
169
+ * A new first hop is a new publisher: holders keep their broadcast to drain, but the
170
+ * next consume starts fresh rather than reusing the old publisher's cached track info.
171
+ */
172
+ #updateAnnounce(path, route) {
173
+ const existing = this.#announced.get(path);
174
+ if (existing === undefined || routesEqual(existing.route, route))
175
+ return;
176
+ if (existing.route.hops[0] !== route.hops[0])
177
+ this.#consumes.evict(path);
178
+ existing.route = route;
179
+ console.debug(`announced: broadcast=${path} rerouted`);
180
+ for (const [consumer, filter] of this.#announcedConsumers) {
181
+ if (!sees(filter, path))
182
+ continue;
183
+ const scope = filter.scope;
184
+ consumer.append({ prefix: path, captures: scopeCaptures(scope, path), kind: "updated", route });
115
185
  }
116
186
  }
117
187
  /**
118
188
  * Drop one advertisement for a path, retracting it only once the last one goes.
119
189
  */
120
190
  #detachAnnounce(path) {
121
- const count = this.#announced.get(path);
122
- if (count === undefined)
191
+ const existing = this.#announced.get(path);
192
+ if (existing === undefined)
123
193
  return;
124
- if (count > 1) {
125
- this.#announced.set(path, count - 1);
194
+ if (existing.count > 1) {
195
+ existing.count -= 1;
126
196
  return;
127
197
  }
128
198
  this.#announced.delete(path);
@@ -130,19 +200,24 @@ export class Subscriber {
130
200
  // would otherwise hand the dead generation to whoever consumes the path next.
131
201
  this.#consumes.evict(path);
132
202
  console.debug(`announced: broadcast=${path} active=false`);
133
- for (const consumer of this.#announcedConsumers) {
134
- const suffix = Path.stripPrefix(consumer.prefix, path);
135
- if (suffix === null)
203
+ for (const [consumer, filter] of this.#announcedConsumers) {
204
+ if (!sees(filter, path))
136
205
  continue;
206
+ const scope = filter.scope;
137
207
  try {
138
- consumer.append({ path: suffix, active: false });
208
+ consumer.append({
209
+ prefix: path,
210
+ captures: scopeCaptures(scope, path),
211
+ kind: "retracted",
212
+ route: existing.route,
213
+ });
139
214
  }
140
215
  catch {
141
216
  // Consumer already closed, will be cleaned up
142
217
  }
143
218
  }
144
219
  }
145
- async #runAnnounced(announced, prefix) {
220
+ async #runAnnounced(announced, prefix, hidden) {
146
221
  const version = this.#session.version;
147
222
  // Suffixes live on this stream, so a repeat is recognized as an update to the
148
223
  // advertisement rather than a second one, which would leak the count.
@@ -171,11 +246,11 @@ export class Subscriber {
171
246
  version === Version.DRAFT_16 ||
172
247
  version === Version.DRAFT_17) {
173
248
  await stream.writer.u53(SubscribeNamespaceLegacy.id);
174
- await new SubscribeNamespaceLegacy({ namespace: prefix, requestId }).encode(stream.writer, version);
249
+ await new SubscribeNamespaceLegacy({ namespace: prefix, requestId, hidden }).encode(stream.writer, version);
175
250
  }
176
251
  else {
177
252
  await stream.writer.u53(SubscribeNamespace.id);
178
- await new SubscribeNamespace({ namespace: prefix, requestId }).encode(stream.writer, version);
253
+ await new SubscribeNamespace({ namespace: prefix, requestId, hidden }).encode(stream.writer, version);
179
254
  }
180
255
  console.debug(`subscribe_namespace written: requestId=${requestId}`);
181
256
  // Read response
@@ -212,10 +287,15 @@ export class Subscriber {
212
287
  this.#detachAnnounce(path);
213
288
  continue;
214
289
  }
215
- // A repeat updates the advertisement; only the first is news.
216
- if (!live.has(path)) {
290
+ // A repeat replaces the advertisement in place: HOP_PATH / ROUTE_COST
291
+ // can change without a NAMESPACE_DONE. Only the first is a new path.
292
+ const route = this.#route(entry.cluster);
293
+ if (live.has(path)) {
294
+ this.#updateAnnounce(path, route);
295
+ }
296
+ else {
217
297
  live.add(path);
218
- this.#attachAnnounce(path);
298
+ this.#attachAnnounce(path, route);
219
299
  }
220
300
  }
221
301
  else if (msgType === SubscribeNamespaceEntryDone.id) {
@@ -246,7 +326,7 @@ export class Subscriber {
246
326
  this.#session.close();
247
327
  });
248
328
  // Wait for either the read loop or the announced to close
249
- await Promise.race([readLoop, announced.closed]);
329
+ await race([readLoop, announced.closed]);
250
330
  // For v14/v15: send UnsubscribeNamespace before closing
251
331
  if (version === Version.DRAFT_14 || version === Version.DRAFT_15) {
252
332
  try {
@@ -308,7 +388,7 @@ export class Subscriber {
308
388
  const consumer = new ConsumeBroadcast();
309
389
  void (async () => {
310
390
  for (;;) {
311
- const request = await consumer.requested();
391
+ const request = await wireOf(consumer).requested();
312
392
  if (!request)
313
393
  break;
314
394
  void this.#runSubscribe(path, request);
@@ -316,28 +396,43 @@ export class Subscriber {
316
396
  })();
317
397
  return consumer;
318
398
  }
399
+ // The adapter is gone. If the transport has already closed, that close is the
400
+ // error. A still-open transport, such as a GOAWAY drain, has no peer code yet,
401
+ // so this does not wait for it.
402
+ async #closedSession() {
403
+ const quic = this.#quic;
404
+ if (!quic)
405
+ return new Error("session closed");
406
+ return Promise.race([
407
+ closeError(quic),
408
+ new Promise((resolve) => queueMicrotask(() => resolve(new Error("session closed")))),
409
+ ]);
410
+ }
319
411
  async #runSubscribe(broadcast, request) {
320
412
  const requestId = await this.#session.nextRequestId();
321
413
  if (requestId === undefined) {
322
- request.reject(new Error("session closed"));
414
+ request.reject(await this.#closedSession());
323
415
  return;
324
416
  }
325
417
  console.debug(`subscribe start: id=${requestId} broadcast=${broadcast} track=${request.name}`);
326
- // IETF negotiates group order in SUBSCRIBE_OK; this implementation only
327
- // supports descending (newest-first), so commit ordered: false. (There's no
328
- // per-frame timescale, so the rest stay at their defaults.) This
329
- // resolves the consumer's track.info() and gives us the write side that
330
- // incoming object streams are routed into.
331
- const producer = request.accept({ ordered: false });
418
+ // Keep the request pending until SUBSCRIBE_OK supplies immutable track metadata.
419
+ // Group streams already wait on the alias, so early data stays behind this response.
420
+ const producer = hooks.pendingTrackProducer(request);
421
+ const subscription = { track: producer, tail: new Tail() };
332
422
  // Open the stream and wait for SUBSCRIBE_OK under a timeout. State
333
423
  // flows back via `state` so the timeout path can clean up the stream
334
424
  // and any registration if setup eventually finishes.
335
425
  const state = {};
336
- const setup = this.#openSubscribe(state, broadcast, request, producer, requestId);
426
+ const setup = this.#openSubscribe(state, broadcast, request, subscription, requestId);
337
427
  // The publisher can be serving before it answers, so waiting only on the response
338
428
  // would miss the local side going away and leave it serving a track nobody reads.
339
429
  // Demand returning before we commit is not abandonment, matching the serving loop.
340
430
  const waitAbandoned = async () => {
431
+ // An info-only lookup attaches no subscriber yet still waits on SUBSCRIBE_OK for
432
+ // the track info, so only demand that arrived and then left is abandonment.
433
+ while (!producer.used.peek() && producer.closed.peek() === undefined) {
434
+ await Signal.race(producer.used, producer.closed);
435
+ }
341
436
  for (;;) {
342
437
  await producer.unused();
343
438
  if (producer.closed.peek() !== undefined || !producer.used.peek())
@@ -347,7 +442,7 @@ export class Subscriber {
347
442
  let stream;
348
443
  let trackAlias;
349
444
  try {
350
- const result = await Promise.race([
445
+ const result = await race([
351
446
  withTimeout(setup, SUBSCRIBE_OK_TIMEOUT_MS, `subscribe timed out after ${SUBSCRIBE_OK_TIMEOUT_MS}ms waiting for SUBSCRIBE_OK (browser stream limit reached?)`),
352
447
  waitAbandoned(),
353
448
  ]);
@@ -358,8 +453,9 @@ export class Subscriber {
358
453
  console.debug(`subscribe ok: id=${requestId} broadcast=${broadcast} track=${request.name}`);
359
454
  }
360
455
  catch (err) {
361
- const e = error(err);
362
- producer.close(e);
456
+ // A control request that timed out is not late content, so it carries its own code.
457
+ const e = err instanceof TimeoutError ? controlTimeout(err) : await sessionCause(this.#quic, err);
458
+ request.reject(e);
363
459
  console.warn(`subscribe error: id=${requestId} broadcast=${broadcast} track=${request.name} error=${reason(e)}`);
364
460
  // Runs now for whatever is already open, and again if `setup` settles late.
365
461
  // Retirement is repeated because a late SUBSCRIBE_OK can register an alias after
@@ -367,7 +463,7 @@ export class Subscriber {
367
463
  let torn = false;
368
464
  const cleanup = async (afterSetup) => {
369
465
  state.cancelled = true;
370
- if (state.registeredAlias !== undefined && this.#aliases.retire(state.registeredAlias, producer)) {
466
+ if (state.registeredAlias !== undefined && this.#aliases.retire(state.registeredAlias, subscription)) {
371
467
  this.#timescales.delete(state.registeredAlias);
372
468
  }
373
469
  if (!state.stream || torn)
@@ -402,10 +498,10 @@ export class Subscriber {
402
498
  const publisherEnded = Symbol("publisher");
403
499
  const localEnded = Symbol("local");
404
500
  const idle = Symbol("idle");
405
- // Terminal conditions settle at most once (stream close = PublishDone, track close =
406
- // local unsubscribe); race them once so the demand loop doesn't re-subscribe each pass.
407
- const done = Promise.race([
408
- stream.reader.closed.then(() => publisherEnded),
501
+ // Terminal conditions settle at most once (PublishDone, track close = local
502
+ // unsubscribe); race them once so the demand loop doesn't re-subscribe each pass.
503
+ const done = race([
504
+ this.#runPublishDone(stream, subscription).then(() => publisherEnded),
409
505
  producer.closed.then(() => localEnded),
410
506
  ]);
411
507
  // Serve until a terminal condition fires or the last local subscriber leaves. The unused
@@ -413,22 +509,26 @@ export class Subscriber {
413
509
  // down resumes on the same stream.
414
510
  let terminal = localEnded;
415
511
  for (;;) {
416
- const reason = await Promise.race([done, producer.unused().then(() => idle)]);
512
+ const reason = await race([done, producer.unused().then(() => idle)]);
417
513
  if (reason === idle && producer.closed.peek() === undefined && producer.used.peek())
418
514
  continue;
419
515
  terminal = reason;
420
516
  break;
421
517
  }
518
+ // Close before the cancellation is written, not after: awaiting the write first
519
+ // reopens the window the demand re-check above just closed, and a subscriber that
520
+ // returned during it would be closed by this line. The lite subscriber closes
521
+ // straight out of its loop for the same reason.
522
+ producer.close();
422
523
  // The publisher already ended the request, so there is nothing to cancel. Sending
423
524
  // UNSUBSCRIBE here would name a request it has already torn down.
424
525
  if (terminal !== publisherEnded)
425
526
  await this.#cancelSubscribe(stream, requestId);
426
- producer.close();
427
527
  stream.close();
428
528
  console.debug(`subscribe close: id=${requestId} broadcast=${broadcast} track=${request.name}`);
429
529
  }
430
530
  catch (err) {
431
- const e = error(err);
531
+ const e = await sessionCause(this.#quic, err);
432
532
  producer.close(e);
433
533
  stream.abort(e);
434
534
  console.warn(`subscribe error: id=${requestId} broadcast=${broadcast} track=${request.name} error=${reason(e)}`);
@@ -436,10 +536,36 @@ export class Subscriber {
436
536
  finally {
437
537
  // Only the owner tears down the alias metadata: a later subscription may have
438
538
  // reclaimed the alias and installed its own timescale.
439
- if (this.#aliases.retire(trackAlias, producer))
539
+ if (this.#aliases.retire(trackAlias, subscription))
440
540
  this.#timescales.delete(trackAlias);
441
541
  }
442
542
  }
543
+ /**
544
+ * Read the PUBLISH_DONE that ends a subscription, then wait for the data streams it counts.
545
+ *
546
+ * An error status aborts the track with it. A clean one leaves streams in flight, since
547
+ * QUIC does not order them, so wait until the Stream Count many have been read, or a
548
+ * bounded grace for the ones that never arrive (the draft says to use a timeout). The count
549
+ * is only a hint: a peer may send 0 regardless, so 0 waits out the grace. A request
550
+ * stream that FINs without PUBLISH_DONE is a protocol violation.
551
+ */
552
+ async #runPublishDone(stream, subscription) {
553
+ const version = this.#session.version;
554
+ if (await stream.reader.done())
555
+ throw new ProtocolViolation("subscribe stream ended without PUBLISH_DONE");
556
+ const typeId = await stream.reader.u53();
557
+ if (typeId !== PublishDone.id) {
558
+ throw new ProtocolViolation(`unexpected message on a subscription: 0x${typeId.toString(16)}`);
559
+ }
560
+ const done = await PublishDone.decode(stream.reader, version);
561
+ if (!publishDoneClean(done.statusCode, version)) {
562
+ throw new Error(`publish done: status=0x${done.statusCode.toString(16)} reason=${done.reasonPhrase}`);
563
+ }
564
+ const count = done.streamCount;
565
+ const { tail, track } = subscription;
566
+ const complete = () => count > 0n && BigInt(tail.streams) >= count;
567
+ await tail.settle(complete, track.closed);
568
+ }
443
569
  /**
444
570
  * Tell the publisher to stop serving a subscription we are walking away from.
445
571
  *
@@ -465,7 +591,7 @@ export class Subscriber {
465
591
  // `state` is populated as soon as the stream opens and again when the
466
592
  // trackAlias is registered, so the caller can clean both up on timeout
467
593
  // even before this promise settles.
468
- async #openSubscribe(state, broadcast, request, producer, requestId) {
594
+ async #openSubscribe(state, broadcast, request, subscription, requestId) {
469
595
  const version = this.#session.version;
470
596
  state.stream = await this.#session.openBi();
471
597
  // The timeout can fire while the open is still in flight, in which case cleanup ran
@@ -503,7 +629,7 @@ export class Subscriber {
503
629
  const err = version === Version.DRAFT_14
504
630
  ? await SubscribeError.decode(state.stream.reader, version)
505
631
  : await RequestError.decode(state.stream.reader, version);
506
- reasonPhrase = `code=${err.errorCode} reason=${err.reasonPhrase}`;
632
+ reasonPhrase = requestReason(err.errorCode, err.reasonPhrase, "subscribe", version);
507
633
  }
508
634
  }
509
635
  catch {
@@ -513,8 +639,11 @@ export class Subscriber {
513
639
  throw new Error(`SUBSCRIBE error: ${reasonPhrase}`);
514
640
  }
515
641
  const ok = await SubscribeOk.decode(state.stream.reader, version);
642
+ if (state.cancelled)
643
+ throw new Error("subscribe cancelled before acceptance");
644
+ request.accept({ priority: fromWire(ok.properties.priority ?? 128) });
516
645
  try {
517
- this.#aliases.set(ok.trackAlias, producer, { broadcast, name: request.name });
646
+ this.#aliases.set(ok.trackAlias, subscription, { broadcast, name: request.name });
518
647
  const timescale = ok.properties.timescale;
519
648
  if (timescale !== undefined) {
520
649
  this.#timescales.set(ok.trackAlias, timescale);
@@ -548,7 +677,7 @@ export class Subscriber {
548
677
  await stream.writer.u53(RequestError.id);
549
678
  await new RequestError({
550
679
  requestId: msg.requestId,
551
- errorCode: 400,
680
+ errorCode: toRequestCode("uninterested", "publish_namespace", version),
552
681
  reasonPhrase: "route loops back",
553
682
  }).encode(stream.writer, version);
554
683
  stream.close();
@@ -563,11 +692,14 @@ export class Subscriber {
563
692
  const legacy = version === Version.DRAFT_14 || version === Version.DRAFT_15;
564
693
  if (legacy && (this.#announced.has(path) || this.#legacyRequests.has(path))) {
565
694
  console.warn("duplicate PublishNamespace");
695
+ // No draft registers a code for a duplicate, and this refusal is a draft-14/15
696
+ // implementation limit, which is what INTERNAL_ERROR describes.
697
+ const errorCode = toRequestCode("internal", "publish_namespace", version);
566
698
  if (version === Version.DRAFT_14) {
567
699
  await stream.writer.u53(PublishNamespaceError.id);
568
700
  await new PublishNamespaceError({
569
701
  requestId: msg.requestId,
570
- errorCode: 409,
702
+ errorCode,
571
703
  reasonPhrase: "duplicate namespace",
572
704
  }).encode(stream.writer, version);
573
705
  }
@@ -575,7 +707,7 @@ export class Subscriber {
575
707
  await stream.writer.u53(RequestError.id);
576
708
  await new RequestError({
577
709
  requestId: msg.requestId,
578
- errorCode: 409,
710
+ errorCode,
579
711
  reasonPhrase: "duplicate namespace",
580
712
  }).encode(stream.writer, version);
581
713
  }
@@ -608,10 +740,12 @@ export class Subscriber {
608
740
  // Only now is the advertisement ours to announce, for the reason above: a
609
741
  // consumer reacting with a SUBSCRIBE must not interleave with that OK.
610
742
  attached = true;
611
- this.#attachAnnounce(path);
612
- // An advertisement is updated in place, by repeating PUBLISH_NAMESPACE on the
613
- // stream that already carries it, so read until the stream ends rather than
614
- // waiting on the close. Nothing else would deliver a re-parented route.
743
+ this.#attachAnnounce(path, this.#route(msg.cluster));
744
+ // An advertisement is updated in place with REQUEST_UPDATE on the stream that
745
+ // already carries it, so read until the stream ends rather than waiting on the
746
+ // close. Nothing else would deliver a re-parented route. What the peer holds
747
+ // is kept current, since an update carries only what changed.
748
+ let held = msg.cluster;
615
749
  const done = version === Version.DRAFT_16 || legacy;
616
750
  for (;;) {
617
751
  if (await stream.reader.done())
@@ -621,32 +755,47 @@ export class Subscriber {
621
755
  await PublishNamespaceDone.decode(stream.reader, version);
622
756
  break;
623
757
  }
624
- if (typeId !== PublishNamespace.id) {
758
+ // A repeated PUBLISH_NAMESPACE lands here too: a second request on the
759
+ // stream is the base draft's duplicate request ID.
760
+ if (typeId !== PublishNamespaceUpdate.id) {
625
761
  throw new ProtocolViolation(`unexpected message on publish_namespace stream: 0x${typeId.toString(16)}`);
626
762
  }
627
- const update = await PublishNamespace.decode(stream.reader, version, Cluster.negotiated(this.#cluster));
628
- // The stream is the advertisement, so an update on it must name the same
629
- // one. Applying a mismatched update would retarget this path with metadata
630
- // meant for a different request.
631
- if (update.requestId !== msg.requestId || update.trackNamespace !== path) {
632
- throw new ProtocolViolation("publish_namespace update does not match its stream");
763
+ const update = await PublishNamespaceUpdate.decode(stream.reader, version);
764
+ // The parameters exist only on a session that negotiated the extension;
765
+ // anywhere else they are the peer's violation.
766
+ if (held === undefined) {
767
+ if (update.update.hops !== undefined || update.update.cost !== undefined) {
768
+ throw new ProtocolViolation("cluster parameters on a session that negotiated none");
769
+ }
770
+ }
771
+ else {
772
+ // A different original publisher applies in place too, as it does inline.
773
+ held = Cluster.apply(held, update.update);
633
774
  }
634
- // A path that now runs through us is unusable, so give it back. Keep
635
- // reading: this stream is the advertisement's only channel, so a later
636
- // clean path arrives here or nowhere.
637
- if (this.#reflected(update.cluster)) {
775
+ // A path that now runs through us is unusable, so give it back. The update
776
+ // itself is accepted, and reading continues: this stream is the
777
+ // advertisement's only channel, so a later clean path arrives here or
778
+ // nowhere.
779
+ if (this.#reflected(held)) {
638
780
  if (attached) {
639
781
  attached = false;
640
782
  console.debug(`publish_namespace now loops back, detaching: broadcast=${path}`);
641
783
  this.#detachAnnounce(path);
642
784
  }
643
- continue;
644
785
  }
645
- // Re-attach: a clean path replaced the reflected one we detached from.
646
- if (!attached) {
786
+ else if (!attached) {
787
+ // Re-attach: a clean path replaced the reflected one we detached from.
647
788
  attached = true;
648
- this.#attachAnnounce(path);
789
+ this.#attachAnnounce(path, this.#route(held));
790
+ }
791
+ else {
792
+ this.#updateAnnounce(path, this.#route(held));
649
793
  }
794
+ // Nothing here can fail to apply, so every update is acknowledged. A leaf
795
+ // routes nothing, so a repricing changes nothing it holds; consumers still
796
+ // hear the new route so a forwarder can reprice.
797
+ await stream.writer.u53(RequestOk.id);
798
+ await new RequestOk({}).encode(stream.writer, version);
650
799
  }
651
800
  }
652
801
  finally {
@@ -667,17 +816,17 @@ export class Subscriber {
667
816
  */
668
817
  async runPublish(msg, stream) {
669
818
  const version = this.#session.version;
670
- // NOT_SUPPORTED, from the PUBLISH error codes in draft-19 section 10.10. We decline
671
- // the method itself rather than this particular track, which is UNINTERESTED (0x4).
819
+ // We decline the method itself rather than this particular track, which would be
820
+ // UNINTERESTED.
672
821
  //
673
822
  // The alias the message carries is deliberately not recorded. Nothing will ever bind
674
823
  // it, and a rejected request has no lifetime of ours to hang the cleanup on.
675
- const NOT_SUPPORTED = 0x3;
824
+ const errorCode = toRequestCode("not_supported", "publish", version);
676
825
  if (version === Version.DRAFT_14) {
677
826
  await stream.writer.u53(PublishError.id);
678
827
  const err = new PublishError({
679
828
  requestId: msg.requestId,
680
- errorCode: NOT_SUPPORTED,
829
+ errorCode,
681
830
  reasonPhrase: "publish not supported",
682
831
  });
683
832
  await err.encode(stream.writer, version);
@@ -686,7 +835,7 @@ export class Subscriber {
686
835
  await stream.writer.u53(RequestError.id);
687
836
  const err = new RequestError({
688
837
  requestId: version === Version.DRAFT_15 || version === Version.DRAFT_16 ? msg.requestId : undefined,
689
- errorCode: NOT_SUPPORTED,
838
+ errorCode,
690
839
  reasonPhrase: "publish not supported",
691
840
  });
692
841
  await err.encode(stream.writer, version);
@@ -702,48 +851,114 @@ export class Subscriber {
702
851
  if (group.subGroupId !== 0) {
703
852
  throw new Error("subgroups are not supported");
704
853
  }
705
- // FIRST_OBJECT clear says this stream starts partway through the group, which the
706
- // draft lets a publisher do to answer a filter. Nothing above here can use it: the
707
- // objects that would arrive are not decodable without the missing head, and a group
708
- // is the unit an application resyncs on. Drop it and pick up at the next group, the
709
- // same degradation as a publisher that no longer holds the head.
710
- //
711
- // This only saves reading a stream we would throw away. The bit is the publisher's
712
- // claim, so what is enforced is the object ids themselves: `Frame.decode` holds every
713
- // object to starting at 0 and incrementing by 1, whatever the header said and on the
714
- // drafts that have no such bit to read.
715
- if (!group.flags.firstObject) {
716
- console.debug(`dropping a group with no head: alias=${group.trackAlias} group=${group.groupId}`);
717
- stream.stop(new Error("a group must start at object 0"));
854
+ let subscription;
855
+ try {
856
+ // The control message establishing this alias can arrive after the data stream.
857
+ subscription = await this.#aliases.get(group.trackAlias);
858
+ }
859
+ catch (err) {
860
+ const e = error(err);
861
+ // Ours: we cancelled the subscription and the publisher has not stopped yet.
862
+ // Anything else on this alias is the publisher sending data for a track it never
863
+ // acknowledged, which is worth seeing.
864
+ if (e instanceof RetiredTrackAlias) {
865
+ console.debug(`dropping group for a cancelled subscription: alias=${group.trackAlias}`);
866
+ }
867
+ stream.stop(e);
718
868
  return;
719
869
  }
720
- const producer = new netGroup.Producer(group.groupId);
870
+ const { track, tail } = subscription;
871
+ // Every data stream counts toward PUBLISH_DONE's Stream Count, even one dropped below.
872
+ const read = tail.open(group.groupId);
873
+ // Created on the first object rather than the header: an END_OF_TRACK at object 0
874
+ // means the group does not exist at all.
875
+ let producer;
876
+ const open = () => {
877
+ if (!producer) {
878
+ // The publisher contradicted its own end, which no later group can repair.
879
+ if (subscription.end !== undefined && group.groupId >= subscription.end) {
880
+ throw new ProtocolViolation(`group ${group.groupId} is at or past the declared end ${subscription.end}`);
881
+ }
882
+ producer = new netGroup.Producer(group.groupId);
883
+ track.writeGroup(producer);
884
+ }
885
+ return producer;
886
+ };
721
887
  try {
722
- // The control message establishing this alias can arrive after the data stream.
723
- const track = await this.#aliases.get(group.trackAlias);
724
- track.writeGroup(producer);
888
+ // FIRST_OBJECT clear says this stream starts partway through the group, which the
889
+ // draft lets a publisher do to answer a filter. Nothing above here can use it: the
890
+ // objects that would arrive are not decodable without the missing head, and a group
891
+ // is the unit an application resyncs on. Drop it and pick up at the next group, the
892
+ // same degradation as a publisher that no longer holds the head.
893
+ //
894
+ // This only saves reading a stream we would throw away. The bit is the publisher's
895
+ // claim, so what is enforced is the object ids themselves: `Frame.decode` holds every
896
+ // object to starting at 0 and incrementing by 1, whatever the header said and on the
897
+ // drafts that have no such bit to read.
898
+ if (!group.flags.firstObject) {
899
+ console.debug(`dropping a group with no head: alias=${group.trackAlias} group=${group.groupId}`);
900
+ stream.stop(new Error("a group must start at object 0"));
901
+ return;
902
+ }
903
+ // The alias binds after SUBSCRIBE_OK commits the track property; an omitted
904
+ // header priority inherits it (draft-21 section 10.4).
905
+ if (!group.flags.hasPriority)
906
+ group.publisherPriority = toWire((await track.info()).priority);
907
+ const decode = (c) => Frame.decode(c, group.flags, this.#timescales.get(group.trackAlias));
725
908
  for (;;) {
726
- const done = await Promise.race([stream.done(), producer.closed, track.closed]);
727
- if (done !== false)
909
+ // Every object already buffered is written without an await, so the reader wakes
910
+ // once per batch rather than once per object. Only the group's own stream ends it:
911
+ // a track that closes first has already closed (or aborted) this group through its
912
+ // cache.
913
+ const frame = stream.tryDecode(decode) ??
914
+ (await (producer
915
+ ? race([stream.decodeMaybe(decode), producer.closed])
916
+ : stream.decodeMaybe(decode)));
917
+ if (!frame || frame instanceof Error)
728
918
  break;
729
- const frame = await Frame.decode(stream, group.flags, this.#timescales.get(group.trackAlias), this.#session.version);
919
+ if (frame.endOfTrack) {
920
+ // No object at or past this location exists: after the group's last object
921
+ // the track ends with it, and at object 0 it ends before it.
922
+ const end = producer ? group.groupId + 1 : group.groupId;
923
+ producer?.close();
924
+ try {
925
+ track.finishAt(end);
926
+ }
927
+ catch (err) {
928
+ throw new ProtocolViolation(`invalid END_OF_TRACK: ${reason(error(err))}`);
929
+ }
930
+ subscription.end ??= end;
931
+ return;
932
+ }
730
933
  if (frame.payload === undefined)
731
934
  break;
732
- producer.writeFrame({ payload: frame.payload, timestamp: frame.timestamp ?? Timestamp.now() });
935
+ open().writeFrame({ payload: frame.payload, timestamp: frame.timestamp ?? Timestamp.now() });
733
936
  }
734
- producer.close();
937
+ // A group with no objects still exists.
938
+ open().close();
735
939
  }
736
940
  catch (err) {
737
941
  const e = error(err);
738
- // Ours: we cancelled the subscription and the publisher has not stopped yet.
739
- // Anything else on this alias is the publisher sending data for a track it never
740
- // acknowledged, which is worth seeing.
741
- if (e instanceof RetiredTrackAlias) {
742
- console.debug(`dropping group for a cancelled subscription: alias=${group.trackAlias}`);
942
+ if (e instanceof ProtocolViolation) {
943
+ // The publisher broke the track's end, which no later group can repair.
944
+ producer?.close(e);
945
+ track.close(e);
946
+ }
947
+ else {
948
+ // A stream that fails before its first object still names a group, which the
949
+ // reader sees fail rather than silently go missing.
950
+ try {
951
+ open().close(e);
952
+ }
953
+ catch {
954
+ // The track has already closed or ended below this group.
955
+ }
743
956
  }
744
- producer.close(e);
745
957
  stream.stop(e);
746
958
  }
959
+ finally {
960
+ read();
961
+ }
747
962
  }
748
963
  }
749
964
  /**
@@ -752,16 +967,15 @@ export class Subscriber {
752
967
  * group fetch, so `track.Consumer.fetchGroup()` is rejected.
753
968
  */
754
969
  class ConsumeBroadcast extends broadcast.Consumer {
755
- // biome-ignore lint/complexity/noUselessConstructor: widens the protected base constructor to public
756
970
  constructor(state) {
757
971
  super(state);
972
+ overrideBroadcastWire(this, {
973
+ fetchGroup: () => Promise.reject(new Error("fetch group is not supported for moq-transport")),
974
+ });
758
975
  }
759
976
  // Preserve the subclass when the consume cache shares this broadcast across callers.
760
977
  clone() {
761
978
  return new ConsumeBroadcast(this.shareState());
762
979
  }
763
- fetchGroup() {
764
- return Promise.reject(new Error("fetch group is not supported for moq-transport"));
765
- }
766
980
  }
767
981
  //# sourceMappingURL=subscriber.js.map