@norskvideo/moq-net 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (310) hide show
  1. package/README.md +1 -0
  2. package/announced.d.ts +111 -21
  3. package/announced.d.ts.map +1 -1
  4. package/announced.js +213 -40
  5. package/announced.js.map +1 -1
  6. package/broadcast.d.ts +80 -25
  7. package/broadcast.d.ts.map +1 -1
  8. package/broadcast.js +270 -49
  9. package/broadcast.js.map +1 -1
  10. package/connection/accept.d.ts +6 -0
  11. package/connection/accept.d.ts.map +1 -1
  12. package/connection/accept.js +42 -13
  13. package/connection/accept.js.map +1 -1
  14. package/connection/browser.d.ts +5 -0
  15. package/connection/browser.d.ts.map +1 -0
  16. package/connection/browser.js +31 -0
  17. package/connection/browser.js.map +1 -0
  18. package/connection/connect.d.ts +43 -1
  19. package/connection/connect.d.ts.map +1 -1
  20. package/connection/connect.js +118 -123
  21. package/connection/connect.js.map +1 -1
  22. package/connection/established.d.ts +43 -16
  23. package/connection/established.d.ts.map +1 -1
  24. package/connection/established.js.map +1 -1
  25. package/connection/handshake.d.ts +12 -1
  26. package/connection/handshake.d.ts.map +1 -1
  27. package/connection/handshake.js +30 -14
  28. package/connection/handshake.js.map +1 -1
  29. package/connection/index.d.ts +8 -0
  30. package/connection/index.d.ts.map +1 -1
  31. package/connection/index.js +6 -0
  32. package/connection/index.js.map +1 -1
  33. package/connection/reload.d.ts +66 -9
  34. package/connection/reload.d.ts.map +1 -1
  35. package/connection/reload.js +192 -66
  36. package/connection/reload.js.map +1 -1
  37. package/connection/stats.d.ts +92 -0
  38. package/connection/stats.d.ts.map +1 -0
  39. package/connection/stats.js +35 -0
  40. package/connection/stats.js.map +1 -0
  41. package/connection/transport.d.ts +10 -0
  42. package/connection/transport.d.ts.map +1 -0
  43. package/connection/transport.js +12 -0
  44. package/connection/transport.js.map +1 -0
  45. package/consume.d.ts +44 -0
  46. package/consume.d.ts.map +1 -0
  47. package/consume.js +60 -0
  48. package/consume.js.map +1 -0
  49. package/datagram.d.ts +23 -0
  50. package/datagram.d.ts.map +1 -0
  51. package/datagram.js +3 -0
  52. package/datagram.js.map +1 -0
  53. package/error.d.ts +76 -0
  54. package/error.d.ts.map +1 -0
  55. package/error.js +107 -0
  56. package/error.js.map +1 -0
  57. package/group.d.ts +143 -38
  58. package/group.d.ts.map +1 -1
  59. package/group.js +270 -68
  60. package/group.js.map +1 -1
  61. package/ietf/adapter.d.ts +2 -2
  62. package/ietf/adapter.d.ts.map +1 -1
  63. package/ietf/adapter.js +20 -10
  64. package/ietf/adapter.js.map +1 -1
  65. package/ietf/aliases.d.ts +73 -5
  66. package/ietf/aliases.d.ts.map +1 -1
  67. package/ietf/aliases.js +121 -14
  68. package/ietf/aliases.js.map +1 -1
  69. package/ietf/cluster.d.ts +141 -0
  70. package/ietf/cluster.d.ts.map +1 -0
  71. package/ietf/cluster.js +204 -0
  72. package/ietf/cluster.js.map +1 -0
  73. package/ietf/connection.d.ts +39 -6
  74. package/ietf/connection.d.ts.map +1 -1
  75. package/ietf/connection.js +73 -12
  76. package/ietf/connection.js.map +1 -1
  77. package/ietf/fetch.d.ts +17 -0
  78. package/ietf/fetch.d.ts.map +1 -1
  79. package/ietf/fetch.js +21 -0
  80. package/ietf/fetch.js.map +1 -1
  81. package/ietf/filter.d.ts +87 -0
  82. package/ietf/filter.d.ts.map +1 -0
  83. package/ietf/filter.js +292 -0
  84. package/ietf/filter.js.map +1 -0
  85. package/ietf/index.d.ts +2 -0
  86. package/ietf/index.d.ts.map +1 -1
  87. package/ietf/index.js +3 -0
  88. package/ietf/index.js.map +1 -1
  89. package/ietf/namespace.d.ts +6 -0
  90. package/ietf/namespace.d.ts.map +1 -1
  91. package/ietf/namespace.js +27 -2
  92. package/ietf/namespace.js.map +1 -1
  93. package/ietf/object.d.ts +54 -6
  94. package/ietf/object.d.ts.map +1 -1
  95. package/ietf/object.js +178 -25
  96. package/ietf/object.js.map +1 -1
  97. package/ietf/parameters.d.ts +35 -10
  98. package/ietf/parameters.d.ts.map +1 -1
  99. package/ietf/parameters.js +236 -45
  100. package/ietf/parameters.js.map +1 -1
  101. package/ietf/priority.d.ts +5 -0
  102. package/ietf/priority.d.ts.map +1 -0
  103. package/ietf/priority.js +10 -0
  104. package/ietf/priority.js.map +1 -0
  105. package/ietf/properties.d.ts +13 -2
  106. package/ietf/properties.d.ts.map +1 -1
  107. package/ietf/properties.js +65 -8
  108. package/ietf/properties.js.map +1 -1
  109. package/ietf/publish.d.ts.map +1 -1
  110. package/ietf/publish.js +13 -3
  111. package/ietf/publish.js.map +1 -1
  112. package/ietf/publish_namespace.d.ts +16 -2
  113. package/ietf/publish_namespace.d.ts.map +1 -1
  114. package/ietf/publish_namespace.js +25 -6
  115. package/ietf/publish_namespace.js.map +1 -1
  116. package/ietf/publisher.d.ts +40 -7
  117. package/ietf/publisher.d.ts.map +1 -1
  118. package/ietf/publisher.js +751 -134
  119. package/ietf/publisher.js.map +1 -1
  120. package/ietf/request.js +1 -1
  121. package/ietf/request.js.map +1 -1
  122. package/ietf/solicit.d.ts +41 -0
  123. package/ietf/solicit.d.ts.map +1 -0
  124. package/ietf/solicit.js +47 -0
  125. package/ietf/solicit.js.map +1 -0
  126. package/ietf/subscribe.d.ts +41 -2
  127. package/ietf/subscribe.d.ts.map +1 -1
  128. package/ietf/subscribe.js +103 -22
  129. package/ietf/subscribe.js.map +1 -1
  130. package/ietf/subscribe_namespace.d.ts +21 -7
  131. package/ietf/subscribe_namespace.d.ts.map +1 -1
  132. package/ietf/subscribe_namespace.js +31 -14
  133. package/ietf/subscribe_namespace.js.map +1 -1
  134. package/ietf/subscriber.d.ts +20 -7
  135. package/ietf/subscriber.d.ts.map +1 -1
  136. package/ietf/subscriber.js +512 -142
  137. package/ietf/subscriber.js.map +1 -1
  138. package/ietf/version.d.ts +7 -1
  139. package/ietf/version.d.ts.map +1 -1
  140. package/ietf/version.js +7 -0
  141. package/ietf/version.js.map +1 -1
  142. package/index.d.ts +10 -5
  143. package/index.d.ts.map +1 -1
  144. package/index.js +10 -5
  145. package/index.js.map +1 -1
  146. package/internal.d.ts +29 -0
  147. package/internal.d.ts.map +1 -0
  148. package/internal.js +8 -0
  149. package/internal.js.map +1 -0
  150. package/lite/announce.d.ts +62 -15
  151. package/lite/announce.d.ts.map +1 -1
  152. package/lite/announce.js +230 -94
  153. package/lite/announce.js.map +1 -1
  154. package/lite/connection.d.ts +69 -20
  155. package/lite/connection.d.ts.map +1 -1
  156. package/lite/connection.js +128 -81
  157. package/lite/connection.js.map +1 -1
  158. package/lite/datagram.d.ts +23 -0
  159. package/lite/datagram.d.ts.map +1 -0
  160. package/lite/datagram.js +57 -0
  161. package/lite/datagram.js.map +1 -0
  162. package/lite/datagram_stream.d.ts +8 -0
  163. package/lite/datagram_stream.d.ts.map +1 -0
  164. package/lite/datagram_stream.js +44 -0
  165. package/lite/datagram_stream.js.map +1 -0
  166. package/lite/fetch.d.ts.map +1 -1
  167. package/lite/fetch.js +2 -2
  168. package/lite/fetch.js.map +1 -1
  169. package/lite/index.d.ts +3 -0
  170. package/lite/index.d.ts.map +1 -1
  171. package/lite/index.js +3 -0
  172. package/lite/index.js.map +1 -1
  173. package/lite/priority.d.ts +67 -0
  174. package/lite/priority.d.ts.map +1 -0
  175. package/lite/priority.js +114 -0
  176. package/lite/priority.js.map +1 -0
  177. package/lite/probe.d.ts +18 -2
  178. package/lite/probe.d.ts.map +1 -1
  179. package/lite/probe.js +24 -22
  180. package/lite/probe.js.map +1 -1
  181. package/lite/publisher.d.ts +14 -7
  182. package/lite/publisher.d.ts.map +1 -1
  183. package/lite/publisher.js +406 -84
  184. package/lite/publisher.js.map +1 -1
  185. package/lite/setup.d.ts +83 -6
  186. package/lite/setup.d.ts.map +1 -1
  187. package/lite/setup.js +203 -31
  188. package/lite/setup.js.map +1 -1
  189. package/lite/stream.d.ts +12 -3
  190. package/lite/stream.d.ts.map +1 -1
  191. package/lite/stream.js +7 -4
  192. package/lite/stream.js.map +1 -1
  193. package/lite/subscribe.d.ts +37 -23
  194. package/lite/subscribe.d.ts.map +1 -1
  195. package/lite/subscribe.js +70 -55
  196. package/lite/subscribe.js.map +1 -1
  197. package/lite/subscriber.d.ts +29 -23
  198. package/lite/subscriber.d.ts.map +1 -1
  199. package/lite/subscriber.js +689 -112
  200. package/lite/subscriber.js.map +1 -1
  201. package/lite/track.d.ts +25 -13
  202. package/lite/track.d.ts.map +1 -1
  203. package/lite/track.js +60 -35
  204. package/lite/track.js.map +1 -1
  205. package/lite/version.d.ts +39 -4
  206. package/lite/version.d.ts.map +1 -1
  207. package/lite/version.js +139 -17
  208. package/lite/version.js.map +1 -1
  209. package/mock.d.ts +53 -3
  210. package/mock.d.ts.map +1 -1
  211. package/mock.js +110 -21
  212. package/mock.js.map +1 -1
  213. package/{lite/origin.d.ts → origin.d.ts} +25 -1
  214. package/origin.d.ts.map +1 -0
  215. package/{lite/origin.js → origin.js} +25 -1
  216. package/origin.js.map +1 -0
  217. package/package.json +6 -4
  218. package/path.d.ts +67 -0
  219. package/path.d.ts.map +1 -1
  220. package/path.js +128 -0
  221. package/path.js.map +1 -1
  222. package/stream.d.ts +94 -4
  223. package/stream.d.ts.map +1 -1
  224. package/stream.js +128 -17
  225. package/stream.js.map +1 -1
  226. package/time.d.ts +109 -86
  227. package/time.d.ts.map +1 -1
  228. package/time.js +80 -30
  229. package/time.js.map +1 -1
  230. package/track.d.ts +298 -47
  231. package/track.d.ts.map +1 -1
  232. package/track.js +721 -109
  233. package/track.js.map +1 -1
  234. package/util/timeout.d.ts +5 -0
  235. package/util/timeout.d.ts.map +1 -0
  236. package/util/timeout.js +18 -0
  237. package/util/timeout.js.map +1 -0
  238. package/util/utf8.d.ts +3 -0
  239. package/util/utf8.d.ts.map +1 -0
  240. package/util/utf8.js +7 -0
  241. package/util/utf8.js.map +1 -0
  242. package/varint.d.ts +14 -3
  243. package/varint.d.ts.map +1 -1
  244. package/varint.js +23 -12
  245. package/varint.js.map +1 -1
  246. package/zod.d.ts +5 -5
  247. package/zod.d.ts.map +1 -1
  248. package/zod.js.map +1 -1
  249. package/bandwidth.d.ts +0 -11
  250. package/bandwidth.d.ts.map +0 -1
  251. package/bandwidth.js +0 -7
  252. package/bandwidth.js.map +0 -1
  253. package/connection/cert.test.d.ts +0 -2
  254. package/connection/cert.test.d.ts.map +0 -1
  255. package/connection/cert.test.js +0 -26
  256. package/connection/cert.test.js.map +0 -1
  257. package/connection/reload.test.d.ts +0 -2
  258. package/connection/reload.test.d.ts.map +0 -1
  259. package/connection/reload.test.js +0 -39
  260. package/connection/reload.test.js.map +0 -1
  261. package/group.test.d.ts +0 -2
  262. package/group.test.d.ts.map +0 -1
  263. package/group.test.js +0 -66
  264. package/group.test.js.map +0 -1
  265. package/ietf/aliases.test.d.ts +0 -2
  266. package/ietf/aliases.test.d.ts.map +0 -1
  267. package/ietf/aliases.test.js +0 -30
  268. package/ietf/aliases.test.js.map +0 -1
  269. package/ietf/ietf.test.d.ts +0 -2
  270. package/ietf/ietf.test.d.ts.map +0 -1
  271. package/ietf/ietf.test.js +0 -873
  272. package/ietf/ietf.test.js.map +0 -1
  273. package/integration.test.d.ts +0 -2
  274. package/integration.test.d.ts.map +0 -1
  275. package/integration.test.js +0 -90
  276. package/integration.test.js.map +0 -1
  277. package/lite/origin.d.ts.map +0 -1
  278. package/lite/origin.js.map +0 -1
  279. package/lite/subscriber.test.d.ts +0 -2
  280. package/lite/subscriber.test.d.ts.map +0 -1
  281. package/lite/subscriber.test.js +0 -30
  282. package/lite/subscriber.test.js.map +0 -1
  283. package/lite/track.test.d.ts +0 -2
  284. package/lite/track.test.d.ts.map +0 -1
  285. package/lite/track.test.js +0 -53
  286. package/lite/track.test.js.map +0 -1
  287. package/path.test.d.ts +0 -2
  288. package/path.test.d.ts.map +0 -1
  289. package/path.test.js +0 -169
  290. package/path.test.js.map +0 -1
  291. package/stream.test.d.ts +0 -2
  292. package/stream.test.d.ts.map +0 -1
  293. package/stream.test.js +0 -210
  294. package/stream.test.js.map +0 -1
  295. package/track.test.d.ts +0 -2
  296. package/track.test.d.ts.map +0 -1
  297. package/track.test.js +0 -56
  298. package/track.test.js.map +0 -1
  299. package/util/error.d.ts +0 -3
  300. package/util/error.d.ts.map +0 -1
  301. package/util/error.js +0 -9
  302. package/util/error.js.map +0 -1
  303. package/util/index.d.ts +0 -2
  304. package/util/index.d.ts.map +0 -1
  305. package/util/index.js +0 -3
  306. package/util/index.js.map +0 -1
  307. package/varint.test.d.ts +0 -2
  308. package/varint.test.d.ts.map +0 -1
  309. package/varint.test.js +0 -106
  310. package/varint.test.js.map +0 -1
package/ietf/publisher.js CHANGED
@@ -1,9 +1,15 @@
1
1
  /* @ts-self-types="./publisher.d.ts" */
2
- import { Announced } from "../announced.js";
2
+ import { Signal } from "@norskvideo/moq-signals";
3
+ import { error, reason } from "../error.js";
3
4
  import * as Path from "../path.js";
4
5
  import { Writer } from "../stream.js";
5
- import { error } from "../util/error.js";
6
- import { Frame, Group as GroupMessage } from "./object.js";
6
+ import { withTimeout } from "../util/timeout.js";
7
+ import * as Cluster from "./cluster.js";
8
+ import { FetchHeader } from "./fetch.js";
9
+ import * as Filter from "./filter.js";
10
+ import { FetchFrame, Frame, Group as GroupMessage } from "./object.js";
11
+ import { fromWire } from "./priority.js";
12
+ import * as Properties from "./properties.js";
7
13
  import { PublishDone } from "./publish.js";
8
14
  import { PublishNamespace, PublishNamespaceDone, PublishNamespaceOk } from "./publish_namespace.js";
9
15
  import { RequestError, RequestOk } from "./request.js";
@@ -11,6 +17,25 @@ import { SubscribeError, SubscribeOk } from "./subscribe.js";
11
17
  import { SubscribeNamespaceEntry, SubscribeNamespaceEntryDone, SubscribeNamespaceOk, } from "./subscribe_namespace.js";
12
18
  import { TrackStatus } from "./track.js";
13
19
  import { Version } from "./version.js";
20
+ /** First wait before re-offering a namespace the peer refused or we couldn't open for. */
21
+ const RETRY_BASE = 100;
22
+ /** Ceiling on that wait. The loop retries for the life of the session, so it must not spin. */
23
+ 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
+ };
29
+ /**
30
+ * How long one advertisement may take to be answered. Matches the Rust publisher, and the
31
+ * peer accepting the stream is only half the exchange: one it never answers on holds the
32
+ * loop just as effectively as one it never grants.
33
+ */
34
+ const ADVERTISE_TIMEOUT_MS = 5000;
35
+ /** Sleep `delay`, jittered, so a relay's namespaces don't all retry on the same tick. */
36
+ function retryAfter(delay) {
37
+ return new Promise((resolve) => setTimeout(resolve, delay * (0.5 + Math.random() / 2)));
38
+ }
14
39
  /**
15
40
  * Handles publishing broadcasts using moq-transport protocol.
16
41
  * Uses the stream-per-request pattern (real bidi streams for v17, virtual for v14-v16).
@@ -20,86 +45,44 @@ import { Version } from "./version.js";
20
45
  export class Publisher {
21
46
  #quic;
22
47
  #session;
48
+ #requiresSolicitation;
49
+ // What every advertisement carries on a session that negotiated the MoQ Cluster
50
+ // extension: a hop path holding our own id, so the peer can tell that what it hears
51
+ // back came from us. `undefined` when nothing negotiated it.
52
+ #advert;
23
53
  // Our published broadcasts.
24
- #broadcasts = new Map();
25
- // Any consumers that want each new announcement.
26
- #announcedConsumers = new Set();
54
+ // It's a signal so we can live update any subscribe_namespace streams.
55
+ #broadcasts = new Signal(new Map());
27
56
  /**
28
57
  * Creates a new Publisher instance.
29
- * @param quic - The WebTransport session (for uni streams)
30
- * @param session - The session abstraction for bidi streams and request IDs
31
58
  *
32
59
  * @internal
33
60
  */
34
- constructor(quic, session) {
61
+ constructor({ quic, session, requiresSolicitation, cluster, }) {
35
62
  this.#quic = quic;
36
63
  this.#session = session;
64
+ this.#requiresSolicitation = requiresSolicitation;
65
+ this.#advert = Cluster.advertise(cluster);
37
66
  }
38
67
  /**
39
68
  * Publishes a broadcast with any associated tracks.
40
- * Opens a bidi stream to send PublishNamespace and waits for response.
69
+ * The namespace is advertised with an unsolicited PUBLISH_NAMESPACE, or on request
70
+ * if the peer asked for that (see {@link runPublishNamespaces}).
41
71
  */
42
72
  publish(path, broadcast) {
43
- this.#broadcasts.set(path, broadcast);
44
- this.#notifyConsumers(path, true);
45
- void this.#runPublish(path, broadcast);
46
- }
47
- async #runPublish(path, broadcast) {
48
- try {
49
- const requestId = await this.#session.nextRequestId();
50
- if (requestId === undefined)
51
- return;
52
- const stream = await this.#session.openBi();
53
- try {
54
- // Write PublishNamespace
55
- await stream.writer.u53(PublishNamespace.id);
56
- const msg = new PublishNamespace({ requestId, trackNamespace: path });
57
- await msg.encode(stream.writer, this.#session.version);
58
- // Read response (RequestOk and PublishNamespaceOk share 0x07)
59
- const respTypeId = await stream.reader.u53();
60
- if (respTypeId === RequestOk.id) {
61
- // Draft-14 sends PublishNamespaceOk (requestId only, no parameters)
62
- if (this.#session.version === Version.DRAFT_14) {
63
- await PublishNamespaceOk.decode(stream.reader, this.#session.version);
64
- }
65
- else {
66
- await RequestOk.decode(stream.reader, this.#session.version);
67
- }
68
- }
69
- else {
70
- throw new Error(`PublishNamespace rejected: typeId=0x${respTypeId.toString(16)}`);
71
- }
72
- // Wait for broadcast to close or stream to close (peer cancelled)
73
- await Promise.race([broadcast.closed, stream.reader.closed]);
74
- // For v14-v16: send explicit PublishNamespaceDone (removed in v17+)
75
- if (this.#session.version === Version.DRAFT_14 ||
76
- this.#session.version === Version.DRAFT_15 ||
77
- this.#session.version === Version.DRAFT_16) {
78
- try {
79
- await stream.writer.u53(PublishNamespaceDone.id);
80
- const done = new PublishNamespaceDone({ trackNamespace: path, requestId });
81
- await done.encode(stream.writer, this.#session.version);
82
- }
83
- catch {
84
- // Stream might already be closed
85
- }
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);
86
83
  }
87
- stream.close();
88
- }
89
- catch (err) {
90
- stream.abort(error(err));
91
- throw err;
92
- }
93
- }
94
- catch (err) {
95
- const e = error(err);
96
- console.warn(`announce failed: broadcast=${path} error=${e.message}`);
97
- }
98
- finally {
99
- broadcast.close();
100
- this.#broadcasts.delete(path);
101
- this.#notifyConsumers(path, false);
102
- }
84
+ });
85
+ });
103
86
  }
104
87
  /**
105
88
  * Handles an incoming SUBSCRIBE request on a bidi stream.
@@ -110,7 +93,7 @@ export class Publisher {
110
93
  async runSubscribe(msg, stream) {
111
94
  const version = this.#session.version;
112
95
  const name = msg.trackNamespace;
113
- const broadcast = this.#broadcasts.get(name);
96
+ const broadcast = this.#broadcasts.peek()?.get(name);
114
97
  if (!broadcast) {
115
98
  // Write error response
116
99
  if (version === Version.DRAFT_14) {
@@ -134,8 +117,37 @@ export class Publisher {
134
117
  stream.close();
135
118
  return;
136
119
  }
137
- const track = broadcast.subscribe(msg.trackName, msg.subscriberPriority);
120
+ 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.
124
+ let cache;
138
125
  try {
126
+ const timescale = (await track.info()).timescale;
127
+ // The filter and any fill are relative to the live edge, so snapshot it once: the
128
+ // fill ends exactly where a Next Object subscription begins, which is what lets
129
+ // the draft's current-group join (Next Object plus a StartGroup=1 fill) cover the
130
+ // group with no gap and no overlap.
131
+ const edge = liveEdge(track);
132
+ const range = subscribeRange(msg, edge, version);
133
+ // The wire request tells an upstream what we need; the cursor is what actually
134
+ // trims this subscriber, since the producer fans every cached group out to every
135
+ // sink regardless. An absent start joins at the latest group, which is what
136
+ // moq-lite means by joining a live track.
137
+ track.update({
138
+ priority,
139
+ startGroup: range.start && Number(range.start.group),
140
+ endGroup: range.end && Number(range.end.group),
141
+ });
142
+ const startGroup = range.start ? Number(range.start.group) : track.latest();
143
+ if (startGroup !== undefined)
144
+ track.startAt(startGroup);
145
+ // A fill reads the group cache through its own consumer, independent of the
146
+ // subscription's cursor. Forked from this subscriber rather than resolved through
147
+ // the broadcast again: a dynamic serve is one request per peer subscription, so
148
+ // asking the broadcast would mint a second producer nobody has accepted.
149
+ 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;
139
151
  // Send SUBSCRIBE_OK
140
152
  await stream.writer.u53(SubscribeOk.id);
141
153
  const ok = new SubscribeOk({
@@ -143,73 +155,183 @@ export class Publisher {
143
155
  ? msg.requestId
144
156
  : undefined,
145
157
  trackAlias: msg.requestId,
158
+ // 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 },
161
+ properties: msg.propertiesWanted
162
+ ? // Declaring the timescale is what opts the track into timestamps; every
163
+ // object Timestamp below is in these units. We serve the newest group
164
+ // first, matching moq-lite.
165
+ { timescale, groupOrder: Properties.DESCENDING }
166
+ : // INCLUDE_PROPERTIES=0. The block stays present but empty, which also means
167
+ // the track opts out of timestamps for this subscriber.
168
+ {},
146
169
  });
147
170
  await ok.encode(stream.writer, version);
148
171
  console.debug(`publish ok: broadcast=${name} track=${track.name}`);
172
+ // Cancels groups still queued for a stream slot. Only the subscriber leaving counts:
173
+ // a track that ran out of groups still has to flush the ones already queued, and we
174
+ // close the stream ourselves below to say so.
175
+ let finished = false;
176
+ let unsubscribe;
177
+ const unsubscribed = new Promise((resolve) => {
178
+ unsubscribe = resolve;
179
+ });
180
+ void stream.reader.closed.then(() => {
181
+ if (!finished)
182
+ unsubscribe();
183
+ },
184
+ // A reset is always the peer.
185
+ () => unsubscribe());
149
186
  // Serve track groups, racing with stream close (= Unsubscribe)
150
187
  const serving = (async () => {
151
188
  for (;;) {
152
189
  const group = await track.recvGroup();
153
190
  if (!group)
154
191
  return;
155
- void this.#runGroup(msg.requestId, group);
156
- }
157
- })();
158
- await Promise.race([serving, stream.reader.closed]);
159
- console.debug(`publish done: broadcast=${name} track=${track.name}`);
160
- // v14-v16: send PublishDone before closing
161
- if (version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16) {
162
- try {
163
- await stream.writer.u53(PublishDone.id);
164
- const done = new PublishDone({
192
+ // Past the filter's end. Dropped here rather than through `endAt`, which
193
+ // parks a capped group instead: this range is fixed for the life of the
194
+ // subscription, so a group above it is never coming back in, and holding
195
+ // one keeps the loop from ever ending. A producer that publishes beyond
196
+ // the end and then closes would otherwise strand PUBLISH_DONE.
197
+ if (range.end !== undefined && BigInt(group.sequence) > range.end.group) {
198
+ group.close();
199
+ continue;
200
+ }
201
+ void this.#runGroup({
165
202
  requestId: msg.requestId,
166
- statusCode: 200,
167
- reasonPhrase: "OK",
203
+ group,
204
+ timescale,
205
+ stamped: msg.propertiesWanted,
206
+ slice: groupSlice(range, group.sequence),
207
+ unsubscribed,
168
208
  });
169
- await done.encode(stream.writer, version);
170
- }
171
- catch {
172
- // Stream might already be closed by peer
173
209
  }
210
+ })();
211
+ // The fill (when one was requested) runs alongside on its own fetch stream; its
212
+ // failures reset that stream and never touch the subscription.
213
+ const filling = fill && cache
214
+ ? this.#runFill({
215
+ requestId: msg.requestId,
216
+ priority,
217
+ fill,
218
+ cache,
219
+ timescale,
220
+ stamped: msg.propertiesWanted,
221
+ unsubscribed,
222
+ })
223
+ : Promise.resolve();
224
+ let publishError;
225
+ try {
226
+ await Promise.race([Promise.all([serving, filling]), stream.reader.closed]);
227
+ }
228
+ catch (err) {
229
+ publishError = error(err);
230
+ }
231
+ console.debug(`publish done: broadcast=${name} track=${track.name}`);
232
+ if (publishError) {
233
+ console.warn(`publish error: broadcast=${name} track=${track.name} error=${reason(publishError)}`);
234
+ }
235
+ // PUBLISH_DONE is required for every supported draft. The peer may have already
236
+ // closed its side to unsubscribe, in which case there is nowhere to send it.
237
+ try {
238
+ await stream.writer.u53(PublishDone.id);
239
+ const done = new PublishDone({
240
+ requestId: version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16
241
+ ? msg.requestId
242
+ : undefined,
243
+ statusCode: publishError ? PUBLISH_DONE_STATUS.INTERNAL_ERROR : PUBLISH_DONE_STATUS.TRACK_ENDED,
244
+ reasonPhrase: publishError ? "internal error" : "track ended",
245
+ });
246
+ await done.encode(stream.writer, version);
247
+ }
248
+ catch {
249
+ // Stream might already be closed by peer.
174
250
  }
251
+ // Only now is the close below ours. Claiming it any earlier would read a peer FIN
252
+ // that lands while PublishDone is still going out as our own completion, leaving
253
+ // queued groups to open for a subscriber that has already left.
254
+ finished = true;
175
255
  stream.close();
176
256
  }
177
257
  catch (err) {
178
258
  const e = error(err);
179
- console.warn(`publish error: broadcast=${name} track=${track.name} error=${e.message}`);
259
+ console.warn(`publish error: broadcast=${name} track=${track.name} error=${reason(e)}`);
180
260
  stream.abort(e);
181
261
  }
182
262
  finally {
183
263
  track.close();
264
+ // Idempotent, and the backstop for a throw between the fork and the fill starting:
265
+ // #runFill closes it itself on every path it reaches.
266
+ cache?.close();
184
267
  }
185
268
  }
186
269
  /**
187
270
  * Runs a group and sends its frames using ObjectStream (Subgroup delivery mode).
188
271
  */
189
- async #runGroup(requestId, group) {
272
+ async #runGroup(options) {
273
+ const { requestId, group, timescale, stamped, slice, unsubscribed } = options;
190
274
  try {
191
- const stream = await Writer.open(this.#quic, this.#session.version);
275
+ // One stream per group is faster than a peer at its limit can retire them, so this
276
+ // is the one path that doesn't wait for a slot: the transport would serve the opens
277
+ // in the order we asked, which is oldest-first, exactly backwards for live media.
278
+ // Failing here drops the group and lets the next one compete for the next slot.
279
+ const stream = await Writer.tryOpen(this.#quic, {
280
+ cancel: unsubscribed,
281
+ version: this.#session.version,
282
+ waitUntilAvailable: false,
283
+ });
284
+ if (!stream) {
285
+ group.close(new Error("no stream slot"));
286
+ return;
287
+ }
192
288
  const header = new GroupMessage({
193
289
  trackAlias: requestId,
194
290
  groupId: group.sequence,
195
291
  subGroupId: 0,
196
292
  publisherPriority: 0,
197
293
  flags: {
198
- hasExtensions: false,
294
+ // The object properties carry the timestamp, so there is nothing to write
295
+ // when the track declared no units to read one in.
296
+ hasExtensions: stamped,
199
297
  hasSubgroup: false,
200
298
  hasSubgroupObject: false,
201
299
  hasEnd: true,
202
300
  hasPriority: true,
301
+ // Only honest when the stream really starts at the group's first object;
302
+ // a trimmed head starts partway through.
303
+ firstObject: slice.skip === 0,
203
304
  },
204
305
  });
205
306
  await header.encode(stream, this.#session.version);
206
307
  try {
308
+ // The first written object goes on the wire as its absolute id, so a trimmed
309
+ // head shows the true numbering rather than a silently renumbered group.
310
+ let first = true;
311
+ // The next object id that could be written, which starts at the filter rather
312
+ // than at zero. A backwards range (`startObject` above `endObject` in the same
313
+ // group) is empty, and starting here is what lets the check below see that
314
+ // before the read parks on an object the range already excludes.
315
+ let next = slice.skip;
207
316
  for (;;) {
208
- const frame = await Promise.race([group.readFrame(), stream.closed]);
317
+ // The filter ends inside this group: everything at `until` and beyond is
318
+ // outside the requested range, so stop without waiting for the group's end.
319
+ if (slice.until !== undefined && next >= slice.until)
320
+ 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]);
209
326
  if (!frame)
210
327
  break;
211
- const obj = new Frame({ payload: frame });
212
- await obj.encode(stream, header.flags);
328
+ next = frame.sequence + 1;
329
+ if (slice.until !== undefined && frame.sequence >= slice.until)
330
+ 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);
213
335
  }
214
336
  stream.close();
215
337
  }
@@ -221,15 +343,112 @@ export class Publisher {
221
343
  group.close();
222
344
  }
223
345
  }
346
+ /**
347
+ * Serve a draft-20 fill on its own fetch stream: the requested range, read from the
348
+ * group cache, capped at the Largest Object snapshot.
349
+ *
350
+ * A fill is a promise once requested. An empty range opens no stream, but a range we
351
+ * cannot serve still opens one and resets it right after the FETCH_HEADER, the draft's
352
+ * fill-failure signal. Nothing here touches the subscription either way.
353
+ */
354
+ async #runFill(options) {
355
+ const { requestId, fill, cache, timescale, stamped, unsubscribed } = options;
356
+ const version = this.#session.version;
357
+ // Everything is inside the try so the cache fork is released on every path out,
358
+ // including a transport failure while opening the stream.
359
+ let stream;
360
+ try {
361
+ if (fill.kind === "empty")
362
+ return;
363
+ stream = await Writer.tryOpen(this.#quic, { cancel: unsubscribed, version });
364
+ if (!stream) {
365
+ console.debug(`fill stream failed to open: fill=${requestId}`);
366
+ return;
367
+ }
368
+ await stream.u53(FetchHeader.type);
369
+ await new FetchHeader({ requestId }).encode(stream, version);
370
+ if (fill.kind !== "group") {
371
+ throw new Error("a fill spanning several groups is not supported");
372
+ }
373
+ const group = takeGroup(cache, Number(fill.sequence));
374
+ try {
375
+ await this.#writeFillGroup(stream, group, fill, timescale, stamped, unsubscribed);
376
+ }
377
+ finally {
378
+ group.close();
379
+ }
380
+ stream.close();
381
+ console.debug(`fill complete: fill=${requestId}`);
382
+ }
383
+ catch (err) {
384
+ // A fill never fails the subscription; its own stream carries the failure.
385
+ const e = error(err);
386
+ console.debug(`fill failed, resetting its stream: fill=${requestId} error=${reason(e)}`);
387
+ stream?.reset(e);
388
+ }
389
+ finally {
390
+ cache.close();
391
+ }
392
+ }
393
+ /**
394
+ * Write one cached group's frames as draft-20 fetch objects.
395
+ *
396
+ * The cap is the Largest Object snapshot: the group may keep growing, but everything
397
+ * past the snapshot belongs to the subscription, not the fill.
398
+ */
399
+ async #writeFillGroup(stream, group, fill, timescale, stamped, unsubscribed) {
400
+ let first = true;
401
+ let next = 0n;
402
+ // The subscriber leaving has to end the fill too. An absolute filter ending below the
403
+ // edge with no end object leaves `until` unset, so this reads until the group closes,
404
+ // which a still-open past group may never do. Watching only the fetch stream would
405
+ // then pin the group and the cache subscription behind it for the life of the track.
406
+ let left = false;
407
+ const cancelled = unsubscribed.then(() => {
408
+ left = true;
409
+ return undefined;
410
+ });
411
+ for (;;) {
412
+ // The group may keep growing, but everything at `until` and beyond belongs to the
413
+ // subscription, so stop without waiting for the group's end.
414
+ if (fill.until !== undefined && next >= fill.until)
415
+ 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
+ ]);
423
+ if (left)
424
+ throw new Error("unsubscribed before the fill finished");
425
+ if (!frame)
426
+ break;
427
+ next = BigInt(frame.sequence) + 1n;
428
+ if (fill.until !== undefined && BigInt(frame.sequence) >= fill.until)
429
+ break;
430
+ const obj = new FetchFrame({ payload: frame.payload, timestamp: stamped ? frame.timestamp : undefined });
431
+ await obj.encode(stream, { group: Number(fill.sequence), object: frame.sequence, first }, timescale, this.#session.version);
432
+ first = false;
433
+ }
434
+ }
224
435
  /**
225
436
  * Handles an incoming SUBSCRIBE_NAMESPACE on a bidi stream.
226
- * Sends RequestOk, then streams Namespace/NamespaceDone entries.
437
+ *
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.
227
443
  *
228
444
  * @internal
229
445
  */
230
446
  async runSubscribeNamespace(msg, stream) {
231
447
  const version = this.#session.version;
232
448
  const prefix = msg.namespace;
449
+ const legacy = version === Version.DRAFT_14 || version === Version.DRAFT_15;
450
+ // Draft-14/15: the open PUBLISH_NAMESPACE request per advertised suffix.
451
+ const requests = new Map();
233
452
  try {
234
453
  // Send OK response
235
454
  if (version === Version.DRAFT_14) {
@@ -244,45 +463,298 @@ export class Publisher {
244
463
  });
245
464
  await ok.encode(stream.writer, version);
246
465
  }
247
- // Create an Announced consumer and seed it with current broadcasts
248
- const announced = new Announced(prefix);
249
- for (const name of this.#broadcasts.keys()) {
250
- const suffix = Path.stripPrefix(prefix, name);
251
- if (suffix === null)
252
- continue;
253
- announced.append({ path: suffix, active: true });
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;
254
471
  }
255
- this.#announcedConsumers.add(announced);
256
- // Close the consumer when the stream closes
257
- stream.reader.closed.then(() => announced.close(), () => announced.close());
258
- try {
259
- for (;;) {
260
- const entry = await announced.next();
261
- if (!entry)
262
- break;
263
- if (entry.active) {
264
- await stream.writer.u53(SubscribeNamespaceEntry.id);
265
- const e = new SubscribeNamespaceEntry({ suffix: entry.path });
266
- await e.encode(stream.writer, version);
267
- }
268
- else {
269
- await stream.writer.u53(SubscribeNamespaceEntryDone.id);
270
- const e = new SubscribeNamespaceEntryDone({ suffix: entry.path });
271
- await e.encode(stream.writer, version);
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
+ }
478
+ await stream.writer.u53(SubscribeNamespaceEntry.id);
479
+ await new SubscribeNamespaceEntry({ suffix, cluster: this.#advert }).encode(stream.writer, version);
480
+ return true;
481
+ };
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);
489
+ }
490
+ };
491
+ let active = new Set();
492
+ let retry = 0;
493
+ // What the peer refused, and whether coming back is worth anything.
494
+ const refused = new Map();
495
+ for (;;) {
496
+ // Subscribe BEFORE reconciling, for the same reason as
497
+ // {@link runPublishNamespaces}: a publish landing while an advertisement
498
+ // waits for its reply only notifies listeners already registered.
499
+ // TODO Make a better helper within Signals.
500
+ let dispose;
501
+ const changed = new Promise((resolve) => {
502
+ dispose = this.#broadcasts.changed(resolve);
503
+ });
504
+ const broadcasts = this.#broadcasts.peek();
505
+ if (!broadcasts) {
506
+ dispose();
507
+ break;
508
+ }
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);
272
531
  }
273
532
  }
274
- }
275
- finally {
276
- announced.close();
277
- this.#announcedConsumers.delete(announced);
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));
542
+ retry = outstanding ? Math.min(retry ? retry * 2 : RETRY_BASE, RETRY_MAX) : 0;
543
+ // Wait for the next change, or for the peer to unsubscribe.
544
+ const next = await (retry
545
+ ? Promise.race([changed, stream.reader.closed, retryAfter(retry).then(() => broadcasts)])
546
+ : Promise.race([changed, stream.reader.closed]));
547
+ dispose();
548
+ if (!next)
549
+ break;
278
550
  }
279
551
  stream.close();
280
552
  }
281
553
  catch (err) {
282
554
  const e = error(err);
283
- console.debug(`subscribe_namespace stream error: ${e.message}`);
555
+ console.debug(`subscribe_namespace stream error: ${reason(e)}`);
284
556
  stream.abort(e);
285
557
  }
558
+ finally {
559
+ // This subscription's advertisements die with it.
560
+ for (const path of [...requests.keys()]) {
561
+ await this.#withdraw(path, requests);
562
+ }
563
+ }
564
+ }
565
+ /**
566
+ * Advertise every published broadcast with an unsolicited PUBLISH_NAMESPACE, until
567
+ * the publisher is closed.
568
+ *
569
+ * The peers that never send SUBSCRIBE_NAMESPACE are exactly the ones expecting a
570
+ * publisher to announce itself, so announcing is the default. A peer that would
571
+ * rather ask says so in its SETUP (MoQ Solicit) and this does nothing, leaving
572
+ * {@link runSubscribeNamespace} to carry the advertisements instead. Exactly one of
573
+ * the two is live, so the peer never hears a namespace twice.
574
+ *
575
+ * @internal
576
+ */
577
+ async runPublishNamespaces() {
578
+ if (this.#requiresSolicitation) {
579
+ // The peer asked to be told on request; runSubscribeNamespace answers it.
580
+ return;
581
+ }
582
+ // The open PUBLISH_NAMESPACE request per advertised path.
583
+ const requests = new Map();
584
+ try {
585
+ let active = new Set();
586
+ let retry = 0;
587
+ // What the peer refused, and whether coming back is worth anything.
588
+ const refused = new Map();
589
+ for (;;) {
590
+ // Subscribe BEFORE reconciling. Each advertisement below waits a round trip
591
+ // for the peer's reply, and a publish that lands in that window notifies
592
+ // only the listeners already registered; one created afterwards would sleep
593
+ // through it and leave the namespace unadvertised until something unrelated
594
+ // changed.
595
+ // TODO Make a better helper within Signals.
596
+ let dispose;
597
+ const changed = new Promise((resolve) => {
598
+ dispose = this.#broadcasts.changed(resolve);
599
+ });
600
+ const broadcasts = this.#broadcasts.peek();
601
+ if (!broadcasts) {
602
+ dispose();
603
+ break;
604
+ }
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);
619
+ }
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));
627
+ retry = outstanding ? Math.min(retry ? retry * 2 : RETRY_BASE, RETRY_MAX) : 0;
628
+ // Wait for the next change, which has already fired if one landed above.
629
+ const next = await (retry
630
+ ? Promise.race([changed, retryAfter(retry).then(() => broadcasts)])
631
+ : changed);
632
+ dispose();
633
+ if (!next)
634
+ break;
635
+ }
636
+ }
637
+ catch (err) {
638
+ // Nothing restarts this loop, so whatever got us here cost the session its
639
+ // discovery. Not a debug-level event.
640
+ console.warn(`publish_namespace loop failed: ${reason(error(err))}`);
641
+ }
642
+ finally {
643
+ // Close out every open PUBLISH_NAMESPACE request.
644
+ for (const path of [...requests.keys()]) {
645
+ await this.#withdraw(path, requests);
646
+ }
647
+ }
648
+ }
649
+ /**
650
+ * Whether a namespace may be offered to the peer right now.
651
+ *
652
+ * A peer that asked never to be offered it again means it, whatever brought us back;
653
+ * one that named a minimum wait gets it, even when our own backoff comes round sooner.
654
+ */
655
+ #offerable(path, refused) {
656
+ const entry = refused.get(path);
657
+ if (entry === undefined)
658
+ return true;
659
+ return entry !== "never" && Date.now() >= entry;
660
+ }
661
+ /**
662
+ * Whether the loop should keep coming back to a namespace the peer does not hold.
663
+ *
664
+ * Distinct from {@link offerable}, and the difference is what arms the retry: a
665
+ * namespace waiting out a minimum is not offerable yet but is still pending, and
666
+ * gating the timer on offerable instead would disarm it for exactly the wait it is
667
+ * supposed to be counting. Only a refusal that forbids retrying ends it.
668
+ */
669
+ #pending(path, refused) {
670
+ return refused.get(path) !== "never";
671
+ }
672
+ /**
673
+ * Advertise one namespace on its own PUBLISH_NAMESPACE request. A declined request
674
+ * is logged and skipped: a peer that wants none of this rejects each one and stays
675
+ * 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
+ */
680
+ async #advertise(path, requests, refused) {
681
+ const requestId = await this.#session.nextRequestId();
682
+ if (requestId === undefined)
683
+ return false;
684
+ // Opening is inside the try because it fails like any other advertisement does:
685
+ // a peer out of stream credit rejects here, and letting that escape would unwind
686
+ // the announce loop and take discovery down for the whole session, rather than
687
+ // costing this one namespace a turn.
688
+ let request;
689
+ try {
690
+ request = await this.#session.openBi();
691
+ const stream = request;
692
+ // Bounded, like the subscriber's own request: the loop is single-threaded over
693
+ // advertisements, so a peer that takes the stream and answers nothing strands
694
+ // every publish and withdrawal behind it. Timing out costs this namespace a
695
+ // turn and leaves it outstanding, which the retry above re-offers.
696
+ await withTimeout((async () => {
697
+ await stream.writer.u53(PublishNamespace.id);
698
+ const msg = new PublishNamespace({ requestId, trackNamespace: path, cluster: this.#advert });
699
+ await msg.encode(stream.writer, this.#session.version);
700
+ // Read response (RequestOk and PublishNamespaceOk share 0x07)
701
+ const respTypeId = await stream.reader.u53();
702
+ 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
+ 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}`);
712
+ }
713
+ if (respTypeId !== RequestOk.id) {
714
+ throw new Error(`PublishNamespace rejected: typeId=0x${respTypeId.toString(16)}`);
715
+ }
716
+ // Draft-14 sends PublishNamespaceOk (requestId only, no parameters)
717
+ if (this.#session.version === Version.DRAFT_14) {
718
+ await PublishNamespaceOk.decode(stream.reader, this.#session.version);
719
+ }
720
+ else {
721
+ await RequestOk.decode(stream.reader, this.#session.version);
722
+ }
723
+ })(), 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;
726
+ }
727
+ catch (err) {
728
+ const e = error(err);
729
+ console.warn(`announce failed: broadcast=${path} error=${reason(e)}`);
730
+ request?.abort(e);
731
+ return false;
732
+ }
733
+ }
734
+ /**
735
+ * Close out a namespace's PUBLISH_NAMESPACE request with PUBLISH_NAMESPACE_DONE.
736
+ */
737
+ async #withdraw(path, requests) {
738
+ const request = requests.get(path);
739
+ if (!request)
740
+ return;
741
+ requests.delete(path);
742
+ // Draft-17+ removed PUBLISH_NAMESPACE_DONE: the close below is the whole
743
+ // withdrawal. Sending it anyway puts the type on the wire before the body throws,
744
+ // and a receiver reading 0x09 there has no choice but to treat it as a protocol
745
+ // violation.
746
+ const version = this.#session.version;
747
+ if (version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16) {
748
+ try {
749
+ await request.stream.writer.u53(PublishNamespaceDone.id);
750
+ const done = new PublishNamespaceDone({ trackNamespace: request.path, requestId: request.requestId });
751
+ await done.encode(request.stream.writer, version);
752
+ }
753
+ catch {
754
+ // Stream might already be closed
755
+ }
756
+ }
757
+ request.stream.close();
286
758
  }
287
759
  /**
288
760
  * Handles an incoming TRACK_STATUS_REQUEST on a bidi stream.
@@ -313,18 +785,163 @@ export class Publisher {
313
785
  }
314
786
  stream.close();
315
787
  }
316
- #notifyConsumers(path, active) {
317
- for (const consumer of this.#announcedConsumers) {
318
- const suffix = Path.stripPrefix(consumer.prefix, path);
319
- if (suffix === null)
320
- continue;
321
- try {
322
- consumer.append({ path: suffix, active });
323
- }
324
- catch {
325
- // Consumer already closed, will be cleaned up
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();
326
797
  }
798
+ return undefined;
799
+ });
800
+ }
801
+ }
802
+ /**
803
+ * Take one group out of a cache view, without waiting.
804
+ *
805
+ * A fill's group is at or below the Largest Object snapshot, so it is either still in the
806
+ * retained window or gone for good: nothing republishes an old sequence, and waiting for one
807
+ * would park for the life of the track. Scanning past it, or running dry, is a cache miss,
808
+ * which the caller turns into the draft's fill-failure reset.
809
+ */
810
+ function takeGroup(cache, sequence) {
811
+ for (;;) {
812
+ const group = cache.tryRecvGroup();
813
+ if (!group)
814
+ throw new Error(`group not found: ${sequence}`);
815
+ if (group.sequence === sequence)
816
+ return group;
817
+ group.close();
818
+ if (group.sequence > sequence)
819
+ throw new Error(`group not found: ${sequence}`);
820
+ }
821
+ }
822
+ /** `a - b`, saturating at zero the way the draft's unsigned arithmetic does. */
823
+ function saturatingSub(a, b) {
824
+ return a > b ? a - b : 0n;
825
+ }
826
+ /** Snapshot the live edge of a track. */
827
+ function liveEdge(track) {
828
+ const latest = track.latest();
829
+ if (latest === undefined)
830
+ return {};
831
+ const largest = track.largest();
832
+ if (!largest)
833
+ return { latest: BigInt(latest), next: { group: BigInt(latest), object: 0n } };
834
+ const group = BigInt(largest.group);
835
+ const object = BigInt(largest.frame);
836
+ return { latest: BigInt(latest), largest: { group, object }, next: { group, object: object + 1n } };
837
+ }
838
+ /**
839
+ * Resolve a SUBSCRIBE's Location Filter into the range to serve.
840
+ *
841
+ * Only draft-20 is honored. Earlier drafts have a Filter Type tag whose absolute forms we
842
+ * never served, and starting to interpret them now would change what an existing peer
843
+ * receives; draft-20 is also the first version whose relative forms can name a past group
844
+ * without the subscriber knowing Largest Object.
845
+ */
846
+ function subscribeRange(msg, edge, version) {
847
+ if (!Filter.isDraft20(version)) {
848
+ if (msg.filter.kind !== "nextObject" && msg.filter.kind !== "unfiltered") {
849
+ console.warn(`filter not supported before draft-20, ignoring: ${msg.filter.kind}`);
327
850
  }
851
+ return {};
852
+ }
853
+ return filterRange(msg.filter, edge);
854
+ }
855
+ /** The Locations a single Location Filter selects, resolved against the live edge. */
856
+ function filterRange(filter, edge) {
857
+ switch (filter.kind) {
858
+ // No restriction. moq-lite starts at the beginning of the latest group, which is the
859
+ // join point it is built around; a subscription passes objects as they are published,
860
+ // so an absent filter is not a request to replay history.
861
+ case "unfiltered":
862
+ return {};
863
+ // `{Largest.Group, Largest.Object + 1}`. Everything below it, including the already
864
+ // published head of the current group, is outside the requested range, so the join is
865
+ // mid-group by construction. The draft pairs this with a fill when the subscriber
866
+ // wants the head; see {@link Publisher.runFill}.
867
+ case "nextObject":
868
+ return { start: edge.next };
869
+ // `{Largest.Group + 1 - groups, 0}`: 0 is the next group and 1 is the current one.
870
+ // Counted from `Largest.Group`, which sits below the newest group while that group has
871
+ // no objects yet; only with no largest at all does the newest group stand in for it.
872
+ case "relative": {
873
+ const base = edge.largest?.group ?? edge.latest;
874
+ if (base === undefined)
875
+ return {};
876
+ return { start: { group: saturatingSub(base + 1n, filter.groups), object: 0n } };
877
+ }
878
+ case "absolute":
879
+ return {
880
+ start: { group: filter.startGroup, object: filter.startObject },
881
+ end: filter.endGroup === undefined ? undefined : { group: filter.endGroup, object: filter.endObject },
882
+ };
883
+ }
884
+ }
885
+ /**
886
+ * Trim a group to the filter's object bounds, so nothing outside the requested range is
887
+ * sent. Interior groups are served whole.
888
+ */
889
+ function groupSlice(range, sequence) {
890
+ const at = BigInt(sequence);
891
+ return {
892
+ skip: range.start?.group === at ? Number(range.start.object) : 0,
893
+ until: range.end?.group === at && range.end.object !== undefined ? Number(range.end.object) + 1 : undefined,
894
+ };
895
+ }
896
+ /**
897
+ * Resolve a fill request using the Fetch rules: relative to Largest Object and never
898
+ * extending beyond it. An omitted Location Filter inherits the subscription's.
899
+ */
900
+ function fillRange(fill, subscription, largest) {
901
+ // A Range Filter narrows which objects pass, which we do not implement; serving the
902
+ // unfiltered range instead would deliver objects the peer excluded, so refuse it.
903
+ if (fill.rangeFilters)
904
+ return { kind: "unsupported" };
905
+ const filter = fill.filter ?? subscription;
906
+ // Nothing published (or no precise edge to cap at) means no fill is servable; an empty
907
+ // range opens no stream.
908
+ if (!largest)
909
+ return { kind: "empty" };
910
+ let start;
911
+ switch (filter.kind) {
912
+ // A Fetch without a filter is the whole track up to Largest Object.
913
+ case "unfiltered":
914
+ start = { group: 0n, object: 0n };
915
+ break;
916
+ // One past the edge, which for a Fetch is always empty.
917
+ case "nextObject":
918
+ return { kind: "empty" };
919
+ case "relative":
920
+ start = { group: saturatingSub(largest.group + 1n, filter.groups), object: 0n };
921
+ break;
922
+ case "absolute":
923
+ start = { group: filter.startGroup, object: filter.startObject };
924
+ break;
925
+ }
926
+ // Cap the requested end at Largest Object.
927
+ let end = { group: largest.group, object: largest.object };
928
+ if (filter.kind === "absolute" && filter.endGroup !== undefined) {
929
+ const below = filter.endGroup < largest.group ||
930
+ (filter.endGroup === largest.group && filter.endObject !== undefined && filter.endObject < largest.object);
931
+ if (below)
932
+ end = { group: filter.endGroup, object: filter.endObject };
933
+ }
934
+ if (start.group > end.group ||
935
+ (start.group === end.group && end.object !== undefined && end.object < start.object)) {
936
+ return { kind: "empty" };
328
937
  }
938
+ if (start.group !== end.group)
939
+ return { kind: "unsupported" };
940
+ return {
941
+ kind: "group",
942
+ sequence: start.group,
943
+ skip: start.object,
944
+ until: end.object === undefined ? undefined : end.object + 1n,
945
+ };
329
946
  }
330
947
  //# sourceMappingURL=publisher.js.map