@norskvideo/moq-net 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/README.md +2 -2
  2. package/announce.d.ts +7 -0
  3. package/announce.d.ts.map +1 -0
  4. package/announce.js +8 -0
  5. package/announce.js.map +1 -0
  6. package/announced.d.ts +49 -91
  7. package/announced.d.ts.map +1 -1
  8. package/announced.js +21 -156
  9. package/announced.js.map +1 -1
  10. package/bandwidth.d.ts +163 -0
  11. package/bandwidth.d.ts.map +1 -0
  12. package/bandwidth.js +304 -0
  13. package/bandwidth.js.map +1 -0
  14. package/bandwidth_api.d.ts +7 -0
  15. package/bandwidth_api.d.ts.map +1 -0
  16. package/bandwidth_api.js +8 -0
  17. package/bandwidth_api.js.map +1 -0
  18. package/broadcast.d.ts +44 -35
  19. package/broadcast.d.ts.map +1 -1
  20. package/broadcast.js +104 -60
  21. package/broadcast.js.map +1 -1
  22. package/connection/accept.d.ts +16 -1
  23. package/connection/accept.d.ts.map +1 -1
  24. package/connection/accept.js +52 -28
  25. package/connection/accept.js.map +1 -1
  26. package/connection/browser.d.ts.map +1 -1
  27. package/connection/browser.js +9 -7
  28. package/connection/browser.js.map +1 -1
  29. package/connection/connect.d.ts +30 -6
  30. package/connection/connect.d.ts.map +1 -1
  31. package/connection/connect.js +110 -54
  32. package/connection/connect.js.map +1 -1
  33. package/connection/established.d.ts +17 -21
  34. package/connection/established.d.ts.map +1 -1
  35. package/connection/established.js.map +1 -1
  36. package/connection/forward.d.ts +2 -0
  37. package/connection/forward.d.ts.map +1 -0
  38. package/connection/forward.js +173 -0
  39. package/connection/forward.js.map +1 -0
  40. package/connection/handshake.d.ts +1 -0
  41. package/connection/handshake.d.ts.map +1 -1
  42. package/connection/handshake.js +5 -2
  43. package/connection/handshake.js.map +1 -1
  44. package/connection/index.d.ts +5 -5
  45. package/connection/index.d.ts.map +1 -1
  46. package/connection/index.js +4 -5
  47. package/connection/index.js.map +1 -1
  48. package/connection/pool.d.ts +186 -0
  49. package/connection/pool.d.ts.map +1 -0
  50. package/connection/pool.js +361 -0
  51. package/connection/pool.js.map +1 -0
  52. package/connection/reload.d.ts +14 -93
  53. package/connection/reload.d.ts.map +1 -1
  54. package/connection/reload.js +217 -81
  55. package/connection/reload.js.map +1 -1
  56. package/connection/stats.d.ts +3 -26
  57. package/connection/stats.d.ts.map +1 -1
  58. package/connection/stats.js.map +1 -1
  59. package/connection/transport.d.ts +0 -7
  60. package/connection/transport.d.ts.map +1 -1
  61. package/consume.d.ts +1 -43
  62. package/consume.d.ts.map +1 -1
  63. package/consume.js +1 -1
  64. package/consume.js.map +1 -1
  65. package/error.d.ts +180 -29
  66. package/error.d.ts.map +1 -1
  67. package/error.js +331 -16
  68. package/error.js.map +1 -1
  69. package/errors.d.ts +7 -0
  70. package/errors.d.ts.map +1 -0
  71. package/errors.js +8 -0
  72. package/errors.js.map +1 -0
  73. package/group.d.ts +9 -43
  74. package/group.d.ts.map +1 -1
  75. package/group.js +284 -69
  76. package/group.js.map +1 -1
  77. package/hop.d.ts +115 -0
  78. package/hop.d.ts.map +1 -0
  79. package/hop.js +119 -0
  80. package/hop.js.map +1 -0
  81. package/ietf/adapter.d.ts +5 -1
  82. package/ietf/adapter.d.ts.map +1 -1
  83. package/ietf/adapter.js +105 -60
  84. package/ietf/adapter.js.map +1 -1
  85. package/ietf/aliases.d.ts +1 -78
  86. package/ietf/aliases.d.ts.map +1 -1
  87. package/ietf/cluster.d.ts +9 -123
  88. package/ietf/cluster.d.ts.map +1 -1
  89. package/ietf/cluster.js +84 -44
  90. package/ietf/cluster.js.map +1 -1
  91. package/ietf/connection.d.ts +6 -72
  92. package/ietf/connection.d.ts.map +1 -1
  93. package/ietf/connection.js +62 -56
  94. package/ietf/connection.js.map +1 -1
  95. package/ietf/error.d.ts +11 -0
  96. package/ietf/error.d.ts.map +1 -0
  97. package/ietf/error.js +193 -0
  98. package/ietf/error.js.map +1 -0
  99. package/ietf/fetch.d.ts +7 -20
  100. package/ietf/fetch.d.ts.map +1 -1
  101. package/ietf/fetch.js +52 -22
  102. package/ietf/fetch.js.map +1 -1
  103. package/ietf/filter.d.ts +2 -0
  104. package/ietf/filter.d.ts.map +1 -1
  105. package/ietf/filter.js +10 -1
  106. package/ietf/filter.js.map +1 -1
  107. package/ietf/goaway.d.ts.map +1 -1
  108. package/ietf/goaway.js +22 -5
  109. package/ietf/goaway.js.map +1 -1
  110. package/ietf/hidden.d.ts +2 -0
  111. package/ietf/hidden.d.ts.map +1 -0
  112. package/ietf/hidden.js +30 -0
  113. package/ietf/hidden.js.map +1 -0
  114. package/ietf/index.d.ts +2 -0
  115. package/ietf/index.d.ts.map +1 -1
  116. package/ietf/index.js +2 -0
  117. package/ietf/index.js.map +1 -1
  118. package/ietf/object.d.ts +15 -8
  119. package/ietf/object.d.ts.map +1 -1
  120. package/ietf/object.js +51 -36
  121. package/ietf/object.js.map +1 -1
  122. package/ietf/parameters.d.ts +14 -2
  123. package/ietf/parameters.d.ts.map +1 -1
  124. package/ietf/parameters.js +97 -29
  125. package/ietf/parameters.js.map +1 -1
  126. package/ietf/properties.d.ts +1 -0
  127. package/ietf/properties.d.ts.map +1 -1
  128. package/ietf/properties.js +14 -0
  129. package/ietf/properties.js.map +1 -1
  130. package/ietf/publish.d.ts +19 -2
  131. package/ietf/publish.d.ts.map +1 -1
  132. package/ietf/publish.js +40 -6
  133. package/ietf/publish.js.map +1 -1
  134. package/ietf/publish_namespace.d.ts +25 -0
  135. package/ietf/publish_namespace.d.ts.map +1 -1
  136. package/ietf/publish_namespace.js +63 -0
  137. package/ietf/publish_namespace.js.map +1 -1
  138. package/ietf/publisher.d.ts +1 -82
  139. package/ietf/publisher.d.ts.map +1 -1
  140. package/ietf/publisher.js +483 -235
  141. package/ietf/publisher.js.map +1 -1
  142. package/ietf/solicit.d.ts +1 -40
  143. package/ietf/solicit.d.ts.map +1 -1
  144. package/ietf/subscribe.d.ts +8 -6
  145. package/ietf/subscribe.d.ts.map +1 -1
  146. package/ietf/subscribe.js +33 -27
  147. package/ietf/subscribe.js.map +1 -1
  148. package/ietf/subscribe_namespace.d.ts +8 -2
  149. package/ietf/subscribe_namespace.d.ts.map +1 -1
  150. package/ietf/subscribe_namespace.js +18 -8
  151. package/ietf/subscribe_namespace.js.map +1 -1
  152. package/ietf/subscriber.d.ts +1 -65
  153. package/ietf/subscriber.d.ts.map +1 -1
  154. package/ietf/subscriber.js +337 -123
  155. package/ietf/subscriber.js.map +1 -1
  156. package/ietf/token.d.ts +2 -0
  157. package/ietf/token.d.ts.map +1 -0
  158. package/ietf/token.js +99 -0
  159. package/ietf/token.js.map +1 -0
  160. package/ietf/track.d.ts +4 -0
  161. package/ietf/track.d.ts.map +1 -1
  162. package/ietf/track.js +6 -20
  163. package/ietf/track.js.map +1 -1
  164. package/ietf/version.d.ts +12 -1
  165. package/ietf/version.d.ts.map +1 -1
  166. package/ietf/version.js +13 -0
  167. package/ietf/version.js.map +1 -1
  168. package/index.d.ts +12 -7
  169. package/index.d.ts.map +1 -1
  170. package/index.js +10 -5
  171. package/index.js.map +1 -1
  172. package/internal.d.ts +115 -1
  173. package/internal.d.ts.map +1 -1
  174. package/internal.js +108 -0
  175. package/internal.js.map +1 -1
  176. package/lite/announce.d.ts +60 -10
  177. package/lite/announce.d.ts.map +1 -1
  178. package/lite/announce.js +176 -31
  179. package/lite/announce.js.map +1 -1
  180. package/lite/connection.d.ts +9 -59
  181. package/lite/connection.d.ts.map +1 -1
  182. package/lite/connection.js +34 -35
  183. package/lite/connection.js.map +1 -1
  184. package/lite/datagram.d.ts +3 -2
  185. package/lite/datagram.d.ts.map +1 -1
  186. package/lite/datagram.js +7 -8
  187. package/lite/datagram.js.map +1 -1
  188. package/lite/fetch.d.ts +15 -1
  189. package/lite/fetch.d.ts.map +1 -1
  190. package/lite/fetch.js +39 -7
  191. package/lite/fetch.js.map +1 -1
  192. package/lite/goaway.d.ts.map +1 -1
  193. package/lite/goaway.js +7 -1
  194. package/lite/goaway.js.map +1 -1
  195. package/lite/group.d.ts +30 -12
  196. package/lite/group.d.ts.map +1 -1
  197. package/lite/group.js +68 -26
  198. package/lite/group.js.map +1 -1
  199. package/lite/message.d.ts +2 -2
  200. package/lite/message.d.ts.map +1 -1
  201. package/lite/message.js +14 -5
  202. package/lite/message.js.map +1 -1
  203. package/lite/priority.d.ts +1 -61
  204. package/lite/priority.d.ts.map +1 -1
  205. package/lite/priority.js +4 -5
  206. package/lite/priority.js.map +1 -1
  207. package/lite/publisher.d.ts +1 -69
  208. package/lite/publisher.d.ts.map +1 -1
  209. package/lite/publisher.js +630 -262
  210. package/lite/publisher.js.map +1 -1
  211. package/lite/setup.d.ts +8 -8
  212. package/lite/setup.d.ts.map +1 -1
  213. package/lite/setup.js +33 -30
  214. package/lite/setup.js.map +1 -1
  215. package/lite/subscribe.d.ts +71 -17
  216. package/lite/subscribe.d.ts.map +1 -1
  217. package/lite/subscribe.js +205 -53
  218. package/lite/subscribe.js.map +1 -1
  219. package/lite/subscriber.d.ts +15 -56
  220. package/lite/subscriber.d.ts.map +1 -1
  221. package/lite/subscriber.js +451 -247
  222. package/lite/subscriber.js.map +1 -1
  223. package/lite/track.d.ts +4 -10
  224. package/lite/track.d.ts.map +1 -1
  225. package/lite/track.js +34 -29
  226. package/lite/track.js.map +1 -1
  227. package/lite/version.d.ts +42 -6
  228. package/lite/version.d.ts.map +1 -1
  229. package/lite/version.js +126 -10
  230. package/lite/version.js.map +1 -1
  231. package/origin.d.ts +256 -29
  232. package/origin.d.ts.map +1 -1
  233. package/origin.js +1427 -37
  234. package/origin.js.map +1 -1
  235. package/package.json +8 -3
  236. package/path.d.ts +25 -7
  237. package/path.d.ts.map +1 -1
  238. package/path.js +5 -3
  239. package/path.js.map +1 -1
  240. package/stream.d.ts +73 -14
  241. package/stream.d.ts.map +1 -1
  242. package/stream.js +372 -141
  243. package/stream.js.map +1 -1
  244. package/tail.d.ts +18 -0
  245. package/tail.d.ts.map +1 -0
  246. package/tail.js +167 -0
  247. package/tail.js.map +1 -0
  248. package/time.d.ts +15 -2
  249. package/time.d.ts.map +1 -1
  250. package/time.js +28 -9
  251. package/time.js.map +1 -1
  252. package/track.d.ts +211 -83
  253. package/track.d.ts.map +1 -1
  254. package/track.js +816 -205
  255. package/track.js.map +1 -1
  256. package/util/abort.d.ts +2 -0
  257. package/util/abort.d.ts.map +1 -0
  258. package/util/abort.js +20 -0
  259. package/util/abort.js.map +1 -0
  260. package/util/log.d.ts +5 -0
  261. package/util/log.d.ts.map +1 -0
  262. package/util/log.js +17 -0
  263. package/util/log.js.map +1 -0
  264. package/util/u64.d.ts +39 -0
  265. package/util/u64.d.ts.map +1 -0
  266. package/util/u64.js +83 -0
  267. package/util/u64.js.map +1 -0
  268. package/util/varint.d.ts +29 -0
  269. package/util/varint.d.ts.map +1 -0
  270. package/util/varint.js +198 -0
  271. package/util/varint.js.map +1 -0
  272. package/varint.d.ts +10 -6
  273. package/varint.d.ts.map +1 -1
  274. package/varint.js +40 -237
  275. package/varint.js.map +1 -1
  276. package/wire.d.ts +80 -0
  277. package/wire.d.ts.map +1 -0
  278. package/wire.js +32 -0
  279. package/wire.js.map +1 -0
  280. package/zod.d.ts +1 -1
  281. package/zod.d.ts.map +1 -1
  282. package/zod.js.map +1 -1
  283. package/mock.d.ts +0 -66
  284. package/mock.d.ts.map +0 -1
  285. package/mock.js +0 -243
  286. package/mock.js.map +0 -1
package/track.js CHANGED
@@ -5,43 +5,70 @@
5
5
  * @module
6
6
  */
7
7
  import { Once, Signal } from "@norskvideo/moq-signals";
8
- import { Producer as GroupProducer, Lagged } from "./group.js";
9
- import { hooks } from "./internal.js";
10
- import { Timescale } from "./time.js";
11
- /** Default {@link Info.latencyMax} window (milliseconds) when the publisher does not set one. */
12
- export const DEFAULT_LATENCY_MAX_MS = 5000;
13
- /**
14
- * How long (milliseconds) a datagram stays in the per-subscriber buffer before it is dropped.
15
- *
16
- * Datagrams are a best-effort send buffer, not a replay cache (unlike groups): only the last few
17
- * tens of milliseconds are kept, so a consumer that stalls loses stale datagrams instead of
18
- * replaying them. Mirrors the Rust `MAX_DATAGRAM_AGE`.
19
- */
20
- const MAX_DATAGRAM_AGE_MS = 50;
8
+ import { GroupTooLarge, TooFarBehind } from "./error.js";
9
+ import { Producer as GroupProducer } from "./group.js";
10
+ import { groupBounds, hooks, } from "./internal.js";
11
+ import { Milli, Timescale } from "./time.js";
12
+ import { registerTrackConsumer } from "./wire.js";
13
+ // The largest delay setTimeout keeps: anything more truncates to a signed 32-bit int
14
+ // and fires right away.
15
+ const MAX_TIMEOUT_MS = 2 ** 31 - 1;
16
+ // The cache scans at most this many times per retention window.
17
+ const PRUNE_SLICES = 8;
18
+ /** Default {@link Info.maxAge} window (milliseconds) when the publisher does not set one. */
19
+ export const DEFAULT_MAX_AGE_MS = Milli(5000);
20
+ // The higher-first midpoint. IETF flips priority (lower first), so this goes out as 128, the
21
+ // draft's usual publisher priority, while moq-lite carries 127 as written: one urgency on both.
22
+ const DEFAULT_PRIORITY = 127;
23
+ /** Maximum buffered datagrams per subscriber; mirrors Rust's bounded send buffer. */
24
+ const MAX_DATAGRAMS = 64;
21
25
  /**
22
26
  * Sanity cap on a datagram payload: the QUIC DATAGRAM frame ceiling. The real limit is
23
27
  * per-hop (the negotiated transport datagram size minus a small header) and oversize
24
28
  * datagrams are dropped there; a payload above this cap could never fit anywhere.
25
29
  */
26
30
  const MAX_DATAGRAM_BYTES = 65535;
31
+ // Normalize a latency budget for the wire, which carries it as an unsigned varint.
32
+ //
33
+ // Callers derive it from measurements (a jitter estimate scaled off RTT), so a fractional
34
+ // millisecond is expected; ceil rather than round, because a budget shortened by rounding
35
+ // skips a group the subscriber still wants. Anything that is not a duration is refused
36
+ // here, where the field is named, rather than deep in the encoder.
37
+ function maxAgeMillis(value) {
38
+ if (!Number.isFinite(value) || value < 0) {
39
+ throw new RangeError(`maxAge must be a non-negative number of milliseconds: ${value}`);
40
+ }
41
+ const millis = Math.ceil(value);
42
+ if (!Number.isSafeInteger(millis)) {
43
+ throw new RangeError(`maxAge exceeds the safe integer millisecond range: ${value}`);
44
+ }
45
+ return Milli(millis);
46
+ }
47
+ function priorityByte(value) {
48
+ if (!Number.isInteger(value) || value < 0 || value > 255) {
49
+ throw new RangeError(`priority must be an integer in 0..=255: ${value}`);
50
+ }
51
+ return value;
52
+ }
27
53
  /** Fill in any unset {@link Info} fields with their defaults. */
28
54
  export function infoDefaults(info = {}) {
29
55
  return {
30
- timescale: info.timescale ?? Timescale.MILLI,
31
- latencyMax: info.latencyMax ?? DEFAULT_LATENCY_MAX_MS,
32
- priority: info.priority ?? 0,
33
- ordered: info.ordered ?? false,
56
+ timescale: Timescale(info.timescale ?? Timescale.MILLI),
57
+ maxAge: maxAgeMillis(info.maxAge ?? DEFAULT_MAX_AGE_MS),
58
+ priority: priorityByte(info.priority ?? DEFAULT_PRIORITY),
34
59
  };
35
60
  }
36
61
  // Materialize the defaults at the model boundary so every layer observes a complete
37
62
  // subscription rather than interpreting an omitted field differently.
38
63
  function subscriptionDefaults(subscription = {}) {
64
+ const bounds = groupBounds(subscription.groups ?? {});
39
65
  return {
40
- priority: subscription.priority ?? 0,
41
- ordered: subscription.ordered ?? false,
42
- latencyMax: subscription.latencyMax ?? 0,
43
- startGroup: subscription.startGroup,
44
- endGroup: subscription.endGroup,
66
+ priority: priorityByte(subscription.priority ?? 0),
67
+ maxAge: maxAgeMillis(subscription.maxAge ?? Milli.zero),
68
+ groups: {
69
+ start: subscription.groups?.start === undefined ? undefined : { included: bounds.start },
70
+ end: bounds.end === undefined ? undefined : { excluded: bounds.end },
71
+ },
45
72
  };
46
73
  }
47
74
  // Aggregate the preferences of every live subscriber, matching Rust's Subscription::poll_combined.
@@ -56,25 +83,22 @@ function combineSubscriptions(states) {
56
83
  continue;
57
84
  }
58
85
  combined.priority = Math.max(combined.priority ?? 0, subscription.priority ?? 0);
59
- combined.ordered = (combined.ordered ?? false) && (subscription.ordered ?? false);
60
- combined.latencyMax = Math.max(combined.latencyMax ?? 0, subscription.latencyMax ?? 0);
61
- if (subscription.startGroup !== undefined) {
62
- combined.startGroup =
63
- combined.startGroup === undefined
64
- ? subscription.startGroup
65
- : Math.min(combined.startGroup, subscription.startGroup);
66
- }
67
- if (combined.endGroup === undefined || subscription.endGroup === undefined) {
68
- combined.endGroup = undefined;
69
- }
70
- else {
71
- combined.endGroup = Math.max(combined.endGroup, subscription.endGroup);
72
- }
86
+ combined.maxAge = Milli(Math.max(combined.maxAge ?? Milli.zero, subscription.maxAge ?? Milli.zero));
87
+ // A floor only restricts, so a subscriber without one clears the aggregate:
88
+ // its budget may reach below any floor the others set.
89
+ const a = groupBounds(combined.groups ?? {});
90
+ const b = groupBounds(subscription.groups ?? {});
91
+ combined.groups = {
92
+ start: combined.groups?.start === undefined || subscription.groups?.start === undefined
93
+ ? undefined
94
+ : { included: Math.min(a.start, b.start) },
95
+ end: a.end === undefined || b.end === undefined ? undefined : { excluded: Math.max(a.end, b.end) },
96
+ };
73
97
  }
74
98
  return combined;
75
99
  }
76
100
  /**
77
- * A request for a track the peer wants, yielded by `Broadcast.Producer.requested`.
101
+ * A request for a track the peer wants, delivered to the publishing wire layer.
78
102
  *
79
103
  * Created internally by the broadcast when a subscription (or info lookup) needs a track
80
104
  * served; answer it with {@link accept} or {@link reject}.
@@ -86,13 +110,17 @@ export class Request {
86
110
  name;
87
111
  #producer;
88
112
  #sequences;
113
+ #pending;
89
114
  constructor(options) {
90
115
  this.name = options.name;
91
116
  this.#producer = options.producer;
92
117
  this.#sequences = options.sequences;
118
+ this.#pending = options.pending;
119
+ this.#pending.add(this);
93
120
  }
94
121
  static {
95
122
  hooks.makeRequest = (options) => new Request(options);
123
+ hooks.pendingTrackProducer = (request) => request.#producer;
96
124
  }
97
125
  /** The aggregate subscription requested for this track. */
98
126
  get subscription() {
@@ -104,11 +132,13 @@ export class Request {
104
132
  }
105
133
  /** Accept the request, committing the track's immutable {@link Info}. */
106
134
  accept(info = {}) {
135
+ this.#pending.delete(this);
107
136
  bindProducer(this.name, this.#producer, this.#sequences);
108
137
  return this.#producer.accept(info);
109
138
  }
110
139
  /** Reject the request, closing the track optionally with an error. */
111
140
  reject(err) {
141
+ this.#pending.delete(this);
112
142
  this.#producer.close(err);
113
143
  }
114
144
  }
@@ -125,7 +155,17 @@ export class Consumer {
125
155
  this.name = name;
126
156
  this.#broadcast = broadcast;
127
157
  }
128
- /** Open a live subscription to the track. */
158
+ static {
159
+ registerTrackConsumer((name, broadcast) => new Consumer(name, broadcast));
160
+ }
161
+ /**
162
+ * Open a live subscription to the track.
163
+ *
164
+ * The cursor starts at the group the subscription named (its floor), or 0.
165
+ * {@link Subscription.maxAge} is what asks for data: delivery skips everything above
166
+ * the floor that the budget convicts, so the default budget of zero delivers only the
167
+ * latest group and a larger one reaches back over what it can still use.
168
+ */
129
169
  subscribe(options) {
130
170
  return this.#broadcast.subscribe(this.name, options);
131
171
  }
@@ -138,15 +178,57 @@ export class Consumer {
138
178
  return this.#broadcast.fetchGroup(this.name, sequence, options);
139
179
  }
140
180
  }
181
+ // The index of the first timeline group at or after `sequence`.
182
+ function timelineIndex(timeline, sequence) {
183
+ let lo = 0;
184
+ let hi = timeline.length;
185
+ while (lo < hi) {
186
+ const mid = (lo + hi) >>> 1;
187
+ if (timeline[mid].sequence < sequence)
188
+ lo = mid + 1;
189
+ else
190
+ hi = mid;
191
+ }
192
+ return lo;
193
+ }
194
+ // Add a group to a sorted timeline, replacing any group with the same sequence. Groups
195
+ // usually arrive in order, so the append is checked first.
196
+ function timelineInsert(timeline, group) {
197
+ const last = timeline.at(-1);
198
+ if (!last || last.sequence < group.sequence) {
199
+ timeline.push(group);
200
+ return;
201
+ }
202
+ const index = timelineIndex(timeline, group.sequence);
203
+ if (timeline[index]?.sequence === group.sequence)
204
+ timeline[index] = group;
205
+ else
206
+ timeline.splice(index, 0, group);
207
+ }
141
208
  // The shared state behind a Producer / Subscriber pair. Package-internal
142
209
  // wiring, unexported so it never appears in the published type declarations.
143
210
  class TrackState {
144
211
  /** The producer fanning into this sink, so a subscriber can mint a sibling of itself. */
145
212
  producer;
146
213
  groups = new Signal([]);
147
- /** Best-effort datagram channel, parallel to {@link groups}; an age-evicted send buffer per subscriber. */
214
+ // Every group still in the producer's replay cache, including groups this
215
+ // subscriber already consumed, sorted by sequence. Drift anchors have the same
216
+ // lifetime as content. Sorted so the latency guard, evaluated per group and per
217
+ // arrival, searches it instead of scanning the whole retained window.
218
+ timeline = [];
219
+ // First timestamps mutate group state rather than track state, so held groups
220
+ // watch this revision as well as arrivals when enforcing latency after handoff.
221
+ timelineChanged = new Signal(0);
222
+ /** Best-effort datagram channel, parallel to {@link groups}; a bounded send buffer per subscriber. */
148
223
  datagrams = new Signal([]);
149
224
  latest;
225
+ /**
226
+ * The exclusive final boundary, declared by {@link Producer.finishAt} or stamped by a
227
+ * clean close as one past the highest sequence produced. Groups and datagrams share the
228
+ * namespace, so this can exceed `latest + 1` (which only tracks groups). Mirrors the
229
+ * Rust `final_sequence`.
230
+ */
231
+ final = new Signal(undefined);
150
232
  closed = new Once();
151
233
  update;
152
234
  /** Resolved once the producer commits the immutable properties. */
@@ -190,6 +272,11 @@ function bindProducer(name, producer, sequences) {
190
272
  bindProducerSequence(producer, shared);
191
273
  }
192
274
  let bindProducerSequence;
275
+ // The sequence-order cursor lives inside `Subscriber` (it shares the group buffer and the
276
+ // drift anchor with the arrival cursor), so `Ordered` reaches it through this bridge,
277
+ // assigned in the class's static block.
278
+ let makeOrdered;
279
+ let ordered_;
193
280
  // Constructs a Subscriber from within this module without exposing a public
194
281
  // constructor that would leak the unexported TrackState. Assigned in the class's
195
282
  // static block.
@@ -201,7 +288,7 @@ let makeSubscriber;
201
288
  * subscription the publisher serves from it) gets an independent
202
289
  * {@link Subscriber} that receives a full copy of the groups, each with its own
203
290
  * read cursor. Groups are mirrored into every live subscriber and retained for the
204
- * track's `latencyMax` window so a late subscriber replays the recent groups.
291
+ * track's `maxAge` window so a late subscriber replays the recent groups.
205
292
  *
206
293
  * Obtained from {@link Request.accept} (the wire asks the application for a track to
207
294
  * serve) or constructed directly for an in-process track.
@@ -213,11 +300,24 @@ export class Producer {
213
300
  // read mirrored sinks, never this state directly.
214
301
  #state = new TrackState();
215
302
  #sequence = { next: 0 };
303
+ // One past the highest group or datagram this producer received, like the Rust
304
+ // `max_sequence`. The shared counter above can run ahead of it: sibling producers of
305
+ // the same track advance it too.
306
+ #received = 0;
216
307
  // Recently written source groups, retained for replay to late subscribers and
217
- // pruned once closed and older than the cache window. Each entry tracks the mirror
308
+ // pruned once idle for longer than the cache window. Each entry tracks the mirror
218
309
  // it handed to every sink so eviction can drop them too: otherwise a slow consumer
219
310
  // that never reads would pin old groups (and their frame bytes) forever.
220
311
  #cache = [];
312
+ // The same entries by sequence, so a write finds a duplicate without a scan.
313
+ #cached = new Map();
314
+ // When the cache was last scanned. See #prune.
315
+ #pruned = Number.NEGATIVE_INFINITY;
316
+ // Wakeup for the next entry due to age out. Writes settle retention inline, but a
317
+ // publisher that stalls stops writing, so without this an abandoned group (and any
318
+ // reader parked in it) would wait for a write that never comes.
319
+ #pruneTimer;
320
+ #pruneTimerAt = 0;
221
321
  // One independent downstream state per live subscriber.
222
322
  #sinks = new Set();
223
323
  // Whether any subscriber is currently attached. Exposed as {@link used}; the consumer wire
@@ -238,6 +338,15 @@ export class Producer {
238
338
  info() {
239
339
  return resolveInfo(this.#state);
240
340
  }
341
+ /**
342
+ * Publisher priority from the committed {@link Info}, or the default before {@link accept}.
343
+ *
344
+ * Higher is served first. Hang publishers set this from `Catalog.PRIORITY` so
345
+ * audio outranks video on the wire and in the bandwidth allocator.
346
+ */
347
+ get priority() {
348
+ return this.#state.info.peek()?.priority ?? DEFAULT_PRIORITY;
349
+ }
241
350
  /**
242
351
  * Settles once the track closes: `null` on a clean close, or the abort {@link Error}.
243
352
  * Peek it synchronously (`undefined` while open), observe it reactively, or `await` it.
@@ -254,14 +363,24 @@ export class Producer {
254
363
  }
255
364
  /** Commit the immutable publisher properties, resolving {@link info}. Returns `this`. */
256
365
  accept(info = {}) {
366
+ if (this.#state.closed.peek() !== undefined)
367
+ return this;
257
368
  const resolved = infoDefaults(info);
258
369
  this.#state.info.set(resolved);
259
370
  // Propagate to any sink handed out before accept (the on-demand path).
260
371
  for (const sink of this.#sinks)
261
372
  sink.info.set(resolved);
373
+ this.#updateSubscription();
262
374
  return this;
263
375
  }
264
- /** An independent {@link Subscriber} receiving a full copy of this track's groups. */
376
+ /**
377
+ * An independent {@link Subscriber} reading this track's groups.
378
+ *
379
+ * Its cursor starts at the group the subscription named (its floor), or 0.
380
+ * {@link Subscription.maxAge} is what asks for data: delivery skips everything above
381
+ * the floor that the budget convicts, so the default budget of zero delivers only the
382
+ * latest group and a larger one reaches back over what it can still use.
383
+ */
265
384
  subscribe(options = {}) {
266
385
  const sink = new TrackState(options);
267
386
  this.#addSink(sink);
@@ -310,6 +429,15 @@ export class Producer {
310
429
  forward();
311
430
  this.#sinks.delete(sink);
312
431
  this.#updateSubscription();
432
+ // Update demand: once the last subscriber leaves, the consumer wire (watching
433
+ // {@link unused}) tears the upstream down instead of downloading to nobody.
434
+ this.#used.set(this.#sinks.size > 0);
435
+ // The producer closing every sink keeps its mirrors tracked, so what the sink
436
+ // still buffers ages out with the cache instead of staying pinned.
437
+ if (this.#state.closed.peek() !== undefined) {
438
+ dispose();
439
+ return;
440
+ }
313
441
  for (const entry of this.#cache) {
314
442
  const mirror = entry.mirrors.get(sink);
315
443
  if (mirror) {
@@ -320,21 +448,23 @@ export class Producer {
320
448
  for (const group of sink.groups.peek())
321
449
  group.close(abort);
322
450
  dispose();
323
- // Update demand: once the last subscriber leaves, the consumer wire (watching
324
- // {@link unused}) tears the upstream down instead of downloading to nobody.
325
- this.#used.set(this.#sinks.size > 0);
326
451
  });
327
452
  }
328
453
  this.#prune();
329
454
  for (const entry of this.#cache)
330
455
  this.#mirror(entry, sink);
456
+ sink.final.set(this.#state.final.peek());
331
457
  if (closed !== undefined)
332
458
  closeTrackState(sink, closed instanceof Error ? closed : undefined);
333
459
  }
334
460
  // Recompute from every live sink because an update or close can narrow as well as widen
335
461
  // the aggregate. The wire layer observes this signal and emits SUBSCRIBE_UPDATE.
336
462
  #updateSubscription() {
337
- this.#state.update.set(combineSubscriptions(this.#sinks));
463
+ const combined = combineSubscriptions(this.#sinks);
464
+ const retained = this.#state.info.peek()?.maxAge;
465
+ if (combined && retained !== undefined)
466
+ combined.maxAge = Milli.min(combined.maxAge ?? Milli.zero, retained);
467
+ this.#state.update.set(combined);
338
468
  }
339
469
  // Mirror a cached source group into a sink. The mirror fills synchronously as the
340
470
  // source is written and keeps its own read cursor; frame bytes are shared by
@@ -342,6 +472,8 @@ export class Producer {
342
472
  #mirror(entry, sink) {
343
473
  const dst = entry.group.mirror();
344
474
  entry.mirrors.set(sink, dst);
475
+ timelineInsert(sink.timeline, dst);
476
+ void dst.readable().then(() => sink.timelineChanged.update((revision) => revision + 1));
345
477
  sink.latest = Math.max(sink.latest ?? 0, dst.sequence);
346
478
  sink.groups.mutate((groups) => {
347
479
  groups.push(dst);
@@ -350,43 +482,135 @@ export class Producer {
350
482
  }
351
483
  // Drop a cached group's mirror from every sink so no consumer can pin it.
352
484
  #evict(entry) {
485
+ // Reclaiming a group the publisher never closed ends it as the gap it is, before
486
+ // the mirrors below would otherwise report a clean finish to a reader that had
487
+ // drained it. The usual case, an already-closed group aging out, keeps its own
488
+ // terminal state.
489
+ if (!entry.group.isClosed)
490
+ entry.group.close(new TooFarBehind());
491
+ const mirrors = [...entry.mirrors.values()];
492
+ for (const mirror of mirrors)
493
+ hooks.evictGroup(mirror);
494
+ this.#unlink(entry);
495
+ for (const mirror of mirrors)
496
+ mirror.close();
497
+ }
498
+ // Take a cached group's mirrors out of every sink, so a subscriber can no longer
499
+ // receive them. A reader already holding one keeps it as is.
500
+ #unlink(entry) {
353
501
  for (const [sink, mirror] of entry.mirrors) {
354
502
  sink.groups.mutate((groups) => {
355
503
  const i = groups.indexOf(mirror);
356
504
  if (i >= 0)
357
505
  groups.splice(i, 1);
358
506
  });
359
- mirror.close();
507
+ const index = timelineIndex(sink.timeline, mirror.sequence);
508
+ if (sink.timeline[index] === mirror)
509
+ sink.timeline.splice(index, 1);
360
510
  }
361
511
  entry.mirrors.clear();
362
512
  }
363
- // Evict cached groups that are closed and older than the cache window.
513
+ // Take a cached group out of the cache lookups.
514
+ #uncache(entry) {
515
+ this.#cache.splice(this.#cache.indexOf(entry), 1);
516
+ this.#cached.delete(entry.group.sequence);
517
+ }
518
+ // The one group retention never takes: the newest, while it is still open. That is
519
+ // the live edge a publisher is appending to, and a track may legitimately keep it
520
+ // open across a long quiet stretch (a catalog snapshot, a JSON stream). Every other
521
+ // open group is an abandoned one, and ages out like a closed one.
522
+ #liveEdge() {
523
+ const latest = this.#cache.at(-1)?.group;
524
+ return latest?.closed.peek() === undefined ? latest : undefined;
525
+ }
526
+ // Evict cached groups idle for longer than the cache window. Idle means nothing
527
+ // written, so an abandoned open group ages out instead of pinning its buffer (and
528
+ // any reader parked in it) forever.
529
+ //
530
+ // Scans at most once per slice of the window, so a track publishing faster than that
531
+ // evicts a run of groups per scan instead of scanning everything to evict one per
532
+ // write. A group can outlive the window by up to one slice.
364
533
  #prune() {
365
- const latencyMaxMs = this.#state.info.peek()?.latencyMax ?? DEFAULT_LATENCY_MAX_MS;
366
- const cutoff = Date.now() - latencyMaxMs;
534
+ const maxAgeMs = this.#state.info.peek()?.maxAge ?? DEFAULT_MAX_AGE_MS;
535
+ const now = performance.now();
536
+ const slice = maxAgeMs / PRUNE_SLICES;
537
+ if (now < this.#pruned + slice) {
538
+ // Something may have come due since the last scan, so make sure another follows.
539
+ this.#wake(this.#pruned + slice);
540
+ return;
541
+ }
542
+ this.#pruned = now;
543
+ const cutoff = now - maxAgeMs;
544
+ const live = this.#liveEdge();
545
+ let oldest;
367
546
  const retained = [];
368
547
  for (const entry of this.#cache) {
369
- if (entry.time > cutoff || entry.group.closed.peek() === undefined) {
548
+ if (entry.group === live) {
370
549
  retained.push(entry);
371
- continue;
372
550
  }
373
- this.#evict(entry);
551
+ else if (entry.group.activity >= cutoff) {
552
+ retained.push(entry);
553
+ if (oldest === undefined || entry.group.activity < oldest)
554
+ oldest = entry.group.activity;
555
+ }
556
+ else {
557
+ this.#cached.delete(entry.group.sequence);
558
+ this.#evict(entry);
559
+ }
374
560
  }
375
561
  this.#cache = retained;
562
+ // Replace the wakeup with one for the next entry due to age out. Writes settle
563
+ // retention inline, so this only has to cover the case no write follows. Cheap to
564
+ // over-arm: an entry written since is retained and re-armed.
565
+ clearTimeout(this.#pruneTimer);
566
+ this.#pruneTimer = undefined;
567
+ if (oldest !== undefined)
568
+ this.#wake(Math.max(oldest + maxAgeMs, now + slice));
569
+ }
570
+ // Arm the prune wakeup for `at`, unless one is already armed sooner. Kept after a close,
571
+ // until the cache empties, so what the track left behind still ages out.
572
+ #wake(at) {
573
+ if (this.#pruneTimer !== undefined && this.#pruneTimerAt <= at)
574
+ return;
575
+ clearTimeout(this.#pruneTimer);
576
+ // setTimeout truncates its delay to a signed 32-bit int, so a longer window
577
+ // would fire immediately and spin. Wake at the cap instead and re-arm: #prune
578
+ // retains anything still fresh, so the extra wakeups are the only cost.
579
+ const delay = Math.min(MAX_TIMEOUT_MS, Math.max(0, at - performance.now()));
580
+ const timer = setTimeout(() => {
581
+ this.#pruneTimer = undefined;
582
+ this.#prune();
583
+ }, delay);
584
+ // A cache prune is never a reason to hold a Node/Bun process open.
585
+ timer.unref?.();
586
+ this.#pruneTimer = timer;
587
+ this.#pruneTimerAt = at;
376
588
  }
377
589
  // Retain a source group and fan it out to every live sink.
378
590
  #publish(group) {
379
- const entry = { group, time: Date.now(), mirrors: new Map() };
591
+ const entry = { group, mirrors: new Map() };
380
592
  this.#cache.push(entry);
381
- this.#prune();
593
+ this.#cached.set(group.sequence, entry);
594
+ this.#received = Math.max(this.#received, group.sequence + 1);
382
595
  for (const sink of this.#sinks)
383
596
  this.#mirror(entry, sink);
597
+ // Give held mirrors the new live edge before pruning their timeline entry,
598
+ // so their latency guard can preserve a terminal expiry verdict.
599
+ this.#prune();
384
600
  }
385
- /** Append a new group with the next sequence number. */
386
- appendGroup() {
601
+ // Refuse a write once the track is closed, or at or past its declared end.
602
+ #writable(sequence) {
387
603
  if (this.#state.closed.peek() !== undefined)
388
604
  throw new Error("track is closed");
605
+ const final = this.#state.final.peek();
606
+ if (final !== undefined && sequence >= final) {
607
+ throw new Error(`sequence ${sequence} is at or past the track's end ${final}`);
608
+ }
609
+ }
610
+ /** Append a new group with the next sequence number. */
611
+ appendGroup() {
389
612
  const sequence = this.#sequence;
613
+ this.#writable(sequence.next);
390
614
  const group = new GroupProducer(sequence.next);
391
615
  sequence.next = group.sequence + 1;
392
616
  this.#publish(group);
@@ -401,16 +625,14 @@ export class Producer {
401
625
  * entry is already gone, so a long-evicted sequence is accepted as new.
402
626
  */
403
627
  writeGroup(group) {
404
- if (this.#state.closed.peek() !== undefined)
405
- throw new Error("track is closed");
406
- const existing = this.#cache.findIndex((entry) => entry.group.sequence === group.sequence);
407
- if (existing >= 0) {
408
- const entry = this.#cache[existing];
409
- if (!(entry.group.closed.peek() instanceof Error)) {
628
+ this.#writable(group.sequence);
629
+ const existing = this.#cached.get(group.sequence);
630
+ if (existing) {
631
+ if (!(existing.group.closed.peek() instanceof Error)) {
410
632
  throw new Error(`duplicate group: sequence=${group.sequence}`);
411
633
  }
412
- this.#evict(entry);
413
- this.#cache.splice(existing, 1);
634
+ this.#evict(existing);
635
+ this.#uncache(existing);
414
636
  }
415
637
  // Only advance the shared counter upward (for appendGroup auto-increment).
416
638
  const sequence = this.#sequence;
@@ -422,13 +644,12 @@ export class Producer {
422
644
  // Fan a datagram out to every live subscriber, dropping the oldest once the ring is full.
423
645
  // Late subscribers do NOT replay old datagrams (best-effort, unlike the group cache).
424
646
  #publishDatagram(datagram) {
425
- const now = performance.now();
647
+ this.#received = Math.max(this.#received, datagram.sequence + 1);
426
648
  for (const sink of this.#sinks) {
427
649
  sink.datagrams.mutate((list) => {
428
- list.push({ datagram, time: now });
429
- // Drop anything older than the send-buffer window.
430
- while (list.length > 0 && now - list[0].time > MAX_DATAGRAM_AGE_MS)
650
+ if (list.length === MAX_DATAGRAMS)
431
651
  list.shift();
652
+ list.push(datagram);
432
653
  });
433
654
  }
434
655
  }
@@ -442,48 +663,106 @@ export class Producer {
442
663
  * keep datagram payloads small (e.g. a single audio frame). Datagrams are never delivered
443
664
  * over IETF moq-transport or stream-only transports (the WebSocket fallback). A payload over
444
665
  * 65535 bytes (the QUIC datagram frame ceiling) throws. An origin publisher uses this; a
445
- * relay preserving upstream numbering uses {@link writeDatagram}.
666
+ * relay preserving upstream numbering uses {@link insertDatagram}.
446
667
  */
447
668
  appendDatagram(timestamp, payload) {
448
- if (this.#state.closed.peek() !== undefined)
449
- throw new Error("track is closed");
450
- if (payload.byteLength > MAX_DATAGRAM_BYTES)
451
- throw new Error("datagram payload too large");
452
669
  const counter = this.#sequence;
453
670
  const sequence = counter.next;
671
+ this.#writable(sequence);
672
+ if (payload.byteLength > MAX_DATAGRAM_BYTES)
673
+ throw new Error("datagram payload too large");
454
674
  counter.next = sequence + 1;
455
675
  this.#publishDatagram({ sequence, timestamp, payload });
456
676
  return sequence;
457
677
  }
458
678
  /**
459
- * Write a datagram with an explicit sequence number.
679
+ * Insert a datagram with an explicit sequence number.
460
680
  *
461
681
  * Preserves the supplied sequence (advancing the shared counter if needed) so a relay can
462
682
  * forward a datagram without renumbering it. The size limits of {@link appendDatagram}
463
683
  * apply. Most origin publishers want {@link appendDatagram} instead.
464
684
  */
465
- writeDatagram(datagram) {
685
+ insertDatagram(sequence, timestamp, payload) {
686
+ this.#writable(sequence);
687
+ if (payload.byteLength > MAX_DATAGRAM_BYTES)
688
+ throw new Error("datagram payload too large");
689
+ const counter = this.#sequence;
690
+ if (sequence >= counter.next) {
691
+ counter.next = sequence + 1;
692
+ }
693
+ this.#publishDatagram({ sequence, timestamp, payload });
694
+ }
695
+ /**
696
+ * Declare the track's exclusive end, possibly ahead of the live edge, mirroring the Rust
697
+ * `finish_at`.
698
+ *
699
+ * `final` is the first sequence that will never be produced, so a track whose last group
700
+ * is 89 finishes at 90. Groups and datagrams below it are still accepted; anything at or
701
+ * above it is refused. Unlike {@link close} it is not terminal: call `close()` once the
702
+ * remaining groups are written. Throws if the track is closed, already has an end, or
703
+ * `final` is at or below a sequence already produced.
704
+ */
705
+ finishAt(final) {
466
706
  if (this.#state.closed.peek() !== undefined)
467
707
  throw new Error("track is closed");
468
- if (datagram.payload.byteLength > MAX_DATAGRAM_BYTES)
469
- throw new Error("datagram payload too large");
470
- const sequence = this.#sequence;
471
- if (datagram.sequence >= sequence.next) {
472
- sequence.next = datagram.sequence + 1;
708
+ if (!Number.isSafeInteger(final) || final < 0)
709
+ throw new RangeError(`invalid track end: ${final}`);
710
+ const declared = this.#state.final.peek();
711
+ if (declared !== undefined)
712
+ throw new Error(`track already ends at ${declared}`);
713
+ if (final < this.#received) {
714
+ throw new Error(`track end ${final} is below the next sequence ${this.#received}`);
473
715
  }
474
- this.#publishDatagram(datagram);
716
+ this.#declareFinal(final);
717
+ }
718
+ #declareFinal(final) {
719
+ this.#state.final.set(final);
720
+ for (const sink of this.#sinks)
721
+ sink.final.set(final);
475
722
  }
476
- /** Close the track and every subscriber, mirroring the abort to their groups. Idempotent. */
723
+ /**
724
+ * Close the track and every subscriber, mirroring the abort to their groups. Idempotent.
725
+ *
726
+ * A clean close keeps the end {@link finishAt} declared, or declares one past the highest
727
+ * sequence produced; an abort ends without one. Subscribers still draining get the
728
+ * finished groups first, then the end or the abort. An abort after the declared end
729
+ * settled (reached, with every group below it finished) is a clean close. The groups
730
+ * left behind still age out after the track's `maxAge`, so a stale subscriber can't pin
731
+ * them.
732
+ */
477
733
  close(abort) {
734
+ if (this.#state.closed.peek() !== undefined)
735
+ return;
736
+ if (abort && this.#settled())
737
+ abort = undefined;
738
+ if (abort === undefined && this.#state.final.peek() === undefined) {
739
+ this.#declareFinal(this.#received);
740
+ }
741
+ // Nobody will finish these, so a subscriber that has not taken one yet never sees it.
742
+ // Not evicted: a reader already holding one keeps its frames and sees the abort.
743
+ const open = abort ? this.#cache.filter((entry) => entry.group.closed.peek() === undefined) : [];
478
744
  closeTrackState(this.#state, abort);
479
745
  for (const { group } of this.#cache)
480
746
  group.close(abort);
747
+ for (const entry of open) {
748
+ this.#unlink(entry);
749
+ this.#uncache(entry);
750
+ }
481
751
  for (const sink of this.#sinks) {
482
752
  for (const group of sink.groups.peek())
483
753
  group.close(abort);
484
754
  closeTrackState(sink, abort);
485
755
  }
486
756
  this.#sinks.clear();
757
+ this.#prune();
758
+ }
759
+ // Whether the declared end was reached and every cached group below it finished, so
760
+ // the track already holds everything it promised. Mirrors the Rust `is_settled`.
761
+ #settled() {
762
+ const final = this.#state.final.peek();
763
+ if (final === undefined || this.#received < final)
764
+ return false;
765
+ return this.#cache.every(({ group }) => group.sequence >= final || group.closed.peek() === null);
487
766
  }
488
767
  /** Append a frame as its own single-frame group. */
489
768
  writeFrame(frame) {
@@ -523,12 +802,136 @@ export class Subscriber {
523
802
  #state;
524
803
  #nextSequence = 0;
525
804
  #cursor = new Signal({ start: 0 });
805
+ #enforceLatency = true;
806
+ // Which cursor owns this subscription. Both cursors draw from one buffer, so the
807
+ // first group read commits the track to arrival order and {@link ordered} commits
808
+ // it to sequence order; whichever wins is the only one allowed from here on.
809
+ // Datagrams are a separate cursor and never commit.
810
+ #mode;
811
+ // The group the frame-level helpers are currently draining, acquired through the
812
+ // sequence cursor so frame reads and {@link Ordered.nextGroup} share one floor.
813
+ #frameGroup;
814
+ #drift() {
815
+ const { end } = this.#cursor.peek();
816
+ const timeline = this.#state.timeline;
817
+ let presentation;
818
+ // The edge wants the newest content that exists, so it takes the newest
819
+ // stamped group's latest frame: walk back from the cap to the first one.
820
+ for (let i = (end === undefined ? timeline.length : timelineIndex(timeline, end)) - 1; i >= 0; i--) {
821
+ const group = timeline[i];
822
+ if (group.closed.peek() instanceof Error)
823
+ continue;
824
+ const timestamp = hooks.groupTimestamp(group);
825
+ if (timestamp === undefined)
826
+ continue;
827
+ presentation = { sequence: group.sequence, timestamp: hooks.groupLatest(group) ?? timestamp };
828
+ break;
829
+ }
830
+ const requested = this.#state.update.peek()?.maxAge ?? 0;
831
+ const retained = this.#state.info.peek()?.maxAge;
832
+ return {
833
+ budget: this.#enforceLatency
834
+ ? retained === undefined
835
+ ? requested
836
+ : Math.min(requested, retained)
837
+ : Number.POSITIVE_INFINITY,
838
+ presentation,
839
+ end,
840
+ };
841
+ }
842
+ // The furthest presentation time the group at `sequence` could still reach: where its
843
+ // immediate servable successor begins, or undefined when nothing proves where it
844
+ // stops. An upper bound, deliberately: a frame's duration is not on the wire, so a
845
+ // group's own last timestamp says where it starts presenting, not where it ends.
846
+ // Only the *immediate* successor counts: timestamps need not rise with sequence (a
847
+ // rewind reorders them), so a later stamped group proves nothing about where an
848
+ // unstamped successor will begin, and shrinking the bound is the unsafe direction.
849
+ // An unstamped successor leaves the reach unbounded until it presents a frame.
850
+ #reach(sequence, end) {
851
+ const timeline = this.#state.timeline;
852
+ for (let i = timelineIndex(timeline, sequence + 1); i < timeline.length; i++) {
853
+ const successor = timeline[i];
854
+ if (end !== undefined && successor.sequence >= end)
855
+ break;
856
+ if (successor.closed.peek() instanceof Error)
857
+ continue;
858
+ return hooks.groupTimestamp(successor)?.asMillis();
859
+ }
860
+ return undefined;
861
+ }
862
+ // Whether the drift budget says to give up on `group`.
863
+ //
864
+ // Presentation time measures a group by how far it could still reach, not by how far
865
+ // behind it started. Being behind is survivable: priority transmits newer groups first,
866
+ // so a backlog bursts at whatever rate is left over and closes the gap faster than the
867
+ // live edge advances. A group is abandoned only once everything it could still present
868
+ // falls outside the budget.
869
+ //
870
+ // A group's reach is bounded by its nearest successor: it cannot present past where the
871
+ // next group begins. Its own frames say nothing, since a frame's duration is not on the
872
+ // wire, and the candidate needs no timestamp of its own: an empty group is bounded by
873
+ // its stamped successor the same way. The bound is exclusive, so the comparison is `>=`:
874
+ // the freshest frame a group could still hold sits just below its reach, so an age equal
875
+ // to the budget already puts every frame in it strictly past the budget. A zero budget
876
+ // falls out for free. Only timestamps drive expiry; wall-clock reclamation of idle
877
+ // content is the cache's own policy, not the budget's.
878
+ #isStale(group, drift) {
879
+ if (this.#state.timeline[timelineIndex(this.#state.timeline, group.sequence)] !== group)
880
+ return false;
881
+ const reach = this.#reach(group.sequence, drift.end);
882
+ return (drift.presentation !== undefined &&
883
+ drift.presentation.sequence > group.sequence &&
884
+ reach !== undefined &&
885
+ drift.presentation.timestamp.asMillis() - reach >= drift.budget);
886
+ }
887
+ #guard(group) {
888
+ if (!this.#enforceLatency)
889
+ return group;
890
+ hooks.expireGroup(group, {
891
+ expired: () => this.#isStale(group, this.#drift()),
892
+ changed: [
893
+ this.#state.groups,
894
+ this.#state.timelineChanged,
895
+ this.#state.update,
896
+ this.#state.info,
897
+ this.#cursor,
898
+ this.#state.closed,
899
+ ],
900
+ });
901
+ return group;
902
+ }
526
903
  constructor(name, state) {
527
904
  this.name = name;
528
905
  this.#state = state;
906
+ // The cursor's floor is the group the subscription named, or 0. A floor is the
907
+ // only thing a start contributes; {@link Subscription.maxAge} is what asks for
908
+ // data, and delivery skips everything above the floor that the budget convicts.
909
+ this.#cursor.set({ start: groupBounds(state.update.peek()?.groups ?? {}).start });
529
910
  }
530
911
  static {
531
912
  makeSubscriber = (name, state) => new Subscriber(name, state);
913
+ hooks.tryRecvGroup = (subscriber) => subscriber.#tryRecvGroup();
914
+ hooks.groupChanged = (subscriber, fn) => subscriber.#groupChanged(fn);
915
+ hooks.exemptFetch = (subscriber) => {
916
+ subscriber.#enforceLatency = false;
917
+ };
918
+ hooks.replaceGroups = (subscriber, groups) => subscriber.#replaceGroups(groups);
919
+ // The sequence cursor lives here (it shares the buffer and the drift anchor with
920
+ // the arrival cursor); `Ordered` is the handle that reaches it.
921
+ ordered_ = {
922
+ nextGroup: (subscriber) => subscriber.#nextGroup(),
923
+ readFrame: (subscriber) => subscriber.#readFrame(),
924
+ readString: (subscriber) => subscriber.#readString(),
925
+ readJson: (subscriber) => subscriber.#readJson(),
926
+ readBool: (subscriber) => subscriber.#readBool(),
927
+ // Unordered either way, so both handles reach the same cursor.
928
+ recvDatagram: (subscriber) => subscriber.#recvDatagram(),
929
+ };
930
+ }
931
+ // Refuse a read on this handle once `ordered()` has taken the subscription.
932
+ #live() {
933
+ if (this.#mode === "ordered")
934
+ throw new Error("track is read in sequence order; use the Ordered handle");
532
935
  }
533
936
  /**
534
937
  * Resolve this track's immutable publisher properties.
@@ -552,6 +955,37 @@ export class Subscriber {
552
955
  latest() {
553
956
  return this.#state.latest;
554
957
  }
958
+ /**
959
+ * The track's exclusive final boundary: the end {@link Producer.finishAt} declared, which
960
+ * can be ahead of the live edge, or one past the highest sequence produced once the
961
+ * producer closes cleanly (0 for a track that produced none). Groups and datagrams share
962
+ * the sequence namespace, so this can exceed `latest() + 1`. Undefined until declared,
963
+ * and after an abort that declared none.
964
+ */
965
+ final() {
966
+ return this.#state.final.peek();
967
+ }
968
+ /**
969
+ * Resolve with the track's exclusive final boundary once it is known, mirroring the Rust
970
+ * `finished`.
971
+ *
972
+ * Resolves as soon as the end is declared, which may be ahead of the live edge, so it
973
+ * says nothing about every group having arrived: read until the cursor returns
974
+ * `undefined` for that. Rejects with the abort, or if the track closes without an end.
975
+ */
976
+ async finished() {
977
+ for (;;) {
978
+ const final = this.#state.final.peek();
979
+ if (final !== undefined)
980
+ return final;
981
+ const closed = this.#state.closed.peek();
982
+ if (closed instanceof Error)
983
+ throw closed;
984
+ if (closed !== undefined)
985
+ throw new Error("track closed before its end was known");
986
+ await Signal.race(this.#state.final, this.#state.closed);
987
+ }
988
+ }
555
989
  /**
556
990
  * The newest frame this track has produced, or `undefined` while it has none.
557
991
  *
@@ -592,22 +1026,33 @@ export class Subscriber {
592
1026
  throw new Error("track has no producer to fork from");
593
1027
  return producer.subscribe(options);
594
1028
  }
595
- /** Start this subscriber's local read cursor at `sequence`, without changing its wire request. */
596
- startAt(sequence) {
597
- this.#cursor.update((cursor) => ({ ...cursor, start: sequence }));
1029
+ /** Limit subsequent reads to these groups and return this reader for chaining. */
1030
+ withGroups(groups) {
1031
+ this.setGroups(groups);
1032
+ return this;
598
1033
  }
599
1034
  /**
600
- * Cap {@link nextGroup} and {@link recvGroup} at `sequence` inclusively, or omit it to
601
- * remove the cap. Groups above the cap remain buffered and become readable if the cap
602
- * is raised. This local cursor does not change the subscription's wire request.
1035
+ * Limit subsequent reads without rewinding read progress or changing the wire request.
1036
+ * An omitted start preserves the current floor; an omitted end removes the cap.
1037
+ * Raising the end makes unread buffered groups available again.
603
1038
  */
604
- endAt(sequence) {
605
- this.#cursor.update((cursor) => ({ ...cursor, end: sequence }));
1039
+ setGroups(groups) {
1040
+ const { start, end } = groupBounds(groups);
1041
+ this.#cursor.update((cursor) => ({ start: Math.max(cursor.start, start), end }));
1042
+ }
1043
+ // Serving counterpart of setGroups: a named start replaces the floor, matching
1044
+ // Rust `start_at`. Local readers stay monotonic; only the wire publisher lowers.
1045
+ #replaceGroups(groups) {
1046
+ const { start, end } = groupBounds(groups);
1047
+ this.#cursor.update((cursor) => ({
1048
+ start: groups.start === undefined ? cursor.start : start,
1049
+ end,
1050
+ }));
606
1051
  }
607
1052
  /** Close the track (optionally with an error), closing any pending groups. Idempotent. */
608
1053
  close(abort) {
609
1054
  // Settle if we're first (the producer may already have); either way drop anything
610
- // still buffered. Groups parked at the endAt cap deliberately outlive a clean
1055
+ // still buffered. Groups parked at the setGroups cap deliberately outlive a clean
611
1056
  // producer close, so the subscriber leaving is what must release them: closing
612
1057
  // and clearing wakes a pending read to observe the end instead of hanging.
613
1058
  closeTrackState(this.#state, abort);
@@ -616,55 +1061,108 @@ export class Subscriber {
616
1061
  group.close(abort);
617
1062
  groups.length = 0;
618
1063
  });
1064
+ this.#frameGroup?.close(abort);
1065
+ this.#frameGroup = undefined;
1066
+ this.#state.timeline.length = 0;
619
1067
  }
620
1068
  /**
621
1069
  * Receive every group on this track exactly once, as it becomes available.
622
1070
  *
623
1071
  * Groups may arrive out of order or with gaps due to network conditions; unlike
624
- * {@link nextGroup}, one that arrives after a newer group was already returned is
625
- * still delivered. When several groups are buffered, the lowest sequence is
1072
+ * {@link Ordered.nextGroup}, one that arrives after a newer group was already returned
1073
+ * is still delivered. When several groups are buffered, the lowest sequence is
626
1074
  * returned first.
627
1075
  *
628
- * Honors the floor set by {@link startAt} and the cap set by {@link endAt}: a group
1076
+ * Honors the range set by {@link setGroups}: a group
629
1077
  * beyond the cap stays buffered (not dropped) and is offered once the cap rises, even
630
1078
  * after a clean close, without blocking in-range groups that arrive behind it.
1079
+ * A group whose presentation time is further behind the live edge than this
1080
+ * subscriber's `maxAge` is skipped. The default of zero takes the live edge.
1081
+ * The budget remains attached after return, so a pending frame read rejects if a stalled
1082
+ * group becomes stale while newer data advances.
1083
+ *
1084
+ * The first call commits this track to arrival order: {@link ordered} throws afterwards.
631
1085
  */
632
1086
  async recvGroup() {
1087
+ this.#live();
1088
+ this.#mode = "arrival";
633
1089
  for (;;) {
634
- const group = this.tryRecvGroup();
635
- if (group)
636
- return group;
637
- const closed = this.#state.closed.peek();
638
- if (closed instanceof Error)
639
- throw closed;
640
- // A group beyond the cap outlives a clean close: it becomes deliverable if
641
- // the cap rises, so the track isn't over while any are held.
642
- if (closed !== undefined && !this.#state.groups.peek()[0])
643
- return undefined;
1090
+ const recv = this.#tryRecvGroup();
1091
+ switch (recv.kind) {
1092
+ case "group":
1093
+ return recv.group;
1094
+ case "done":
1095
+ return undefined;
1096
+ case "error":
1097
+ throw recv.error;
1098
+ }
1099
+ // Idle, or parked at the boundary waiting for the cap to rise.
644
1100
  await Signal.race(this.#state.groups, this.#cursor, this.#state.closed);
645
1101
  }
646
1102
  }
1103
+ // Package-internal synchronous half of recvGroup. The lite publisher uses this so applying
1104
+ // control state, popping the group, and positioning its frames are one JavaScript turn.
1105
+ #tryRecvGroup() {
1106
+ const groups = this.#state.groups.peek();
1107
+ const { start, end } = this.#cursor.peek();
1108
+ while (groups.length > 0 && groups[0].sequence < start)
1109
+ groups.shift()?.close();
1110
+ const drift = this.#drift();
1111
+ for (;;) {
1112
+ // The buffer is sequence-sorted, so an in-range group that arrives behind a
1113
+ // beyond-cap one sorts in front of it and is never blocked by it.
1114
+ const group = groups[0];
1115
+ if (!group || (end !== undefined && group.sequence >= end))
1116
+ break;
1117
+ groups.shift();
1118
+ if (this.#isStale(group, drift)) {
1119
+ group.close();
1120
+ continue;
1121
+ }
1122
+ return { kind: "group", group: this.#guard(group) };
1123
+ }
1124
+ const group = groups[0];
1125
+ const closed = this.#state.closed.peek();
1126
+ if (closed instanceof Error)
1127
+ return { kind: "error", error: closed };
1128
+ if (closed === undefined)
1129
+ return { kind: "idle" };
1130
+ // A group beyond the cap outlives a clean close: it becomes deliverable if
1131
+ // the cap rises, so the track isn't over while any are held.
1132
+ return group ? { kind: "boundary" } : { kind: "done" };
1133
+ }
1134
+ // Package-internal readiness half of recvGroup. Each registration fires at most once, and
1135
+ // the caller disposes the losers after whichever source wakes it. A declared end wakes it
1136
+ // too, so a publisher can forward the end before the live edge reaches it.
1137
+ #groupChanged(fn) {
1138
+ const dispose = [
1139
+ this.#state.groups.changed(fn),
1140
+ this.#cursor.changed(fn),
1141
+ this.#state.closed.changed(fn),
1142
+ this.#state.final.changed(fn),
1143
+ ];
1144
+ return () => {
1145
+ for (const close of dispose)
1146
+ close();
1147
+ };
1148
+ }
647
1149
  /**
648
1150
  * Take the next buffered group without blocking, honoring the same cursor bounds as
649
1151
  * {@link recvGroup}.
650
1152
  *
651
1153
  * Returns `undefined` when nothing is deliverable right now, which is not by itself the
652
1154
  * end of the track: a group may still arrive, or one may be parked beyond the
653
- * {@link endAt} cap. Use it to drain what the retained window already holds, where
1155
+ * {@link setGroups} cap. Use it to drain what the retained window already holds, where
654
1156
  * waiting for a sequence nothing will republish would park forever.
655
1157
  */
656
1158
  tryRecvGroup() {
657
- const groups = this.#state.groups.peek();
658
- const { start, end } = this.#cursor.peek();
659
- while (groups.length > 0 && groups[0].sequence < start)
660
- groups.shift()?.close();
661
- // The buffer is sequence-sorted, so an in-range group that arrives behind a
662
- // beyond-cap one sorts in front of it and is never blocked by it.
663
- const group = groups[0];
664
- if (group && (end === undefined || group.sequence <= end)) {
665
- groups.shift();
666
- return group;
667
- }
1159
+ this.#live();
1160
+ this.#mode = "arrival";
1161
+ const recv = this.#tryRecvGroup();
1162
+ if (recv.kind === "group")
1163
+ return recv.group;
1164
+ if (recv.kind === "error")
1165
+ throw recv.error;
668
1166
  return undefined;
669
1167
  }
670
1168
  /**
@@ -673,23 +1171,20 @@ export class Subscriber {
673
1171
  * Datagrams are a separate best-effort channel from groups (see
674
1172
  * {@link Producer.appendDatagram}); they share only the sequence namespace. A consumer
675
1173
  * that falls too far behind silently loses the oldest datagrams. Read this alongside
676
- * {@link recvGroup} (e.g. in a separate loop) to receive both channels concurrently. Returning
677
- * a datagram advances {@link nextGroup} past that sequence.
1174
+ * {@link recvGroup} (e.g. in a separate loop) to receive both channels concurrently.
1175
+ * The two cursors are independent: a datagram never moves the group cursor.
678
1176
  */
679
1177
  async recvDatagram() {
1178
+ this.#live();
1179
+ return this.#recvDatagram();
1180
+ }
1181
+ // The datagram cursor, reachable from either handle: unordered by construction, so the
1182
+ // choice of group order says nothing about it.
1183
+ async #recvDatagram() {
680
1184
  for (;;) {
681
1185
  const datagrams = this.#state.datagrams.peek();
682
- // Evict datagrams older than the send-buffer window (also enforced on write), so a
683
- // reader that stalled skips stale datagrams instead of replaying them.
684
- const cutoff = performance.now() - MAX_DATAGRAM_AGE_MS;
685
- while (datagrams.length > 0 && datagrams[0].time < cutoff)
686
- datagrams.shift();
687
1186
  if (datagrams.length > 0) {
688
- const datagram = datagrams.shift()?.datagram;
689
- if (datagram) {
690
- this.#nextSequence = Math.max(this.#nextSequence, datagram.sequence + 1);
691
- }
692
- return datagram;
1187
+ return datagrams.shift();
693
1188
  }
694
1189
  const closed = this.#state.closed.peek();
695
1190
  if (closed instanceof Error)
@@ -699,128 +1194,111 @@ export class Subscriber {
699
1194
  await Signal.race(this.#state.datagrams, this.#state.closed);
700
1195
  }
701
1196
  }
702
- /**
703
- * Return the next group with a strictly-greater sequence number than the last returned.
704
- *
705
- * Late arrivals (sequence at or below the last returned) are silently skipped.
706
- * Use {@link recvGroup} to see every group in arrival order instead.
707
- */
708
- async nextGroup() {
1197
+ // The sequence cursor behind {@link Ordered}, which owns the only public door to it.
1198
+ async #nextGroup() {
709
1199
  for (;;) {
710
1200
  const groups = this.#state.groups.peek();
711
1201
  const cursor = this.#cursor.peek();
712
1202
  const start = Math.max(cursor.start, this.#nextSequence);
713
1203
  while (groups.length > 0 && groups[0].sequence < start)
714
1204
  groups.shift()?.close();
715
- const group = groups[0];
716
- if (group && (cursor.end === undefined || group.sequence <= cursor.end)) {
1205
+ // One anchor for the whole pass, so walking a backlog off stays linear.
1206
+ const drift = this.#drift();
1207
+ for (;;) {
1208
+ const group = groups[0];
1209
+ if (!group || (cursor.end !== undefined && group.sequence >= cursor.end))
1210
+ break;
717
1211
  groups.shift();
718
1212
  this.#nextSequence = group.sequence + 1;
719
- return group;
1213
+ // Every frame this group could still hold is past the budget, so keep
1214
+ // scanning: one pass walks a whole backlog off rather than replaying it.
1215
+ if (this.#isStale(group, drift)) {
1216
+ group.close();
1217
+ continue;
1218
+ }
1219
+ // One cursor: the frame helpers must not keep draining a group this
1220
+ // read just moved past, or interleaved reads would run backwards.
1221
+ if (this.#frameGroup && this.#frameGroup.sequence < group.sequence) {
1222
+ this.#frameGroup.close();
1223
+ this.#frameGroup = undefined;
1224
+ }
1225
+ return this.#guard(group);
720
1226
  }
721
1227
  const closed = this.#state.closed.peek();
722
1228
  if (closed instanceof Error)
723
1229
  throw closed;
724
- if (closed !== undefined && !group)
1230
+ // A group parked above the cap stays deliverable even after a clean close
1231
+ // (its frames remain buffered), so keep waiting for a cap raise. Only a
1232
+ // drained track reports finished.
1233
+ if (closed !== undefined && !groups[0])
725
1234
  return undefined;
726
1235
  await Signal.race(this.#state.groups, this.#cursor, this.#state.closed);
727
1236
  }
728
1237
  }
729
1238
  /**
730
- * Reads the next frame across all groups, discarding older groups.
731
- * Treat the returned frame bytes as read-only; they are shared with other consumers.
732
- */
733
- async readFrame() {
734
- const next = await this.readFrameSequence();
735
- return next ? { payload: next.payload, timestamp: next.timestamp } : undefined;
736
- }
737
- /**
738
- * Reads the next frame along with its group and frame sequence numbers.
1239
+ * Reads the next frame across groups, in sequence order, with its group and frame numbers.
739
1240
  * Treat the returned frame bytes as read-only; they are shared with other consumers.
1241
+ *
1242
+ * Groups are acquired through the same sequence cursor as {@link Ordered.nextGroup},
1243
+ * so frames never run backwards: a late lower-sequence group is skipped, and so is
1244
+ * one every frame of which `maxAge` proves is too old. A group the budget abandons
1245
+ * mid-stall ends cleanly and the cursor resyncs from the next group; a gap inside a
1246
+ * group still surfaces as {@link TooFarBehind} or {@link GroupTooLarge}.
740
1247
  */
741
- async readFrameSequence() {
1248
+ async #readFrame() {
742
1249
  for (;;) {
743
- const groups = this.#state.groups.peek();
744
- const { start } = this.#cursor.peek();
745
- while (groups.length > 0 && groups[0].sequence < start)
746
- groups.shift()?.close();
747
- // Drain older groups first, dropping each once empty.
748
- while (groups.length > 1) {
749
- if (groups[0].skipped) {
750
- // The reader fell behind this group's eviction window. Drop it and
751
- // signal the gap; the next read resyncs from the following group.
752
- groups.shift()?.close();
753
- throw new Lagged();
754
- }
755
- const next = groups[0].tryReadFrameSequence();
756
- if (next) {
757
- return {
758
- group: groups[0].sequence,
759
- frame: next.sequence,
760
- payload: next.payload,
761
- timestamp: next.timestamp,
762
- };
763
- }
764
- groups.shift()?.close();
1250
+ if (!this.#frameGroup) {
1251
+ this.#frameGroup = await this.#nextGroup();
1252
+ if (!this.#frameGroup)
1253
+ return undefined;
1254
+ }
1255
+ const group = this.#frameGroup;
1256
+ let next;
1257
+ try {
1258
+ next = await group.readFrameSequence();
765
1259
  }
766
- if (groups.length === 0) {
1260
+ catch (err) {
1261
+ // The group failed underneath us: resync from the next one, surfacing
1262
+ // only what the caller can act on (a gap, or the track's own abort).
1263
+ this.#frameGroup = undefined;
1264
+ group.close();
1265
+ if (err instanceof TooFarBehind || err instanceof GroupTooLarge)
1266
+ throw err;
767
1267
  const closed = this.#state.closed.peek();
768
1268
  if (closed instanceof Error)
769
1269
  throw closed;
770
- if (closed !== undefined)
771
- return undefined;
772
- await Signal.race(this.#state.groups, this.#cursor, this.#state.closed);
773
1270
  continue;
774
1271
  }
775
- const group = groups[0];
776
- if (group.skipped) {
777
- // Fell behind this group's eviction window. Drop it and signal the gap;
778
- // the next read resyncs from the following group.
779
- groups.shift()?.close();
780
- throw new Lagged();
781
- }
782
- const next = group.tryReadFrameSequence();
783
- if (next)
1272
+ if (next) {
784
1273
  return {
785
1274
  group: group.sequence,
786
1275
  frame: next.sequence,
787
1276
  payload: next.payload,
788
1277
  timestamp: next.timestamp,
789
1278
  };
790
- const closed = this.#state.closed.peek();
791
- if (closed instanceof Error)
792
- throw closed;
793
- if (closed !== undefined)
794
- return undefined;
795
- // A finished (drained + closed) group has nothing left: drop it and loop, rather than
796
- // busy-waiting on its already-resolved readable() (which would livelock and starve the
797
- // macrotask that delivers the next group).
798
- if (group.done) {
799
- groups.shift()?.close();
800
- continue;
801
1279
  }
802
- // Lone open group with nothing buffered yet: wait for a frame on it, a new group, or
803
- // the track closing.
804
- await Promise.race([Signal.race(this.#state.groups, this.#cursor, this.#state.closed), group.readable()]);
1280
+ // The group is exhausted (or the budget abandoned its stall); move on.
1281
+ this.#frameGroup = undefined;
1282
+ group.close();
805
1283
  }
806
1284
  }
807
1285
  /** Reads the next frame and decodes it as a UTF-8 string. */
808
- async readString() {
809
- const next = await this.readFrame();
1286
+ async #readString() {
1287
+ const next = await this.#readFrame();
810
1288
  if (!next)
811
1289
  return undefined;
812
1290
  return new TextDecoder().decode(next.payload);
813
1291
  }
814
1292
  /** Reads the next frame and parses it as JSON. */
815
- async readJson() {
816
- const next = await this.readString();
1293
+ async #readJson() {
1294
+ const next = await this.#readString();
817
1295
  if (!next)
818
1296
  return undefined;
819
1297
  return JSON.parse(next);
820
1298
  }
821
1299
  /** Reads the next frame and decodes it as a one-byte boolean, throwing on a malformed frame. */
822
- async readBool() {
823
- const next = await this.readFrame();
1300
+ async #readBool() {
1301
+ const next = await this.#readFrame();
824
1302
  if (!next)
825
1303
  return undefined;
826
1304
  const payload = next.payload;
@@ -828,6 +1306,25 @@ export class Subscriber {
828
1306
  throw new Error("invalid bool frame");
829
1307
  return payload[0] === 1;
830
1308
  }
1309
+ /**
1310
+ * Read this track's groups in sequence order instead of arrival order.
1311
+ *
1312
+ * Both cursors draw from the same buffer, so a track is read one way or the other: this
1313
+ * hands the subscription to the returned {@link Ordered} and leaves this handle inert.
1314
+ * {@link recvGroup} and {@link recvDatagram} throw afterwards. Throws once a
1315
+ * {@link recvGroup} call has already committed the track to arrival order.
1316
+ *
1317
+ * Datagrams come along: they are a separate cursor either way, so the choice of group
1318
+ * order says nothing about them, and reading them commits nothing.
1319
+ */
1320
+ ordered() {
1321
+ if (this.#mode === "ordered")
1322
+ throw new Error("track is already read in sequence order");
1323
+ if (this.#mode === "arrival")
1324
+ throw new Error("track is already read in arrival order");
1325
+ this.#mode = "ordered";
1326
+ return makeOrdered(this);
1327
+ }
831
1328
  /**
832
1329
  * Update this subscription's options (e.g. priority), triggering a SUBSCRIBE_UPDATE to the
833
1330
  * publisher. Mirrors the Rust `Subscriber::update`.
@@ -836,4 +1333,118 @@ export class Subscriber {
836
1333
  this.#state.update.set(subscriptionDefaults(options));
837
1334
  }
838
1335
  }
1336
+ /**
1337
+ * A {@link Subscriber} that reads groups in sequence order.
1338
+ *
1339
+ * Created by {@link Subscriber.ordered}, which takes the subscription over: the two
1340
+ * cursors draw from one buffer, so a track is read one way or the other and never both.
1341
+ * Every group this returns has a higher sequence than the last, so a late arrival is
1342
+ * skipped rather than delivered out of turn.
1343
+ *
1344
+ * `maxAge` applies as this cursor reads, exactly as it does on the arrival cursor: a
1345
+ * group is skipped once its reach, where its immediate successor begins, is that far
1346
+ * behind the newest frame on the track. Nothing weaker convicts it, since the reach is
1347
+ * the only proof that every frame it could still hold is past the budget, so a backlog
1348
+ * inside the budget is still delivered whole, as a burst in order. The budget follows a
1349
+ * group already handed out too: a stalled group's pending frame read rejects once newer
1350
+ * content has pulled that far ahead.
1351
+ */
1352
+ export class Ordered {
1353
+ /** The track name. */
1354
+ name;
1355
+ #subscriber;
1356
+ constructor(subscriber) {
1357
+ this.name = subscriber.name;
1358
+ this.#subscriber = subscriber;
1359
+ }
1360
+ static {
1361
+ makeOrdered = (subscriber) => new Ordered(subscriber);
1362
+ }
1363
+ /** Resolve this track's immutable publisher properties; see {@link Subscriber.info}. */
1364
+ info() {
1365
+ return this.#subscriber.info();
1366
+ }
1367
+ /** Settles once the track closes; see {@link Producer.closed}. */
1368
+ get closed() {
1369
+ return this.#subscriber.closed;
1370
+ }
1371
+ /** This subscriber's current options, including defaults and the last {@link update}. */
1372
+ get subscription() {
1373
+ return this.#subscriber.subscription;
1374
+ }
1375
+ /** The latest group sequence observed on this track, if any. */
1376
+ latest() {
1377
+ return this.#subscriber.latest();
1378
+ }
1379
+ /** The track's exclusive final boundary; see {@link Subscriber.final}. */
1380
+ final() {
1381
+ return this.#subscriber.final();
1382
+ }
1383
+ /** Resolve with the track's exclusive final boundary once known; see {@link Subscriber.finished}. */
1384
+ finished() {
1385
+ return this.#subscriber.finished();
1386
+ }
1387
+ /** Limit subsequent reads to these groups and return this reader for chaining. */
1388
+ withGroups(groups) {
1389
+ this.setGroups(groups);
1390
+ return this;
1391
+ }
1392
+ /** Limit subsequent reads to these groups; see {@link Subscriber.setGroups}. */
1393
+ setGroups(groups) {
1394
+ this.#subscriber.setGroups(groups);
1395
+ }
1396
+ /** Update this subscription's options; see {@link Subscriber.update}. */
1397
+ update(options) {
1398
+ this.#subscriber.update(options);
1399
+ }
1400
+ /** Close the track (optionally with an error), closing any pending groups. Idempotent. */
1401
+ close(abort) {
1402
+ this.#subscriber.close(abort);
1403
+ }
1404
+ /**
1405
+ * Return the next group with a strictly-greater sequence number than the last returned.
1406
+ *
1407
+ * Late arrivals (sequence at or below the last returned) are silently skipped, as is a
1408
+ * group whose every frame is further behind the live edge than `maxAge` (the default of
1409
+ * zero keeps only what nothing newer has superseded). Honors the bounds set by
1410
+ * {@link setGroups}.
1411
+ */
1412
+ nextGroup() {
1413
+ return ordered_.nextGroup(this.#subscriber);
1414
+ }
1415
+ /**
1416
+ * Read the next frame across groups, in sequence order, with its group and frame numbers.
1417
+ *
1418
+ * Rides the same cursor as {@link nextGroup} and shares this handle's contract: a
1419
+ * buffered backlog is drained in full up to the point `maxAge` proves it useless.
1420
+ * Treat the returned frame bytes as read-only; they are shared with other consumers.
1421
+ */
1422
+ readFrame() {
1423
+ return ordered_.readFrame(this.#subscriber);
1424
+ }
1425
+ /** Read the next frame and decode it as a UTF-8 string. */
1426
+ readString() {
1427
+ return ordered_.readString(this.#subscriber);
1428
+ }
1429
+ /** Read the next frame and parse it as JSON. */
1430
+ readJson() {
1431
+ return ordered_.readJson(this.#subscriber);
1432
+ }
1433
+ /** Read the next frame and decode it as a one-byte boolean, throwing on a malformed frame. */
1434
+ readBool() {
1435
+ return ordered_.readBool(this.#subscriber);
1436
+ }
1437
+ /**
1438
+ * Receive the next datagram in arrival order.
1439
+ *
1440
+ * Datagrams are a separate best-effort channel from groups (see
1441
+ * {@link Producer.appendDatagram}); they share only the sequence namespace, and
1442
+ * neither cursor moves the other. Unordered by construction, so this behaves
1443
+ * identically on either handle; it is here so a track carrying both channels needs
1444
+ * one subscription rather than two.
1445
+ */
1446
+ recvDatagram() {
1447
+ return ordered_.recvDatagram(this.#subscriber);
1448
+ }
1449
+ }
839
1450
  //# sourceMappingURL=track.js.map