@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
package/ietf/publisher.js CHANGED
@@ -1,31 +1,65 @@
1
1
  /* @ts-self-types="./publisher.d.ts" */
2
- import { Signal } from "@norskvideo/moq-signals";
3
- import { error, reason } from "../error.js";
2
+ import { race, Signal } from "@norskvideo/moq-signals";
3
+ import { controlTimeout, error, reason, StreamCode, StreamError } from "../error.js";
4
+ import { hiddenBelow, hooks, presented } from "../internal.js";
4
5
  import * as Path from "../path.js";
5
6
  import { Writer } from "../stream.js";
6
- import { withTimeout } from "../util/timeout.js";
7
+ import { Milli, Timescale } from "../time.js";
8
+ import { TimeoutError, withTimeout } from "../util/timeout.js";
9
+ import * as Varint from "../varint.js";
10
+ import { wireOf } from "../wire.js";
7
11
  import * as Cluster from "./cluster.js";
8
- import { FetchHeader } from "./fetch.js";
12
+ import { requestReason, toRequestCode } from "./error.js";
13
+ import { FetchError, FetchHeader } from "./fetch.js";
9
14
  import * as Filter from "./filter.js";
10
15
  import { FetchFrame, Frame, Group as GroupMessage } from "./object.js";
11
- import { fromWire } from "./priority.js";
16
+ import { fromWire, toWire } from "./priority.js";
12
17
  import * as Properties from "./properties.js";
13
- import { PublishDone } from "./publish.js";
14
- import { PublishNamespace, PublishNamespaceDone, PublishNamespaceOk } from "./publish_namespace.js";
18
+ import { PublishDone, PublishDoneStatus } from "./publish.js";
19
+ import { PublishNamespace, PublishNamespaceDone, PublishNamespaceOk, PublishNamespaceUpdate, } from "./publish_namespace.js";
15
20
  import { RequestError, RequestOk } from "./request.js";
16
21
  import { SubscribeError, SubscribeOk } from "./subscribe.js";
17
22
  import { SubscribeNamespaceEntry, SubscribeNamespaceEntryDone, SubscribeNamespaceOk, } from "./subscribe_namespace.js";
18
- import { TrackStatus } from "./track.js";
19
23
  import { Version } from "./version.js";
20
24
  /** First wait before re-offering a namespace the peer refused or we couldn't open for. */
21
25
  const RETRY_BASE = 100;
22
26
  /** Ceiling on that wait. The loop retries for the life of the session, so it must not spin. */
23
27
  const RETRY_MAX = 5000;
24
- /** PUBLISH_DONE statuses this implementation emits. Stable across drafts 14 through 19. */
25
- const PUBLISH_DONE_STATUS = {
26
- INTERNAL_ERROR: 0x0,
27
- TRACK_ENDED: 0x2,
28
- };
28
+ /** Cluster parameters for one snapshot: the advertised hops, then our own id last. */
29
+ function clusterFor(base, route) {
30
+ if (!base)
31
+ return undefined;
32
+ const self = base.hops.at(-1);
33
+ if (self === undefined)
34
+ return undefined;
35
+ const hops = route.hops.at(-1) === self ? [...route.hops] : [...route.hops, self];
36
+ return { hops, cost: route.cost.warm };
37
+ }
38
+ /**
39
+ * What it takes to move the peer from `held` to `next`.
40
+ *
41
+ * - `"same"`: the peer would decode the same advertisement. The cold cost, and the whole
42
+ * route without Cluster, never reach the wire, so a change there sends nothing.
43
+ * - `"restart"`: nothing is held, nothing is wanted, or a different broadcast, which is
44
+ * withdrawn and advertised again.
45
+ * - Otherwise the same broadcast at a new hop chain or warm cost: the cluster parameters
46
+ * the peer holds and the ones it should, for the target to update.
47
+ */
48
+ function change(base, held, next) {
49
+ if (held === undefined || next === undefined || held.identity !== next.identity)
50
+ return "restart";
51
+ const from = clusterFor(base, held.route);
52
+ const to = clusterFor(base, next.route);
53
+ if (from === undefined || to === undefined)
54
+ return from === to ? "same" : "restart";
55
+ if (from.cost === to.cost && sameHops(from.hops, to.hops))
56
+ return "same";
57
+ return { from, to };
58
+ }
59
+ /** Whether two hop paths list the same hops in the same order. */
60
+ function sameHops(a, b) {
61
+ return a.length === b.length && a.every((hop, i) => hop === b[i]);
62
+ }
29
63
  /**
30
64
  * How long one advertisement may take to be answered. Matches the Rust publisher, and the
31
65
  * peer accepting the stream is only half the exchange: one it never answers on holds the
@@ -46,44 +80,32 @@ export class Publisher {
46
80
  #quic;
47
81
  #session;
48
82
  #requiresSolicitation;
83
+ #hidden;
84
+ // The origin this session serves, borrowed: it outlives the session, and closing the
85
+ // session leaves its broadcasts alone. The namespaces are advertised with an unsolicited
86
+ // PUBLISH_NAMESPACE (see {@link runPublishNamespaces}), or on request if the peer asked
87
+ // for that (see {@link runSubscribeNamespace}).
88
+ #advertised;
89
+ #publish;
49
90
  // What every advertisement carries on a session that negotiated the MoQ Cluster
50
91
  // extension: a hop path holding our own id, so the peer can tell that what it hears
51
92
  // back came from us. `undefined` when nothing negotiated it.
52
93
  #advert;
53
- // Our published broadcasts.
54
- // It's a signal so we can live update any subscribe_namespace streams.
55
- #broadcasts = new Signal(new Map());
56
94
  /**
57
95
  * Creates a new Publisher instance.
58
96
  *
59
97
  * @internal
60
98
  */
61
- constructor({ quic, session, requiresSolicitation, cluster, }) {
99
+ constructor({ quic, session, publish, requiresSolicitation, hidden = false, cluster, }) {
62
100
  this.#quic = quic;
63
101
  this.#session = session;
102
+ const origin = publish && wireOf(publish);
103
+ this.#advertised = origin?.advertised ?? new Signal(new Map());
104
+ this.#publish = publish;
64
105
  this.#requiresSolicitation = requiresSolicitation;
106
+ this.#hidden = hidden;
65
107
  this.#advert = Cluster.advertise(cluster);
66
108
  }
67
- /**
68
- * Publishes a broadcast with any associated tracks.
69
- * The namespace is advertised with an unsolicited PUBLISH_NAMESPACE, or on request
70
- * if the peer asked for that (see {@link runPublishNamespaces}).
71
- */
72
- publish(path, broadcast) {
73
- this.#broadcasts.mutate((broadcasts) => {
74
- if (!broadcasts)
75
- throw new Error("closed");
76
- broadcasts.set(path, broadcast);
77
- });
78
- // Remove the broadcast from the lookup when it's closed, unless the path was republished.
79
- void broadcast.closed.then(() => {
80
- this.#broadcasts.mutate((broadcasts) => {
81
- if (broadcasts?.get(path) === broadcast) {
82
- broadcasts.delete(path);
83
- }
84
- });
85
- });
86
- }
87
109
  /**
88
110
  * Handles an incoming SUBSCRIBE request on a bidi stream.
89
111
  * Owns the full lifecycle: sends response, serves track data, waits for close.
@@ -93,15 +115,43 @@ export class Publisher {
93
115
  async runSubscribe(msg, stream) {
94
116
  const version = this.#session.version;
95
117
  const name = msg.trackNamespace;
96
- const broadcast = this.#broadcasts.peek()?.get(name);
97
- if (!broadcast) {
98
- // Write error response
118
+ let broadcast;
119
+ let refusal;
120
+ // Legal requests we can't honor are refused one at a time. A subscription that
121
+ // forwards nothing is only useful to a subscriber that later turns forwarding on,
122
+ // and serving a Range Filter unfiltered would deliver objects it excluded.
123
+ const unsupported = !msg.forward
124
+ ? "FORWARD=0 not supported"
125
+ : msg.rangeFilters
126
+ ? "range filters not supported"
127
+ : undefined;
128
+ if (unsupported) {
129
+ refusal = { errorCode: toRequestCode("not_supported", "subscribe", version), reasonPhrase: unsupported };
130
+ }
131
+ else {
132
+ try {
133
+ broadcast =
134
+ this.#publish && (wireOf(this.#publish).local(name) ?? (await wireOf(this.#publish).demand(name)));
135
+ if (!broadcast) {
136
+ refusal = {
137
+ errorCode: toRequestCode("does_not_exist", "subscribe", version),
138
+ reasonPhrase: "broadcast not found",
139
+ };
140
+ }
141
+ }
142
+ catch (err) {
143
+ const e = error(err);
144
+ const condition = e instanceof StreamError && e.code === StreamCode.NotFound ? "does_not_exist" : "internal";
145
+ refusal = { errorCode: toRequestCode(condition, "subscribe", version), reasonPhrase: reason(e) };
146
+ }
147
+ }
148
+ if (refusal) {
99
149
  if (version === Version.DRAFT_14) {
100
150
  await stream.writer.u53(SubscribeError.id);
101
151
  const err = new SubscribeError({
102
152
  requestId: msg.requestId,
103
- errorCode: 404,
104
- reasonPhrase: "Broadcast not found",
153
+ errorCode: refusal.errorCode,
154
+ reasonPhrase: refusal.reasonPhrase,
105
155
  });
106
156
  await err.encode(stream.writer, version);
107
157
  }
@@ -109,21 +159,36 @@ export class Publisher {
109
159
  await stream.writer.u53(RequestError.id);
110
160
  const err = new RequestError({
111
161
  requestId: version === Version.DRAFT_15 || version === Version.DRAFT_16 ? msg.requestId : undefined,
112
- errorCode: 404,
113
- reasonPhrase: "Broadcast not found",
162
+ errorCode: refusal.errorCode,
163
+ reasonPhrase: refusal.reasonPhrase,
114
164
  });
115
165
  await err.encode(stream.writer, version);
116
166
  }
117
167
  stream.close();
118
168
  return;
119
169
  }
170
+ if (!broadcast)
171
+ return;
120
172
  const priority = fromWire(msg.subscriberPriority);
121
- const track = broadcast.subscribe(msg.trackName, { priority });
122
- // Hoisted so the cleanup below reaches it: the fork happens before SUBSCRIBE_OK is
123
- // written, and a write that throws must not strand the sink it attached.
173
+ const track = wireOf(broadcast).subscribe(msg.trackName, {
174
+ priority,
175
+ // moq-transport has no subscriber latency parameter. Keep everything the
176
+ // producer retained and let the receiving subscriber enforce its own budget.
177
+ // Keep the sentinel encodable if this demand crosses a Lite hop before the
178
+ // producer's retention bound is known.
179
+ maxAge: Milli(Varint.MAX_U53),
180
+ });
124
181
  let cache;
125
182
  try {
126
- const timescale = (await track.info()).timescale;
183
+ // Declaring the timescale is what opts the track into timestamps; every object
184
+ // Timestamp below is in these units.
185
+ const info = await track.info();
186
+ const timescale = info.timescale;
187
+ // The model ranks higher-first, the IETF wire lower-first. Every group this
188
+ // subscription serves carries the same publisher priority, which is what lets a
189
+ // relay prefer catalog and audio over video when it has no subscriber preference
190
+ // to go on.
191
+ const publisherPriority = toWire(info.priority);
127
192
  // The filter and any fill are relative to the live edge, so snapshot it once: the
128
193
  // fill ends exactly where a Next Object subscription begins, which is what lets
129
194
  // the draft's current-group join (Next Object plus a StartGroup=1 fill) cover the
@@ -133,21 +198,25 @@ export class Publisher {
133
198
  // The wire request tells an upstream what we need; the cursor is what actually
134
199
  // trims this subscriber, since the producer fans every cached group out to every
135
200
  // sink regardless. An absent start joins at the latest group, which is what
136
- // moq-lite means by joining a live track.
201
+ // moq-lite means by joining a live track. The range's end is the inclusive last
202
+ // group; the model's `endGroup` is exclusive.
137
203
  track.update({
138
204
  priority,
139
- startGroup: range.start && Number(range.start.group),
140
- endGroup: range.end && Number(range.end.group),
205
+ maxAge: Milli(Varint.MAX_U53),
206
+ groups: {
207
+ start: range.start ? { included: Number(range.start.group) } : undefined,
208
+ end: range.end ? { included: Number(range.end.group) } : undefined,
209
+ },
141
210
  });
142
211
  const startGroup = range.start ? Number(range.start.group) : track.latest();
143
212
  if (startGroup !== undefined)
144
- track.startAt(startGroup);
213
+ track.setGroups({ start: { included: startGroup } });
145
214
  // A fill reads the group cache through its own consumer, independent of the
146
215
  // subscription's cursor. Forked from this subscriber rather than resolved through
147
216
  // the broadcast again: a dynamic serve is one request per peer subscription, so
148
217
  // asking the broadcast would mint a second producer nobody has accepted.
149
218
  const fill = msg.fill && Filter.isDraft20(version) ? fillRange(msg.fill, msg.filter, edge.largest) : undefined;
150
- cache = fill && fill.kind !== "empty" ? track.fork({ priority }) : undefined;
219
+ cache = fill && fill.kind !== "empty" ? track.fork({ priority, maxAge: Milli(Varint.MAX_U53) }) : undefined;
151
220
  // Send SUBSCRIBE_OK
152
221
  await stream.writer.u53(SubscribeOk.id);
153
222
  const ok = new SubscribeOk({
@@ -156,13 +225,20 @@ export class Publisher {
156
225
  : undefined,
157
226
  trackAlias: msg.requestId,
158
227
  // Required once the track has content; a fill-requesting subscriber sizes its
159
- // backfill against this.
160
- largest: edge.largest && { groupId: edge.largest.group, objectId: edge.largest.object },
228
+ // backfill against this. Below draft-20 round it down to the start of its
229
+ // group, matching what the subscription serves there: a draft-20 subscriber
230
+ // recovers a group's head with a FILL, which we serve, while older drafts
231
+ // recover it with a joining FETCH, which we do not, so naming a mid-group
232
+ // start would strand them.
233
+ largest: edge.largest &&
234
+ (Filter.isDraft20(version)
235
+ ? { groupId: edge.largest.group, objectId: edge.largest.object }
236
+ : { groupId: edge.largest.group, objectId: 0n }),
161
237
  properties: msg.propertiesWanted
162
238
  ? // Declaring the timescale is what opts the track into timestamps; every
163
239
  // object Timestamp below is in these units. We serve the newest group
164
240
  // first, matching moq-lite.
165
- { timescale, groupOrder: Properties.DESCENDING }
241
+ { timescale, priority: publisherPriority, groupOrder: Properties.DESCENDING }
166
242
  : // INCLUDE_PROPERTIES=0. The block stays present but empty, which also means
167
243
  // the track opts out of timestamps for this subscriber.
168
244
  {},
@@ -183,13 +259,17 @@ export class Publisher {
183
259
  },
184
260
  // A reset is always the peer.
185
261
  () => unsubscribe());
262
+ // Every group started, until its stream finishes or resets, and the data streams
263
+ // opened for PUBLISH_DONE to report.
264
+ const groups = new Set();
265
+ const streams = { opened: 0 };
186
266
  // Serve track groups, racing with stream close (= Unsubscribe)
187
267
  const serving = (async () => {
188
268
  for (;;) {
189
269
  const group = await track.recvGroup();
190
270
  if (!group)
191
271
  return;
192
- // Past the filter's end. Dropped here rather than through `endAt`, which
272
+ // Past the filter's end. Dropped here rather than through `setGroups`, which
193
273
  // parks a capped group instead: this range is fixed for the life of the
194
274
  // subscription, so a group above it is never coming back in, and holding
195
275
  // one keeps the loop from ever ending. A producer that publishes beyond
@@ -198,14 +278,18 @@ export class Publisher {
198
278
  group.close();
199
279
  continue;
200
280
  }
201
- void this.#runGroup({
281
+ const task = this.#runGroup({
202
282
  requestId: msg.requestId,
203
283
  group,
204
284
  timescale,
285
+ publisherPriority,
205
286
  stamped: msg.propertiesWanted,
206
287
  slice: groupSlice(range, group.sequence),
207
288
  unsubscribed,
289
+ streams,
208
290
  });
291
+ groups.add(task);
292
+ void task.finally(() => groups.delete(task));
209
293
  }
210
294
  })();
211
295
  // The fill (when one was requested) runs alongside on its own fetch stream; its
@@ -219,15 +303,35 @@ export class Publisher {
219
303
  timescale,
220
304
  stamped: msg.propertiesWanted,
221
305
  unsubscribed,
306
+ streams,
222
307
  })
223
308
  : Promise.resolve();
224
309
  let publishError;
310
+ let ended = false;
225
311
  try {
226
- await Promise.race([Promise.all([serving, filling]), stream.reader.closed]);
312
+ const served = Symbol("served");
313
+ ended =
314
+ (await race([Promise.all([serving, filling]).then(() => served), stream.reader.closed])) === served;
227
315
  }
228
316
  catch (err) {
229
317
  publishError = error(err);
230
318
  }
319
+ // PUBLISH_DONE waits until every stream this subscription will open is closed, as
320
+ // the draft requires, so its count is final. The subscriber leaving cancels the
321
+ // ones still queued instead.
322
+ await race([Promise.all(groups), unsubscribed]);
323
+ // Draft 14 on has no end location in PUBLISH_DONE: an END_OF_TRACK object is what
324
+ // tells the subscriber where the track ended.
325
+ const final = track.final();
326
+ if (ended && !publishError && final !== undefined) {
327
+ await this.#runEndOfTrack({
328
+ requestId: msg.requestId,
329
+ final,
330
+ publisherPriority,
331
+ unsubscribed,
332
+ streams,
333
+ });
334
+ }
231
335
  console.debug(`publish done: broadcast=${name} track=${track.name}`);
232
336
  if (publishError) {
233
337
  console.warn(`publish error: broadcast=${name} track=${track.name} error=${reason(publishError)}`);
@@ -240,7 +344,8 @@ export class Publisher {
240
344
  requestId: version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16
241
345
  ? msg.requestId
242
346
  : undefined,
243
- statusCode: publishError ? PUBLISH_DONE_STATUS.INTERNAL_ERROR : PUBLISH_DONE_STATUS.TRACK_ENDED,
347
+ statusCode: publishError ? PublishDoneStatus.INTERNAL_ERROR : PublishDoneStatus.TRACK_ENDED,
348
+ streamCount: BigInt(streams.opened),
244
349
  reasonPhrase: publishError ? "internal error" : "track ended",
245
350
  });
246
351
  await done.encode(stream.writer, version);
@@ -270,7 +375,7 @@ export class Publisher {
270
375
  * Runs a group and sends its frames using ObjectStream (Subgroup delivery mode).
271
376
  */
272
377
  async #runGroup(options) {
273
- const { requestId, group, timescale, stamped, slice, unsubscribed } = options;
378
+ const { requestId, group, timescale, publisherPriority, stamped, slice, unsubscribed, streams } = options;
274
379
  try {
275
380
  // One stream per group is faster than a peer at its limit can retire them, so this
276
381
  // is the one path that doesn't wait for a slot: the transport would serve the opens
@@ -285,11 +390,12 @@ export class Publisher {
285
390
  group.close(new Error("no stream slot"));
286
391
  return;
287
392
  }
393
+ streams.opened += 1;
288
394
  const header = new GroupMessage({
289
395
  trackAlias: requestId,
290
396
  groupId: group.sequence,
291
397
  subGroupId: 0,
292
- publisherPriority: 0,
398
+ publisherPriority,
293
399
  flags: {
294
400
  // The object properties carry the timestamp, so there is nothing to write
295
401
  // when the track declared no units to read one in.
@@ -303,8 +409,8 @@ export class Publisher {
303
409
  firstObject: slice.skip === 0,
304
410
  },
305
411
  });
306
- await header.encode(stream, this.#session.version);
307
412
  try {
413
+ await hooks.guardGroup(group, () => header.encode(stream, this.#session.version));
308
414
  // The first written object goes on the wire as its absolute id, so a trimmed
309
415
  // head shows the true numbering rather than a silently renumbered group.
310
416
  let first = true;
@@ -318,20 +424,25 @@ export class Publisher {
318
424
  // outside the requested range, so stop without waiting for the group's end.
319
425
  if (slice.until !== undefined && next >= slice.until)
320
426
  break;
321
- // Reading from the filter's start drops the objects below it, including any the
322
- // group's cache evicted: they are outside the requested range, so losing them is
323
- // not the gap that would otherwise reset this stream and forfeit the rest of the
324
- // group. An eviction at or above the start is a real gap and still throws.
325
- const frame = await Promise.race([group.readFrameSequence({ from: slice.skip }), stream.closed]);
326
- if (!frame)
427
+ // Reading from the filter's start drops the objects below it: they are outside
428
+ // the requested range, so skipping them is not a gap.
429
+ const read = await race([hooks.readGroupFrame(group, slice.skip), stream.closed]);
430
+ if (!read)
327
431
  break;
328
- next = frame.sequence + 1;
329
- if (slice.until !== undefined && frame.sequence >= slice.until)
432
+ next = read.sequence + 1;
433
+ if (slice.until !== undefined && read.sequence >= slice.until) {
434
+ read.complete();
330
435
  break;
331
- const obj = new Frame({ payload: frame.payload, timestamp: frame.timestamp });
332
- const delta = first ? frame.sequence : 0;
333
- first = false;
334
- await obj.encode(stream, header.flags, timescale, this.#session.version, delta);
436
+ }
437
+ try {
438
+ const obj = new Frame({ payload: read.frame.payload, timestamp: read.frame.timestamp });
439
+ const delta = first ? read.sequence : 0;
440
+ first = false;
441
+ await hooks.guardGroup(group, () => obj.encode(stream, header.flags, timescale, this.#session.version, delta));
442
+ }
443
+ finally {
444
+ read.complete();
445
+ }
335
446
  }
336
447
  stream.close();
337
448
  }
@@ -343,6 +454,43 @@ export class Publisher {
343
454
  group.close();
344
455
  }
345
456
  }
457
+ /**
458
+ * Mark the track's end with an END_OF_TRACK object on its own stream, at object 0 of the
459
+ * group that will never exist.
460
+ *
461
+ * The last group's stream has usually finished before the track ends, so the marker cannot
462
+ * ride on it. A failure only costs the subscriber the early boundary.
463
+ */
464
+ async #runEndOfTrack(options) {
465
+ const { requestId, final, publisherPriority, unsubscribed, streams } = options;
466
+ const version = this.#session.version;
467
+ const stream = await Writer.tryOpen(this.#quic, { cancel: unsubscribed, version }).catch(() => undefined);
468
+ if (!stream)
469
+ return;
470
+ streams.opened += 1;
471
+ try {
472
+ const header = new GroupMessage({
473
+ trackAlias: requestId,
474
+ groupId: final,
475
+ subGroupId: 0,
476
+ publisherPriority,
477
+ flags: {
478
+ hasExtensions: false,
479
+ hasSubgroup: false,
480
+ hasSubgroupObject: false,
481
+ hasEnd: false,
482
+ hasPriority: true,
483
+ firstObject: true,
484
+ },
485
+ });
486
+ await header.encode(stream, version);
487
+ await new Frame({ endOfTrack: true }).encode(stream, header.flags, Timescale.MILLI, version);
488
+ stream.close();
489
+ }
490
+ catch (err) {
491
+ stream.reset(error(err));
492
+ }
493
+ }
346
494
  /**
347
495
  * Serve a draft-20 fill on its own fetch stream: the requested range, read from the
348
496
  * group cache, capped at the Largest Object snapshot.
@@ -352,7 +500,7 @@ export class Publisher {
352
500
  * fill-failure signal. Nothing here touches the subscription either way.
353
501
  */
354
502
  async #runFill(options) {
355
- const { requestId, fill, cache, timescale, stamped, unsubscribed } = options;
503
+ const { requestId, fill, cache, timescale, stamped, unsubscribed, streams } = options;
356
504
  const version = this.#session.version;
357
505
  // Everything is inside the try so the cache fork is released on every path out,
358
506
  // including a transport failure while opening the stream.
@@ -365,6 +513,7 @@ export class Publisher {
365
513
  console.debug(`fill stream failed to open: fill=${requestId}`);
366
514
  return;
367
515
  }
516
+ streams.opened += 1;
368
517
  await stream.u53(FetchHeader.type);
369
518
  await new FetchHeader({ requestId }).encode(stream, version);
370
519
  if (fill.kind !== "group") {
@@ -413,13 +562,8 @@ export class Publisher {
413
562
  // subscription, so stop without waiting for the group's end.
414
563
  if (fill.until !== undefined && next >= fill.until)
415
564
  break;
416
- // Reading from the fill's start drops everything below it, evicted objects included;
417
- // see the same read in #runGroup.
418
- const frame = await Promise.race([
419
- group.readFrameSequence({ from: Number(fill.skip) }),
420
- stream.closed,
421
- cancelled,
422
- ]);
565
+ // Reading from the fill's start drops everything below it; see the same read in #runGroup.
566
+ const frame = await race([group.readFrameSequence({ from: Number(fill.skip) }), stream.closed, cancelled]);
423
567
  if (left)
424
568
  throw new Error("unsubscribed before the fill finished");
425
569
  if (!frame)
@@ -435,11 +579,12 @@ export class Publisher {
435
579
  /**
436
580
  * Handles an incoming SUBSCRIBE_NAMESPACE on a bidi stream.
437
581
  *
438
- * This carries the advertisements only when the peer asked to be told on request
439
- * (MoQ Solicit); otherwise {@link runPublishNamespaces} has already announced
440
- * everything and repeating it here would leave the peer holding two sources for one
441
- * broadcast. Draft-16+ streams Namespace entries inline; draft-14/15 predate those
442
- * messages, so each advertisement is a PUBLISH_NAMESPACE request of its own.
582
+ * This carries the advertisements when the peer asked to be told on request (MoQ
583
+ * Solicit); otherwise {@link runPublishNamespaces} has already announced everything
584
+ * visible and repeating it here would leave the peer holding two sources for one
585
+ * broadcast, so only the hidden namespaces it may see ride here (MoQ Hidden).
586
+ * Draft-16+ streams Namespace entries inline; draft-14/15 predate those messages, so
587
+ * each advertisement is a PUBLISH_NAMESPACE request of its own.
443
588
  *
444
589
  * @internal
445
590
  */
@@ -463,35 +608,38 @@ export class Publisher {
463
608
  });
464
609
  await ok.encode(stream.writer, version);
465
610
  }
466
- if (!this.#requiresSolicitation) {
467
- // Already announced, unasked. Hold the stream open until the peer is done.
468
- await stream.reader.closed;
469
- stream.close();
470
- return;
471
- }
472
- // Reports whether the peer now holds the namespace: an inline entry always
473
- // lands, but a PUBLISH_NAMESPACE request can be declined.
474
- const advertise = async (suffix) => {
475
- if (legacy) {
476
- return await this.#advertise(Path.join(prefix, suffix), requests, refused);
477
- }
611
+ // Hidden namespaces are left out unless the peer opted in (MoQ Hidden). Unless the
612
+ // peer asked to be told only on request, it has already heard everything visible
613
+ // from the empty prefix unasked, so this stream carries only what that hid.
614
+ const carries = (covered) => (!this.#hidden || msg.hidden || !hiddenBelow(prefix, covered)) &&
615
+ (this.#requiresSolicitation || (this.#hidden && hiddenBelow(Path.empty(), covered)));
616
+ // Inline entries always land, and the receiver treats a repeated NAMESPACE as a
617
+ // replacement, so repricing one is just sending it again, a new original
618
+ // publisher included, as Rust does. Only a legacy PUBLISH_NAMESPACE request can
619
+ // be declined.
620
+ const namespace = async (suffix, snap) => {
478
621
  await stream.writer.u53(SubscribeNamespaceEntry.id);
479
- await new SubscribeNamespaceEntry({ suffix, cluster: this.#advert }).encode(stream.writer, version);
480
- return true;
622
+ const cluster = clusterFor(this.#advert, snap.route);
623
+ await new SubscribeNamespaceEntry({ suffix, cluster }).encode(stream.writer, version);
624
+ return "held";
481
625
  };
482
- const withdraw = async (suffix) => {
483
- if (legacy) {
484
- await this.#withdraw(Path.join(prefix, suffix), requests);
485
- }
486
- else {
487
- await stream.writer.u53(SubscribeNamespaceEntryDone.id);
488
- await new SubscribeNamespaceEntryDone({ suffix }).encode(stream.writer, version);
626
+ const target = legacy
627
+ ? {
628
+ advertise: (suffix, snap) => this.#advertise(Path.join(prefix, suffix), requests, clusterFor(this.#advert, snap.route)),
629
+ update: (suffix, _snap, reprice) => this.#update(Path.join(prefix, suffix), requests, reprice),
630
+ withdraw: (suffix) => this.#withdraw(Path.join(prefix, suffix), requests),
489
631
  }
490
- };
491
- let active = new Set();
632
+ : {
633
+ advertise: namespace,
634
+ update: namespace,
635
+ withdraw: async (suffix) => {
636
+ await stream.writer.u53(SubscribeNamespaceEntryDone.id);
637
+ await new SubscribeNamespaceEntryDone({ suffix }).encode(stream.writer, version);
638
+ },
639
+ };
640
+ // Keyed by suffix, as the entries name them.
641
+ const ns = { active: new Map(), refused: new Map(), offered: new Map() };
492
642
  let retry = 0;
493
- // What the peer refused, and whether coming back is worth anything.
494
- const refused = new Map();
495
643
  for (;;) {
496
644
  // Subscribe BEFORE reconciling, for the same reason as
497
645
  // {@link runPublishNamespaces}: a publish landing while an advertisement
@@ -499,51 +647,19 @@ export class Publisher {
499
647
  // TODO Make a better helper within Signals.
500
648
  let dispose;
501
649
  const changed = new Promise((resolve) => {
502
- dispose = this.#broadcasts.changed(resolve);
650
+ dispose = this.#advertised.changed(resolve);
503
651
  });
504
- const broadcasts = this.#broadcasts.peek();
505
- if (!broadcasts) {
652
+ const advertised = this.#advertised.peek();
653
+ if (!advertised) {
506
654
  dispose();
507
655
  break;
508
656
  }
509
- const updated = new Set();
510
- for (const name of broadcasts.keys()) {
511
- const suffix = Path.stripPrefix(prefix, name);
512
- if (suffix === null)
513
- continue;
514
- updated.add(suffix);
515
- }
516
- // A namespace that is gone takes its refusal with it, so re-announcing the
517
- // path offers it again.
518
- const live = new Set([...updated].map((suffix) => Path.join(prefix, suffix)));
519
- for (const path of [...refused.keys()]) {
520
- if (!live.has(path))
521
- refused.delete(path);
522
- }
523
- // Track what the peer holds rather than what we attempted: a declined
524
- // advertisement stays out of `held`, so the next turn retries it instead of
525
- // believing the namespace is already up.
526
- const held = new Set(active);
527
- for (const added of updated.difference(active)) {
528
- if (this.#offerable(Path.join(prefix, added), refused)) {
529
- if (await advertise(added))
530
- held.add(added);
531
- }
532
- }
533
- for (const removed of active.difference(updated)) {
534
- await withdraw(removed);
535
- held.delete(removed);
536
- }
537
- active = held;
538
- // Whatever we wanted up and could not get up, as {@link runPublishNamespaces}
539
- // does: only a legacy request can be declined, and nothing about the peer
540
- // starting to answer raises a signal this loop is watching.
541
- const outstanding = [...updated.difference(active)].some((suffix) => this.#pending(Path.join(prefix, suffix), refused));
657
+ const outstanding = await this.#reconcile(ns, presented(prefix, advertised, carries), target);
542
658
  retry = outstanding ? Math.min(retry ? retry * 2 : RETRY_BASE, RETRY_MAX) : 0;
543
659
  // Wait for the next change, or for the peer to unsubscribe.
544
660
  const next = await (retry
545
- ? Promise.race([changed, stream.reader.closed, retryAfter(retry).then(() => broadcasts)])
546
- : Promise.race([changed, stream.reader.closed]));
661
+ ? race([changed, stream.reader.closed, retryAfter(retry).then(() => advertised)])
662
+ : race([changed, stream.reader.closed]));
547
663
  dispose();
548
664
  if (!next)
549
665
  break;
@@ -581,11 +697,22 @@ export class Publisher {
581
697
  }
582
698
  // The open PUBLISH_NAMESPACE request per advertised path.
583
699
  const requests = new Map();
700
+ const target = {
701
+ advertise: (path, snap) => this.#advertise(path, requests, clusterFor(this.#advert, snap.route)),
702
+ update: (path, _snap, reprice) => this.#update(path, requests, reprice),
703
+ withdraw: (path) => this.#withdraw(path, requests),
704
+ };
705
+ // The origin outlives the session and its signal never ends, so this loop needs the
706
+ // session's own ending to stop: without it a closed connection leaves the loop parked
707
+ // on a shared origin forever, waking on someone else's publish to fail on a dead
708
+ // transport. `runSubscribeNamespace` gets this from the stream the peer asked on; an
709
+ // unsolicited loop has no stream of its own, so the session is what it watches.
710
+ const closed = this.#quic.closed.then(() => undefined, () => undefined);
711
+ let dispose;
584
712
  try {
585
- let active = new Set();
713
+ // Keyed by path.
714
+ const ns = { active: new Map(), refused: new Map(), offered: new Map() };
586
715
  let retry = 0;
587
- // What the peer refused, and whether coming back is worth anything.
588
- const refused = new Map();
589
716
  for (;;) {
590
717
  // Subscribe BEFORE reconciling. Each advertisement below waits a round trip
591
718
  // for the peer's reply, and a publish that lands in that window notifies
@@ -593,43 +720,28 @@ export class Publisher {
593
720
  // through it and leave the namespace unadvertised until something unrelated
594
721
  // changed.
595
722
  // TODO Make a better helper within Signals.
596
- let dispose;
597
723
  const changed = new Promise((resolve) => {
598
- dispose = this.#broadcasts.changed(resolve);
724
+ dispose = this.#advertised.changed(resolve);
599
725
  });
600
- const broadcasts = this.#broadcasts.peek();
601
- if (!broadcasts) {
602
- dispose();
726
+ const advertised = this.#advertised.peek();
727
+ if (!advertised) {
728
+ dispose?.();
603
729
  break;
604
730
  }
605
- const updated = new Set(broadcasts.keys());
606
- // A namespace that is gone takes its refusal with it, so re-announcing the
607
- // path offers it again. Rust gets this by rebuilding the watched entry.
608
- for (const path of [...refused.keys()]) {
609
- if (!updated.has(path))
610
- refused.delete(path);
611
- }
612
- for (const added of updated.difference(active)) {
613
- if (this.#offerable(added, refused)) {
614
- await this.#advertise(added, requests, refused);
615
- }
616
- }
617
- for (const removed of active.difference(updated)) {
618
- await this.#withdraw(removed, requests);
731
+ const updated = new Map();
732
+ for (const [covered, candidates] of advertised) {
733
+ // Only peers that declared MoQ Hidden filter unsolicited discovery.
734
+ if ((this.#hidden && hiddenBelow(Path.empty(), covered)) || candidates.length === 0)
735
+ continue;
736
+ updated.set(covered, candidates[0]);
619
737
  }
620
- // What the peer holds, not what we attempted: a declined PUBLISH_NAMESPACE
621
- // leaves no request behind, so it stays outstanding below.
622
- active = new Set(requests.keys());
623
- // Whatever we wanted up and could not get up. Stream credit freeing, a
624
- // transient failure clearing, or the peer starting to answer raises no
625
- // signal of its own, so the only way back is to ask again on a timer.
626
- const outstanding = [...updated.difference(active)].some((path) => this.#pending(path, refused));
738
+ const outstanding = await this.#reconcile(ns, updated, target);
627
739
  retry = outstanding ? Math.min(retry ? retry * 2 : RETRY_BASE, RETRY_MAX) : 0;
628
740
  // Wait for the next change, which has already fired if one landed above.
629
741
  const next = await (retry
630
- ? Promise.race([changed, retryAfter(retry).then(() => broadcasts)])
631
- : changed);
632
- dispose();
742
+ ? race([changed, closed, retryAfter(retry).then(() => advertised)])
743
+ : race([changed, closed]));
744
+ dispose?.();
633
745
  if (!next)
634
746
  break;
635
747
  }
@@ -640,20 +752,86 @@ export class Publisher {
640
752
  console.warn(`publish_namespace loop failed: ${reason(error(err))}`);
641
753
  }
642
754
  finally {
755
+ dispose?.();
643
756
  // Close out every open PUBLISH_NAMESPACE request.
644
757
  for (const path of [...requests.keys()]) {
645
758
  await this.#withdraw(path, requests);
646
759
  }
647
760
  }
648
761
  }
762
+ /**
763
+ * Bring the peer's view in line with `updated`, one namespace at a time, and report
764
+ * whether anything it should hold is still missing and worth coming back for.
765
+ *
766
+ * Nothing the peer cannot see is sent, a reprice updates the namespace in place, and
767
+ * anything else is withdrawn first so a republish reads as withdraw-then-advertise.
768
+ * `ns` records what the peer holds rather than what we attempted: a declined
769
+ * advertisement or update stays out of it, so the next turn offers it fresh instead
770
+ * of believing it is already up.
771
+ */
772
+ async #reconcile(ns, updated, target) {
773
+ // A namespace that is gone, or that a republish replaced, takes its refusal with
774
+ // it: the peer refused a broadcast, not a path forever, so a different one at the
775
+ // same path is offered again. The origin swaps the front in one mutation, so the
776
+ // path never leaves `updated` and only the front says they differ. Rust gets this
777
+ // by rebuilding the watched entry.
778
+ for (const key of [...ns.refused.keys()]) {
779
+ const snap = updated.get(key);
780
+ if (snap === undefined || ns.offered.get(key) !== snap.identity) {
781
+ ns.refused.delete(key);
782
+ ns.offered.delete(key);
783
+ }
784
+ }
785
+ const held = new Map(ns.active);
786
+ for (const [key, snap] of ns.active) {
787
+ if (change(this.#advert, snap, updated.get(key)) !== "restart")
788
+ continue;
789
+ await target.withdraw(key);
790
+ held.delete(key);
791
+ }
792
+ for (const [key, snap] of updated) {
793
+ const prev = held.get(key);
794
+ const diff = change(this.#advert, prev, snap);
795
+ if (diff === "same")
796
+ continue;
797
+ let answer;
798
+ if (prev !== undefined && diff !== "restart") {
799
+ answer = await target.update(key, snap, diff);
800
+ }
801
+ else {
802
+ // A fresh offer waits for what a refusal asked for, whatever brought us back.
803
+ if (!this.#offerable(key, ns.refused))
804
+ continue;
805
+ answer = await target.advertise(key, snap);
806
+ }
807
+ // `offered` pairs with `refused`, so a held or withdrawn path leaves nothing behind.
808
+ if (answer === "held") {
809
+ held.set(key, snap);
810
+ ns.refused.delete(key);
811
+ ns.offered.delete(key);
812
+ }
813
+ else {
814
+ held.delete(key);
815
+ if (answer !== "dropped") {
816
+ ns.refused.set(key, answer);
817
+ ns.offered.set(key, snap.identity);
818
+ }
819
+ }
820
+ }
821
+ ns.active = held;
822
+ // Whatever we wanted up and could not get up. Stream credit freeing, a transient
823
+ // failure clearing, or the peer starting to answer raises no signal of its own, so
824
+ // the only way back is to ask again on a timer.
825
+ return [...updated].some(([key, snap]) => change(this.#advert, held.get(key), snap) !== "same" && this.#pending(key, ns.refused));
826
+ }
649
827
  /**
650
828
  * Whether a namespace may be offered to the peer right now.
651
829
  *
652
830
  * A peer that asked never to be offered it again means it, whatever brought us back;
653
831
  * one that named a minimum wait gets it, even when our own backoff comes round sooner.
654
832
  */
655
- #offerable(path, refused) {
656
- const entry = refused.get(path);
833
+ #offerable(key, refused) {
834
+ const entry = refused.get(key);
657
835
  if (entry === undefined)
658
836
  return true;
659
837
  return entry !== "never" && Date.now() >= entry;
@@ -666,21 +844,31 @@ export class Publisher {
666
844
  * gating the timer on offerable instead would disarm it for exactly the wait it is
667
845
  * supposed to be counting. Only a refusal that forbids retrying ends it.
668
846
  */
669
- #pending(path, refused) {
670
- return refused.get(path) !== "never";
847
+ #pending(key, refused) {
848
+ return refused.get(key) !== "never";
849
+ }
850
+ /**
851
+ * What a REQUEST_ERROR's retry interval, in milliseconds, says about re-offering the
852
+ * namespace.
853
+ *
854
+ * Draft-14/15 carry no such field, so a 0 there says nothing and our own backoff
855
+ * stands; everywhere else 0 means the peer does not want this again.
856
+ */
857
+ #refusal(err) {
858
+ const version = this.#session.version;
859
+ if (version === Version.DRAFT_14 || version === Version.DRAFT_15)
860
+ return "dropped";
861
+ return err.retryInterval === 0n ? "never" : Date.now() + Number(err.retryInterval);
671
862
  }
672
863
  /**
673
864
  * Advertise one namespace on its own PUBLISH_NAMESPACE request. A declined request
674
865
  * is logged and skipped: a peer that wants none of this rejects each one and stays
675
866
  * connected.
676
- *
677
- * `refused` records what a refusal said about coming back, so a peer that asked not to
678
- * be offered a namespace again is not re-offered it by the retry above.
679
867
  */
680
- async #advertise(path, requests, refused) {
868
+ async #advertise(path, requests, cluster = this.#advert) {
681
869
  const requestId = await this.#session.nextRequestId();
682
870
  if (requestId === undefined)
683
- return false;
871
+ return "dropped";
684
872
  // Opening is inside the try because it fails like any other advertisement does:
685
873
  // a peer out of stream credit rejects here, and letting that escape would unwind
686
874
  // the announce loop and take discovery down for the whole session, rather than
@@ -693,22 +881,18 @@ export class Publisher {
693
881
  // advertisements, so a peer that takes the stream and answers nothing strands
694
882
  // every publish and withdrawal behind it. Timing out costs this namespace a
695
883
  // turn and leaves it outstanding, which the retry above re-offers.
696
- await withTimeout((async () => {
884
+ const answer = await withTimeout((async () => {
697
885
  await stream.writer.u53(PublishNamespace.id);
698
- const msg = new PublishNamespace({ requestId, trackNamespace: path, cluster: this.#advert });
886
+ const msg = new PublishNamespace({ requestId, trackNamespace: path, cluster });
699
887
  await msg.encode(stream.writer, this.#session.version);
700
888
  // Read response (RequestOk and PublishNamespaceOk share 0x07)
701
889
  const respTypeId = await stream.reader.u53();
702
890
  if (respTypeId === RequestError.id) {
703
- // The peer named how long to stay away, in milliseconds. Draft-14/15
704
- // carry no such field, so a 0 there says nothing and our own backoff
705
- // stands; everywhere else 0 means it does not want this again.
706
891
  const err = await RequestError.decode(stream.reader, this.#session.version);
707
- const legacy = this.#session.version === Version.DRAFT_14 || this.#session.version === Version.DRAFT_15;
708
- if (!legacy) {
709
- refused.set(path, err.retryInterval === 0n ? "never" : Date.now() + Number(err.retryInterval));
710
- }
711
- throw new Error(`PublishNamespace rejected: ${err.errorCode} ${err.reasonPhrase}`);
892
+ const why = requestReason(err.errorCode, err.reasonPhrase, "publish_namespace", this.#session.version);
893
+ console.warn(`announce refused: broadcast=${path} error=${why}`);
894
+ stream.abort(new Error(`PublishNamespace rejected: ${why}`));
895
+ return this.#refusal(err);
712
896
  }
713
897
  if (respTypeId !== RequestOk.id) {
714
898
  throw new Error(`PublishNamespace rejected: typeId=0x${respTypeId.toString(16)}`);
@@ -720,16 +904,78 @@ export class Publisher {
720
904
  else {
721
905
  await RequestOk.decode(stream.reader, this.#session.version);
722
906
  }
907
+ return "held";
723
908
  })(), ADVERTISE_TIMEOUT_MS, `advertisement timed out after ${ADVERTISE_TIMEOUT_MS}ms waiting for the peer's answer`);
724
- requests.set(path, { path, requestId, stream: request });
725
- return true;
909
+ if (answer === "held")
910
+ requests.set(path, { path, requestId, stream });
911
+ return answer;
726
912
  }
727
913
  catch (err) {
728
- const e = error(err);
914
+ // The peer never answered the advertisement: a control timeout, not late content.
915
+ const e = err instanceof TimeoutError ? controlTimeout(err) : error(err);
729
916
  console.warn(`announce failed: broadcast=${path} error=${reason(e)}`);
730
917
  request?.abort(e);
731
- return false;
918
+ return "dropped";
919
+ }
920
+ }
921
+ /**
922
+ * Update a namespace the peer holds: REQUEST_UPDATE on the request that carries it,
923
+ * with only the parameters that changed, then its answer.
924
+ *
925
+ * Waiting for the answer keeps one update outstanding per stream, which satisfies any
926
+ * MAX_REQUEST_UPDATES the peer set without reading it; a change landing meanwhile waits
927
+ * for the next turn. The answer decides whether the peer still holds the namespace: a
928
+ * REQUEST_ERROR closes the peer's side and withdraws the advertisement, so ours is
929
+ * finished too, and an unanswered update is dropped abruptly, since a peer that ignored
930
+ * it cannot be assumed to hold either price. Either way the retry re-offers it fresh.
931
+ *
932
+ * A different original publisher is an update like any other: the receiver drains what
933
+ * it already serves from the old one and never splices the two.
934
+ */
935
+ async #update(path, requests, { from, to }) {
936
+ const request = requests.get(path);
937
+ if (!request)
938
+ return "dropped";
939
+ const update = {
940
+ hops: sameHops(from.hops, to.hops) ? undefined : to.hops,
941
+ cost: from.cost === to.cost ? undefined : to.cost,
942
+ };
943
+ const version = this.#session.version;
944
+ let answer;
945
+ try {
946
+ const requestId = await this.#session.nextRequestId();
947
+ if (requestId === undefined)
948
+ throw new Error("no request ID for the update");
949
+ answer = await withTimeout((async () => {
950
+ const { writer, reader } = request.stream;
951
+ await writer.u53(PublishNamespaceUpdate.id);
952
+ await new PublishNamespaceUpdate({ requestId, update }).encode(writer, version);
953
+ const typeId = await reader.u53();
954
+ if (typeId === RequestError.id) {
955
+ const err = await RequestError.decode(reader, version);
956
+ const why = requestReason(err.errorCode, err.reasonPhrase, "publish_namespace", version);
957
+ console.warn(`publish_namespace update refused: broadcast=${path} error=${why}`);
958
+ return this.#refusal(err);
959
+ }
960
+ if (typeId !== RequestOk.id) {
961
+ throw new Error(`unexpected answer to the update: typeId=0x${typeId.toString(16)}`);
962
+ }
963
+ await RequestOk.decode(reader, version);
964
+ return "held";
965
+ })(), ADVERTISE_TIMEOUT_MS, `update timed out after ${ADVERTISE_TIMEOUT_MS}ms waiting for the peer's answer`);
966
+ }
967
+ catch (err) {
968
+ const e = err instanceof TimeoutError ? controlTimeout(err) : error(err);
969
+ console.warn(`publish_namespace update failed: broadcast=${path} error=${reason(e)}`);
970
+ requests.delete(path);
971
+ request.stream.abort(e);
972
+ return "dropped";
732
973
  }
974
+ // The peer already closed out a refused request, and its STOP_SENDING can reject the
975
+ // wait on our FIN. That is the withdrawal finishing, not a reason to end the loop.
976
+ if (answer !== "held")
977
+ await this.#withdraw(path, requests).catch(() => undefined);
978
+ return answer;
733
979
  }
734
980
  /**
735
981
  * Close out a namespace's PUBLISH_NAMESPACE request with PUBLISH_NAMESPACE_DONE.
@@ -753,8 +999,14 @@ export class Publisher {
753
999
  catch {
754
1000
  // Stream might already be closed
755
1001
  }
1002
+ request.stream.close();
1003
+ return;
756
1004
  }
757
1005
  request.stream.close();
1006
+ // Wait for transport acknowledgment before opening a replacement request.
1007
+ // This assumes the peer processes the withdrawal by then; FIN acknowledgment
1008
+ // does not itself acknowledge application processing across streams.
1009
+ await request.stream.writer.closed;
758
1010
  }
759
1011
  /**
760
1012
  * Handles an incoming TRACK_STATUS_REQUEST on a bidi stream.
@@ -762,42 +1014,38 @@ export class Publisher {
762
1014
  * @internal
763
1015
  */
764
1016
  async runTrackStatusRequest(msg, stream) {
1017
+ // TRACK_STATUS_ERROR is 0x0f on draft-14.
1018
+ await this.#refuseUnsupported(stream, msg.requestId, "track_status", 0x0f, "TRACK_STATUS is not supported");
1019
+ }
1020
+ /**
1021
+ * Handles an incoming FETCH on a bidi stream. We serve none.
1022
+ *
1023
+ * @internal
1024
+ */
1025
+ async runFetch(msg, stream) {
1026
+ await this.#refuseUnsupported(stream, msg.requestId, "fetch", FetchError.id, "FETCH is not supported");
1027
+ }
1028
+ /**
1029
+ * Refuse a request NOT_SUPPORTED. Draft-14 gives each request its own error message,
1030
+ * `errorId14`, with the SUBSCRIBE_ERROR body; later drafts use REQUEST_ERROR.
1031
+ */
1032
+ async #refuseUnsupported(stream, requestId, kind, errorId14, reasonPhrase) {
765
1033
  const version = this.#session.version;
1034
+ const errorCode = toRequestCode("not_supported", kind, version);
766
1035
  if (version === Version.DRAFT_14) {
767
- // v14: respond with TrackStatus (0x0E = TRACK_STATUS_OK)
768
- await stream.writer.u53(TrackStatus.id);
769
- const status = new TrackStatus({
770
- trackNamespace: msg.trackNamespace,
771
- trackName: msg.trackName,
772
- statusCode: TrackStatus.STATUS_NOT_FOUND,
773
- lastGroupId: 0n,
774
- lastObjectId: 0n,
775
- });
776
- await status.encode(stream.writer, version);
1036
+ await stream.writer.u53(errorId14);
1037
+ await new SubscribeError({ requestId, errorCode, reasonPhrase }).encode(stream.writer, version);
777
1038
  }
778
1039
  else {
779
- // v15+: respond with RequestOk (0x07)
780
- await stream.writer.u53(RequestOk.id);
781
- const ok = new RequestOk({
782
- requestId: version === Version.DRAFT_15 || version === Version.DRAFT_16 ? msg.requestId : undefined,
783
- });
784
- await ok.encode(stream.writer, version);
1040
+ await stream.writer.u53(RequestError.id);
1041
+ await new RequestError({
1042
+ requestId: version === Version.DRAFT_15 || version === Version.DRAFT_16 ? requestId : undefined,
1043
+ errorCode,
1044
+ reasonPhrase,
1045
+ }).encode(stream.writer, version);
785
1046
  }
786
1047
  stream.close();
787
1048
  }
788
- /**
789
- * Closes every published broadcast and stops accepting new ones.
790
- *
791
- * @internal
792
- */
793
- close() {
794
- this.#broadcasts.update((broadcasts) => {
795
- for (const broadcast of broadcasts?.values() ?? []) {
796
- broadcast.close();
797
- }
798
- return undefined;
799
- });
800
- }
801
1049
  }
802
1050
  /**
803
1051
  * Take one group out of a cache view, without waiting.