@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.
Files changed (46) hide show
  1. package/dist/abr-controller.d.ts +94 -0
  2. package/dist/abr-controller.d.ts.map +1 -0
  3. package/dist/abr-controller.js +174 -0
  4. package/dist/abr-controller.js.map +1 -0
  5. package/dist/amt-gateway.d.ts +160 -0
  6. package/dist/amt-gateway.d.ts.map +1 -0
  7. package/dist/amt-gateway.js +390 -0
  8. package/dist/amt-gateway.js.map +1 -0
  9. package/dist/clock.d.ts +104 -0
  10. package/dist/clock.d.ts.map +1 -0
  11. package/dist/clock.js +183 -0
  12. package/dist/clock.js.map +1 -0
  13. package/dist/driad-discovery.d.ts +50 -0
  14. package/dist/driad-discovery.d.ts.map +1 -0
  15. package/dist/driad-discovery.js +170 -0
  16. package/dist/driad-discovery.js.map +1 -0
  17. package/dist/fec-client.d.ts +442 -0
  18. package/dist/fec-client.d.ts.map +1 -0
  19. package/dist/fec-client.js +784 -0
  20. package/dist/fec-client.js.map +1 -0
  21. package/dist/fec-client.test.d.ts +8 -0
  22. package/dist/fec-client.test.d.ts.map +1 -0
  23. package/dist/fec-client.test.js +112 -0
  24. package/dist/fec-client.test.js.map +1 -0
  25. package/dist/index.d.ts +34 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +43 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/transport-manager.d.ts +114 -0
  30. package/dist/transport-manager.d.ts.map +1 -0
  31. package/dist/transport-manager.js +396 -0
  32. package/dist/transport-manager.js.map +1 -0
  33. package/dist/types.d.ts +356 -0
  34. package/dist/types.d.ts.map +1 -0
  35. package/dist/types.js +85 -0
  36. package/dist/types.js.map +1 -0
  37. package/package.json +60 -0
  38. package/src/abr-controller.ts +227 -0
  39. package/src/amt-gateway.ts +511 -0
  40. package/src/clock.ts +212 -0
  41. package/src/driad-discovery.ts +193 -0
  42. package/src/fec-client.test.ts +140 -0
  43. package/src/fec-client.ts +1097 -0
  44. package/src/index.ts +122 -0
  45. package/src/transport-manager.ts +460 -0
  46. 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
+ });