@blockcast/mmt-transport 0.2.2 → 0.3.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 (167) hide show
  1. package/README.md +44 -58
  2. package/dist/abr-controller.d.ts +34 -4
  3. package/dist/abr-controller.d.ts.map +1 -1
  4. package/dist/abr-controller.js +66 -17
  5. package/dist/abr-controller.js.map +1 -1
  6. package/dist/amt-gateway.d.ts.map +1 -1
  7. package/dist/amt-gateway.js +3 -4
  8. package/dist/amt-gateway.js.map +1 -1
  9. package/dist/driad-discovery.d.ts.map +1 -1
  10. package/dist/driad-discovery.js +3 -6
  11. package/dist/driad-discovery.js.map +1 -1
  12. package/dist/endpoint-config.d.ts +68 -0
  13. package/dist/endpoint-config.d.ts.map +1 -1
  14. package/dist/endpoint-config.js +118 -3
  15. package/dist/endpoint-config.js.map +1 -1
  16. package/dist/index.d.ts +10 -8
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +8 -11
  19. package/dist/index.js.map +1 -1
  20. package/dist/mmt-transport.no-modules.js +881 -60
  21. package/dist/mmt-transport.no-modules.js.map +4 -4
  22. package/dist/moqt-priority.d.ts +39 -0
  23. package/dist/moqt-priority.d.ts.map +1 -0
  24. package/dist/moqt-priority.js +41 -0
  25. package/dist/moqt-priority.js.map +1 -0
  26. package/dist/multicast-catalog-policy.d.ts +96 -0
  27. package/dist/multicast-catalog-policy.d.ts.map +1 -0
  28. package/dist/multicast-catalog-policy.js +660 -0
  29. package/dist/multicast-catalog-policy.js.map +1 -0
  30. package/dist/multicast-orchestrator.d.ts +24 -0
  31. package/dist/multicast-orchestrator.d.ts.map +1 -1
  32. package/dist/multicast-orchestrator.js +102 -9
  33. package/dist/multicast-orchestrator.js.map +1 -1
  34. package/dist/multicast-signaling-reconciler.d.ts +37 -0
  35. package/dist/multicast-signaling-reconciler.d.ts.map +1 -0
  36. package/dist/multicast-signaling-reconciler.js +161 -0
  37. package/dist/multicast-signaling-reconciler.js.map +1 -0
  38. package/dist/multicast-subscription-pool.d.ts.map +1 -1
  39. package/dist/multicast-subscription-pool.js +37 -10
  40. package/dist/multicast-subscription-pool.js.map +1 -1
  41. package/dist/multicast-tier.d.ts +19 -0
  42. package/dist/multicast-tier.d.ts.map +1 -1
  43. package/dist/multicast-tier.js +21 -4
  44. package/dist/multicast-tier.js.map +1 -1
  45. package/dist/no-modules.d.ts +40 -3
  46. package/dist/no-modules.d.ts.map +1 -1
  47. package/dist/no-modules.js +30 -3
  48. package/dist/no-modules.js.map +1 -1
  49. package/dist/packet-batch.d.ts +13 -0
  50. package/dist/packet-batch.d.ts.map +1 -0
  51. package/dist/packet-batch.js +14 -0
  52. package/dist/packet-batch.js.map +1 -0
  53. package/dist/stall-detector.d.ts +10 -0
  54. package/dist/stall-detector.d.ts.map +1 -1
  55. package/dist/stall-detector.js +21 -0
  56. package/dist/stall-detector.js.map +1 -1
  57. package/dist/takeover.d.ts +3 -0
  58. package/dist/takeover.d.ts.map +1 -1
  59. package/dist/takeover.js +35 -2
  60. package/dist/takeover.js.map +1 -1
  61. package/dist/transport-manager.d.ts +21 -17
  62. package/dist/transport-manager.d.ts.map +1 -1
  63. package/dist/transport-manager.js +63 -29
  64. package/dist/transport-manager.js.map +1 -1
  65. package/dist/types.d.ts +98 -123
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/types.js +12 -50
  68. package/dist/types.js.map +1 -1
  69. package/node_modules/@blockcast/mmt-alta-parse/README.md +181 -0
  70. package/node_modules/@blockcast/mmt-alta-parse/dist/.build-stamp +0 -0
  71. package/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts +57 -0
  72. package/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts.map +1 -0
  73. package/node_modules/@blockcast/mmt-alta-parse/dist/index.js +115 -0
  74. package/node_modules/@blockcast/mmt-alta-parse/dist/index.js.map +1 -0
  75. package/node_modules/@blockcast/mmt-alta-parse/package.json +50 -0
  76. package/node_modules/@blockcast/mmt-alta-parse/src/index.ts +136 -0
  77. package/node_modules/@blockcast/mmt-base/README.md +134 -0
  78. package/node_modules/@blockcast/mmt-base/dist/.build-stamp +0 -0
  79. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts +55 -0
  80. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts.map +1 -0
  81. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js +128 -0
  82. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js.map +1 -0
  83. package/node_modules/@blockcast/mmt-base/dist/index.d.ts +7 -0
  84. package/node_modules/@blockcast/mmt-base/dist/index.d.ts.map +1 -0
  85. package/node_modules/@blockcast/mmt-base/dist/index.js +7 -0
  86. package/node_modules/@blockcast/mmt-base/dist/index.js.map +1 -0
  87. package/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts +292 -0
  88. package/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts.map +1 -0
  89. package/node_modules/@blockcast/mmt-base/dist/mmtp.js +612 -0
  90. package/node_modules/@blockcast/mmt-base/dist/mmtp.js.map +1 -0
  91. package/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts +18 -0
  92. package/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts.map +1 -0
  93. package/node_modules/@blockcast/mmt-base/dist/net-constants.js +18 -0
  94. package/node_modules/@blockcast/mmt-base/dist/net-constants.js.map +1 -0
  95. package/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts +47 -0
  96. package/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts.map +1 -0
  97. package/node_modules/@blockcast/mmt-base/dist/ntp-short.js +54 -0
  98. package/node_modules/@blockcast/mmt-base/dist/ntp-short.js.map +1 -0
  99. package/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts +57 -0
  100. package/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts.map +1 -0
  101. package/node_modules/@blockcast/mmt-base/dist/serial-number.js +98 -0
  102. package/node_modules/@blockcast/mmt-base/dist/serial-number.js.map +1 -0
  103. package/node_modules/@blockcast/mmt-base/dist/signalling.d.ts +312 -0
  104. package/node_modules/@blockcast/mmt-base/dist/signalling.d.ts.map +1 -0
  105. package/node_modules/@blockcast/mmt-base/dist/signalling.js +637 -0
  106. package/node_modules/@blockcast/mmt-base/dist/signalling.js.map +1 -0
  107. package/node_modules/@blockcast/mmt-base/package.json +49 -0
  108. package/node_modules/@blockcast/mmt-base/src/fec-block-profile.ts +145 -0
  109. package/node_modules/@blockcast/mmt-base/src/index.ts +6 -0
  110. package/node_modules/@blockcast/mmt-base/src/mmtp.ts +892 -0
  111. package/node_modules/@blockcast/mmt-base/src/net-constants.ts +17 -0
  112. package/node_modules/@blockcast/mmt-base/src/ntp-short.ts +67 -0
  113. package/node_modules/@blockcast/mmt-base/src/serial-number.ts +107 -0
  114. package/node_modules/@blockcast/mmt-base/src/signalling.ts +862 -0
  115. package/package.json +13 -6
  116. package/src/abr-controller.ts +81 -17
  117. package/src/amt-gateway.ts +3 -5
  118. package/src/driad-discovery.ts +3 -6
  119. package/src/endpoint-config.ts +157 -3
  120. package/src/index.ts +54 -33
  121. package/src/moqt-priority.ts +51 -0
  122. package/src/multicast-catalog-policy.ts +753 -0
  123. package/src/multicast-orchestrator.ts +114 -10
  124. package/src/multicast-signaling-reconciler.ts +156 -0
  125. package/src/multicast-subscription-pool.ts +46 -11
  126. package/src/multicast-tier.ts +30 -3
  127. package/src/no-modules.ts +54 -2
  128. package/src/packet-batch.ts +17 -0
  129. package/src/stall-detector.ts +37 -0
  130. package/src/takeover.ts +41 -3
  131. package/src/transport-manager.ts +64 -34
  132. package/src/types.ts +103 -160
  133. package/dist/endpoint-config.test.d.ts +0 -2
  134. package/dist/endpoint-config.test.d.ts.map +0 -1
  135. package/dist/endpoint-config.test.js +0 -39
  136. package/dist/endpoint-config.test.js.map +0 -1
  137. package/dist/fec-client.d.ts +0 -442
  138. package/dist/fec-client.d.ts.map +0 -1
  139. package/dist/fec-client.js +0 -789
  140. package/dist/fec-client.js.map +0 -1
  141. package/dist/fec-client.test.d.ts +0 -8
  142. package/dist/fec-client.test.d.ts.map +0 -1
  143. package/dist/fec-client.test.js +0 -112
  144. package/dist/fec-client.test.js.map +0 -1
  145. package/dist/multicast-subscription-pool.test.d.ts +0 -2
  146. package/dist/multicast-subscription-pool.test.d.ts.map +0 -1
  147. package/dist/multicast-subscription-pool.test.js +0 -108
  148. package/dist/multicast-subscription-pool.test.js.map +0 -1
  149. package/dist/multicast-tier.test.d.ts +0 -2
  150. package/dist/multicast-tier.test.d.ts.map +0 -1
  151. package/dist/multicast-tier.test.js +0 -75
  152. package/dist/multicast-tier.test.js.map +0 -1
  153. package/dist/stall-detector.test.d.ts +0 -2
  154. package/dist/stall-detector.test.d.ts.map +0 -1
  155. package/dist/stall-detector.test.js +0 -33
  156. package/dist/stall-detector.test.js.map +0 -1
  157. package/dist/transport-manager.test.d.ts +0 -2
  158. package/dist/transport-manager.test.d.ts.map +0 -1
  159. package/dist/transport-manager.test.js +0 -155
  160. package/dist/transport-manager.test.js.map +0 -1
  161. package/src/endpoint-config.test.ts +0 -54
  162. package/src/fec-client.test.ts +0 -140
  163. package/src/fec-client.ts +0 -1103
  164. package/src/multicast-subscription-pool.test.ts +0 -115
  165. package/src/multicast-tier.test.ts +0 -80
  166. package/src/stall-detector.test.ts +0 -34
  167. package/src/transport-manager.test.ts +0 -192
@@ -1,4 +1,5 @@
1
- import { endpointKey, selectMulticastTier, type MulticastTierEndpoint } from "./multicast-tier.js";
1
+ import { compareNtpShort, isNtpShort } from "@blockcast/mmt-base";
2
+ import { carriesMedia, endpointKey, selectMulticastTier, type MulticastTierEndpoint } from "./multicast-tier.js";
2
3
 
3
4
  export type OrchestratorState = "stable" | "joining";
4
5
  export type OrchestratorCommand =
@@ -9,6 +10,16 @@ export type OrchestratorCommand =
9
10
  export interface MulticastTierPacket {
10
11
  endpointKey: string;
11
12
  keyframe: boolean;
13
+ /**
14
+ * The packet's MMTP header `timestamp`: its UTC sending instant in NTP short
15
+ * format (ISO/IEC 23008-1 §9.2.2), which wraps every 2^16 s (~18.2 h). The
16
+ * orchestrator orders these only with RFC 1982 serial arithmetic
17
+ * (`compareNtpShort`), never with `<` or `Math.max`.
18
+ *
19
+ * A non-finite value means the host could not read a header; that packet
20
+ * neither raises the high-water mark nor commits. Any other value that is
21
+ * not NTP short is a host bug and throws a RangeError.
22
+ */
12
23
  timestamp: number;
13
24
  hasValidCodecConfig: boolean;
14
25
  }
@@ -23,6 +34,7 @@ export interface OrchestratorOptions {
23
34
  }
24
35
 
25
36
  export interface GoodputSample { bitrate: number | undefined; }
37
+ export interface HeadroomSample { healthy: boolean; }
26
38
 
27
39
  export const DEFAULT_DWELL_MS = 3_000;
28
40
  export const DEFAULT_JOIN_DEADLINE_MS = 5_000;
@@ -44,9 +56,14 @@ export class MulticastOrchestrator {
44
56
  #current: MulticastTierEndpoint | undefined;
45
57
  #target: MulticastTierEndpoint | undefined;
46
58
  #estimate: number | undefined;
47
- #favored: { key: string; since: number } | undefined;
59
+ #favored: { key: string; since: number; source: "goodput" | "headroom" } | undefined;
48
60
  #joinStartedAt = 0;
49
- #lastCommittedTimestamp = Number.NEGATIVE_INFINITY;
61
+ /**
62
+ * Latest active-tier send time (NTP short), or undefined before the first.
63
+ * "Latest" is serial order, not the numerically largest value: after the
64
+ * field wraps, the largest value is the stale one.
65
+ */
66
+ #highWater: number | undefined;
50
67
  readonly #dwellMs: number;
51
68
  readonly #joinDeadlineMs: number;
52
69
 
@@ -71,20 +88,70 @@ export class MulticastOrchestrator {
71
88
 
72
89
  onGoodput(sample: GoodputSample): OrchestratorCommand | undefined {
73
90
  const bitrate = sample.bitrate;
74
- this.#estimate = bitrate;
75
91
  if (!this.#current || this.options.adaptive === false) return undefined;
76
92
  if (this.#state === "joining") return this.poll();
93
+ if (bitrate === undefined || !Number.isFinite(bitrate)) return undefined;
94
+ this.#estimate = bitrate;
77
95
  const desired = selectMulticastTier(this.options.endpoints, bitrate);
78
96
  if (!desired) return undefined;
79
97
  const desiredKey = endpointKey(desired);
80
98
  if (desiredKey === endpointKey(this.#current)) {
99
+ if (this.#favored?.source === "goodput") this.#favored = undefined;
100
+ return undefined;
101
+ }
102
+ if (typeof desired.bandwidth !== "number" || typeof this.#current.bandwidth !== "number" ||
103
+ desired.bandwidth >= this.#current.bandwidth) {
104
+ if (this.#favored?.source === "goodput") this.#favored = undefined;
105
+ return undefined;
106
+ }
107
+
108
+ const now = this.#now();
109
+ if (!this.#favored || this.#favored.key !== desiredKey || this.#favored.source !== "goodput") {
110
+ this.#favored = { key: desiredKey, since: now, source: "goodput" };
111
+ return undefined;
112
+ }
113
+ if (now - this.#favored.since < this.#dwellMs) return undefined;
114
+
115
+ this.#state = "joining";
116
+ this.#target = desired;
117
+ this.#joinStartedAt = now;
118
+ this.#favored = undefined;
119
+ return { kind: "join-target", current: this.#current, target: desired };
120
+ }
121
+
122
+ onHeadroom(sample: HeadroomSample): OrchestratorCommand | undefined {
123
+ if (!this.#current || this.options.adaptive === false || this.#state === "joining") return undefined;
124
+ if (!sample.healthy) {
125
+ if (this.#favored?.source === "headroom") this.#favored = undefined;
126
+ return undefined;
127
+ }
128
+ if (this.#favored?.source === "goodput") return undefined;
129
+
130
+ const currentBandwidth = this.#current.bandwidth;
131
+ if (typeof currentBandwidth !== "number") return undefined;
132
+ // Ranked here rather than through selectMulticastTier: up-selection
133
+ // advances exactly one adjacent rung, where the selector fits a budget to
134
+ // an estimate. The media filter is shared so a repair-only endpoint above
135
+ // the current rung is not joined as an upgrade, which would swap the
136
+ // receiver onto repair symbols and no media.
137
+ const desired = this.options.endpoints
138
+ .filter((endpoint) =>
139
+ carriesMedia(endpoint) && typeof endpoint.bandwidth === "number" && endpoint.bandwidth > currentBandwidth,
140
+ )
141
+ .sort((a, b) => a.bandwidth! - b.bandwidth!)[0];
142
+ if (!desired) {
81
143
  this.#favored = undefined;
82
144
  return undefined;
83
145
  }
84
146
 
147
+ const desiredKey = endpointKey(desired);
148
+ if (desiredKey === endpointKey(this.#current)) {
149
+ this.#favored = undefined;
150
+ return undefined;
151
+ }
85
152
  const now = this.#now();
86
153
  if (!this.#favored || this.#favored.key !== desiredKey) {
87
- this.#favored = { key: desiredKey, since: now };
154
+ this.#favored = { key: desiredKey, since: now, source: "headroom" };
88
155
  return undefined;
89
156
  }
90
157
  if (now - this.#favored.since < this.#dwellMs) return undefined;
@@ -96,10 +163,25 @@ export class MulticastOrchestrator {
96
163
  return { kind: "join-target", current: this.#current, target: desired };
97
164
  }
98
165
 
166
+ /**
167
+ * Feed a packet from the active tier (to advance the high-water mark) or
168
+ * from the in-flight target (to find a committable RAP).
169
+ *
170
+ * Serial order is only the true order while the compared instants are less
171
+ * than `NTP_SHORT_HALF_RANGE` (2^15 s, ~9.1 h) apart. Feeding every
172
+ * active-tier packet keeps the mark that close to the live stream. A mark
173
+ * refreshed only by commits can fall further behind than that, and a
174
+ * target RAP may then compare as older, so the join is abandoned.
175
+ */
99
176
  onTierPacket(packet: MulticastTierPacket): OrchestratorCommand | undefined {
100
177
  if (!this.#current) return undefined;
101
- if (packet.endpointKey === endpointKey(this.#current) && Number.isFinite(packet.timestamp)) {
102
- this.#lastCommittedTimestamp = Math.max(this.#lastCommittedTimestamp, packet.timestamp);
178
+ const sentAt = tierPacketSendTime(packet);
179
+ // An instant exactly half the space from the mark (RFC 1982's undefined
180
+ // case) is neither later nor earlier, so it leaves the mark alone, as a
181
+ // reordered earlier packet does.
182
+ if (packet.endpointKey === endpointKey(this.#current) && sentAt !== undefined &&
183
+ (this.#highWater === undefined || compareNtpShort(sentAt, this.#highWater) === "after")) {
184
+ this.#highWater = sentAt;
103
185
  }
104
186
  if (this.#state !== "joining" || !this.#target) return undefined;
105
187
  const expired = this.poll();
@@ -108,8 +190,8 @@ export class MulticastOrchestrator {
108
190
  packet.endpointKey !== endpointKey(this.#target) ||
109
191
  !packet.keyframe ||
110
192
  !packet.hasValidCodecConfig ||
111
- !Number.isFinite(packet.timestamp) ||
112
- packet.timestamp < this.#lastCommittedTimestamp
193
+ sentAt === undefined ||
194
+ !this.#reachesHighWater(sentAt)
113
195
  ) return undefined;
114
196
 
115
197
  const command: OrchestratorCommand = {
@@ -117,7 +199,7 @@ export class MulticastOrchestrator {
117
199
  target: this.#target,
118
200
  old: this.#current,
119
201
  };
120
- this.#lastCommittedTimestamp = packet.timestamp;
202
+ this.#highWater = sentAt;
121
203
  this.#current = this.#target;
122
204
  this.#target = undefined;
123
205
  this.#state = "stable";
@@ -142,9 +224,31 @@ export class MulticastOrchestrator {
142
224
  return command;
143
225
  }
144
226
 
227
+ /**
228
+ * Whether a target RAP sent at `sentAt` is at or beyond the active tier's
229
+ * high-water mark. A pair exactly half the NTP-short space apart has no
230
+ * serial order, so it cannot show that and does not commit.
231
+ */
232
+ #reachesHighWater(sentAt: number): boolean {
233
+ if (this.#highWater === undefined) return true;
234
+ const order = compareNtpShort(sentAt, this.#highWater);
235
+ return order === "after" || order === "equal";
236
+ }
237
+
145
238
  #now(): number {
146
239
  const now = this.options.now();
147
240
  if (!Number.isFinite(now)) throw new Error("Multicast orchestrator clock must be finite");
148
241
  return now;
149
242
  }
150
243
  }
244
+
245
+ /** A tier packet's NTP-short send time, or undefined when the host had no header to read. */
246
+ function tierPacketSendTime(packet: MulticastTierPacket): number | undefined {
247
+ if (!Number.isFinite(packet.timestamp)) return undefined;
248
+ if (!isNtpShort(packet.timestamp)) {
249
+ throw new RangeError(
250
+ `Multicast tier packet timestamp must be an MMTP NTP-short send time, got ${packet.timestamp}`,
251
+ );
252
+ }
253
+ return packet.timestamp;
254
+ }
@@ -0,0 +1,156 @@
1
+ import { isMulticastTrackPacketId, isValidMulticastGroupAddress } from "./endpoint-config.js";
2
+
3
+ export interface MulticastSignalingEndpoint {
4
+ endpointId: string;
5
+ endpointVersion: number;
6
+ protocol: "ssm" | "asm" | "amt";
7
+ sourceAddress?: string;
8
+ groupAddress: string;
9
+ port: number;
10
+ validUntil?: number;
11
+ tracks: Array<{ name: string; packetId?: number; [key: string]: unknown }>;
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ type Catalog = Record<string, any>;
16
+ type Signal = Record<string, any>;
17
+
18
+ export function createMulticastSignalingReconciler(catalog: Catalog, options: { catalog?: Catalog } = {}) {
19
+ const initial = normalizeCatalogEndpoints(catalog);
20
+ const endpoints = new Map(initial.map(endpoint => [endpoint.endpointId, copyEndpoint(endpoint)]));
21
+ const versions = new Map(initial.map(endpoint => [endpoint.endpointId, endpoint.endpointVersion]));
22
+ const seen = new Set<string>();
23
+ const dormant = new Set<string>();
24
+ let catalogVersion = Number(catalog?.multicast?.version ?? catalog?.version ?? 0) || 0;
25
+ const snapshot = (now = Date.now()) => ({
26
+ catalogVersion,
27
+ endpoints: [...endpoints.values()].filter(endpoint => !dormant.has(endpoint.endpointId) && !isExpired(endpoint, now)).map(copyEndpoint),
28
+ dormantEndpointIds: [...dormant].sort(),
29
+ });
30
+
31
+ return {
32
+ apply(signal: Signal, now = Date.now()) {
33
+ if (!isObject(signal) || typeof signal.type !== "string" || typeof signal.msgId !== "string") return { applied: false, reason: "invalid-message" };
34
+ if (seen.has(signal.msgId)) return { applied: false, reason: "duplicate-message" };
35
+ if (signal.expiresAt != null && Number(signal.expiresAt) < now) return { applied: false, reason: "expired" };
36
+ const signalCatalogVersion = Number(signal.catalogVersion ?? 0);
37
+ if (!Number.isFinite(signalCatalogVersion)) return { applied: false, reason: "invalid-message" };
38
+ if (signalCatalogVersion < catalogVersion) return { applied: false, reason: "stale-catalog" };
39
+ const result = applyFresh(signal, now);
40
+ if (result.applied) {
41
+ seen.add(signal.msgId);
42
+ // A heartbeat proves liveness but carries no catalog state. Ratcheting
43
+ // here would incorrectly reject the next real snapshot as stale.
44
+ if (signal.type !== "heartbeat") {
45
+ catalogVersion = Math.max(catalogVersion, signalCatalogVersion);
46
+ }
47
+ }
48
+ return result;
49
+ },
50
+ snapshot,
51
+ toCatalog(now = Date.now()) {
52
+ const current = snapshot(now);
53
+ return { ...catalog, multicast: { ...catalog.multicast, version: current.catalogVersion, endpoints: current.endpoints } };
54
+ },
55
+ };
56
+
57
+ function applyFresh(signal: Signal, now: number): { applied: boolean; reason?: string; action?: string } {
58
+ switch (signal.type) {
59
+ case "snapshot": {
60
+ const next = signal.payload?.endpoints;
61
+ if (!Array.isArray(next)) return { applied: false, reason: "invalid-message" };
62
+ const normalized = next.map((endpoint, index) => normalizeEndpoint(endpoint, index));
63
+ if (normalized.some(endpoint => !endpoint || !isValidEndpoint(endpoint, options.catalog))) return { applied: false, reason: "invalid-endpoint" };
64
+ const ids = new Set(normalized.map(endpoint => endpoint!.endpointId));
65
+ if (ids.size !== normalized.length) return { applied: false, reason: "duplicate-endpoint" };
66
+ endpoints.clear(); versions.clear(); dormant.clear();
67
+ for (const endpoint of normalized as MulticastSignalingEndpoint[]) {
68
+ versions.set(endpoint.endpointId, endpoint.endpointVersion);
69
+ if (!isExpired(endpoint, now)) endpoints.set(endpoint.endpointId, copyEndpoint(endpoint));
70
+ }
71
+ return { applied: true, action: "snapshot" };
72
+ }
73
+ case "add": return upsert(signal.payload?.endpoint, now);
74
+ case "update": {
75
+ const id = signal.payload?.endpointId;
76
+ const fields = signal.payload?.fields;
77
+ if (!endpoints.has(id)) return { applied: false, reason: "unknown-endpoint" };
78
+ if (isStale(id, signal.payload?.endpointVersion)) return { applied: false, reason: "stale-endpoint" };
79
+ if (!isObject(fields) || ["protocol", "sourceAddress", "groupAddress", "port"].some(key => Object.hasOwn(fields, key))) return { applied: false, reason: "invalid-update" };
80
+ return upsert({ ...endpoints.get(id), ...fields, endpointVersion: signal.payload.endpointVersion }, now);
81
+ }
82
+ case "remove": {
83
+ const id = signal.payload?.endpointId;
84
+ if (!endpoints.has(id)) return { applied: false, reason: "unknown-endpoint" };
85
+ const endpointVersion = Number(signal.payload?.endpointVersion);
86
+ if (!Number.isFinite(endpointVersion)) return { applied: false, reason: "invalid-message" };
87
+ if (isStale(id, endpointVersion)) return { applied: false, reason: "stale-endpoint" };
88
+ endpoints.delete(id); dormant.delete(id); versions.set(id, endpointVersion);
89
+ return { applied: true, action: "drop" };
90
+ }
91
+ case "invalidate": {
92
+ const id = signal.payload?.endpointId;
93
+ if (!endpoints.has(id)) return { applied: false, reason: "unknown-endpoint" };
94
+ dormant.add(id);
95
+ return { applied: true, action: "drop" };
96
+ }
97
+ case "heartbeat": return { applied: true, action: "heartbeat" };
98
+ default: return { applied: false, reason: "unknown-message-type" };
99
+ }
100
+ }
101
+
102
+ function upsert(value: unknown, now: number) {
103
+ const endpoint = normalizeEndpoint(value, endpoints.size);
104
+ if (!endpoint || !isValidEndpoint(endpoint, options.catalog)) return { applied: false, reason: "invalid-endpoint" };
105
+ if (isStale(endpoint.endpointId, endpoint.endpointVersion)) return { applied: false, reason: "stale-endpoint" };
106
+ versions.set(endpoint.endpointId, endpoint.endpointVersion);
107
+ dormant.delete(endpoint.endpointId);
108
+ if (isExpired(endpoint, now)) endpoints.delete(endpoint.endpointId);
109
+ else endpoints.set(endpoint.endpointId, copyEndpoint(endpoint));
110
+ return { applied: true, action: isExpired(endpoint, now) ? "drop" : "candidate" };
111
+ }
112
+
113
+ function isStale(id: string, version: unknown) {
114
+ const current = versions.get(id);
115
+ return current !== undefined && Number(version) <= current;
116
+ }
117
+ }
118
+
119
+ export function normalizeCatalogEndpoints(catalog: Catalog): MulticastSignalingEndpoint[] {
120
+ const endpoints = catalog?.multicast?.endpoints;
121
+ if (!Array.isArray(endpoints)) return [];
122
+ return endpoints
123
+ .map(normalizeEndpoint)
124
+ .filter((endpoint): endpoint is MulticastSignalingEndpoint => !!endpoint && isValidEndpoint(endpoint, catalog));
125
+ }
126
+
127
+ function normalizeEndpoint(value: unknown, index: number): MulticastSignalingEndpoint | undefined {
128
+ if (!isObject(value)) return undefined;
129
+ const endpoint = value as Record<string, any>;
130
+ const tracks = Array.isArray(endpoint.tracks) ? endpoint.tracks.map(track => typeof track === "string" ? { name: track } : { ...track }).filter(track => track.name) : [];
131
+ return { ...endpoint, endpointId: endpoint.endpointId || `${endpoint.sourceAddress || ""}|${endpoint.groupAddress || ""}|${endpoint.port || ""}|${index}`, endpointVersion: Number(endpoint.endpointVersion ?? 0), tracks } as MulticastSignalingEndpoint;
132
+ }
133
+
134
+ function isValidEndpoint(endpoint: MulticastSignalingEndpoint, catalog?: Catalog): boolean {
135
+ if (!endpoint.endpointId || !Number.isFinite(endpoint.endpointVersion)) return false;
136
+ if (!["ssm", "asm", "amt"].includes(endpoint.protocol)) return false;
137
+ if (endpoint.protocol === "ssm" && (typeof endpoint.sourceAddress !== "string" || endpoint.sourceAddress.trim().length === 0)) return false;
138
+ if (!isValidMulticastGroupAddress(endpoint.groupAddress) || !Number.isInteger(Number(endpoint.port)) || endpoint.port <= 0 || endpoint.port > 65535 || !endpoint.tracks.length) return false;
139
+ const catalogTracks = new Set([...Object.keys(catalog?.video?.renditions ?? {}), ...Object.keys(catalog?.audio?.renditions ?? {})]);
140
+ // Every track must carry a valid packet_id (isMulticastTrackPacketId); one
141
+ // that does not -- including packet_id 0, the MMTP signaling flow --
142
+ // rejects the whole endpoint rather than silently dropping that track.
143
+ return endpoint.tracks.every(track => !!track.name && isMulticastTrackPacketId(track.packetId) && (catalogTracks.size === 0 || catalogTracks.has(track.name) || catalogTracks.has(track.name.replace(/\/repair$/, ""))));
144
+ }
145
+
146
+ function copyEndpoint(endpoint: MulticastSignalingEndpoint): MulticastSignalingEndpoint {
147
+ return { ...endpoint, tracks: endpoint.tracks.map(track => ({ ...track })) };
148
+ }
149
+
150
+ function isExpired(endpoint: MulticastSignalingEndpoint, now: number): boolean {
151
+ return endpoint.validUntil != null && Number(endpoint.validUntil) < now;
152
+ }
153
+
154
+ function isObject(value: unknown): value is Record<string, unknown> {
155
+ return value !== null && typeof value === "object";
156
+ }
@@ -1,4 +1,9 @@
1
- import { multicastEndpointKey, normalizeMulticastEndpoint } from "./endpoint-config.js";
1
+ import {
2
+ multicastEndpointKey,
3
+ normalizeMulticastEndpoint,
4
+ toWindowMulticastSubscribeConfig,
5
+ } from "./endpoint-config.js";
6
+ import { subscriptionDeliversPacketBatches } from "./packet-batch.js";
2
7
  import type {
3
8
  TransportConfig,
4
9
  WindowMulticastGateway,
@@ -15,7 +20,13 @@ export interface MulticastSubscriptionConsumer {
15
20
  interface PoolEntry {
16
21
  subscription: WindowMulticastSubscription;
17
22
  consumers: Map<symbol, MulticastSubscriptionConsumer>;
18
- bytesListener: (event: CustomEvent<ArrayBuffer>) => void;
23
+ /**
24
+ * Exactly one packet surface is listened to: `packets` when the provider
25
+ * advertises it, `bytes` otherwise (BLO-36599).
26
+ */
27
+ packetListener:
28
+ | { type: "bytes"; listener: (event: CustomEvent<ArrayBuffer>) => void }
29
+ | { type: "packets"; listener: (event: CustomEvent<readonly ArrayBuffer[]>) => void };
19
30
  stateListener: (event: CustomEvent<WindowMulticastSubscriptionState>) => void;
20
31
  errorListener: (event: CustomEvent<unknown>) => void;
21
32
  closed: boolean;
@@ -103,20 +114,35 @@ export class MulticastSubscriptionPool {
103
114
  endpoint: NonNullable<ReturnType<typeof normalizeMulticastEndpoint>>,
104
115
  onClosed: (entry: PoolEntry) => void,
105
116
  ): Promise<PoolEntry> {
106
- const subscription = await gateway.subscribe!({
117
+ const subscription = await gateway.subscribe!(toWindowMulticastSubscribeConfig({
107
118
  group: endpoint.groupAddress,
108
119
  ...(endpoint.sourceAddress ? { source: endpoint.sourceAddress } : {}),
109
120
  port: endpoint.port,
110
- consume: "bytes",
111
121
  ...(endpoint.mode ? { mode: endpoint.mode } : {}),
112
122
  ...(endpoint.amtRelay ? { amtRelay: endpoint.amtRelay } : {}),
113
123
  ...(endpoint.amtRelayPort ? { amtRelayPort: endpoint.amtRelayPort } : {}),
114
- });
124
+ }));
115
125
  const consumers = new Map<symbol, MulticastSubscriptionConsumer>();
116
126
  let entry: PoolEntry;
117
- const bytesListener = (event: CustomEvent<ArrayBuffer>) => {
118
- this.#dispatch(consumers, (consumer) => consumer.onBytes(event.detail));
119
- };
127
+ // A batch is delivered as the same per-packet onBytes calls the bytes
128
+ // surface makes, in arrival order and with each call isolated, so a
129
+ // consumer cannot tell the two apart; only the DOM event per packet is
130
+ // gone. The batch array and its elements are the provider's own, uncopied.
131
+ const packetListener: PoolEntry["packetListener"] = subscriptionDeliversPacketBatches(subscription)
132
+ ? {
133
+ type: "packets",
134
+ listener: (event) => {
135
+ for (const data of event.detail) {
136
+ this.#dispatch(consumers, (consumer) => consumer.onBytes(data));
137
+ }
138
+ },
139
+ }
140
+ : {
141
+ type: "bytes",
142
+ listener: (event) => {
143
+ this.#dispatch(consumers, (consumer) => consumer.onBytes(event.detail));
144
+ },
145
+ };
120
146
  const stateListener = (event: CustomEvent<WindowMulticastSubscriptionState>) => {
121
147
  this.#dispatch(consumers, (consumer) => consumer.onState?.(event.detail));
122
148
  if (event.detail === "closed" && !entry.closed) {
@@ -128,8 +154,12 @@ export class MulticastSubscriptionPool {
128
154
  const error = event.detail instanceof Error ? event.detail : new Error(String(event.detail));
129
155
  this.#dispatch(consumers, (consumer) => consumer.onError?.(error));
130
156
  };
131
- entry = { subscription, consumers, bytesListener, stateListener, errorListener, closed: false };
132
- subscription.addEventListener("bytes", bytesListener);
157
+ entry = { subscription, consumers, packetListener, stateListener, errorListener, closed: false };
158
+ if (packetListener.type === "packets") {
159
+ subscription.addEventListener("packets", packetListener.listener);
160
+ } else {
161
+ subscription.addEventListener("bytes", packetListener.listener);
162
+ }
133
163
  subscription.addEventListener("state", stateListener);
134
164
  subscription.addEventListener("error", errorListener);
135
165
  return entry;
@@ -146,7 +176,12 @@ export class MulticastSubscriptionPool {
146
176
  }
147
177
 
148
178
  #detach(entry: PoolEntry): void {
149
- entry.subscription.removeEventListener("bytes", entry.bytesListener);
179
+ const { packetListener } = entry;
180
+ if (packetListener.type === "packets") {
181
+ entry.subscription.removeEventListener("packets", packetListener.listener);
182
+ } else {
183
+ entry.subscription.removeEventListener("bytes", packetListener.listener);
184
+ }
150
185
  entry.subscription.removeEventListener("state", entry.stateListener);
151
186
  entry.subscription.removeEventListener("error", entry.errorListener);
152
187
  }
@@ -5,22 +5,49 @@ export interface MulticastTierEndpoint {
5
5
  tracks: { name: string; packetId?: number }[];
6
6
  bandwidth?: number;
7
7
  protocol?: string;
8
+ /** False when every track on this endpoint is a `fec-repair` track, so the
9
+ * endpoint carries repair symbols only and no media. A repair-layer
10
+ * endpoint is joined by the loss-driven layer policy, never by the
11
+ * bandwidth ladder: selecting one as a tier would leave the receiver with
12
+ * no media at all. Set by the catalog policy, which resolves repair-track
13
+ * membership from `depends`/`fec.repairTrack` rather than from names.
14
+ * Undefined means "not classified" and is treated as carrying media, so a
15
+ * caller that builds endpoints without the policy is unaffected. */
16
+ carriesMedia?: boolean;
8
17
  }
9
18
 
10
19
  export function endpointKey(endpoint: MulticastTierEndpoint): string {
11
20
  return `${endpoint.sourceAddress ?? ""}|${endpoint.groupAddress}|${endpoint.port}`;
12
21
  }
13
22
 
23
+ /**
24
+ * Whether this endpoint may be selected as a media tier.
25
+ *
26
+ * Every path that picks a tier must apply this, not only `selectMulticastTier`:
27
+ * the orchestrator's headroom up-selection ranks endpoints itself, because it
28
+ * advances one adjacent rung rather than fitting a bandwidth budget. Exported
29
+ * so both rankings share one definition of "carries media" instead of
30
+ * repeating the comparison and drifting apart.
31
+ */
32
+ export function carriesMedia(endpoint: MulticastTierEndpoint): boolean {
33
+ return endpoint.carriesMedia !== false;
34
+ }
35
+
14
36
  export function selectMulticastTier(
15
37
  endpoints: readonly MulticastTierEndpoint[],
16
38
  estimate: number | undefined,
17
39
  ): MulticastTierEndpoint | undefined {
18
- if (endpoints.length <= 1) return endpoints[0];
40
+ // Repair-only endpoints are never a media tier. Filtering once here covers
41
+ // every return path below, including the two early returns and the fallback:
42
+ // a ladder that has only repair-layer endpoints returns undefined rather
43
+ // than joining one and receiving no media.
44
+ const media = endpoints.filter(carriesMedia);
45
+ if (media.length <= 1) return media[0];
19
46
 
20
- const ranked = endpoints
47
+ const ranked = media
21
48
  .filter((endpoint) => typeof endpoint.bandwidth === "number" && endpoint.bandwidth > 0)
22
49
  .sort((a, b) => a.bandwidth! - b.bandwidth!);
23
- if (ranked.length < 2) return endpoints[0];
50
+ if (ranked.length < 2) return media[0];
24
51
  if (estimate === undefined) return ranked[0];
25
52
 
26
53
  const budget = estimate * 0.8;
package/src/no-modules.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AdaptiveBitrateController } from "./abr-controller.js";
1
+ import { AdaptiveBitrateController, validateQualityLadder } from "./abr-controller.js";
2
2
  import {
3
3
  isMulticastExplicitlyDisabled,
4
4
  isUsableMulticastEndpoint,
@@ -6,19 +6,60 @@ import {
6
6
  multicastEndpointKey,
7
7
  normalizeMulticastEndpoint,
8
8
  readUrlMulticastConfig,
9
+ toWindowMulticastSubscribeConfig,
9
10
  } from "./endpoint-config.js";
10
11
  import { MulticastOrchestrator } from "./multicast-orchestrator.js";
12
+ import {
13
+ endpointToMulticastConfig,
14
+ hasMulticastCatalogBaseline,
15
+ hasUnicastMediaRenditions,
16
+ isMulticastOnlyCatalog,
17
+ isRecoverableMulticastSignalReadError,
18
+ mergeUrlMulticastPolicy,
19
+ normalizeMulticastConfig,
20
+ readPlayerMulticastConfig,
21
+ requiresMulticastCapability,
22
+ sameMulticastConfig,
23
+ selectEndpointPacketIds,
24
+ selectMulticastCandidate,
25
+ catalogQualityLadder,
26
+ urlOverrideBlocksMulticastCandidate,
27
+ withMulticastEndpoints,
28
+ } from "./multicast-catalog-policy.js";
29
+ import {
30
+ createMulticastSignalingReconciler,
31
+ normalizeCatalogEndpoints,
32
+ } from "./multicast-signaling-reconciler.js";
11
33
  import { MulticastSubscriptionPool } from "./multicast-subscription-pool.js";
12
34
  import { endpointKey, selectMulticastTier } from "./multicast-tier.js";
13
- import { TransportStallDetector } from "./stall-detector.js";
35
+ import {
36
+ MOQT_PUBLISHER_PRIORITY,
37
+ MOQT_SUBSCRIBER_PRIORITY,
38
+ moqtPublisherPriority,
39
+ moqtSubscriberPriority,
40
+ } from "./moqt-priority.js";
41
+ import { classifyStall, TransportStallDetector } from "./stall-detector.js";
14
42
  import { makeSourceSwitchHandler, wireStarvationDetector } from "./takeover.js";
15
43
  import { TransportManager } from "./transport-manager.js";
16
44
  import { runUnicastFallback } from "./unicast-fallback.js";
17
45
 
18
46
  const api = {
19
47
  AdaptiveBitrateController,
48
+ validateQualityLadder,
49
+ catalogQualityLadder,
50
+ classifyStall,
51
+ createMulticastSignalingReconciler,
52
+ endpointToMulticastConfig,
53
+ hasMulticastCatalogBaseline,
54
+ hasUnicastMediaRenditions,
55
+ isRecoverableMulticastSignalReadError,
56
+ isMulticastOnlyCatalog,
20
57
  MulticastOrchestrator,
21
58
  MulticastSubscriptionPool,
59
+ MOQT_PUBLISHER_PRIORITY,
60
+ MOQT_SUBSCRIBER_PRIORITY,
61
+ moqtPublisherPriority,
62
+ moqtSubscriberPriority,
22
63
  TransportManager,
23
64
  TransportStallDetector,
24
65
  endpointKey,
@@ -26,11 +67,22 @@ const api = {
26
67
  isUsableMulticastEndpoint,
27
68
  isValidMulticastGroupAddress,
28
69
  makeSourceSwitchHandler,
70
+ mergeUrlMulticastPolicy,
29
71
  multicastEndpointKey,
72
+ normalizeCatalogEndpoints,
73
+ normalizeMulticastConfig,
30
74
  normalizeMulticastEndpoint,
75
+ readPlayerMulticastConfig,
31
76
  readUrlMulticastConfig,
77
+ toWindowMulticastSubscribeConfig,
78
+ requiresMulticastCapability,
32
79
  runUnicastFallback,
80
+ sameMulticastConfig,
81
+ selectEndpointPacketIds,
82
+ selectMulticastCandidate,
33
83
  selectMulticastTier,
84
+ urlOverrideBlocksMulticastCandidate,
85
+ withMulticastEndpoints,
34
86
  wireStarvationDetector,
35
87
  };
36
88
 
@@ -0,0 +1,17 @@
1
+ import type { WindowMulticastSubscription } from "./types.js";
2
+
3
+ /**
4
+ * True when `subscription` delivers packets in batches through the `packets`
5
+ * provider extension (BLO-36599).
6
+ *
7
+ * The pim window-multicast-bridge (0.2.0 and later) advertises the extension
8
+ * in `eventTypes`. It builds only the events some listener asked for, so a
9
+ * consumer that listens to `packets` alone gets one event per reader batch
10
+ * instead of one DOM event per packet. A provider without the field, or one
11
+ * that does not list `packets`, is a v1 provider: listen to `bytes`.
12
+ */
13
+ export function subscriptionDeliversPacketBatches(
14
+ subscription: Pick<WindowMulticastSubscription, "eventTypes">,
15
+ ): boolean {
16
+ return subscription.eventTypes?.includes("packets") === true;
17
+ }
@@ -13,6 +13,43 @@ export interface StallDetectorConfig {
13
13
  now?: () => number;
14
14
  }
15
15
 
16
+ export interface PlayerStallSnapshot {
17
+ liveQ?: number | null;
18
+ decodeCallsDelta?: number | null;
19
+ fps?: number | null;
20
+ }
21
+
22
+ export interface PlayerStallClassification {
23
+ verdict: "DECODE-BOUND" | "PIPELINE-STARVED" | "STARVED-OR-SLOW" | "INDETERMINATE";
24
+ reason: string;
25
+ }
26
+
27
+ export function classifyStall({
28
+ liveQ = null,
29
+ decodeCallsDelta = null,
30
+ fps = null,
31
+ }: PlayerStallSnapshot = {}): PlayerStallClassification {
32
+ if (liveQ != null && liveQ >= 20) {
33
+ return {
34
+ verdict: "DECODE-BOUND",
35
+ reason: `decode queue backlogged now (liveQ=${liveQ})`,
36
+ };
37
+ }
38
+ if (liveQ != null && liveQ <= 2 && decodeCallsDelta != null && decodeCallsDelta <= 1) {
39
+ return {
40
+ verdict: "PIPELINE-STARVED",
41
+ reason: `queue empty (liveQ=${liveQ}) and decode flatlined (Δcalls=${decodeCallsDelta}) — nothing reaching the decoder; suspect delivery/FEC/failback, not decode perf`,
42
+ };
43
+ }
44
+ if (fps != null && fps < 45) {
45
+ return {
46
+ verdict: "STARVED-OR-SLOW",
47
+ reason: `low decode rate (fps=${fps}) without a queue/flatline signal to disambiguate`,
48
+ };
49
+ }
50
+ return { verdict: "INDETERMINATE", reason: "no signal supports a verdict" };
51
+ }
52
+
16
53
  export class TransportStallDetector {
17
54
  #last: FecProgressSnapshot | null = null;
18
55
  #lastSampleAt = 0;