@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,121 @@
|
|
|
1
|
+
export type MulticastTransportPreference = "auto" | "native" | "tunnel";
|
|
2
|
+
|
|
3
|
+
export interface MulticastEndpointConfig {
|
|
4
|
+
groupAddress: string;
|
|
5
|
+
sourceAddress?: string;
|
|
6
|
+
port: number;
|
|
7
|
+
mode?: MulticastTransportPreference;
|
|
8
|
+
amtRelay?: string;
|
|
9
|
+
amtRelayPort?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isValidMulticastGroupAddress(address: string | undefined): boolean {
|
|
13
|
+
const value = address?.trim().toLowerCase();
|
|
14
|
+
if (!value) return false;
|
|
15
|
+
if (value.startsWith("ff")) return isValidIpv6MulticastAddress(value);
|
|
16
|
+
|
|
17
|
+
const parts = value.split(".");
|
|
18
|
+
if (parts.length !== 4) return false;
|
|
19
|
+
const octets = parts.map((part) => {
|
|
20
|
+
if (!/^\d+$/.test(part)) return Number.NaN;
|
|
21
|
+
const octet = Number(part);
|
|
22
|
+
return Number.isInteger(octet) && octet >= 0 && octet <= 255 ? octet : Number.NaN;
|
|
23
|
+
});
|
|
24
|
+
return octets.every(Number.isInteger) && octets[0] >= 224 && octets[0] <= 239;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function normalizeMulticastEndpoint(
|
|
28
|
+
endpoint: MulticastEndpointConfig,
|
|
29
|
+
): MulticastEndpointConfig | undefined {
|
|
30
|
+
const groupAddress = endpoint.groupAddress.trim().toLowerCase();
|
|
31
|
+
const sourceAddress = endpoint.sourceAddress?.trim().toLowerCase() || undefined;
|
|
32
|
+
const amtRelay = endpoint.amtRelay?.trim().toLowerCase() || undefined;
|
|
33
|
+
if (!isValidMulticastGroupAddress(groupAddress) || !isPositivePort(endpoint.port)) return undefined;
|
|
34
|
+
if (endpoint.mode && !["auto", "native", "tunnel"].includes(endpoint.mode)) return undefined;
|
|
35
|
+
if (endpoint.amtRelayPort !== undefined && !isPositivePort(endpoint.amtRelayPort)) return undefined;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
groupAddress,
|
|
39
|
+
...(sourceAddress ? { sourceAddress } : {}),
|
|
40
|
+
port: endpoint.port,
|
|
41
|
+
...(endpoint.mode ? { mode: endpoint.mode } : {}),
|
|
42
|
+
...(amtRelay ? { amtRelay } : {}),
|
|
43
|
+
...(endpoint.amtRelayPort ? { amtRelayPort: endpoint.amtRelayPort } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Stable ownership key for one IWA multicast/AMT subscription. */
|
|
48
|
+
export function multicastEndpointKey(endpoint: MulticastEndpointConfig): string {
|
|
49
|
+
const normalized = normalizeMulticastEndpoint(endpoint);
|
|
50
|
+
if (!normalized) throw new Error("Invalid multicast endpoint");
|
|
51
|
+
return JSON.stringify([
|
|
52
|
+
normalized.sourceAddress ?? "",
|
|
53
|
+
normalized.groupAddress,
|
|
54
|
+
normalized.port,
|
|
55
|
+
normalized.mode ?? "auto",
|
|
56
|
+
normalized.amtRelay ?? "",
|
|
57
|
+
normalized.amtRelayPort ?? "",
|
|
58
|
+
]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isUsableMulticastEndpoint(
|
|
62
|
+
endpoint: { groupAddress?: string; port?: unknown } | undefined,
|
|
63
|
+
): boolean {
|
|
64
|
+
const port = Number(endpoint?.port);
|
|
65
|
+
return isValidMulticastGroupAddress(endpoint?.groupAddress) && isPositivePort(port);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function readUrlMulticastConfig(
|
|
69
|
+
search: string | URLSearchParams = typeof location === "undefined" ? "" : location.search,
|
|
70
|
+
): MulticastEndpointConfig | undefined {
|
|
71
|
+
const params = typeof search === "string" ? new URLSearchParams(search) : search;
|
|
72
|
+
const groupAddress = params.get("mc_group") || "";
|
|
73
|
+
const port = positivePort(params.get("mc_port"));
|
|
74
|
+
if (!groupAddress || !port) return undefined;
|
|
75
|
+
|
|
76
|
+
const amtRelay = params.get("mc_amt_relay") || params.get("amt_relay") || undefined;
|
|
77
|
+
const amtRelayPort = positivePort(params.get("mc_amt_relay_port") || params.get("amt_relay_port"));
|
|
78
|
+
const requestedMode = params.get("mc_mode");
|
|
79
|
+
const mode = amtRelay ? "tunnel" : multicastMode(requestedMode);
|
|
80
|
+
|
|
81
|
+
return normalizeMulticastEndpoint({
|
|
82
|
+
groupAddress,
|
|
83
|
+
sourceAddress: params.get("mc_source") || undefined,
|
|
84
|
+
port,
|
|
85
|
+
...(mode ? { mode } : {}),
|
|
86
|
+
...(amtRelay ? { amtRelay } : {}),
|
|
87
|
+
...(amtRelayPort ? { amtRelayPort } : {}),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function isMulticastExplicitlyDisabled(
|
|
92
|
+
search: string | URLSearchParams = typeof location === "undefined" ? "" : location.search,
|
|
93
|
+
): boolean {
|
|
94
|
+
const params = typeof search === "string" ? new URLSearchParams(search) : search;
|
|
95
|
+
const multicast = params.get("multicast")?.trim().toLowerCase();
|
|
96
|
+
const transport = params.get("transport")?.trim().toLowerCase();
|
|
97
|
+
return transport === "moq-only" || ["0", "false", "no", "off"].includes(multicast || "");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isValidIpv6MulticastAddress(value: string): boolean {
|
|
101
|
+
if (!/^[0-9a-f:]+$/.test(value)) return false;
|
|
102
|
+
const compressed = value.includes("::");
|
|
103
|
+
if (value.indexOf("::") !== value.lastIndexOf("::")) return false;
|
|
104
|
+
const groups = value.split(":").filter(Boolean);
|
|
105
|
+
if (groups.length === 0 || groups.length > 8) return false;
|
|
106
|
+
if (!compressed && groups.length !== 8) return false;
|
|
107
|
+
return groups.every((group) => /^[0-9a-f]{1,4}$/.test(group));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function multicastMode(value: string | null): MulticastTransportPreference | undefined {
|
|
111
|
+
return value === "native" || value === "tunnel" || value === "auto" ? value : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function positivePort(value: string | null): number | undefined {
|
|
115
|
+
const port = Number(value);
|
|
116
|
+
return isPositivePort(port) ? port : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isPositivePort(port: number): boolean {
|
|
120
|
+
return Number.isInteger(port) && port > 0 && port <= 65535;
|
|
121
|
+
}
|
package/src/fec-client.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Integrates libmmt WASM FEC decoder with:
|
|
7
7
|
* - MFU fragment reassembly
|
|
8
|
-
* - window.
|
|
8
|
+
* - window.multicast gateway from pim-multicast-gateway
|
|
9
9
|
* - Green-fill fallback for unrecoverable frames
|
|
10
10
|
*
|
|
11
11
|
* Designed for hang and moqtail players.
|
|
@@ -487,7 +487,7 @@ export class MmtFecClient {
|
|
|
487
487
|
}
|
|
488
488
|
|
|
489
489
|
/**
|
|
490
|
-
* Integration with window.
|
|
490
|
+
* Integration with window.multicast gateway (pim-multicast-gateway)
|
|
491
491
|
*/
|
|
492
492
|
export function createMulticastFecHandler(
|
|
493
493
|
client: MmtFecClient,
|
|
@@ -506,9 +506,9 @@ export function createMulticastFecHandler(
|
|
|
506
506
|
export async function setupMmtFec(options?: MmtFecClientOptions): Promise<MmtFecClient> {
|
|
507
507
|
const client = new MmtFecClient(options);
|
|
508
508
|
|
|
509
|
-
// Integrate with window.
|
|
510
|
-
if (typeof window !== "undefined" && (window as unknown as {
|
|
511
|
-
console.log("[MmtFecClient] window.
|
|
509
|
+
// Integrate with window.multicast if available
|
|
510
|
+
if (typeof window !== "undefined" && (window as unknown as { multicast?: unknown }).multicast) {
|
|
511
|
+
console.log("[MmtFecClient] window.multicast detected, ready for integration");
|
|
512
512
|
}
|
|
513
513
|
|
|
514
514
|
return client;
|
|
@@ -835,6 +835,12 @@ export class MoqFecClient {
|
|
|
835
835
|
this.encoder = new this.wasmMoqFecEncoder(this.wasmConfig);
|
|
836
836
|
this.oti = new Uint8Array(this.encoder.getOti());
|
|
837
837
|
|
|
838
|
+
// Create decoder from encoder's OTI so addSourceSymbol/addRepairSymbol
|
|
839
|
+
// work immediately without waiting for an in-band OTI from repair frames.
|
|
840
|
+
if (this.wasmMoqFecDecoder) {
|
|
841
|
+
this.decoder = new this.wasmMoqFecDecoder(this.wasmConfig, this.oti);
|
|
842
|
+
}
|
|
843
|
+
|
|
838
844
|
this.initialized = true;
|
|
839
845
|
console.log(
|
|
840
846
|
`[MoqFecClient] Initialized: K=${this.config.k}, P=${this.config.p}, ` +
|
package/src/index.ts
CHANGED
|
@@ -41,8 +41,12 @@ export type {
|
|
|
41
41
|
MmtFecClientOptions,
|
|
42
42
|
Micro,
|
|
43
43
|
SyncDecision,
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
WindowMulticastSubscribeConfig,
|
|
45
|
+
WindowMulticastSubscriptionState,
|
|
46
|
+
WindowMulticastSubscriptionEventMap,
|
|
47
|
+
WindowMulticastSubscription,
|
|
48
|
+
WindowMulticastCapabilities,
|
|
49
|
+
WindowMulticastGateway,
|
|
46
50
|
// Consolidated from @blockcast/multicast
|
|
47
51
|
MulticastTransportMode,
|
|
48
52
|
MulticastConfig,
|
|
@@ -88,6 +92,72 @@ export {
|
|
|
88
92
|
type FecStatsForAbr,
|
|
89
93
|
} from "./abr-controller.js";
|
|
90
94
|
|
|
95
|
+
export {
|
|
96
|
+
endpointKey,
|
|
97
|
+
selectMulticastTier,
|
|
98
|
+
type MulticastTierEndpoint,
|
|
99
|
+
} from "./multicast-tier.js";
|
|
100
|
+
|
|
101
|
+
export {
|
|
102
|
+
MulticastOrchestrator,
|
|
103
|
+
DEFAULT_DWELL_MS,
|
|
104
|
+
DEFAULT_JOIN_DEADLINE_MS,
|
|
105
|
+
goodputFromStats,
|
|
106
|
+
type GoodputSample,
|
|
107
|
+
type MulticastTierPacket,
|
|
108
|
+
type OrchestratorCommand,
|
|
109
|
+
type OrchestratorOptions,
|
|
110
|
+
type OrchestratorState,
|
|
111
|
+
} from "./multicast-orchestrator.js";
|
|
112
|
+
|
|
113
|
+
export {
|
|
114
|
+
isMulticastExplicitlyDisabled,
|
|
115
|
+
isUsableMulticastEndpoint,
|
|
116
|
+
isValidMulticastGroupAddress,
|
|
117
|
+
multicastEndpointKey,
|
|
118
|
+
normalizeMulticastEndpoint,
|
|
119
|
+
readUrlMulticastConfig,
|
|
120
|
+
type MulticastEndpointConfig,
|
|
121
|
+
type MulticastTransportPreference,
|
|
122
|
+
} from "./endpoint-config.js";
|
|
123
|
+
|
|
124
|
+
export {
|
|
125
|
+
MulticastSubscriptionPool,
|
|
126
|
+
multicastSubscriptionPool,
|
|
127
|
+
type MulticastSubscriptionConsumer,
|
|
128
|
+
type MulticastSubscriptionLease,
|
|
129
|
+
} from "./multicast-subscription-pool.js";
|
|
130
|
+
|
|
131
|
+
export {
|
|
132
|
+
TransportStallDetector,
|
|
133
|
+
type FecProgressSnapshot,
|
|
134
|
+
type StallDetectorConfig,
|
|
135
|
+
} from "./stall-detector.js";
|
|
136
|
+
|
|
137
|
+
export {
|
|
138
|
+
makeSourceSwitchHandler,
|
|
139
|
+
wireStallDetector,
|
|
140
|
+
wireStarvationDetector,
|
|
141
|
+
type ActivatableSessions,
|
|
142
|
+
type FecStarvationSnapshot,
|
|
143
|
+
type LossArcSession,
|
|
144
|
+
type StallDetectorLike,
|
|
145
|
+
type StarvationDetectorLike,
|
|
146
|
+
} from "./takeover.js";
|
|
147
|
+
|
|
148
|
+
export {
|
|
149
|
+
runUnicastFallback,
|
|
150
|
+
type FallbackBroadcast,
|
|
151
|
+
type FallbackBroadcastLike,
|
|
152
|
+
type FallbackGroup,
|
|
153
|
+
type FallbackGroupLike,
|
|
154
|
+
type FallbackSessionManagerLike,
|
|
155
|
+
type FallbackSessions,
|
|
156
|
+
type FallbackSubscription,
|
|
157
|
+
type FallbackSubLike,
|
|
158
|
+
type FallbackTrack,
|
|
159
|
+
} from "./unicast-fallback.js";
|
|
160
|
+
|
|
91
161
|
// AMT Gateway
|
|
92
162
|
export {
|
|
93
163
|
AMTGatewayManager,
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { endpointKey, selectMulticastTier, type MulticastTierEndpoint } from "./multicast-tier.js";
|
|
2
|
+
|
|
3
|
+
export type OrchestratorState = "stable" | "joining";
|
|
4
|
+
export type OrchestratorCommand =
|
|
5
|
+
| { kind: "join-target"; current: MulticastTierEndpoint; target: MulticastTierEndpoint }
|
|
6
|
+
| { kind: "commit-swap"; target: MulticastTierEndpoint; old: MulticastTierEndpoint }
|
|
7
|
+
| { kind: "abandon-join"; current: MulticastTierEndpoint; target: MulticastTierEndpoint };
|
|
8
|
+
|
|
9
|
+
export interface MulticastTierPacket {
|
|
10
|
+
endpointKey: string;
|
|
11
|
+
keyframe: boolean;
|
|
12
|
+
timestamp: number;
|
|
13
|
+
hasValidCodecConfig: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface OrchestratorOptions {
|
|
17
|
+
endpoints: readonly MulticastTierEndpoint[];
|
|
18
|
+
initialEndpoint?: MulticastTierEndpoint;
|
|
19
|
+
adaptive?: boolean;
|
|
20
|
+
now: () => number;
|
|
21
|
+
dwellMs?: number;
|
|
22
|
+
joinDeadlineMs?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GoodputSample { bitrate: number | undefined; }
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_DWELL_MS = 3_000;
|
|
28
|
+
export const DEFAULT_JOIN_DEADLINE_MS = 5_000;
|
|
29
|
+
|
|
30
|
+
export function goodputFromStats(
|
|
31
|
+
previous: { bytesReceived: number; atMs: number } | undefined,
|
|
32
|
+
current: { bytesReceived: number; atMs: number },
|
|
33
|
+
minSampleMs = 500,
|
|
34
|
+
): number | undefined {
|
|
35
|
+
if (!previous) return undefined;
|
|
36
|
+
const durationMs = current.atMs - previous.atMs;
|
|
37
|
+
const bytes = current.bytesReceived - previous.bytesReceived;
|
|
38
|
+
if (durationMs < minSampleMs || bytes <= 0) return undefined;
|
|
39
|
+
return (bytes * 8 * 1000) / durationMs;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class MulticastOrchestrator {
|
|
43
|
+
#state: OrchestratorState = "stable";
|
|
44
|
+
#current: MulticastTierEndpoint | undefined;
|
|
45
|
+
#target: MulticastTierEndpoint | undefined;
|
|
46
|
+
#estimate: number | undefined;
|
|
47
|
+
#favored: { key: string; since: number } | undefined;
|
|
48
|
+
#joinStartedAt = 0;
|
|
49
|
+
#lastCommittedTimestamp = Number.NEGATIVE_INFINITY;
|
|
50
|
+
readonly #dwellMs: number;
|
|
51
|
+
readonly #joinDeadlineMs: number;
|
|
52
|
+
|
|
53
|
+
constructor(private readonly options: OrchestratorOptions) {
|
|
54
|
+
this.#dwellMs = options.dwellMs ?? DEFAULT_DWELL_MS;
|
|
55
|
+
this.#joinDeadlineMs = options.joinDeadlineMs ?? DEFAULT_JOIN_DEADLINE_MS;
|
|
56
|
+
if (!Number.isFinite(this.#dwellMs) || !Number.isFinite(this.#joinDeadlineMs) ||
|
|
57
|
+
this.#dwellMs <= 0 || this.#joinDeadlineMs <= 0) {
|
|
58
|
+
throw new Error("Multicast dwell and join deadline must be positive");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get state(): OrchestratorState { return this.#state; }
|
|
63
|
+
get current(): MulticastTierEndpoint | undefined { return this.#current; }
|
|
64
|
+
get target(): MulticastTierEndpoint | undefined { return this.#target; }
|
|
65
|
+
get estimate(): number | undefined { return this.#estimate; }
|
|
66
|
+
|
|
67
|
+
start(): MulticastTierEndpoint | undefined {
|
|
68
|
+
this.#current ??= this.options.initialEndpoint ?? selectMulticastTier(this.options.endpoints, undefined);
|
|
69
|
+
return this.#current;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
onGoodput(sample: GoodputSample): OrchestratorCommand | undefined {
|
|
73
|
+
const bitrate = sample.bitrate;
|
|
74
|
+
this.#estimate = bitrate;
|
|
75
|
+
if (!this.#current || this.options.adaptive === false) return undefined;
|
|
76
|
+
if (this.#state === "joining") return this.poll();
|
|
77
|
+
const desired = selectMulticastTier(this.options.endpoints, bitrate);
|
|
78
|
+
if (!desired) return undefined;
|
|
79
|
+
const desiredKey = endpointKey(desired);
|
|
80
|
+
if (desiredKey === endpointKey(this.#current)) {
|
|
81
|
+
this.#favored = undefined;
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const now = this.#now();
|
|
86
|
+
if (!this.#favored || this.#favored.key !== desiredKey) {
|
|
87
|
+
this.#favored = { key: desiredKey, since: now };
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
if (now - this.#favored.since < this.#dwellMs) return undefined;
|
|
91
|
+
|
|
92
|
+
this.#state = "joining";
|
|
93
|
+
this.#target = desired;
|
|
94
|
+
this.#joinStartedAt = now;
|
|
95
|
+
this.#favored = undefined;
|
|
96
|
+
return { kind: "join-target", current: this.#current, target: desired };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
onTierPacket(packet: MulticastTierPacket): OrchestratorCommand | undefined {
|
|
100
|
+
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);
|
|
103
|
+
}
|
|
104
|
+
if (this.#state !== "joining" || !this.#target) return undefined;
|
|
105
|
+
const expired = this.poll();
|
|
106
|
+
if (expired) return expired;
|
|
107
|
+
if (
|
|
108
|
+
packet.endpointKey !== endpointKey(this.#target) ||
|
|
109
|
+
!packet.keyframe ||
|
|
110
|
+
!packet.hasValidCodecConfig ||
|
|
111
|
+
!Number.isFinite(packet.timestamp) ||
|
|
112
|
+
packet.timestamp < this.#lastCommittedTimestamp
|
|
113
|
+
) return undefined;
|
|
114
|
+
|
|
115
|
+
const command: OrchestratorCommand = {
|
|
116
|
+
kind: "commit-swap",
|
|
117
|
+
target: this.#target,
|
|
118
|
+
old: this.#current,
|
|
119
|
+
};
|
|
120
|
+
this.#lastCommittedTimestamp = packet.timestamp;
|
|
121
|
+
this.#current = this.#target;
|
|
122
|
+
this.#target = undefined;
|
|
123
|
+
this.#state = "stable";
|
|
124
|
+
return command;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
poll(): OrchestratorCommand | undefined {
|
|
128
|
+
if (this.#state !== "joining" || !this.#target || !this.#current) return undefined;
|
|
129
|
+
if (this.#now() - this.#joinStartedAt < this.#joinDeadlineMs) return undefined;
|
|
130
|
+
return this.cancel();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
cancel(): OrchestratorCommand | undefined {
|
|
134
|
+
if (this.#state !== "joining" || !this.#target || !this.#current) return undefined;
|
|
135
|
+
const command: OrchestratorCommand = {
|
|
136
|
+
kind: "abandon-join",
|
|
137
|
+
current: this.#current,
|
|
138
|
+
target: this.#target,
|
|
139
|
+
};
|
|
140
|
+
this.#target = undefined;
|
|
141
|
+
this.#state = "stable";
|
|
142
|
+
return command;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#now(): number {
|
|
146
|
+
const now = this.options.now();
|
|
147
|
+
if (!Number.isFinite(now)) throw new Error("Multicast orchestrator clock must be finite");
|
|
148
|
+
return now;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { MulticastSubscriptionPool } from "./multicast-subscription-pool.js";
|
|
3
|
+
import type { TransportConfig, WindowMulticastSubscriptionEventMap } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const config: TransportConfig = {
|
|
6
|
+
group: "232.1.1.1",
|
|
7
|
+
source: "192.0.2.1",
|
|
8
|
+
port: 5000,
|
|
9
|
+
streamType: "video",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe("MulticastSubscriptionPool", () => {
|
|
13
|
+
it("shares an endpoint and unsubscribes only after the last owner releases", async () => {
|
|
14
|
+
const listeners = new Map<keyof WindowMulticastSubscriptionEventMap, (event: CustomEvent<never>) => void>();
|
|
15
|
+
const unsubscribe = vi.fn();
|
|
16
|
+
const subscription = {
|
|
17
|
+
addEventListener: vi.fn((type, listener) => listeners.set(type, listener)),
|
|
18
|
+
removeEventListener: vi.fn(),
|
|
19
|
+
unsubscribe,
|
|
20
|
+
};
|
|
21
|
+
const subscribe = vi.fn().mockResolvedValue(subscription);
|
|
22
|
+
const gateway = { subscribe };
|
|
23
|
+
const pool = new MulticastSubscriptionPool();
|
|
24
|
+
const firstPacket = vi.fn();
|
|
25
|
+
const secondPacket = vi.fn();
|
|
26
|
+
|
|
27
|
+
const first = await pool.acquire(gateway, config, { onBytes: firstPacket });
|
|
28
|
+
const second = await pool.acquire(gateway, { ...config, group: " 232.1.1.1 " }, { onBytes: secondPacket });
|
|
29
|
+
expect(subscribe).toHaveBeenCalledOnce();
|
|
30
|
+
|
|
31
|
+
const payload = new Uint8Array([1, 2]).buffer;
|
|
32
|
+
listeners.get("bytes")?.({ detail: payload } as CustomEvent<never>);
|
|
33
|
+
expect(firstPacket).toHaveBeenCalledWith(payload);
|
|
34
|
+
expect(secondPacket).toHaveBeenCalledWith(payload);
|
|
35
|
+
|
|
36
|
+
await first.release();
|
|
37
|
+
expect(unsubscribe).not.toHaveBeenCalled();
|
|
38
|
+
await second.release();
|
|
39
|
+
expect(unsubscribe).toHaveBeenCalledOnce();
|
|
40
|
+
expect(pool.size).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("counts an awaiting acquire before the previous owner releases", async () => {
|
|
44
|
+
const unsubscribe = vi.fn();
|
|
45
|
+
const subscription = {
|
|
46
|
+
addEventListener: vi.fn(), removeEventListener: vi.fn(), unsubscribe,
|
|
47
|
+
};
|
|
48
|
+
const subscribe = vi.fn().mockResolvedValue(subscription);
|
|
49
|
+
const gateway = { subscribe };
|
|
50
|
+
const pool = new MulticastSubscriptionPool();
|
|
51
|
+
const first = await pool.acquire(gateway, config, { onBytes: vi.fn() });
|
|
52
|
+
const secondPending = pool.acquire(gateway, config, { onBytes: vi.fn() });
|
|
53
|
+
await first.release();
|
|
54
|
+
const second = await secondPending;
|
|
55
|
+
expect(unsubscribe).not.toHaveBeenCalled();
|
|
56
|
+
expect(pool.size).toBe(1);
|
|
57
|
+
await second.release();
|
|
58
|
+
expect(unsubscribe).toHaveBeenCalledOnce();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("does not conflate transport preferences or gateway instances", async () => {
|
|
62
|
+
const makeSubscription = () => ({
|
|
63
|
+
addEventListener: vi.fn(), removeEventListener: vi.fn(), unsubscribe: vi.fn(),
|
|
64
|
+
});
|
|
65
|
+
const firstGateway = { subscribe: vi.fn().mockResolvedValue(makeSubscription()) };
|
|
66
|
+
const secondGateway = { subscribe: vi.fn().mockResolvedValue(makeSubscription()) };
|
|
67
|
+
const pool = new MulticastSubscriptionPool();
|
|
68
|
+
const native = await pool.acquire(firstGateway, { ...config, mode: "native" }, { onBytes: vi.fn() });
|
|
69
|
+
const tunnel = await pool.acquire(firstGateway, { ...config, mode: "tunnel", amtRelay: "relay.example" }, { onBytes: vi.fn() });
|
|
70
|
+
const replacement = await pool.acquire(secondGateway, { ...config, mode: "native" }, { onBytes: vi.fn() });
|
|
71
|
+
expect(firstGateway.subscribe).toHaveBeenCalledTimes(2);
|
|
72
|
+
expect(secondGateway.subscribe).toHaveBeenCalledOnce();
|
|
73
|
+
await Promise.all([native.release(), tunnel.release(), replacement.release()]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("evicts a closed entry so the next owner reconnects", async () => {
|
|
77
|
+
const stateListeners: Array<(event: CustomEvent<"closed">) => void> = [];
|
|
78
|
+
const subscribe = vi.fn().mockImplementation(async () => ({
|
|
79
|
+
addEventListener: vi.fn((type, listener) => {
|
|
80
|
+
if (type === "state") stateListeners.push(listener);
|
|
81
|
+
}),
|
|
82
|
+
removeEventListener: vi.fn(),
|
|
83
|
+
unsubscribe: vi.fn(),
|
|
84
|
+
}));
|
|
85
|
+
const gateway = { subscribe };
|
|
86
|
+
const pool = new MulticastSubscriptionPool();
|
|
87
|
+
const first = await pool.acquire(gateway, config, { onBytes: vi.fn() });
|
|
88
|
+
stateListeners[0]({ detail: "closed" } as CustomEvent<"closed">);
|
|
89
|
+
expect(pool.size).toBe(0);
|
|
90
|
+
const second = await pool.acquire(gateway, config, { onBytes: vi.fn() });
|
|
91
|
+
expect(subscribe).toHaveBeenCalledTimes(2);
|
|
92
|
+
await first.release();
|
|
93
|
+
await second.release();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("isolates consumer callback failures", async () => {
|
|
97
|
+
let bytesListener: ((event: CustomEvent<ArrayBuffer>) => void) | undefined;
|
|
98
|
+
const subscribe = vi.fn().mockResolvedValue({
|
|
99
|
+
addEventListener: vi.fn((type, listener) => {
|
|
100
|
+
if (type === "bytes") bytesListener = listener;
|
|
101
|
+
}),
|
|
102
|
+
removeEventListener: vi.fn(), unsubscribe: vi.fn(),
|
|
103
|
+
});
|
|
104
|
+
const gateway = { subscribe };
|
|
105
|
+
const pool = new MulticastSubscriptionPool();
|
|
106
|
+
const secondConsumer = vi.fn();
|
|
107
|
+
const first = await pool.acquire(gateway, config, { onBytes: () => { throw new Error("boom"); } });
|
|
108
|
+
const second = await pool.acquire(gateway, config, { onBytes: secondConsumer });
|
|
109
|
+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
110
|
+
bytesListener?.({ detail: new ArrayBuffer(1) } as CustomEvent<ArrayBuffer>);
|
|
111
|
+
expect(secondConsumer).toHaveBeenCalledOnce();
|
|
112
|
+
consoleError.mockRestore();
|
|
113
|
+
await Promise.all([first.release(), second.release()]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { multicastEndpointKey, normalizeMulticastEndpoint } from "./endpoint-config.js";
|
|
2
|
+
import type {
|
|
3
|
+
TransportConfig,
|
|
4
|
+
WindowMulticastGateway,
|
|
5
|
+
WindowMulticastSubscription,
|
|
6
|
+
WindowMulticastSubscriptionState,
|
|
7
|
+
} from "./types.js";
|
|
8
|
+
|
|
9
|
+
export interface MulticastSubscriptionConsumer {
|
|
10
|
+
onBytes(data: ArrayBuffer): void;
|
|
11
|
+
onState?(state: WindowMulticastSubscriptionState): void;
|
|
12
|
+
onError?(error: Error): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface PoolEntry {
|
|
16
|
+
subscription: WindowMulticastSubscription;
|
|
17
|
+
consumers: Map<symbol, MulticastSubscriptionConsumer>;
|
|
18
|
+
bytesListener: (event: CustomEvent<ArrayBuffer>) => void;
|
|
19
|
+
stateListener: (event: CustomEvent<WindowMulticastSubscriptionState>) => void;
|
|
20
|
+
errorListener: (event: CustomEvent<unknown>) => void;
|
|
21
|
+
closed: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface PoolRecord { claims: number; entry: Promise<PoolEntry>; }
|
|
25
|
+
|
|
26
|
+
export interface MulticastSubscriptionLease {
|
|
27
|
+
readonly key: string;
|
|
28
|
+
release(): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Shares only IWA multicast/AMT subscriptions. Unicast sessions remain per manager. */
|
|
32
|
+
export class MulticastSubscriptionPool {
|
|
33
|
+
readonly #entries = new Map<string, PoolRecord>();
|
|
34
|
+
readonly #gatewayIds = new WeakMap<object, number>();
|
|
35
|
+
#nextGatewayId = 1;
|
|
36
|
+
|
|
37
|
+
async acquire(
|
|
38
|
+
gateway: WindowMulticastGateway,
|
|
39
|
+
config: TransportConfig,
|
|
40
|
+
consumer: MulticastSubscriptionConsumer,
|
|
41
|
+
): Promise<MulticastSubscriptionLease> {
|
|
42
|
+
const endpoint = normalizeMulticastEndpoint({
|
|
43
|
+
groupAddress: config.group,
|
|
44
|
+
sourceAddress: config.source,
|
|
45
|
+
port: config.port,
|
|
46
|
+
mode: config.mode,
|
|
47
|
+
amtRelay: config.amtRelay,
|
|
48
|
+
amtRelayPort: config.amtRelayPort,
|
|
49
|
+
});
|
|
50
|
+
if (!endpoint) throw new Error("Invalid multicast endpoint");
|
|
51
|
+
if (typeof gateway.subscribe !== "function") throw new Error("window.multicast.subscribe unavailable");
|
|
52
|
+
|
|
53
|
+
const key = multicastEndpointKey(endpoint);
|
|
54
|
+
const internalKey = `${this.#gatewayId(gateway)}|${key}`;
|
|
55
|
+
let record = this.#entries.get(internalKey);
|
|
56
|
+
if (!record) {
|
|
57
|
+
record = { claims: 0, entry: Promise.resolve(null as unknown as PoolEntry) };
|
|
58
|
+
const createdRecord = record;
|
|
59
|
+
record.entry = this.#createEntry(gateway, endpoint, (entry) => {
|
|
60
|
+
if (this.#entries.get(internalKey) === createdRecord) this.#entries.delete(internalKey);
|
|
61
|
+
this.#detach(entry);
|
|
62
|
+
}).catch((error) => {
|
|
63
|
+
if (this.#entries.get(internalKey) === createdRecord) this.#entries.delete(internalKey);
|
|
64
|
+
throw error;
|
|
65
|
+
});
|
|
66
|
+
this.#entries.set(internalKey, record);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
record.claims++;
|
|
70
|
+
let entry: PoolEntry;
|
|
71
|
+
try {
|
|
72
|
+
entry = await record.entry;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
record.claims--;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
if (entry.closed) {
|
|
78
|
+
record.claims--;
|
|
79
|
+
return this.acquire(gateway, config, consumer);
|
|
80
|
+
}
|
|
81
|
+
const token = Symbol(key);
|
|
82
|
+
entry.consumers.set(token, consumer);
|
|
83
|
+
let released = false;
|
|
84
|
+
return {
|
|
85
|
+
key,
|
|
86
|
+
release: async () => {
|
|
87
|
+
if (released) return;
|
|
88
|
+
released = true;
|
|
89
|
+
entry.consumers.delete(token);
|
|
90
|
+
record.claims--;
|
|
91
|
+
if (record.claims > 0) return;
|
|
92
|
+
if (this.#entries.get(internalKey) === record) this.#entries.delete(internalKey);
|
|
93
|
+
this.#detach(entry);
|
|
94
|
+
if (!entry.closed) await entry.subscription.unsubscribe();
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
get size(): number { return this.#entries.size; }
|
|
100
|
+
|
|
101
|
+
async #createEntry(
|
|
102
|
+
gateway: WindowMulticastGateway,
|
|
103
|
+
endpoint: NonNullable<ReturnType<typeof normalizeMulticastEndpoint>>,
|
|
104
|
+
onClosed: (entry: PoolEntry) => void,
|
|
105
|
+
): Promise<PoolEntry> {
|
|
106
|
+
const subscription = await gateway.subscribe!({
|
|
107
|
+
group: endpoint.groupAddress,
|
|
108
|
+
...(endpoint.sourceAddress ? { source: endpoint.sourceAddress } : {}),
|
|
109
|
+
port: endpoint.port,
|
|
110
|
+
consume: "bytes",
|
|
111
|
+
...(endpoint.mode ? { mode: endpoint.mode } : {}),
|
|
112
|
+
...(endpoint.amtRelay ? { amtRelay: endpoint.amtRelay } : {}),
|
|
113
|
+
...(endpoint.amtRelayPort ? { amtRelayPort: endpoint.amtRelayPort } : {}),
|
|
114
|
+
});
|
|
115
|
+
const consumers = new Map<symbol, MulticastSubscriptionConsumer>();
|
|
116
|
+
let entry: PoolEntry;
|
|
117
|
+
const bytesListener = (event: CustomEvent<ArrayBuffer>) => {
|
|
118
|
+
this.#dispatch(consumers, (consumer) => consumer.onBytes(event.detail));
|
|
119
|
+
};
|
|
120
|
+
const stateListener = (event: CustomEvent<WindowMulticastSubscriptionState>) => {
|
|
121
|
+
this.#dispatch(consumers, (consumer) => consumer.onState?.(event.detail));
|
|
122
|
+
if (event.detail === "closed" && !entry.closed) {
|
|
123
|
+
entry.closed = true;
|
|
124
|
+
onClosed(entry);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const errorListener = (event: CustomEvent<unknown>) => {
|
|
128
|
+
const error = event.detail instanceof Error ? event.detail : new Error(String(event.detail));
|
|
129
|
+
this.#dispatch(consumers, (consumer) => consumer.onError?.(error));
|
|
130
|
+
};
|
|
131
|
+
entry = { subscription, consumers, bytesListener, stateListener, errorListener, closed: false };
|
|
132
|
+
subscription.addEventListener("bytes", bytesListener);
|
|
133
|
+
subscription.addEventListener("state", stateListener);
|
|
134
|
+
subscription.addEventListener("error", errorListener);
|
|
135
|
+
return entry;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#gatewayId(gateway: WindowMulticastGateway): number {
|
|
139
|
+
const object = gateway as object;
|
|
140
|
+
let id = this.#gatewayIds.get(object);
|
|
141
|
+
if (!id) {
|
|
142
|
+
id = this.#nextGatewayId++;
|
|
143
|
+
this.#gatewayIds.set(object, id);
|
|
144
|
+
}
|
|
145
|
+
return id;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#detach(entry: PoolEntry): void {
|
|
149
|
+
entry.subscription.removeEventListener("bytes", entry.bytesListener);
|
|
150
|
+
entry.subscription.removeEventListener("state", entry.stateListener);
|
|
151
|
+
entry.subscription.removeEventListener("error", entry.errorListener);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#dispatch(
|
|
155
|
+
consumers: Map<symbol, MulticastSubscriptionConsumer>,
|
|
156
|
+
callback: (consumer: MulticastSubscriptionConsumer) => void,
|
|
157
|
+
): void {
|
|
158
|
+
for (const consumer of consumers.values()) {
|
|
159
|
+
try { callback(consumer); } catch (error) { console.error("[Transport] multicast consumer callback failed", error); }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const multicastSubscriptionPool = new MulticastSubscriptionPool();
|