@blockcast/mmt-transport 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/abr-controller.d.ts +94 -0
- package/dist/abr-controller.d.ts.map +1 -0
- package/dist/abr-controller.js +174 -0
- package/dist/abr-controller.js.map +1 -0
- package/dist/amt-gateway.d.ts +160 -0
- package/dist/amt-gateway.d.ts.map +1 -0
- package/dist/amt-gateway.js +390 -0
- package/dist/amt-gateway.js.map +1 -0
- package/dist/clock.d.ts +104 -0
- package/dist/clock.d.ts.map +1 -0
- package/dist/clock.js +183 -0
- package/dist/clock.js.map +1 -0
- package/dist/driad-discovery.d.ts +50 -0
- package/dist/driad-discovery.d.ts.map +1 -0
- package/dist/driad-discovery.js +170 -0
- package/dist/driad-discovery.js.map +1 -0
- package/dist/fec-client.d.ts +442 -0
- package/dist/fec-client.d.ts.map +1 -0
- package/dist/fec-client.js +784 -0
- package/dist/fec-client.js.map +1 -0
- package/dist/fec-client.test.d.ts +8 -0
- package/dist/fec-client.test.d.ts.map +1 -0
- package/dist/fec-client.test.js +112 -0
- package/dist/fec-client.test.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/transport-manager.d.ts +114 -0
- package/dist/transport-manager.d.ts.map +1 -0
- package/dist/transport-manager.js +396 -0
- package/dist/transport-manager.js.map +1 -0
- package/dist/types.d.ts +356 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +85 -0
- package/dist/types.js.map +1 -0
- package/package.json +60 -0
- package/src/abr-controller.ts +227 -0
- package/src/amt-gateway.ts +511 -0
- package/src/clock.ts +212 -0
- package/src/driad-discovery.ts +193 -0
- package/src/fec-client.test.ts +140 -0
- package/src/fec-client.ts +1097 -0
- package/src/index.ts +122 -0
- package/src/transport-manager.ts +460 -0
- package/src/types.ts +420 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blockcast/transport - DRIAD Discovery (RFC 8777)
|
|
3
|
+
*
|
|
4
|
+
* DRIAD (DNS Reverse IP AMT Discovery) discovers AMT relays based on the
|
|
5
|
+
* **source address**, NOT the multicast group. The source network operator
|
|
6
|
+
* configures DNS records for their source IPs to advertise which AMT relay(s)
|
|
7
|
+
* can tunnel their traffic.
|
|
8
|
+
*
|
|
9
|
+
* Example: For source 69.25.95.10 sending to group 232.0.0.1:
|
|
10
|
+
* Query: 10.95.25.69.amt.in-addr.arpa (source-based, NOT group-based)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { DRIADRelay } from "./types.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* DRIAD (DNS Reverse IP AMT Discovery) - RFC 8777
|
|
17
|
+
* Discovers AMT relays based on source address via DNS lookups
|
|
18
|
+
*/
|
|
19
|
+
export class DRIADDiscovery {
|
|
20
|
+
private cache = new Map<string, DRIADRelay[]>();
|
|
21
|
+
private cacheExpiry = 300_000; // 5 minutes
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Discover AMT relays for a multicast source address (RFC 8777)
|
|
25
|
+
*
|
|
26
|
+
* @param source - The multicast source IP address (NOT the group!)
|
|
27
|
+
* @returns Array of discovered AMT relays that can tunnel traffic from this source
|
|
28
|
+
*/
|
|
29
|
+
async discoverRelays(source: string): Promise<DRIADRelay[]> {
|
|
30
|
+
// Check cache first
|
|
31
|
+
const cached = this.cache.get(source);
|
|
32
|
+
if (cached) {
|
|
33
|
+
return cached;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
// Convert source to DRIAD format (reversed octets)
|
|
38
|
+
const reversedSource = source.split(".").reverse().join(".");
|
|
39
|
+
const queryDomain = `${reversedSource}.amt.in-addr.arpa`;
|
|
40
|
+
|
|
41
|
+
console.log(`[DRIAD] Discovering relays for source ${source} via ${queryDomain}`);
|
|
42
|
+
|
|
43
|
+
// In browser, we use a DRIAD proxy service or DNS-over-HTTPS
|
|
44
|
+
const relays = await this.queryDRIADProxy(source);
|
|
45
|
+
|
|
46
|
+
if (relays.length > 0) {
|
|
47
|
+
this.cache.set(source, relays);
|
|
48
|
+
setTimeout(() => this.cache.delete(source), this.cacheExpiry);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return relays;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.warn(`[DRIAD] Discovery failed for source ${source}:`, error);
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Query DRIAD proxy service (browser-compatible)
|
|
60
|
+
*/
|
|
61
|
+
private async queryDRIADProxy(source: string): Promise<DRIADRelay[]> {
|
|
62
|
+
// List of known DRIAD proxy endpoints
|
|
63
|
+
const proxyEndpoints = [
|
|
64
|
+
"https://driad.amt.net/api/v1/relays",
|
|
65
|
+
"https://multicast-gateway.cloudflare.com/driad",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
for (const endpoint of proxyEndpoints) {
|
|
69
|
+
try {
|
|
70
|
+
// Query by source address per RFC 8777
|
|
71
|
+
const response = await fetch(`${endpoint}?source=${encodeURIComponent(source)}`, {
|
|
72
|
+
method: "GET",
|
|
73
|
+
headers: { Accept: "application/json" },
|
|
74
|
+
signal: AbortSignal.timeout(5000),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (response.ok) {
|
|
78
|
+
const data = (await response.json()) as { relays: DRIADRelay[] };
|
|
79
|
+
return data.relays || [];
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// Try next proxy endpoint
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Fallback: Try DNS-over-HTTPS query
|
|
87
|
+
const dohRelay = await this.queryDNSOverHTTPS(source);
|
|
88
|
+
if (dohRelay) {
|
|
89
|
+
return [dohRelay];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Final fallback to well-known AMT relays
|
|
93
|
+
return [
|
|
94
|
+
{ host: "amt-relay.m2icast.net", port: 2268, priority: 10, weight: 100, asn: 0 },
|
|
95
|
+
{ host: "amt.akamaistream.net", port: 2268, priority: 20, weight: 50, asn: 0 },
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Query DNS-over-HTTPS for DRIAD relay based on source address (RFC 8777)
|
|
101
|
+
*/
|
|
102
|
+
private async queryDNSOverHTTPS(source: string): Promise<DRIADRelay | null> {
|
|
103
|
+
try {
|
|
104
|
+
// Build DRIAD query name from source address (reversed octets)
|
|
105
|
+
const reversedSource = source.split(".").reverse().join(".");
|
|
106
|
+
const queryName = `${reversedSource}.amt.in-addr.arpa`;
|
|
107
|
+
|
|
108
|
+
const dohUrl = `https://cloudflare-dns.com/dns-query?name=${queryName}&type=A`;
|
|
109
|
+
const response = await fetch(dohUrl, {
|
|
110
|
+
headers: { Accept: "application/dns-json" },
|
|
111
|
+
signal: AbortSignal.timeout(3000),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const result = (await response.json()) as { Answer?: Array<{ data: string }> };
|
|
119
|
+
|
|
120
|
+
// Extract first A/AAAA record
|
|
121
|
+
if (result.Answer && result.Answer.length > 0) {
|
|
122
|
+
return {
|
|
123
|
+
host: result.Answer[0].data,
|
|
124
|
+
port: 2268,
|
|
125
|
+
priority: 10,
|
|
126
|
+
weight: 100,
|
|
127
|
+
asn: 0,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return null;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
console.warn("[DRIAD] DNS-over-HTTPS query failed:", error);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Select best relay based on RTT measurements
|
|
140
|
+
*/
|
|
141
|
+
async selectBestRelay(relays: DRIADRelay[]): Promise<DRIADRelay | null> {
|
|
142
|
+
if (relays.length === 0) return null;
|
|
143
|
+
|
|
144
|
+
// Measure RTT to each relay
|
|
145
|
+
const measured = await Promise.all(
|
|
146
|
+
relays.map(async (relay) => {
|
|
147
|
+
const rtt = await this.measureRTT(relay.host, relay.port);
|
|
148
|
+
return { ...relay, rtt };
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Sort by priority, then by RTT
|
|
153
|
+
measured.sort((a, b) => {
|
|
154
|
+
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
155
|
+
return (a.rtt || Infinity) - (b.rtt || Infinity);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
console.log(`[DRIAD] Selected relay: ${measured[0].host} (RTT: ${measured[0].rtt}ms)`);
|
|
159
|
+
return measured[0];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async measureRTT(host: string, _port: number): Promise<number> {
|
|
163
|
+
const start = performance.now();
|
|
164
|
+
try {
|
|
165
|
+
// Use HTTP/HTTPS probe since we can't do UDP from browser
|
|
166
|
+
await fetch(`https://${host}/health`, {
|
|
167
|
+
method: "HEAD",
|
|
168
|
+
signal: AbortSignal.timeout(2000),
|
|
169
|
+
mode: "no-cors",
|
|
170
|
+
});
|
|
171
|
+
return performance.now() - start;
|
|
172
|
+
} catch {
|
|
173
|
+
return Infinity;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Clear the relay cache
|
|
179
|
+
*/
|
|
180
|
+
clearCache(): void {
|
|
181
|
+
this.cache.clear();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get cached relays for a source address
|
|
186
|
+
*/
|
|
187
|
+
getCached(source: string): DRIADRelay[] | undefined {
|
|
188
|
+
return this.cache.get(source);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Export singleton instance
|
|
193
|
+
export const driadDiscovery = new DRIADDiscovery();
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for FEC Client
|
|
3
|
+
*
|
|
4
|
+
* Note: MmtFecClient requires WASM module which is tested separately in mmt-wasm.
|
|
5
|
+
* These tests cover the pure TypeScript parts (MfuReassembler) and type exports.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it, beforeEach } from "vitest";
|
|
9
|
+
import type { FecDecoderConfig, MoqFecClientConfig } from "./fec-client.js";
|
|
10
|
+
import { MoqFecClient } from "./fec-client.js";
|
|
11
|
+
|
|
12
|
+
describe("FecDecoderConfig interface", () => {
|
|
13
|
+
it("should have correct shape", () => {
|
|
14
|
+
const config: FecDecoderConfig = {
|
|
15
|
+
symbolSize: 1280,
|
|
16
|
+
sourceBlocks: 1,
|
|
17
|
+
subBlocks: 1,
|
|
18
|
+
alignment: 8,
|
|
19
|
+
interleaveDepth: 30,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
expect(config.symbolSize).toBe(1280);
|
|
23
|
+
expect(config.sourceBlocks).toBe(1);
|
|
24
|
+
expect(config.subBlocks).toBe(1);
|
|
25
|
+
expect(config.alignment).toBe(8);
|
|
26
|
+
expect(config.interleaveDepth).toBe(30);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("MoqFecClient", () => {
|
|
31
|
+
let client: MoqFecClient;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
client = new MoqFecClient({
|
|
35
|
+
symbolSize: 1280,
|
|
36
|
+
k: 10,
|
|
37
|
+
p: 3,
|
|
38
|
+
interleaveDepth: 30,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should create with default config", () => {
|
|
43
|
+
const defaultClient = new MoqFecClient();
|
|
44
|
+
const config = defaultClient.getConfig();
|
|
45
|
+
|
|
46
|
+
expect(config.symbolSize).toBe(1280);
|
|
47
|
+
expect(config.k).toBe(10);
|
|
48
|
+
expect(config.p).toBe(3);
|
|
49
|
+
expect(config.interleaveDepth).toBe(30);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should create with custom config", () => {
|
|
53
|
+
const config = client.getConfig();
|
|
54
|
+
|
|
55
|
+
expect(config.symbolSize).toBe(1280);
|
|
56
|
+
expect(config.k).toBe(10);
|
|
57
|
+
expect(config.p).toBe(3);
|
|
58
|
+
expect(config.interleaveDepth).toBe(30);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should not be initialized without WASM module", () => {
|
|
62
|
+
expect(client.isInitialized()).toBe(false);
|
|
63
|
+
expect(client.isDecoderReady()).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should return default stats when not initialized", () => {
|
|
67
|
+
const stats = client.getStats();
|
|
68
|
+
|
|
69
|
+
expect(stats.blocksComplete).toBe(0);
|
|
70
|
+
expect(stats.blocksIncomplete).toBe(0);
|
|
71
|
+
expect(stats.totalSymbolsReceived).toBe(0);
|
|
72
|
+
expect(stats.effectiveLossRate).toBe(0);
|
|
73
|
+
expect(stats.recoveryRate).toBe(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should return ABR stats when not initialized", () => {
|
|
77
|
+
const abrStats = client.getAbrStats();
|
|
78
|
+
|
|
79
|
+
expect(abrStats.effectiveLossRate).toBe(0);
|
|
80
|
+
expect(abrStats.rawLossRate).toBe(0);
|
|
81
|
+
expect(abrStats.recoveryRate).toBe(1);
|
|
82
|
+
expect(abrStats.fecEnabled).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("should calculate max block size", () => {
|
|
86
|
+
// Without WASM, uses config values: k * symbolSize
|
|
87
|
+
const maxSize = client.getMaxBlockSize();
|
|
88
|
+
expect(maxSize).toBe(10 * 1280); // k=10, symbolSize=1280
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("should calculate overhead ratio", () => {
|
|
92
|
+
// Without WASM, uses config values: p / k
|
|
93
|
+
const overhead = client.getOverheadRatio();
|
|
94
|
+
expect(overhead).toBe(3 / 10); // p=3, k=10
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("should throw when encoding without initialization", () => {
|
|
98
|
+
expect(() => client.encodeBlock(new Uint8Array(100))).toThrow(
|
|
99
|
+
"MoqFecClient not initialized"
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should return null when adding source symbol without decoder", () => {
|
|
104
|
+
const result = client.addSourceSymbol(0, 0, new Uint8Array(100));
|
|
105
|
+
expect(result).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("should return null when adding repair symbol without decoder", () => {
|
|
109
|
+
const result = client.addRepairSymbol(0, 0, new Uint8Array(100));
|
|
110
|
+
expect(result).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("should dispose cleanly", () => {
|
|
114
|
+
client.dispose();
|
|
115
|
+
expect(client.isInitialized()).toBe(false);
|
|
116
|
+
expect(client.getOti()).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("should reject invalid OTI length", () => {
|
|
120
|
+
expect(() => client.setOti(new Uint8Array(10))).toThrow(
|
|
121
|
+
"OTI must be exactly 12 bytes"
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("MoqFecClientConfig type", () => {
|
|
127
|
+
it("should allow partial config", () => {
|
|
128
|
+
const partial: Partial<MoqFecClientConfig> = {
|
|
129
|
+
k: 20,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const client = new MoqFecClient(partial);
|
|
133
|
+
const config = client.getConfig();
|
|
134
|
+
|
|
135
|
+
expect(config.k).toBe(20);
|
|
136
|
+
expect(config.p).toBe(3); // default
|
|
137
|
+
expect(config.symbolSize).toBe(1280); // default
|
|
138
|
+
expect(config.interleaveDepth).toBe(30); // default
|
|
139
|
+
});
|
|
140
|
+
});
|