@blockcast/mmt-transport 0.2.0 → 0.2.2
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.
- package/README.md +264 -0
- package/dist/.build-stamp +0 -0
- package/dist/abr-controller.d.ts +3 -0
- package/dist/abr-controller.d.ts.map +1 -1
- package/dist/abr-controller.js +5 -0
- package/dist/abr-controller.js.map +1 -1
- package/dist/amt-gateway.d.ts.map +1 -1
- package/dist/amt-gateway.js +2 -1
- package/dist/amt-gateway.js.map +1 -1
- package/dist/endpoint-config.d.ts +20 -0
- package/dist/endpoint-config.d.ts.map +1 -0
- package/dist/endpoint-config.js +103 -0
- package/dist/endpoint-config.js.map +1 -0
- package/dist/endpoint-config.test.d.ts +2 -0
- package/dist/endpoint-config.test.d.ts.map +1 -0
- package/dist/endpoint-config.test.js +39 -0
- package/dist/endpoint-config.test.js.map +1 -0
- package/dist/fec-client.d.ts +2 -2
- package/dist/fec-client.d.ts.map +1 -1
- package/dist/fec-client.js +10 -5
- package/dist/fec-client.js.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/mmt-transport.no-modules.js +1154 -0
- package/dist/mmt-transport.no-modules.js.map +7 -0
- package/dist/multicast-orchestrator.d.ts +56 -0
- package/dist/multicast-orchestrator.d.ts.map +1 -0
- package/dist/multicast-orchestrator.js +123 -0
- package/dist/multicast-orchestrator.js.map +1 -0
- package/dist/multicast-subscription-pool.d.ts +18 -0
- package/dist/multicast-subscription-pool.d.ts.map +1 -0
- package/dist/multicast-subscription-pool.js +130 -0
- package/dist/multicast-subscription-pool.js.map +1 -0
- package/dist/multicast-subscription-pool.test.d.ts +2 -0
- package/dist/multicast-subscription-pool.test.d.ts.map +1 -0
- package/dist/multicast-subscription-pool.test.js +108 -0
- package/dist/multicast-subscription-pool.test.js.map +1 -0
- package/dist/multicast-tier.d.ts +14 -0
- package/dist/multicast-tier.d.ts.map +1 -0
- package/dist/multicast-tier.js +22 -0
- package/dist/multicast-tier.js.map +1 -0
- package/dist/multicast-tier.test.d.ts +2 -0
- package/dist/multicast-tier.test.d.ts.map +1 -0
- package/dist/multicast-tier.test.js +75 -0
- package/dist/multicast-tier.test.js.map +1 -0
- package/dist/no-modules.d.ts +29 -0
- package/dist/no-modules.d.ts.map +1 -0
- package/dist/no-modules.js +35 -0
- package/dist/no-modules.js.map +1 -0
- package/dist/stall-detector.d.ts +22 -0
- package/dist/stall-detector.d.ts.map +1 -0
- package/dist/stall-detector.js +46 -0
- package/dist/stall-detector.js.map +1 -0
- package/dist/stall-detector.test.d.ts +2 -0
- package/dist/stall-detector.test.d.ts.map +1 -0
- package/dist/stall-detector.test.js +33 -0
- package/dist/stall-detector.test.js.map +1 -0
- package/dist/takeover.d.ts +49 -0
- package/dist/takeover.d.ts.map +1 -0
- package/dist/takeover.js +43 -0
- package/dist/takeover.js.map +1 -0
- package/dist/transport-manager.d.ts +28 -18
- package/dist/transport-manager.d.ts.map +1 -1
- package/dist/transport-manager.js +192 -138
- package/dist/transport-manager.js.map +1 -1
- package/dist/transport-manager.test.d.ts +2 -0
- package/dist/transport-manager.test.d.ts.map +1 -0
- package/dist/transport-manager.test.js +155 -0
- package/dist/transport-manager.test.js.map +1 -0
- package/dist/types.d.ts +36 -36
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/unicast-fallback.d.ts +36 -0
- package/dist/unicast-fallback.d.ts.map +1 -0
- package/dist/unicast-fallback.js +53 -0
- package/dist/unicast-fallback.js.map +1 -0
- package/package.json +6 -6
- package/src/abr-controller.ts +9 -0
- package/src/amt-gateway.ts +2 -1
- package/src/endpoint-config.test.ts +54 -0
- package/src/endpoint-config.ts +121 -0
- package/src/fec-client.ts +11 -5
- package/src/index.ts +72 -2
- package/src/multicast-orchestrator.ts +150 -0
- package/src/multicast-subscription-pool.test.ts +115 -0
- package/src/multicast-subscription-pool.ts +164 -0
- package/src/multicast-tier.test.ts +80 -0
- package/src/multicast-tier.ts +32 -0
- package/src/no-modules.ts +44 -0
- package/src/stall-detector.test.ts +34 -0
- package/src/stall-detector.ts +63 -0
- package/src/takeover.ts +71 -0
- package/src/transport-manager.test.ts +192 -0
- package/src/transport-manager.ts +203 -153
- package/src/types.ts +50 -31
- package/src/unicast-fallback.ts +57 -0
|
@@ -0,0 +1,1154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
(() => {
|
|
3
|
+
// src/multicast-tier.ts
|
|
4
|
+
function endpointKey(endpoint) {
|
|
5
|
+
return `${endpoint.sourceAddress ?? ""}|${endpoint.groupAddress}|${endpoint.port}`;
|
|
6
|
+
}
|
|
7
|
+
function selectMulticastTier(endpoints, estimate) {
|
|
8
|
+
if (endpoints.length <= 1) return endpoints[0];
|
|
9
|
+
const ranked = endpoints.filter((endpoint) => typeof endpoint.bandwidth === "number" && endpoint.bandwidth > 0).sort((a, b) => a.bandwidth - b.bandwidth);
|
|
10
|
+
if (ranked.length < 2) return endpoints[0];
|
|
11
|
+
if (estimate === void 0) return ranked[0];
|
|
12
|
+
const budget = estimate * 0.8;
|
|
13
|
+
let selected;
|
|
14
|
+
for (const endpoint of ranked) {
|
|
15
|
+
if (endpoint.bandwidth <= budget) selected = endpoint;
|
|
16
|
+
}
|
|
17
|
+
return selected ?? ranked[0];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// src/abr-controller.ts
|
|
21
|
+
var QUALITY_LEVELS = [
|
|
22
|
+
{ name: "240p", bitrate: 3e5, width: 426, height: 240, fps: 15 },
|
|
23
|
+
{ name: "360p", bitrate: 5e5, width: 640, height: 360, fps: 24 },
|
|
24
|
+
{ name: "480p", bitrate: 1e6, width: 854, height: 480, fps: 30 },
|
|
25
|
+
{ name: "720p", bitrate: 25e5, width: 1280, height: 720, fps: 30 },
|
|
26
|
+
{ name: "1080p", bitrate: 5e6, width: 1920, height: 1080, fps: 30 },
|
|
27
|
+
{ name: "1440p", bitrate: 8e6, width: 2560, height: 1440, fps: 30 },
|
|
28
|
+
{ name: "4K", bitrate: 15e6, width: 3840, height: 2160, fps: 30 }
|
|
29
|
+
];
|
|
30
|
+
var AdaptiveBitrateController = class {
|
|
31
|
+
currentQualityIndex;
|
|
32
|
+
history = [];
|
|
33
|
+
historyWindow = 1e4;
|
|
34
|
+
// 10 second window
|
|
35
|
+
upgradeThreshold = 0.8;
|
|
36
|
+
// 80% of target bitrate sustained
|
|
37
|
+
downgradeThreshold = 0.5;
|
|
38
|
+
// 50% of target bitrate triggers downgrade
|
|
39
|
+
stabilityPeriod = 5e3;
|
|
40
|
+
// Wait 5s before upgrading
|
|
41
|
+
lastQualityChange = 0;
|
|
42
|
+
onQualityChange;
|
|
43
|
+
// FEC stats for effective loss rate calculation
|
|
44
|
+
fecStats = {
|
|
45
|
+
rawLossRate: 0,
|
|
46
|
+
effectiveLossRate: 0,
|
|
47
|
+
recoveryRate: 1,
|
|
48
|
+
fecEnabled: false
|
|
49
|
+
};
|
|
50
|
+
constructor(initialQuality) {
|
|
51
|
+
this.currentQualityIndex = initialQuality ? QUALITY_LEVELS.findIndex((q) => q.name === initialQuality.name) : 0;
|
|
52
|
+
if (this.currentQualityIndex < 0) this.currentQualityIndex = 0;
|
|
53
|
+
}
|
|
54
|
+
setCallback(callback) {
|
|
55
|
+
this.onQualityChange = callback;
|
|
56
|
+
}
|
|
57
|
+
getCurrentQuality() {
|
|
58
|
+
return QUALITY_LEVELS[this.currentQualityIndex];
|
|
59
|
+
}
|
|
60
|
+
/** Selects the multicast (S,G) tier without replacing a player's unicast ABR manager. */
|
|
61
|
+
selectMulticastTier(endpoints, estimate = this.getMetrics().measuredBitrate || void 0) {
|
|
62
|
+
return selectMulticastTier(endpoints, estimate);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Record network sample for bitrate adaptation
|
|
66
|
+
*
|
|
67
|
+
* @param bytesReceived - Bytes received in this sample
|
|
68
|
+
* @param packetsLost - Packets lost (raw, before FEC recovery)
|
|
69
|
+
*/
|
|
70
|
+
recordSample(bytesReceived, packetsLost) {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
this.history.push({ timestamp: now, bytesReceived, packetsLost });
|
|
73
|
+
this.history = this.history.filter((s) => now - s.timestamp < this.historyWindow);
|
|
74
|
+
const { measuredBitrate, lossRate: rawLossRate } = this.calculateMetrics();
|
|
75
|
+
const currentQuality = this.getCurrentQuality();
|
|
76
|
+
const effectiveLossRate = this.fecStats.fecEnabled ? this.fecStats.effectiveLossRate : rawLossRate;
|
|
77
|
+
if (now - this.lastQualityChange < this.stabilityPeriod) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (effectiveLossRate > 0.05 || measuredBitrate < currentQuality.bitrate * this.downgradeThreshold) {
|
|
81
|
+
this.downgrade();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (measuredBitrate > currentQuality.bitrate * this.upgradeThreshold && effectiveLossRate < 0.01 && this.currentQualityIndex < QUALITY_LEVELS.length - 1) {
|
|
85
|
+
this.upgrade();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Update FEC statistics for ABR decisions
|
|
90
|
+
*
|
|
91
|
+
* Per draft-ramadan-moq-fec-00, ABR should use the effective loss rate
|
|
92
|
+
* (after FEC recovery) rather than raw transport loss rate.
|
|
93
|
+
*
|
|
94
|
+
* @param stats - FEC statistics from the FEC decoder
|
|
95
|
+
*/
|
|
96
|
+
setFecStats(stats) {
|
|
97
|
+
this.fecStats = stats;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Get current FEC statistics
|
|
101
|
+
*/
|
|
102
|
+
getFecStats() {
|
|
103
|
+
return { ...this.fecStats };
|
|
104
|
+
}
|
|
105
|
+
calculateMetrics() {
|
|
106
|
+
if (this.history.length < 2) {
|
|
107
|
+
return { measuredBitrate: 0, lossRate: 0 };
|
|
108
|
+
}
|
|
109
|
+
const totalBytes = this.history.reduce((sum, s) => sum + s.bytesReceived, 0);
|
|
110
|
+
const totalLost = this.history.reduce((sum, s) => sum + s.packetsLost, 0);
|
|
111
|
+
const duration = (this.history[this.history.length - 1].timestamp - this.history[0].timestamp) / 1e3;
|
|
112
|
+
const measuredBitrate = duration > 0 ? totalBytes * 8 / duration : 0;
|
|
113
|
+
const lossRate = totalLost / (this.history.length + totalLost);
|
|
114
|
+
return { measuredBitrate, lossRate };
|
|
115
|
+
}
|
|
116
|
+
upgrade() {
|
|
117
|
+
if (this.currentQualityIndex >= QUALITY_LEVELS.length - 1) return;
|
|
118
|
+
this.currentQualityIndex++;
|
|
119
|
+
this.lastQualityChange = Date.now();
|
|
120
|
+
const newQuality = this.getCurrentQuality();
|
|
121
|
+
console.log(`[ABR] Upgrading to ${newQuality.name} (${newQuality.bitrate / 1e6}Mbps)`);
|
|
122
|
+
this.onQualityChange?.(newQuality);
|
|
123
|
+
}
|
|
124
|
+
downgrade() {
|
|
125
|
+
if (this.currentQualityIndex <= 0) return;
|
|
126
|
+
this.currentQualityIndex--;
|
|
127
|
+
this.lastQualityChange = Date.now();
|
|
128
|
+
const newQuality = this.getCurrentQuality();
|
|
129
|
+
console.log(`[ABR] Downgrading to ${newQuality.name} (${newQuality.bitrate / 1e6}Mbps)`);
|
|
130
|
+
this.onQualityChange?.(newQuality);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Force quality level (for testing)
|
|
134
|
+
*/
|
|
135
|
+
setQuality(name) {
|
|
136
|
+
const index = QUALITY_LEVELS.findIndex((q) => q.name === name);
|
|
137
|
+
if (index >= 0) {
|
|
138
|
+
this.currentQualityIndex = index;
|
|
139
|
+
this.lastQualityChange = Date.now();
|
|
140
|
+
this.onQualityChange?.(this.getCurrentQuality());
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Get current metrics including both raw and effective loss rates
|
|
145
|
+
*/
|
|
146
|
+
getMetrics() {
|
|
147
|
+
const { measuredBitrate, lossRate: rawLossRate } = this.calculateMetrics();
|
|
148
|
+
return {
|
|
149
|
+
measuredBitrate,
|
|
150
|
+
rawLossRate,
|
|
151
|
+
effectiveLossRate: this.fecStats.fecEnabled ? this.fecStats.effectiveLossRate : rawLossRate,
|
|
152
|
+
fecEnabled: this.fecStats.fecEnabled,
|
|
153
|
+
sampleCount: this.history.length
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Reset history and start fresh
|
|
158
|
+
*/
|
|
159
|
+
reset() {
|
|
160
|
+
this.history = [];
|
|
161
|
+
this.lastQualityChange = 0;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// src/endpoint-config.ts
|
|
166
|
+
function isValidMulticastGroupAddress(address) {
|
|
167
|
+
const value = address?.trim().toLowerCase();
|
|
168
|
+
if (!value) return false;
|
|
169
|
+
if (value.startsWith("ff")) return isValidIpv6MulticastAddress(value);
|
|
170
|
+
const parts = value.split(".");
|
|
171
|
+
if (parts.length !== 4) return false;
|
|
172
|
+
const octets = parts.map((part) => {
|
|
173
|
+
if (!/^\d+$/.test(part)) return Number.NaN;
|
|
174
|
+
const octet = Number(part);
|
|
175
|
+
return Number.isInteger(octet) && octet >= 0 && octet <= 255 ? octet : Number.NaN;
|
|
176
|
+
});
|
|
177
|
+
return octets.every(Number.isInteger) && octets[0] >= 224 && octets[0] <= 239;
|
|
178
|
+
}
|
|
179
|
+
function normalizeMulticastEndpoint(endpoint) {
|
|
180
|
+
const groupAddress = endpoint.groupAddress.trim().toLowerCase();
|
|
181
|
+
const sourceAddress = endpoint.sourceAddress?.trim().toLowerCase() || void 0;
|
|
182
|
+
const amtRelay = endpoint.amtRelay?.trim().toLowerCase() || void 0;
|
|
183
|
+
if (!isValidMulticastGroupAddress(groupAddress) || !isPositivePort(endpoint.port)) return void 0;
|
|
184
|
+
if (endpoint.mode && !["auto", "native", "tunnel"].includes(endpoint.mode)) return void 0;
|
|
185
|
+
if (endpoint.amtRelayPort !== void 0 && !isPositivePort(endpoint.amtRelayPort)) return void 0;
|
|
186
|
+
return {
|
|
187
|
+
groupAddress,
|
|
188
|
+
...sourceAddress ? { sourceAddress } : {},
|
|
189
|
+
port: endpoint.port,
|
|
190
|
+
...endpoint.mode ? { mode: endpoint.mode } : {},
|
|
191
|
+
...amtRelay ? { amtRelay } : {},
|
|
192
|
+
...endpoint.amtRelayPort ? { amtRelayPort: endpoint.amtRelayPort } : {}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function multicastEndpointKey(endpoint) {
|
|
196
|
+
const normalized = normalizeMulticastEndpoint(endpoint);
|
|
197
|
+
if (!normalized) throw new Error("Invalid multicast endpoint");
|
|
198
|
+
return JSON.stringify([
|
|
199
|
+
normalized.sourceAddress ?? "",
|
|
200
|
+
normalized.groupAddress,
|
|
201
|
+
normalized.port,
|
|
202
|
+
normalized.mode ?? "auto",
|
|
203
|
+
normalized.amtRelay ?? "",
|
|
204
|
+
normalized.amtRelayPort ?? ""
|
|
205
|
+
]);
|
|
206
|
+
}
|
|
207
|
+
function isUsableMulticastEndpoint(endpoint) {
|
|
208
|
+
const port = Number(endpoint?.port);
|
|
209
|
+
return isValidMulticastGroupAddress(endpoint?.groupAddress) && isPositivePort(port);
|
|
210
|
+
}
|
|
211
|
+
function readUrlMulticastConfig(search = typeof location === "undefined" ? "" : location.search) {
|
|
212
|
+
const params = typeof search === "string" ? new URLSearchParams(search) : search;
|
|
213
|
+
const groupAddress = params.get("mc_group") || "";
|
|
214
|
+
const port = positivePort(params.get("mc_port"));
|
|
215
|
+
if (!groupAddress || !port) return void 0;
|
|
216
|
+
const amtRelay = params.get("mc_amt_relay") || params.get("amt_relay") || void 0;
|
|
217
|
+
const amtRelayPort = positivePort(params.get("mc_amt_relay_port") || params.get("amt_relay_port"));
|
|
218
|
+
const requestedMode = params.get("mc_mode");
|
|
219
|
+
const mode = amtRelay ? "tunnel" : multicastMode(requestedMode);
|
|
220
|
+
return normalizeMulticastEndpoint({
|
|
221
|
+
groupAddress,
|
|
222
|
+
sourceAddress: params.get("mc_source") || void 0,
|
|
223
|
+
port,
|
|
224
|
+
...mode ? { mode } : {},
|
|
225
|
+
...amtRelay ? { amtRelay } : {},
|
|
226
|
+
...amtRelayPort ? { amtRelayPort } : {}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
function isMulticastExplicitlyDisabled(search = typeof location === "undefined" ? "" : location.search) {
|
|
230
|
+
const params = typeof search === "string" ? new URLSearchParams(search) : search;
|
|
231
|
+
const multicast = params.get("multicast")?.trim().toLowerCase();
|
|
232
|
+
const transport = params.get("transport")?.trim().toLowerCase();
|
|
233
|
+
return transport === "moq-only" || ["0", "false", "no", "off"].includes(multicast || "");
|
|
234
|
+
}
|
|
235
|
+
function isValidIpv6MulticastAddress(value) {
|
|
236
|
+
if (!/^[0-9a-f:]+$/.test(value)) return false;
|
|
237
|
+
const compressed = value.includes("::");
|
|
238
|
+
if (value.indexOf("::") !== value.lastIndexOf("::")) return false;
|
|
239
|
+
const groups = value.split(":").filter(Boolean);
|
|
240
|
+
if (groups.length === 0 || groups.length > 8) return false;
|
|
241
|
+
if (!compressed && groups.length !== 8) return false;
|
|
242
|
+
return groups.every((group) => /^[0-9a-f]{1,4}$/.test(group));
|
|
243
|
+
}
|
|
244
|
+
function multicastMode(value) {
|
|
245
|
+
return value === "native" || value === "tunnel" || value === "auto" ? value : void 0;
|
|
246
|
+
}
|
|
247
|
+
function positivePort(value) {
|
|
248
|
+
const port = Number(value);
|
|
249
|
+
return isPositivePort(port) ? port : void 0;
|
|
250
|
+
}
|
|
251
|
+
function isPositivePort(port) {
|
|
252
|
+
return Number.isInteger(port) && port > 0 && port <= 65535;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// src/multicast-orchestrator.ts
|
|
256
|
+
var DEFAULT_DWELL_MS = 3e3;
|
|
257
|
+
var DEFAULT_JOIN_DEADLINE_MS = 5e3;
|
|
258
|
+
var MulticastOrchestrator = class {
|
|
259
|
+
constructor(options) {
|
|
260
|
+
this.options = options;
|
|
261
|
+
this.#dwellMs = options.dwellMs ?? DEFAULT_DWELL_MS;
|
|
262
|
+
this.#joinDeadlineMs = options.joinDeadlineMs ?? DEFAULT_JOIN_DEADLINE_MS;
|
|
263
|
+
if (!Number.isFinite(this.#dwellMs) || !Number.isFinite(this.#joinDeadlineMs) || this.#dwellMs <= 0 || this.#joinDeadlineMs <= 0) {
|
|
264
|
+
throw new Error("Multicast dwell and join deadline must be positive");
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
#state = "stable";
|
|
268
|
+
#current;
|
|
269
|
+
#target;
|
|
270
|
+
#estimate;
|
|
271
|
+
#favored;
|
|
272
|
+
#joinStartedAt = 0;
|
|
273
|
+
#lastCommittedTimestamp = Number.NEGATIVE_INFINITY;
|
|
274
|
+
#dwellMs;
|
|
275
|
+
#joinDeadlineMs;
|
|
276
|
+
get state() {
|
|
277
|
+
return this.#state;
|
|
278
|
+
}
|
|
279
|
+
get current() {
|
|
280
|
+
return this.#current;
|
|
281
|
+
}
|
|
282
|
+
get target() {
|
|
283
|
+
return this.#target;
|
|
284
|
+
}
|
|
285
|
+
get estimate() {
|
|
286
|
+
return this.#estimate;
|
|
287
|
+
}
|
|
288
|
+
start() {
|
|
289
|
+
this.#current ??= this.options.initialEndpoint ?? selectMulticastTier(this.options.endpoints, void 0);
|
|
290
|
+
return this.#current;
|
|
291
|
+
}
|
|
292
|
+
onGoodput(sample) {
|
|
293
|
+
const bitrate = sample.bitrate;
|
|
294
|
+
this.#estimate = bitrate;
|
|
295
|
+
if (!this.#current || this.options.adaptive === false) return void 0;
|
|
296
|
+
if (this.#state === "joining") return this.poll();
|
|
297
|
+
const desired = selectMulticastTier(this.options.endpoints, bitrate);
|
|
298
|
+
if (!desired) return void 0;
|
|
299
|
+
const desiredKey = endpointKey(desired);
|
|
300
|
+
if (desiredKey === endpointKey(this.#current)) {
|
|
301
|
+
this.#favored = void 0;
|
|
302
|
+
return void 0;
|
|
303
|
+
}
|
|
304
|
+
const now = this.#now();
|
|
305
|
+
if (!this.#favored || this.#favored.key !== desiredKey) {
|
|
306
|
+
this.#favored = { key: desiredKey, since: now };
|
|
307
|
+
return void 0;
|
|
308
|
+
}
|
|
309
|
+
if (now - this.#favored.since < this.#dwellMs) return void 0;
|
|
310
|
+
this.#state = "joining";
|
|
311
|
+
this.#target = desired;
|
|
312
|
+
this.#joinStartedAt = now;
|
|
313
|
+
this.#favored = void 0;
|
|
314
|
+
return { kind: "join-target", current: this.#current, target: desired };
|
|
315
|
+
}
|
|
316
|
+
onTierPacket(packet) {
|
|
317
|
+
if (!this.#current) return void 0;
|
|
318
|
+
if (packet.endpointKey === endpointKey(this.#current) && Number.isFinite(packet.timestamp)) {
|
|
319
|
+
this.#lastCommittedTimestamp = Math.max(this.#lastCommittedTimestamp, packet.timestamp);
|
|
320
|
+
}
|
|
321
|
+
if (this.#state !== "joining" || !this.#target) return void 0;
|
|
322
|
+
const expired = this.poll();
|
|
323
|
+
if (expired) return expired;
|
|
324
|
+
if (packet.endpointKey !== endpointKey(this.#target) || !packet.keyframe || !packet.hasValidCodecConfig || !Number.isFinite(packet.timestamp) || packet.timestamp < this.#lastCommittedTimestamp) return void 0;
|
|
325
|
+
const command = {
|
|
326
|
+
kind: "commit-swap",
|
|
327
|
+
target: this.#target,
|
|
328
|
+
old: this.#current
|
|
329
|
+
};
|
|
330
|
+
this.#lastCommittedTimestamp = packet.timestamp;
|
|
331
|
+
this.#current = this.#target;
|
|
332
|
+
this.#target = void 0;
|
|
333
|
+
this.#state = "stable";
|
|
334
|
+
return command;
|
|
335
|
+
}
|
|
336
|
+
poll() {
|
|
337
|
+
if (this.#state !== "joining" || !this.#target || !this.#current) return void 0;
|
|
338
|
+
if (this.#now() - this.#joinStartedAt < this.#joinDeadlineMs) return void 0;
|
|
339
|
+
return this.cancel();
|
|
340
|
+
}
|
|
341
|
+
cancel() {
|
|
342
|
+
if (this.#state !== "joining" || !this.#target || !this.#current) return void 0;
|
|
343
|
+
const command = {
|
|
344
|
+
kind: "abandon-join",
|
|
345
|
+
current: this.#current,
|
|
346
|
+
target: this.#target
|
|
347
|
+
};
|
|
348
|
+
this.#target = void 0;
|
|
349
|
+
this.#state = "stable";
|
|
350
|
+
return command;
|
|
351
|
+
}
|
|
352
|
+
#now() {
|
|
353
|
+
const now = this.options.now();
|
|
354
|
+
if (!Number.isFinite(now)) throw new Error("Multicast orchestrator clock must be finite");
|
|
355
|
+
return now;
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// src/multicast-subscription-pool.ts
|
|
360
|
+
var MulticastSubscriptionPool = class {
|
|
361
|
+
#entries = /* @__PURE__ */ new Map();
|
|
362
|
+
#gatewayIds = /* @__PURE__ */ new WeakMap();
|
|
363
|
+
#nextGatewayId = 1;
|
|
364
|
+
async acquire(gateway, config, consumer) {
|
|
365
|
+
const endpoint = normalizeMulticastEndpoint({
|
|
366
|
+
groupAddress: config.group,
|
|
367
|
+
sourceAddress: config.source,
|
|
368
|
+
port: config.port,
|
|
369
|
+
mode: config.mode,
|
|
370
|
+
amtRelay: config.amtRelay,
|
|
371
|
+
amtRelayPort: config.amtRelayPort
|
|
372
|
+
});
|
|
373
|
+
if (!endpoint) throw new Error("Invalid multicast endpoint");
|
|
374
|
+
if (typeof gateway.subscribe !== "function") throw new Error("window.multicast.subscribe unavailable");
|
|
375
|
+
const key = multicastEndpointKey(endpoint);
|
|
376
|
+
const internalKey = `${this.#gatewayId(gateway)}|${key}`;
|
|
377
|
+
let record = this.#entries.get(internalKey);
|
|
378
|
+
if (!record) {
|
|
379
|
+
record = { claims: 0, entry: Promise.resolve(null) };
|
|
380
|
+
const createdRecord = record;
|
|
381
|
+
record.entry = this.#createEntry(gateway, endpoint, (entry2) => {
|
|
382
|
+
if (this.#entries.get(internalKey) === createdRecord) this.#entries.delete(internalKey);
|
|
383
|
+
this.#detach(entry2);
|
|
384
|
+
}).catch((error) => {
|
|
385
|
+
if (this.#entries.get(internalKey) === createdRecord) this.#entries.delete(internalKey);
|
|
386
|
+
throw error;
|
|
387
|
+
});
|
|
388
|
+
this.#entries.set(internalKey, record);
|
|
389
|
+
}
|
|
390
|
+
record.claims++;
|
|
391
|
+
let entry;
|
|
392
|
+
try {
|
|
393
|
+
entry = await record.entry;
|
|
394
|
+
} catch (error) {
|
|
395
|
+
record.claims--;
|
|
396
|
+
throw error;
|
|
397
|
+
}
|
|
398
|
+
if (entry.closed) {
|
|
399
|
+
record.claims--;
|
|
400
|
+
return this.acquire(gateway, config, consumer);
|
|
401
|
+
}
|
|
402
|
+
const token = Symbol(key);
|
|
403
|
+
entry.consumers.set(token, consumer);
|
|
404
|
+
let released = false;
|
|
405
|
+
return {
|
|
406
|
+
key,
|
|
407
|
+
release: async () => {
|
|
408
|
+
if (released) return;
|
|
409
|
+
released = true;
|
|
410
|
+
entry.consumers.delete(token);
|
|
411
|
+
record.claims--;
|
|
412
|
+
if (record.claims > 0) return;
|
|
413
|
+
if (this.#entries.get(internalKey) === record) this.#entries.delete(internalKey);
|
|
414
|
+
this.#detach(entry);
|
|
415
|
+
if (!entry.closed) await entry.subscription.unsubscribe();
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
get size() {
|
|
420
|
+
return this.#entries.size;
|
|
421
|
+
}
|
|
422
|
+
async #createEntry(gateway, endpoint, onClosed) {
|
|
423
|
+
const subscription = await gateway.subscribe({
|
|
424
|
+
group: endpoint.groupAddress,
|
|
425
|
+
...endpoint.sourceAddress ? { source: endpoint.sourceAddress } : {},
|
|
426
|
+
port: endpoint.port,
|
|
427
|
+
consume: "bytes",
|
|
428
|
+
...endpoint.mode ? { mode: endpoint.mode } : {},
|
|
429
|
+
...endpoint.amtRelay ? { amtRelay: endpoint.amtRelay } : {},
|
|
430
|
+
...endpoint.amtRelayPort ? { amtRelayPort: endpoint.amtRelayPort } : {}
|
|
431
|
+
});
|
|
432
|
+
const consumers = /* @__PURE__ */ new Map();
|
|
433
|
+
let entry;
|
|
434
|
+
const bytesListener = (event) => {
|
|
435
|
+
this.#dispatch(consumers, (consumer) => consumer.onBytes(event.detail));
|
|
436
|
+
};
|
|
437
|
+
const stateListener = (event) => {
|
|
438
|
+
this.#dispatch(consumers, (consumer) => consumer.onState?.(event.detail));
|
|
439
|
+
if (event.detail === "closed" && !entry.closed) {
|
|
440
|
+
entry.closed = true;
|
|
441
|
+
onClosed(entry);
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
const errorListener = (event) => {
|
|
445
|
+
const error = event.detail instanceof Error ? event.detail : new Error(String(event.detail));
|
|
446
|
+
this.#dispatch(consumers, (consumer) => consumer.onError?.(error));
|
|
447
|
+
};
|
|
448
|
+
entry = { subscription, consumers, bytesListener, stateListener, errorListener, closed: false };
|
|
449
|
+
subscription.addEventListener("bytes", bytesListener);
|
|
450
|
+
subscription.addEventListener("state", stateListener);
|
|
451
|
+
subscription.addEventListener("error", errorListener);
|
|
452
|
+
return entry;
|
|
453
|
+
}
|
|
454
|
+
#gatewayId(gateway) {
|
|
455
|
+
const object = gateway;
|
|
456
|
+
let id = this.#gatewayIds.get(object);
|
|
457
|
+
if (!id) {
|
|
458
|
+
id = this.#nextGatewayId++;
|
|
459
|
+
this.#gatewayIds.set(object, id);
|
|
460
|
+
}
|
|
461
|
+
return id;
|
|
462
|
+
}
|
|
463
|
+
#detach(entry) {
|
|
464
|
+
entry.subscription.removeEventListener("bytes", entry.bytesListener);
|
|
465
|
+
entry.subscription.removeEventListener("state", entry.stateListener);
|
|
466
|
+
entry.subscription.removeEventListener("error", entry.errorListener);
|
|
467
|
+
}
|
|
468
|
+
#dispatch(consumers, callback) {
|
|
469
|
+
for (const consumer of consumers.values()) {
|
|
470
|
+
try {
|
|
471
|
+
callback(consumer);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
console.error("[Transport] multicast consumer callback failed", error);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
var multicastSubscriptionPool = new MulticastSubscriptionPool();
|
|
479
|
+
|
|
480
|
+
// src/stall-detector.ts
|
|
481
|
+
var TransportStallDetector = class {
|
|
482
|
+
constructor(config) {
|
|
483
|
+
this.config = config;
|
|
484
|
+
if (config.windowMs <= 0) throw new Error("Stall window must be positive and catalog-derived");
|
|
485
|
+
this.#now = config.now ?? Date.now;
|
|
486
|
+
}
|
|
487
|
+
#last = null;
|
|
488
|
+
#lastSampleAt = 0;
|
|
489
|
+
#starvedSince = null;
|
|
490
|
+
#latched = false;
|
|
491
|
+
#now;
|
|
492
|
+
get isStarved() {
|
|
493
|
+
return this.#latched;
|
|
494
|
+
}
|
|
495
|
+
update(snapshot) {
|
|
496
|
+
const previous = this.#last;
|
|
497
|
+
const previousAt = this.#lastSampleAt;
|
|
498
|
+
const now = this.#now();
|
|
499
|
+
this.#last = { ...snapshot };
|
|
500
|
+
this.#lastSampleAt = now;
|
|
501
|
+
if (!previous) return;
|
|
502
|
+
const blockDelta = snapshot.blocksComplete - previous.blocksComplete + snapshot.blocksRecovered - previous.blocksRecovered;
|
|
503
|
+
const mediaDelta = (snapshot.mediaFrames ?? 0) - (previous.mediaFrames ?? 0);
|
|
504
|
+
if (blockDelta <= 0 && mediaDelta <= 0) {
|
|
505
|
+
this.#starvedSince ??= previousAt;
|
|
506
|
+
if (!this.#latched && now - this.#starvedSince >= this.config.windowMs) {
|
|
507
|
+
this.#latched = true;
|
|
508
|
+
this.config.onStarvation();
|
|
509
|
+
}
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
this.#starvedSince = null;
|
|
513
|
+
if (this.#latched) {
|
|
514
|
+
this.#latched = false;
|
|
515
|
+
this.config.onRecovery();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
reset() {
|
|
519
|
+
this.#last = null;
|
|
520
|
+
this.#starvedSince = null;
|
|
521
|
+
this.#latched = false;
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
// src/takeover.ts
|
|
526
|
+
function makeSourceSwitchHandler(options) {
|
|
527
|
+
const log = options.log ?? console.log;
|
|
528
|
+
const warn = options.warn ?? console.warn;
|
|
529
|
+
return async (source) => {
|
|
530
|
+
if (source !== "multicast") return;
|
|
531
|
+
if (!options.multicastBridgeAvailable) {
|
|
532
|
+
warn("[MoqWatch] MediaRouter requested multicast without bridge; keeping MoQ active");
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const sessions = await options.sessionsReady;
|
|
536
|
+
if (!sessions) {
|
|
537
|
+
warn("[MoqWatch] SessionManager unavailable; aborting multicast takeover, staying on MoQ");
|
|
538
|
+
options.onAbortTakeover?.();
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
log("[MoqWatch] MediaRouter -> multicast; activating via SessionManager");
|
|
542
|
+
sessions.activateMulticast();
|
|
543
|
+
options.onMulticastConfirmed?.();
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
function wireStallDetector(args) {
|
|
547
|
+
if (!args.session || args.windowMs <= 0) return null;
|
|
548
|
+
return args.makeDetector({
|
|
549
|
+
windowMs: args.windowMs,
|
|
550
|
+
onStarvation: () => args.session.lossDetected(),
|
|
551
|
+
onRecovery: () => args.session.lossRecovered()
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
async function wireStarvationDetector(args) {
|
|
555
|
+
if (!args.session || args.fecInterleaveMs <= 0 || args.fecK <= 0) return null;
|
|
556
|
+
const detector = wireStallDetector({
|
|
557
|
+
session: args.session,
|
|
558
|
+
windowMs: Math.ceil(args.fecInterleaveMs * 3),
|
|
559
|
+
makeDetector: args.makeDetector
|
|
560
|
+
});
|
|
561
|
+
if (!detector) args.warn?.(`Unable to create starvation detector for ${args.trackName}`);
|
|
562
|
+
return detector;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// src/driad-discovery.ts
|
|
566
|
+
var DRIADDiscovery = class {
|
|
567
|
+
cache = /* @__PURE__ */ new Map();
|
|
568
|
+
cacheExpiry = 3e5;
|
|
569
|
+
// 5 minutes
|
|
570
|
+
/**
|
|
571
|
+
* Discover AMT relays for a multicast source address (RFC 8777)
|
|
572
|
+
*
|
|
573
|
+
* @param source - The multicast source IP address (NOT the group!)
|
|
574
|
+
* @returns Array of discovered AMT relays that can tunnel traffic from this source
|
|
575
|
+
*/
|
|
576
|
+
async discoverRelays(source) {
|
|
577
|
+
const cached = this.cache.get(source);
|
|
578
|
+
if (cached) {
|
|
579
|
+
return cached;
|
|
580
|
+
}
|
|
581
|
+
try {
|
|
582
|
+
const reversedSource = source.split(".").reverse().join(".");
|
|
583
|
+
const queryDomain = `${reversedSource}.amt.in-addr.arpa`;
|
|
584
|
+
console.log(`[DRIAD] Discovering relays for source ${source} via ${queryDomain}`);
|
|
585
|
+
const relays = await this.queryDRIADProxy(source);
|
|
586
|
+
if (relays.length > 0) {
|
|
587
|
+
this.cache.set(source, relays);
|
|
588
|
+
setTimeout(() => this.cache.delete(source), this.cacheExpiry);
|
|
589
|
+
}
|
|
590
|
+
return relays;
|
|
591
|
+
} catch (error) {
|
|
592
|
+
console.warn(`[DRIAD] Discovery failed for source ${source}:`, error);
|
|
593
|
+
return [];
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Query DRIAD proxy service (browser-compatible)
|
|
598
|
+
*/
|
|
599
|
+
async queryDRIADProxy(source) {
|
|
600
|
+
const proxyEndpoints = [
|
|
601
|
+
"https://driad.amt.net/api/v1/relays",
|
|
602
|
+
"https://multicast-gateway.cloudflare.com/driad"
|
|
603
|
+
];
|
|
604
|
+
for (const endpoint of proxyEndpoints) {
|
|
605
|
+
try {
|
|
606
|
+
const response = await fetch(`${endpoint}?source=${encodeURIComponent(source)}`, {
|
|
607
|
+
method: "GET",
|
|
608
|
+
headers: { Accept: "application/json" },
|
|
609
|
+
signal: AbortSignal.timeout(5e3)
|
|
610
|
+
});
|
|
611
|
+
if (response.ok) {
|
|
612
|
+
const data = await response.json();
|
|
613
|
+
return data.relays || [];
|
|
614
|
+
}
|
|
615
|
+
} catch {
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
const dohRelay = await this.queryDNSOverHTTPS(source);
|
|
619
|
+
if (dohRelay) {
|
|
620
|
+
return [dohRelay];
|
|
621
|
+
}
|
|
622
|
+
return [
|
|
623
|
+
{ host: "amt-relay.m2icast.net", port: 2268, priority: 10, weight: 100, asn: 0 },
|
|
624
|
+
{ host: "amt.akamaistream.net", port: 2268, priority: 20, weight: 50, asn: 0 }
|
|
625
|
+
];
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Query DNS-over-HTTPS for DRIAD relay based on source address (RFC 8777)
|
|
629
|
+
*/
|
|
630
|
+
async queryDNSOverHTTPS(source) {
|
|
631
|
+
try {
|
|
632
|
+
const reversedSource = source.split(".").reverse().join(".");
|
|
633
|
+
const queryName = `${reversedSource}.amt.in-addr.arpa`;
|
|
634
|
+
const dohUrl = `https://cloudflare-dns.com/dns-query?name=${queryName}&type=A`;
|
|
635
|
+
const response = await fetch(dohUrl, {
|
|
636
|
+
headers: { Accept: "application/dns-json" },
|
|
637
|
+
signal: AbortSignal.timeout(3e3)
|
|
638
|
+
});
|
|
639
|
+
if (!response.ok) {
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
const result = await response.json();
|
|
643
|
+
if (result.Answer && result.Answer.length > 0) {
|
|
644
|
+
return {
|
|
645
|
+
host: result.Answer[0].data,
|
|
646
|
+
port: 2268,
|
|
647
|
+
priority: 10,
|
|
648
|
+
weight: 100,
|
|
649
|
+
asn: 0
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
return null;
|
|
653
|
+
} catch (error) {
|
|
654
|
+
console.warn("[DRIAD] DNS-over-HTTPS query failed:", error);
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Select best relay based on RTT measurements
|
|
660
|
+
*/
|
|
661
|
+
async selectBestRelay(relays) {
|
|
662
|
+
if (relays.length === 0) return null;
|
|
663
|
+
const measured = await Promise.all(
|
|
664
|
+
relays.map(async (relay) => {
|
|
665
|
+
const rtt = await this.measureRTT(relay.host, relay.port);
|
|
666
|
+
return { ...relay, rtt };
|
|
667
|
+
})
|
|
668
|
+
);
|
|
669
|
+
measured.sort((a, b) => {
|
|
670
|
+
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
671
|
+
return (a.rtt || Infinity) - (b.rtt || Infinity);
|
|
672
|
+
});
|
|
673
|
+
console.log(`[DRIAD] Selected relay: ${measured[0].host} (RTT: ${measured[0].rtt}ms)`);
|
|
674
|
+
return measured[0];
|
|
675
|
+
}
|
|
676
|
+
async measureRTT(host, _port) {
|
|
677
|
+
const start = performance.now();
|
|
678
|
+
try {
|
|
679
|
+
await fetch(`https://${host}/health`, {
|
|
680
|
+
method: "HEAD",
|
|
681
|
+
signal: AbortSignal.timeout(2e3),
|
|
682
|
+
mode: "no-cors"
|
|
683
|
+
});
|
|
684
|
+
return performance.now() - start;
|
|
685
|
+
} catch {
|
|
686
|
+
return Infinity;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Clear the relay cache
|
|
691
|
+
*/
|
|
692
|
+
clearCache() {
|
|
693
|
+
this.cache.clear();
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Get cached relays for a source address
|
|
697
|
+
*/
|
|
698
|
+
getCached(source) {
|
|
699
|
+
return this.cache.get(source);
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
var driadDiscovery = new DRIADDiscovery();
|
|
703
|
+
|
|
704
|
+
// src/transport-manager.ts
|
|
705
|
+
var TransportManager = class {
|
|
706
|
+
state = {
|
|
707
|
+
mode: "disconnected",
|
|
708
|
+
connected: false,
|
|
709
|
+
packetsReceived: 0,
|
|
710
|
+
bytesReceived: 0,
|
|
711
|
+
lastPacketTime: 0
|
|
712
|
+
};
|
|
713
|
+
capabilities = null;
|
|
714
|
+
subscription = null;
|
|
715
|
+
driad = new DRIADDiscovery();
|
|
716
|
+
abr;
|
|
717
|
+
multicastOrchestrator = null;
|
|
718
|
+
onOrchestratorCommand;
|
|
719
|
+
connectionGeneration = 0;
|
|
720
|
+
onPacket;
|
|
721
|
+
onError;
|
|
722
|
+
onStateChange;
|
|
723
|
+
constructor() {
|
|
724
|
+
this.abr = new AdaptiveBitrateController();
|
|
725
|
+
}
|
|
726
|
+
getWindow() {
|
|
727
|
+
return typeof window === "undefined" ? void 0 : window;
|
|
728
|
+
}
|
|
729
|
+
getWindowMulticastGateway() {
|
|
730
|
+
return this.getWindow()?.multicast;
|
|
731
|
+
}
|
|
732
|
+
async getGatewayCapabilities(gateway) {
|
|
733
|
+
if (!gateway?.capabilities) return void 0;
|
|
734
|
+
try {
|
|
735
|
+
return typeof gateway.capabilities === "function" ? await gateway.capabilities() : gateway.capabilities;
|
|
736
|
+
} catch (error) {
|
|
737
|
+
console.warn("[Transport] window.multicast capabilities failed:", error);
|
|
738
|
+
return void 0;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
async gatewaySupportsBytes(gateway) {
|
|
742
|
+
if (typeof gateway?.subscribe !== "function") return false;
|
|
743
|
+
if (!gateway.capabilities) return true;
|
|
744
|
+
const capabilities = await this.getGatewayCapabilities(gateway);
|
|
745
|
+
if (!capabilities) return false;
|
|
746
|
+
const consumeModes = capabilities.consumeModes;
|
|
747
|
+
return !consumeModes || consumeModes.includes("bytes");
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Detect available transport capabilities
|
|
751
|
+
*/
|
|
752
|
+
async detectCapabilities() {
|
|
753
|
+
if (this.capabilities) return this.capabilities;
|
|
754
|
+
const gateway = this.getWindowMulticastGateway();
|
|
755
|
+
const hasGatewayBytes = await this.gatewaySupportsBytes(gateway);
|
|
756
|
+
const win = this.getWindow();
|
|
757
|
+
this.capabilities = {
|
|
758
|
+
nativeSSM: hasGatewayBytes,
|
|
759
|
+
nativeIGMP: hasGatewayBytes,
|
|
760
|
+
directSockets: typeof win?.UDPSocket !== "undefined",
|
|
761
|
+
amt: false,
|
|
762
|
+
driad: true,
|
|
763
|
+
// DRIAD uses HTTP proxy, always available
|
|
764
|
+
fecDecode: false
|
|
765
|
+
};
|
|
766
|
+
console.log("[Transport] Capabilities:", this.capabilities);
|
|
767
|
+
return this.capabilities;
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Connect using best available transport
|
|
771
|
+
*
|
|
772
|
+
* Promotion hierarchy:
|
|
773
|
+
* 1. window.multicast gateway bytes
|
|
774
|
+
* 2. AMT proxy (specified or discovered via DRIAD)
|
|
775
|
+
* 3. MOQ WebTransport (final fallback)
|
|
776
|
+
*/
|
|
777
|
+
async connect(config) {
|
|
778
|
+
const generation = ++this.connectionGeneration;
|
|
779
|
+
this.subscription?.close();
|
|
780
|
+
this.subscription = null;
|
|
781
|
+
this.state = {
|
|
782
|
+
mode: "disconnected",
|
|
783
|
+
connected: false,
|
|
784
|
+
packetsReceived: 0,
|
|
785
|
+
bytesReceived: 0,
|
|
786
|
+
lastPacketTime: 0
|
|
787
|
+
};
|
|
788
|
+
this.onStateChange?.(this.state);
|
|
789
|
+
const endpoint = normalizeMulticastEndpoint({
|
|
790
|
+
groupAddress: config.group,
|
|
791
|
+
sourceAddress: config.source,
|
|
792
|
+
port: config.port,
|
|
793
|
+
mode: config.mode,
|
|
794
|
+
amtRelay: config.amtRelay,
|
|
795
|
+
amtRelayPort: config.amtRelayPort
|
|
796
|
+
});
|
|
797
|
+
if (!endpoint) {
|
|
798
|
+
const error = new Error("Invalid multicast endpoint configuration");
|
|
799
|
+
this.onError?.(error);
|
|
800
|
+
return false;
|
|
801
|
+
}
|
|
802
|
+
config = {
|
|
803
|
+
...config,
|
|
804
|
+
group: endpoint.groupAddress,
|
|
805
|
+
source: endpoint.sourceAddress ?? "",
|
|
806
|
+
mode: endpoint.mode,
|
|
807
|
+
amtRelay: endpoint.amtRelay,
|
|
808
|
+
amtRelayPort: endpoint.amtRelayPort
|
|
809
|
+
};
|
|
810
|
+
const caps = await this.detectCapabilities();
|
|
811
|
+
if (generation !== this.connectionGeneration) return false;
|
|
812
|
+
if (config.initialQuality) {
|
|
813
|
+
this.abr = new AdaptiveBitrateController(config.initialQuality);
|
|
814
|
+
}
|
|
815
|
+
this.abr.setCallback((quality) => {
|
|
816
|
+
this.state.quality = quality;
|
|
817
|
+
this.subscription?.onqualitychange?.(quality);
|
|
818
|
+
this.onStateChange?.(this.state);
|
|
819
|
+
});
|
|
820
|
+
let connected = false;
|
|
821
|
+
if (caps.nativeSSM || caps.nativeIGMP) {
|
|
822
|
+
console.log("[Transport] Attempting window.multicast...");
|
|
823
|
+
connected = await this.connectWindowMulticast(config, generation);
|
|
824
|
+
if (connected) {
|
|
825
|
+
if (generation !== this.connectionGeneration) return false;
|
|
826
|
+
this.state.mode = "multicast";
|
|
827
|
+
return true;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
if (!connected) {
|
|
831
|
+
console.log("[Transport] Attempting AMT tunnel...");
|
|
832
|
+
connected = await this.connectAmt(config, generation);
|
|
833
|
+
if (connected) {
|
|
834
|
+
if (generation !== this.connectionGeneration) return false;
|
|
835
|
+
this.state.mode = "amt";
|
|
836
|
+
return true;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
console.log("[Transport] All multicast transports failed, falling back to MOQ");
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Connect via the lowercase window.multicast bytes gateway.
|
|
844
|
+
*/
|
|
845
|
+
async connectWindowMulticast(config, generation) {
|
|
846
|
+
try {
|
|
847
|
+
const gateway = this.getWindowMulticastGateway();
|
|
848
|
+
if (typeof gateway?.subscribe !== "function") return false;
|
|
849
|
+
const lease = await multicastSubscriptionPool.acquire(gateway, config, {
|
|
850
|
+
onBytes: (data) => {
|
|
851
|
+
if (generation === this.connectionGeneration) this.handlePacket(data);
|
|
852
|
+
},
|
|
853
|
+
onState: (state) => {
|
|
854
|
+
if (generation !== this.connectionGeneration) return;
|
|
855
|
+
this.state.connected = state !== "closed";
|
|
856
|
+
this.onStateChange?.(this.state);
|
|
857
|
+
},
|
|
858
|
+
onError: (error) => {
|
|
859
|
+
if (generation !== this.connectionGeneration) return;
|
|
860
|
+
console.error("[Transport] window.multicast error:", error);
|
|
861
|
+
this.onError?.(error);
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
if (generation !== this.connectionGeneration) {
|
|
865
|
+
await lease.release();
|
|
866
|
+
return false;
|
|
867
|
+
}
|
|
868
|
+
this.subscription = {
|
|
869
|
+
id: lease.key,
|
|
870
|
+
config,
|
|
871
|
+
close: () => {
|
|
872
|
+
void lease.release();
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
this.state = {
|
|
876
|
+
mode: "multicast",
|
|
877
|
+
connected: true,
|
|
878
|
+
group: config.group,
|
|
879
|
+
source: config.source,
|
|
880
|
+
quality: this.abr.getCurrentQuality(),
|
|
881
|
+
packetsReceived: 0,
|
|
882
|
+
bytesReceived: 0,
|
|
883
|
+
lastPacketTime: 0
|
|
884
|
+
};
|
|
885
|
+
console.log(`[Transport] Connected via window.multicast: (${config.source}, ${config.group})`);
|
|
886
|
+
this.onStateChange?.(this.state);
|
|
887
|
+
return true;
|
|
888
|
+
} catch (error) {
|
|
889
|
+
console.warn("[Transport] window.multicast failed:", error);
|
|
890
|
+
return false;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Connect via AMT proxy (with optional DRIAD discovery)
|
|
895
|
+
*/
|
|
896
|
+
async connectAmt(config, generation) {
|
|
897
|
+
try {
|
|
898
|
+
let amtRelay = config.amtRelay;
|
|
899
|
+
if (!amtRelay && config.driadEnabled !== false && config.source) {
|
|
900
|
+
console.log(`[Transport] Discovering AMT relay via DRIAD for source ${config.source}...`);
|
|
901
|
+
const relays = await this.driad.discoverRelays(config.source);
|
|
902
|
+
if (generation !== this.connectionGeneration) return false;
|
|
903
|
+
const bestRelay = await this.driad.selectBestRelay(relays);
|
|
904
|
+
if (generation !== this.connectionGeneration) return false;
|
|
905
|
+
if (bestRelay) {
|
|
906
|
+
amtRelay = bestRelay.host;
|
|
907
|
+
this.state.amtRelay = amtRelay;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
if (!amtRelay) {
|
|
911
|
+
console.warn("[Transport] No AMT relay available (DRIAD requires source address for SSM)");
|
|
912
|
+
return false;
|
|
913
|
+
}
|
|
914
|
+
return await this.connectAmtProxy(config, amtRelay, generation);
|
|
915
|
+
} catch (error) {
|
|
916
|
+
console.warn("[Transport] AMT connection failed:", error);
|
|
917
|
+
return false;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Connect via WebSocket-based AMT proxy (browser fallback)
|
|
922
|
+
*/
|
|
923
|
+
async connectAmtProxy(config, amtRelay, generation) {
|
|
924
|
+
try {
|
|
925
|
+
const relayHost = amtRelay.includes(":") && !amtRelay.startsWith("[") ? `[${amtRelay}]` : amtRelay;
|
|
926
|
+
const relayAuthority = config.amtRelayPort ? `${relayHost}:${config.amtRelayPort}` : relayHost;
|
|
927
|
+
const wsUrl = new URL(`wss://${relayAuthority}/amt-proxy`);
|
|
928
|
+
wsUrl.searchParams.set("group", config.group);
|
|
929
|
+
wsUrl.searchParams.set("source", config.source || "");
|
|
930
|
+
wsUrl.searchParams.set("port", String(config.port));
|
|
931
|
+
const ws = new WebSocket(wsUrl);
|
|
932
|
+
return new Promise((resolve, reject) => {
|
|
933
|
+
ws.binaryType = "arraybuffer";
|
|
934
|
+
const timeout = setTimeout(() => {
|
|
935
|
+
ws.close();
|
|
936
|
+
reject(new Error("AMT proxy connection timeout"));
|
|
937
|
+
}, 1e4);
|
|
938
|
+
ws.onopen = () => {
|
|
939
|
+
clearTimeout(timeout);
|
|
940
|
+
if (generation !== this.connectionGeneration) {
|
|
941
|
+
ws.close();
|
|
942
|
+
resolve(false);
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
console.log(`[Transport] Connected to AMT proxy: ${amtRelay}`);
|
|
946
|
+
this.subscription = {
|
|
947
|
+
id: `amt-proxy-${Date.now()}`,
|
|
948
|
+
config,
|
|
949
|
+
close: () => ws.close()
|
|
950
|
+
};
|
|
951
|
+
this.state = {
|
|
952
|
+
mode: "amt",
|
|
953
|
+
connected: true,
|
|
954
|
+
group: config.group,
|
|
955
|
+
source: config.source,
|
|
956
|
+
amtRelay,
|
|
957
|
+
quality: this.abr.getCurrentQuality(),
|
|
958
|
+
packetsReceived: 0,
|
|
959
|
+
bytesReceived: 0,
|
|
960
|
+
lastPacketTime: 0
|
|
961
|
+
};
|
|
962
|
+
this.onStateChange?.(this.state);
|
|
963
|
+
resolve(true);
|
|
964
|
+
};
|
|
965
|
+
ws.onmessage = (event) => {
|
|
966
|
+
if (generation === this.connectionGeneration && event.data instanceof ArrayBuffer) {
|
|
967
|
+
this.handlePacket(event.data);
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
ws.onerror = (error) => {
|
|
971
|
+
clearTimeout(timeout);
|
|
972
|
+
if (generation !== this.connectionGeneration) {
|
|
973
|
+
resolve(false);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
console.error("[Transport] AMT proxy error:", error);
|
|
977
|
+
reject(error);
|
|
978
|
+
};
|
|
979
|
+
ws.onclose = () => {
|
|
980
|
+
clearTimeout(timeout);
|
|
981
|
+
if (generation !== this.connectionGeneration) {
|
|
982
|
+
resolve(false);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
this.state.connected = false;
|
|
986
|
+
this.onStateChange?.(this.state);
|
|
987
|
+
resolve(false);
|
|
988
|
+
};
|
|
989
|
+
});
|
|
990
|
+
} catch (error) {
|
|
991
|
+
console.warn("[Transport] AMT proxy failed:", error);
|
|
992
|
+
return false;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Handle received packet
|
|
997
|
+
*/
|
|
998
|
+
handlePacket(data) {
|
|
999
|
+
this.state.packetsReceived++;
|
|
1000
|
+
this.state.bytesReceived += data.byteLength;
|
|
1001
|
+
this.state.lastPacketTime = Date.now();
|
|
1002
|
+
this.abr.recordSample(data.byteLength, 0);
|
|
1003
|
+
this.onPacket?.(data);
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Get current transport state
|
|
1007
|
+
*/
|
|
1008
|
+
getState() {
|
|
1009
|
+
return { ...this.state };
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Get current quality level
|
|
1013
|
+
*/
|
|
1014
|
+
getQuality() {
|
|
1015
|
+
return this.abr.getCurrentQuality();
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Force quality level
|
|
1019
|
+
*/
|
|
1020
|
+
setQuality(name) {
|
|
1021
|
+
this.abr.setQuality(name);
|
|
1022
|
+
}
|
|
1023
|
+
/** Configures multicast-only ABR while leaving the caller's MoQ ABR untouched. */
|
|
1024
|
+
configureMulticastTiers(options, onCommand) {
|
|
1025
|
+
this.dispatchOrchestratorCommand(this.multicastOrchestrator?.cancel());
|
|
1026
|
+
this.multicastOrchestrator = new MulticastOrchestrator(options);
|
|
1027
|
+
this.onOrchestratorCommand = onCommand;
|
|
1028
|
+
return this.multicastOrchestrator.start();
|
|
1029
|
+
}
|
|
1030
|
+
recordMulticastGoodput(bitrate) {
|
|
1031
|
+
return this.dispatchOrchestratorCommand(this.multicastOrchestrator?.onGoodput({ bitrate }));
|
|
1032
|
+
}
|
|
1033
|
+
recordMulticastTierPacket(packet) {
|
|
1034
|
+
return this.dispatchOrchestratorCommand(this.multicastOrchestrator?.onTierPacket(packet));
|
|
1035
|
+
}
|
|
1036
|
+
pollMulticastTierSwitch() {
|
|
1037
|
+
return this.dispatchOrchestratorCommand(this.multicastOrchestrator?.poll());
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Set packet callback
|
|
1041
|
+
*/
|
|
1042
|
+
setPacketCallback(callback) {
|
|
1043
|
+
this.onPacket = callback;
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Set error callback
|
|
1047
|
+
*/
|
|
1048
|
+
setErrorCallback(callback) {
|
|
1049
|
+
this.onError = callback;
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Set state change callback
|
|
1053
|
+
*/
|
|
1054
|
+
setStateCallback(callback) {
|
|
1055
|
+
this.onStateChange = callback;
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Disconnect
|
|
1059
|
+
*/
|
|
1060
|
+
disconnect() {
|
|
1061
|
+
this.connectionGeneration++;
|
|
1062
|
+
this.subscription?.close();
|
|
1063
|
+
this.subscription = null;
|
|
1064
|
+
this.dispatchOrchestratorCommand(this.multicastOrchestrator?.cancel());
|
|
1065
|
+
this.multicastOrchestrator = null;
|
|
1066
|
+
this.onOrchestratorCommand = void 0;
|
|
1067
|
+
this.state = {
|
|
1068
|
+
mode: "disconnected",
|
|
1069
|
+
connected: false,
|
|
1070
|
+
packetsReceived: 0,
|
|
1071
|
+
bytesReceived: 0,
|
|
1072
|
+
lastPacketTime: 0
|
|
1073
|
+
};
|
|
1074
|
+
this.onStateChange?.(this.state);
|
|
1075
|
+
}
|
|
1076
|
+
dispatchOrchestratorCommand(command) {
|
|
1077
|
+
if (command) this.onOrchestratorCommand?.(command);
|
|
1078
|
+
return command;
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
var transportManager = new TransportManager();
|
|
1082
|
+
|
|
1083
|
+
// src/unicast-fallback.ts
|
|
1084
|
+
function runUnicastFallback(input) {
|
|
1085
|
+
input.sessions.enterUnicastFallback();
|
|
1086
|
+
const tracks = Array.from(new Map(input.tracks.map((track) => [track.name, track])).values());
|
|
1087
|
+
let resolveAbort;
|
|
1088
|
+
const aborted = new Promise((resolve) => {
|
|
1089
|
+
resolveAbort = resolve;
|
|
1090
|
+
if (input.signal.aborted) resolve(void 0);
|
|
1091
|
+
});
|
|
1092
|
+
const onAbort = () => resolveAbort(void 0);
|
|
1093
|
+
if (!input.signal.aborted) input.signal.addEventListener("abort", onAbort, { once: true });
|
|
1094
|
+
const drainers = tracks.map(async (track) => {
|
|
1095
|
+
const subscription = input.broadcast.subscribe(track.name, input.priority);
|
|
1096
|
+
try {
|
|
1097
|
+
while (!input.signal.aborted) {
|
|
1098
|
+
const nextGroup = subscription.nextGroup();
|
|
1099
|
+
const group = await Promise.race([nextGroup, aborted]);
|
|
1100
|
+
if (!group && input.signal.aborted) void nextGroup.then((lateGroup) => lateGroup?.close());
|
|
1101
|
+
if (!group) break;
|
|
1102
|
+
try {
|
|
1103
|
+
while (!input.signal.aborted) {
|
|
1104
|
+
const frame = await Promise.race([group.readFrame(), aborted]);
|
|
1105
|
+
if (!frame) break;
|
|
1106
|
+
input.onPacket(frame);
|
|
1107
|
+
}
|
|
1108
|
+
} finally {
|
|
1109
|
+
group.close();
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
} catch (error) {
|
|
1113
|
+
if (!input.signal.aborted) {
|
|
1114
|
+
if (input.onError) input.onError(track.name, error);
|
|
1115
|
+
else console.warn(`[unicast-fallback] drainer error on ${track.name}:`, error);
|
|
1116
|
+
}
|
|
1117
|
+
} finally {
|
|
1118
|
+
subscription.close();
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
const done = Promise.all(drainers).then(() => void 0).finally(() => {
|
|
1122
|
+
input.signal.removeEventListener("abort", onAbort);
|
|
1123
|
+
});
|
|
1124
|
+
return { done };
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// src/no-modules.ts
|
|
1128
|
+
var api = {
|
|
1129
|
+
AdaptiveBitrateController,
|
|
1130
|
+
MulticastOrchestrator,
|
|
1131
|
+
MulticastSubscriptionPool,
|
|
1132
|
+
TransportManager,
|
|
1133
|
+
TransportStallDetector,
|
|
1134
|
+
endpointKey,
|
|
1135
|
+
isMulticastExplicitlyDisabled,
|
|
1136
|
+
isUsableMulticastEndpoint,
|
|
1137
|
+
isValidMulticastGroupAddress,
|
|
1138
|
+
makeSourceSwitchHandler,
|
|
1139
|
+
multicastEndpointKey,
|
|
1140
|
+
normalizeMulticastEndpoint,
|
|
1141
|
+
readUrlMulticastConfig,
|
|
1142
|
+
runUnicastFallback,
|
|
1143
|
+
selectMulticastTier,
|
|
1144
|
+
wireStarvationDetector
|
|
1145
|
+
};
|
|
1146
|
+
Object.defineProperty(globalThis, "__MmtTransport", {
|
|
1147
|
+
configurable: true,
|
|
1148
|
+
enumerable: false,
|
|
1149
|
+
writable: false,
|
|
1150
|
+
value: Object.freeze(api)
|
|
1151
|
+
});
|
|
1152
|
+
var no_modules_default = api;
|
|
1153
|
+
})();
|
|
1154
|
+
//# sourceMappingURL=mmt-transport.no-modules.js.map
|