cairn-ts 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/README.md +43 -0
- package/dist/index.cjs +1883 -0
- package/dist/index.d.cts +572 -0
- package/dist/index.d.ts +572 -0
- package/dist/index.js +1827 -0
- package/eslint.config.js +24 -0
- package/package.json +54 -0
- package/src/channel.ts +277 -0
- package/src/config.ts +161 -0
- package/src/crypto/aead.ts +80 -0
- package/src/crypto/double-ratchet.ts +355 -0
- package/src/crypto/exchange.ts +51 -0
- package/src/crypto/hkdf.ts +33 -0
- package/src/crypto/identity.ts +84 -0
- package/src/crypto/index.ts +20 -0
- package/src/crypto/noise.ts +415 -0
- package/src/crypto/sas.ts +36 -0
- package/src/crypto/spake2.ts +169 -0
- package/src/discovery/index.ts +38 -0
- package/src/discovery/manager.ts +138 -0
- package/src/discovery/rendezvous.ts +189 -0
- package/src/discovery/tracker.ts +251 -0
- package/src/errors.ts +166 -0
- package/src/index.ts +57 -0
- package/src/mesh/index.ts +48 -0
- package/src/mesh/relay.ts +100 -0
- package/src/mesh/routing-table.ts +196 -0
- package/src/node.ts +619 -0
- package/src/pairing/adapter.ts +51 -0
- package/src/pairing/index.ts +40 -0
- package/src/pairing/link.ts +127 -0
- package/src/pairing/payload.ts +98 -0
- package/src/pairing/pin.ts +115 -0
- package/src/pairing/psk.ts +49 -0
- package/src/pairing/qr.ts +52 -0
- package/src/pairing/rate-limit.ts +134 -0
- package/src/pairing/sas-flow.ts +45 -0
- package/src/pairing/state-machine.ts +438 -0
- package/src/pairing/unpairing.ts +50 -0
- package/src/protocol/custom-handler.ts +52 -0
- package/src/protocol/envelope.ts +138 -0
- package/src/protocol/index.ts +36 -0
- package/src/protocol/message-types.ts +74 -0
- package/src/protocol/version.ts +98 -0
- package/src/server/index.ts +67 -0
- package/src/server/management.ts +285 -0
- package/src/server/store-forward.ts +266 -0
- package/src/session/backoff.ts +58 -0
- package/src/session/heartbeat.ts +79 -0
- package/src/session/index.ts +26 -0
- package/src/session/message-queue.ts +133 -0
- package/src/session/network-monitor.ts +130 -0
- package/src/session/state-machine.ts +122 -0
- package/src/session.ts +223 -0
- package/src/transport/fallback.ts +475 -0
- package/src/transport/index.ts +46 -0
- package/src/transport/libp2p-node.ts +158 -0
- package/src/transport/nat.ts +348 -0
- package/tests/conformance/cbor-vectors.test.ts +250 -0
- package/tests/integration/pairing-session.test.ts +317 -0
- package/tests/unit/config-api.test.ts +310 -0
- package/tests/unit/crypto.test.ts +407 -0
- package/tests/unit/discovery.test.ts +618 -0
- package/tests/unit/double-ratchet.test.ts +185 -0
- package/tests/unit/mesh.test.ts +349 -0
- package/tests/unit/noise.test.ts +346 -0
- package/tests/unit/pairing-extras.test.ts +402 -0
- package/tests/unit/pairing.test.ts +572 -0
- package/tests/unit/protocol.test.ts +438 -0
- package/tests/unit/reconnection.test.ts +402 -0
- package/tests/unit/scaffolding.test.ts +142 -0
- package/tests/unit/server.test.ts +492 -0
- package/tests/unit/sessions.test.ts +595 -0
- package/tests/unit/transport.test.ts +604 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
// Fallback chain
|
|
4
|
+
transportPriority,
|
|
5
|
+
transportDisplayName,
|
|
6
|
+
isTier0Available,
|
|
7
|
+
allTransportsInOrder,
|
|
8
|
+
FallbackChain,
|
|
9
|
+
DEFAULT_TRANSPORT_TIMEOUT_MS,
|
|
10
|
+
defaultConnectionQuality,
|
|
11
|
+
defaultQualityThresholds,
|
|
12
|
+
ConnectionQualityMonitor,
|
|
13
|
+
TransportMigrator,
|
|
14
|
+
// NAT detection
|
|
15
|
+
buildBindingRequest,
|
|
16
|
+
parseBindingResponse,
|
|
17
|
+
classifyNat,
|
|
18
|
+
NatDetector,
|
|
19
|
+
defaultNetworkInfo,
|
|
20
|
+
DEFAULT_STUN_SERVERS,
|
|
21
|
+
// libp2p node
|
|
22
|
+
defaultTransportConfig,
|
|
23
|
+
isNodeEnvironment,
|
|
24
|
+
isBrowserEnvironment,
|
|
25
|
+
BROWSER_TRANSPORT_CHAIN,
|
|
26
|
+
NODEJS_TRANSPORT_CHAIN,
|
|
27
|
+
} from '../../src/transport/index.js';
|
|
28
|
+
import type {
|
|
29
|
+
FallbackTransportType,
|
|
30
|
+
ConnectionQuality,
|
|
31
|
+
DegradationEvent,
|
|
32
|
+
MigrationEvent,
|
|
33
|
+
StunMappedAddress,
|
|
34
|
+
} from '../../src/transport/index.js';
|
|
35
|
+
import { TransportExhaustedError, CairnError } from '../../src/errors.js';
|
|
36
|
+
|
|
37
|
+
// --- FallbackTransportType ---
|
|
38
|
+
|
|
39
|
+
describe('FallbackTransportType', () => {
|
|
40
|
+
it('has 9 transport types in order', () => {
|
|
41
|
+
const all = allTransportsInOrder();
|
|
42
|
+
expect(all.length).toBe(9);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('priorities are sequential 1-9', () => {
|
|
46
|
+
const all = allTransportsInOrder();
|
|
47
|
+
for (let i = 0; i < all.length; i++) {
|
|
48
|
+
expect(transportPriority(all[i])).toBe(i + 1);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('tier0 availability', () => {
|
|
53
|
+
expect(isTier0Available('quic')).toBe(true);
|
|
54
|
+
expect(isTier0Available('stun-udp')).toBe(true);
|
|
55
|
+
expect(isTier0Available('tcp')).toBe(true);
|
|
56
|
+
expect(isTier0Available('turn-udp')).toBe(false);
|
|
57
|
+
expect(isTier0Available('turn-tcp')).toBe(false);
|
|
58
|
+
expect(isTier0Available('websocket-tls')).toBe(false);
|
|
59
|
+
expect(isTier0Available('webtransport')).toBe(false);
|
|
60
|
+
expect(isTier0Available('circuit-relay-v2')).toBe(true);
|
|
61
|
+
expect(isTier0Available('https-long-polling')).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('display names', () => {
|
|
65
|
+
expect(transportDisplayName('quic')).toBe('Direct QUIC v1');
|
|
66
|
+
expect(transportDisplayName('tcp')).toBe('Direct TCP');
|
|
67
|
+
expect(transportDisplayName('https-long-polling')).toBe('HTTPS long-polling (443)');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// --- FallbackChain construction ---
|
|
72
|
+
|
|
73
|
+
describe('FallbackChain', () => {
|
|
74
|
+
it('tier0 chain has correct availability', () => {
|
|
75
|
+
const chain = FallbackChain.tier0();
|
|
76
|
+
const transports = chain.transports;
|
|
77
|
+
expect(transports.length).toBe(9);
|
|
78
|
+
|
|
79
|
+
// Priorities 1-3, 8 should be available
|
|
80
|
+
expect(transports[0].available).toBe(true); // quic
|
|
81
|
+
expect(transports[1].available).toBe(true); // stun-udp
|
|
82
|
+
expect(transports[2].available).toBe(true); // tcp
|
|
83
|
+
expect(transports[3].available).toBe(false); // turn-udp
|
|
84
|
+
expect(transports[4].available).toBe(false); // turn-tcp
|
|
85
|
+
expect(transports[5].available).toBe(false); // websocket-tls
|
|
86
|
+
expect(transports[6].available).toBe(false); // webtransport
|
|
87
|
+
expect(transports[7].available).toBe(true); // circuit-relay-v2
|
|
88
|
+
expect(transports[8].available).toBe(false); // https-long-polling
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('full chain with TURN and relay', () => {
|
|
92
|
+
const chain = FallbackChain.create(10_000, true, true, false);
|
|
93
|
+
expect(chain.transports.every((t) => t.available)).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('default timeout is 10s', () => {
|
|
97
|
+
expect(DEFAULT_TRANSPORT_TIMEOUT_MS).toBe(10_000);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('tier0 has sequential mode', () => {
|
|
101
|
+
const chain = FallbackChain.tier0();
|
|
102
|
+
expect(chain.parallelMode).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('can create with parallel mode', () => {
|
|
106
|
+
const chain = FallbackChain.create(5000, false, false, true);
|
|
107
|
+
expect(chain.parallelMode).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// --- FallbackChain execution ---
|
|
112
|
+
|
|
113
|
+
describe('FallbackChain execution', () => {
|
|
114
|
+
it('sequential: first transport succeeds', async () => {
|
|
115
|
+
const chain = FallbackChain.tier0(5000);
|
|
116
|
+
const result = await chain.execute(async (tt) => {
|
|
117
|
+
if (tt === 'quic') return 42;
|
|
118
|
+
throw new Error('not implemented');
|
|
119
|
+
});
|
|
120
|
+
expect(result.transportType).toBe('quic');
|
|
121
|
+
expect(result.value).toBe(42);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('sequential: falls back to tcp', async () => {
|
|
125
|
+
const chain = FallbackChain.tier0(5000);
|
|
126
|
+
const result = await chain.execute(async (tt) => {
|
|
127
|
+
if (tt === 'tcp') return 'tcp_connected';
|
|
128
|
+
throw new Error(`${tt} failed`);
|
|
129
|
+
});
|
|
130
|
+
expect(result.transportType).toBe('tcp');
|
|
131
|
+
expect(result.value).toBe('tcp_connected');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('sequential: skips unavailable transports', async () => {
|
|
135
|
+
const chain = FallbackChain.tier0(5000);
|
|
136
|
+
const attempted: FallbackTransportType[] = [];
|
|
137
|
+
try {
|
|
138
|
+
await chain.execute(async (tt) => {
|
|
139
|
+
attempted.push(tt);
|
|
140
|
+
throw new Error(`${tt} failed`);
|
|
141
|
+
});
|
|
142
|
+
} catch {
|
|
143
|
+
// expected
|
|
144
|
+
}
|
|
145
|
+
// Should not attempt turn-udp, turn-tcp, websocket-tls, webtransport, https-long-polling
|
|
146
|
+
expect(attempted).not.toContain('turn-udp');
|
|
147
|
+
expect(attempted).not.toContain('turn-tcp');
|
|
148
|
+
expect(attempted).not.toContain('websocket-tls');
|
|
149
|
+
expect(attempted).not.toContain('webtransport');
|
|
150
|
+
expect(attempted).not.toContain('https-long-polling');
|
|
151
|
+
// Should attempt quic, stun-udp, tcp, circuit-relay-v2
|
|
152
|
+
expect(attempted).toContain('quic');
|
|
153
|
+
expect(attempted).toContain('stun-udp');
|
|
154
|
+
expect(attempted).toContain('tcp');
|
|
155
|
+
expect(attempted).toContain('circuit-relay-v2');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('sequential: all fail returns TransportExhaustedError', async () => {
|
|
159
|
+
const chain = FallbackChain.tier0(1000);
|
|
160
|
+
try {
|
|
161
|
+
await chain.execute(async (tt) => {
|
|
162
|
+
throw new Error(`${tt} failed`);
|
|
163
|
+
});
|
|
164
|
+
expect.unreachable('should have thrown');
|
|
165
|
+
} catch (e) {
|
|
166
|
+
expect(e).toBeInstanceOf(TransportExhaustedError);
|
|
167
|
+
const err = e as TransportExhaustedError;
|
|
168
|
+
expect(err.code).toBe('TRANSPORT_EXHAUSTED');
|
|
169
|
+
expect(err.details).toBeDefined();
|
|
170
|
+
expect(typeof err.details!.details).toBe('string');
|
|
171
|
+
expect(typeof err.details!.suggestion).toBe('string');
|
|
172
|
+
// Should mention skipped transports and suggest infrastructure
|
|
173
|
+
expect(err.details!.details).toContain('skipped');
|
|
174
|
+
expect(err.details!.suggestion).toContain('deploy companion infrastructure');
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('sequential: error includes transport names in details', async () => {
|
|
179
|
+
const chain = FallbackChain.tier0(1000);
|
|
180
|
+
try {
|
|
181
|
+
await chain.execute(async (tt) => {
|
|
182
|
+
throw new Error(`${tt} failed`);
|
|
183
|
+
});
|
|
184
|
+
} catch (e) {
|
|
185
|
+
const err = e as TransportExhaustedError;
|
|
186
|
+
const details = err.details!.details as string;
|
|
187
|
+
expect(details).toContain('Direct QUIC v1');
|
|
188
|
+
expect(details).toContain('Direct TCP');
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('parallel: first success wins', async () => {
|
|
193
|
+
const chain = FallbackChain.create(5000, false, false, true);
|
|
194
|
+
const result = await chain.execute(async (tt) => {
|
|
195
|
+
if (tt === 'tcp') {
|
|
196
|
+
// TCP "connects" instantly
|
|
197
|
+
return 'tcp_connected';
|
|
198
|
+
}
|
|
199
|
+
if (tt === 'quic') {
|
|
200
|
+
// QUIC is slower
|
|
201
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
202
|
+
return 'quic_connected';
|
|
203
|
+
}
|
|
204
|
+
throw new Error(`${tt} failed`);
|
|
205
|
+
});
|
|
206
|
+
// One of the fast ones should win
|
|
207
|
+
expect(['quic', 'tcp']).toContain(result.transportType);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('parallel: all fail returns TransportExhaustedError', async () => {
|
|
211
|
+
const chain = FallbackChain.create(1000, false, false, true);
|
|
212
|
+
try {
|
|
213
|
+
await chain.execute(async (tt) => {
|
|
214
|
+
throw new Error(`${tt} failed`);
|
|
215
|
+
});
|
|
216
|
+
expect.unreachable('should have thrown');
|
|
217
|
+
} catch (e) {
|
|
218
|
+
expect(e).toBeInstanceOf(TransportExhaustedError);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('all transports have infrastructure suggestion', async () => {
|
|
223
|
+
// When ALL transports are available and all fail, suggestion is about connectivity
|
|
224
|
+
const chain = FallbackChain.create(1000, true, true, false);
|
|
225
|
+
try {
|
|
226
|
+
await chain.execute(async (tt) => {
|
|
227
|
+
throw new Error(`${tt} failed`);
|
|
228
|
+
});
|
|
229
|
+
} catch (e) {
|
|
230
|
+
const err = e as TransportExhaustedError;
|
|
231
|
+
expect(err.details!.suggestion).toContain('check network connectivity');
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// --- ConnectionQuality ---
|
|
237
|
+
|
|
238
|
+
describe('ConnectionQuality', () => {
|
|
239
|
+
it('default quality metrics', () => {
|
|
240
|
+
const q = defaultConnectionQuality();
|
|
241
|
+
expect(q.latencyMs).toBe(0);
|
|
242
|
+
expect(q.jitterMs).toBe(0);
|
|
243
|
+
expect(q.packetLossRatio).toBe(0);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('default quality thresholds', () => {
|
|
247
|
+
const t = defaultQualityThresholds();
|
|
248
|
+
expect(t.maxLatencyMs).toBe(500);
|
|
249
|
+
expect(t.maxJitterMs).toBe(100);
|
|
250
|
+
expect(t.maxPacketLoss).toBe(0.05);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// --- ConnectionQualityMonitor ---
|
|
255
|
+
|
|
256
|
+
describe('ConnectionQualityMonitor', () => {
|
|
257
|
+
it('creates with default thresholds', () => {
|
|
258
|
+
const monitor = new ConnectionQualityMonitor();
|
|
259
|
+
expect(monitor.thresholds.maxLatencyMs).toBe(500);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('detects high latency', () => {
|
|
263
|
+
const monitor = new ConnectionQualityMonitor();
|
|
264
|
+
const good: ConnectionQuality = { latencyMs: 100, jitterMs: 10, packetLossRatio: 0.01 };
|
|
265
|
+
expect(monitor.isDegraded(good)).toBe(false);
|
|
266
|
+
|
|
267
|
+
const bad: ConnectionQuality = { latencyMs: 600, jitterMs: 10, packetLossRatio: 0.01 };
|
|
268
|
+
expect(monitor.isDegraded(bad)).toBe(true);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('detects high jitter', () => {
|
|
272
|
+
const monitor = new ConnectionQualityMonitor();
|
|
273
|
+
const bad: ConnectionQuality = { latencyMs: 100, jitterMs: 150, packetLossRatio: 0.01 };
|
|
274
|
+
expect(monitor.isDegraded(bad)).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('detects high packet loss', () => {
|
|
278
|
+
const monitor = new ConnectionQualityMonitor();
|
|
279
|
+
const bad: ConnectionQuality = { latencyMs: 100, jitterMs: 10, packetLossRatio: 0.10 };
|
|
280
|
+
expect(monitor.isDegraded(bad)).toBe(true);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('emits degradation event for high latency', () => {
|
|
284
|
+
const monitor = new ConnectionQualityMonitor();
|
|
285
|
+
const events: DegradationEvent[] = [];
|
|
286
|
+
monitor.onDegradation((e) => events.push(e));
|
|
287
|
+
|
|
288
|
+
const bad: ConnectionQuality = { latencyMs: 600, jitterMs: 10, packetLossRatio: 0.01 };
|
|
289
|
+
monitor.reportSample(bad);
|
|
290
|
+
|
|
291
|
+
expect(events.length).toBe(1);
|
|
292
|
+
expect(events[0].reason).toBe('high_latency');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('emits multiple degradation events', () => {
|
|
296
|
+
const monitor = new ConnectionQualityMonitor();
|
|
297
|
+
const events: DegradationEvent[] = [];
|
|
298
|
+
monitor.onDegradation((e) => events.push(e));
|
|
299
|
+
|
|
300
|
+
const bad: ConnectionQuality = { latencyMs: 600, jitterMs: 150, packetLossRatio: 0.10 };
|
|
301
|
+
monitor.reportSample(bad);
|
|
302
|
+
|
|
303
|
+
expect(events.length).toBe(3); // latency + jitter + packet loss
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('no event for good quality', () => {
|
|
307
|
+
const monitor = new ConnectionQualityMonitor();
|
|
308
|
+
const events: DegradationEvent[] = [];
|
|
309
|
+
monitor.onDegradation((e) => events.push(e));
|
|
310
|
+
|
|
311
|
+
const good: ConnectionQuality = { latencyMs: 50, jitterMs: 5, packetLossRatio: 0.001 };
|
|
312
|
+
monitor.reportSample(good);
|
|
313
|
+
|
|
314
|
+
expect(events.length).toBe(0);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('custom thresholds', () => {
|
|
318
|
+
const monitor = new ConnectionQualityMonitor({
|
|
319
|
+
maxLatencyMs: 200,
|
|
320
|
+
maxJitterMs: 50,
|
|
321
|
+
maxPacketLoss: 0.02,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const mid: ConnectionQuality = { latencyMs: 250, jitterMs: 30, packetLossRatio: 0.01 };
|
|
325
|
+
expect(monitor.isDegraded(mid)).toBe(true); // latency exceeds 200
|
|
326
|
+
|
|
327
|
+
const ok: ConnectionQuality = { latencyMs: 150, jitterMs: 30, packetLossRatio: 0.01 };
|
|
328
|
+
expect(monitor.isDegraded(ok)).toBe(false);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('sample interval', () => {
|
|
332
|
+
const monitor = new ConnectionQualityMonitor(undefined, 2000);
|
|
333
|
+
expect(monitor.sampleIntervalMs).toBe(2000);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// --- TransportMigrator ---
|
|
338
|
+
|
|
339
|
+
describe('TransportMigrator', () => {
|
|
340
|
+
it('probes better transports', () => {
|
|
341
|
+
const migrator = new TransportMigrator(30_000, 'websocket-tls'); // priority 6
|
|
342
|
+
const toProbe = migrator.transportsToProbe();
|
|
343
|
+
// Should probe priorities 1-5
|
|
344
|
+
expect(toProbe.length).toBe(5);
|
|
345
|
+
expect(toProbe[0]).toBe('quic');
|
|
346
|
+
expect(toProbe[4]).toBe('turn-tcp');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('quic has nothing better', () => {
|
|
350
|
+
const migrator = new TransportMigrator(30_000, 'quic'); // priority 1
|
|
351
|
+
expect(migrator.transportsToProbe().length).toBe(0);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('emits migration event', () => {
|
|
355
|
+
const migrator = new TransportMigrator(30_000, 'tcp'); // priority 3
|
|
356
|
+
const events: MigrationEvent[] = [];
|
|
357
|
+
migrator.onMigration((e) => events.push(e));
|
|
358
|
+
|
|
359
|
+
migrator.reportBetterTransport('quic');
|
|
360
|
+
|
|
361
|
+
expect(events.length).toBe(1);
|
|
362
|
+
expect(events[0].from).toBe('tcp');
|
|
363
|
+
expect(events[0].to).toBe('quic');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('rejects worse transport', () => {
|
|
367
|
+
const migrator = new TransportMigrator(30_000, 'tcp'); // priority 3
|
|
368
|
+
expect(() => migrator.reportBetterTransport('websocket-tls')).toThrow();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('rejects same priority transport', () => {
|
|
372
|
+
const migrator = new TransportMigrator(30_000, 'tcp');
|
|
373
|
+
expect(() => migrator.reportBetterTransport('tcp')).toThrow();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('set current updates probes', () => {
|
|
377
|
+
const migrator = new TransportMigrator(30_000, 'https-long-polling'); // priority 9
|
|
378
|
+
expect(migrator.transportsToProbe().length).toBe(8);
|
|
379
|
+
|
|
380
|
+
migrator.setCurrentTransport('quic');
|
|
381
|
+
expect(migrator.currentTransport).toBe('quic');
|
|
382
|
+
expect(migrator.transportsToProbe().length).toBe(0);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('probe interval', () => {
|
|
386
|
+
const migrator = new TransportMigrator(60_000, 'tcp');
|
|
387
|
+
expect(migrator.probeIntervalMs).toBe(60_000);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// --- STUN protocol ---
|
|
392
|
+
|
|
393
|
+
describe('STUN protocol', () => {
|
|
394
|
+
it('builds binding request (20 bytes)', () => {
|
|
395
|
+
const txnId = new Uint8Array(12).fill(0xAA);
|
|
396
|
+
const req = buildBindingRequest(txnId);
|
|
397
|
+
expect(req.length).toBe(20);
|
|
398
|
+
|
|
399
|
+
const view = new DataView(req.buffer);
|
|
400
|
+
// Type: Binding Request (0x0001)
|
|
401
|
+
expect(view.getUint16(0)).toBe(0x0001);
|
|
402
|
+
// Length: 0
|
|
403
|
+
expect(view.getUint16(2)).toBe(0);
|
|
404
|
+
// Magic Cookie
|
|
405
|
+
expect(view.getUint32(4)).toBe(0x2112_A442);
|
|
406
|
+
// Transaction ID
|
|
407
|
+
for (let i = 0; i < 12; i++) {
|
|
408
|
+
expect(req[8 + i]).toBe(0xAA);
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('rejects invalid transaction ID length', () => {
|
|
413
|
+
expect(() => buildBindingRequest(new Uint8Array(10))).toThrow();
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('parses binding response with XOR-MAPPED-ADDRESS IPv4', () => {
|
|
417
|
+
const txnId = new Uint8Array(12).fill(0xAA);
|
|
418
|
+
const MAGIC = 0x2112_A442;
|
|
419
|
+
|
|
420
|
+
// Build a response
|
|
421
|
+
const buf = new Uint8Array(32);
|
|
422
|
+
const view = new DataView(buf.buffer);
|
|
423
|
+
// Header
|
|
424
|
+
view.setUint16(0, 0x0101); // Binding Response
|
|
425
|
+
view.setUint16(2, 12); // message length (attr header 4 + data 8)
|
|
426
|
+
view.setUint32(4, MAGIC);
|
|
427
|
+
buf.set(txnId, 8);
|
|
428
|
+
|
|
429
|
+
// XOR-MAPPED-ADDRESS attribute
|
|
430
|
+
view.setUint16(20, 0x0020); // attr type
|
|
431
|
+
view.setUint16(22, 8); // attr len
|
|
432
|
+
buf[24] = 0x00; // reserved
|
|
433
|
+
buf[25] = 0x01; // IPv4
|
|
434
|
+
// XOR'd port: 12345 ^ (magic >> 16)
|
|
435
|
+
const port = 12345;
|
|
436
|
+
const xorPort = port ^ (MAGIC >>> 16);
|
|
437
|
+
view.setUint16(26, xorPort);
|
|
438
|
+
// XOR'd IP: 192.168.1.100 ^ magic
|
|
439
|
+
const ip = (192 << 24) | (168 << 16) | (1 << 8) | 100;
|
|
440
|
+
const xorIp = ip ^ MAGIC;
|
|
441
|
+
view.setUint32(28, xorIp);
|
|
442
|
+
|
|
443
|
+
const addr = parseBindingResponse(buf, txnId);
|
|
444
|
+
expect(addr.port).toBe(12345);
|
|
445
|
+
expect(addr.ip).toBe('192.168.1.100');
|
|
446
|
+
expect(addr.family).toBe('IPv4');
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('rejects short STUN response', () => {
|
|
450
|
+
const txnId = new Uint8Array(12);
|
|
451
|
+
expect(() => parseBindingResponse(new Uint8Array(10), txnId)).toThrow('too short');
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('rejects wrong message type', () => {
|
|
455
|
+
const txnId = new Uint8Array(12);
|
|
456
|
+
const buf = new Uint8Array(20);
|
|
457
|
+
const view = new DataView(buf.buffer);
|
|
458
|
+
view.setUint16(0, 0x0111); // wrong type
|
|
459
|
+
view.setUint32(4, 0x2112_A442);
|
|
460
|
+
buf.set(txnId, 8);
|
|
461
|
+
expect(() => parseBindingResponse(buf, txnId)).toThrow('unexpected STUN message type');
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('rejects wrong transaction ID', () => {
|
|
465
|
+
const txnId = new Uint8Array(12).fill(0xBB);
|
|
466
|
+
const wrongId = new Uint8Array(12).fill(0xCC);
|
|
467
|
+
const buf = new Uint8Array(20);
|
|
468
|
+
const view = new DataView(buf.buffer);
|
|
469
|
+
view.setUint16(0, 0x0101);
|
|
470
|
+
view.setUint32(4, 0x2112_A442);
|
|
471
|
+
buf.set(wrongId, 8);
|
|
472
|
+
expect(() => parseBindingResponse(buf, txnId)).toThrow('transaction ID mismatch');
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('rejects invalid magic cookie', () => {
|
|
476
|
+
const txnId = new Uint8Array(12);
|
|
477
|
+
const buf = new Uint8Array(20);
|
|
478
|
+
const view = new DataView(buf.buffer);
|
|
479
|
+
view.setUint16(0, 0x0101);
|
|
480
|
+
view.setUint32(4, 0xDEADBEEF); // wrong magic
|
|
481
|
+
buf.set(txnId, 8);
|
|
482
|
+
expect(() => parseBindingResponse(buf, txnId)).toThrow('invalid STUN magic cookie');
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('rejects response with no mapped address', () => {
|
|
486
|
+
const txnId = new Uint8Array(12);
|
|
487
|
+
const buf = new Uint8Array(20);
|
|
488
|
+
const view = new DataView(buf.buffer);
|
|
489
|
+
view.setUint16(0, 0x0101);
|
|
490
|
+
view.setUint16(2, 0); // no attributes
|
|
491
|
+
view.setUint32(4, 0x2112_A442);
|
|
492
|
+
buf.set(txnId, 8);
|
|
493
|
+
expect(() => parseBindingResponse(buf, txnId)).toThrow('no mapped address');
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// --- NAT classification ---
|
|
498
|
+
|
|
499
|
+
describe('NAT classification', () => {
|
|
500
|
+
it('empty is unknown', () => {
|
|
501
|
+
expect(classifyNat([])).toBe('unknown');
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('single server is unknown', () => {
|
|
505
|
+
expect(classifyNat([
|
|
506
|
+
{ server: '1.1.1.1:3478', mapped: { ip: '203.0.113.50', port: 54321, family: 'IPv4' } },
|
|
507
|
+
])).toBe('unknown');
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('same mapping is port_restricted_cone', () => {
|
|
511
|
+
const mapped: StunMappedAddress = { ip: '203.0.113.50', port: 54321, family: 'IPv4' };
|
|
512
|
+
expect(classifyNat([
|
|
513
|
+
{ server: '1.1.1.1:3478', mapped },
|
|
514
|
+
{ server: '8.8.8.8:3478', mapped },
|
|
515
|
+
])).toBe('port_restricted_cone');
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('different IPs is symmetric', () => {
|
|
519
|
+
expect(classifyNat([
|
|
520
|
+
{ server: '1.1.1.1:3478', mapped: { ip: '203.0.113.50', port: 54321, family: 'IPv4' } },
|
|
521
|
+
{ server: '8.8.8.8:3478', mapped: { ip: '203.0.113.51', port: 54321, family: 'IPv4' } },
|
|
522
|
+
])).toBe('symmetric');
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('different ports is symmetric', () => {
|
|
526
|
+
expect(classifyNat([
|
|
527
|
+
{ server: '1.1.1.1:3478', mapped: { ip: '203.0.113.50', port: 54321, family: 'IPv4' } },
|
|
528
|
+
{ server: '8.8.8.8:3478', mapped: { ip: '203.0.113.50', port: 54322, family: 'IPv4' } },
|
|
529
|
+
])).toBe('symmetric');
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// --- NatDetector ---
|
|
534
|
+
|
|
535
|
+
describe('NatDetector', () => {
|
|
536
|
+
it('creates with default STUN servers', () => {
|
|
537
|
+
const detector = new NatDetector();
|
|
538
|
+
expect(detector.stunServers.length).toBe(2);
|
|
539
|
+
expect(detector.timeoutMs).toBe(3000);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('creates with custom servers', () => {
|
|
543
|
+
const detector = new NatDetector([{ host: 'custom.stun.server', port: 3478 }], 5000);
|
|
544
|
+
expect(detector.stunServers.length).toBe(1);
|
|
545
|
+
expect(detector.timeoutMs).toBe(5000);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('empty servers returns unknown', async () => {
|
|
549
|
+
const detector = new NatDetector([], 1000);
|
|
550
|
+
const info = await detector.detect();
|
|
551
|
+
expect(info.natType).toBe('unknown');
|
|
552
|
+
expect(info.externalAddr).toBeUndefined();
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('default network info', () => {
|
|
556
|
+
const info = defaultNetworkInfo();
|
|
557
|
+
expect(info.natType).toBe('unknown');
|
|
558
|
+
expect(info.externalAddr).toBeUndefined();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('default STUN servers are configured', () => {
|
|
562
|
+
expect(DEFAULT_STUN_SERVERS.length).toBe(2);
|
|
563
|
+
expect(DEFAULT_STUN_SERVERS[0].host).toBe('stun.l.google.com');
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// --- Environment detection ---
|
|
568
|
+
|
|
569
|
+
describe('Environment detection', () => {
|
|
570
|
+
it('detects Node.js environment', () => {
|
|
571
|
+
// We're running in Node.js via vitest
|
|
572
|
+
expect(isNodeEnvironment()).toBe(true);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('detects non-browser environment', () => {
|
|
576
|
+
// We're running in Node.js, not a browser
|
|
577
|
+
expect(isBrowserEnvironment()).toBe(false);
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// --- Transport config ---
|
|
582
|
+
|
|
583
|
+
describe('Transport config', () => {
|
|
584
|
+
it('default config', () => {
|
|
585
|
+
const config = defaultTransportConfig();
|
|
586
|
+
expect(config.quicEnabled).toBe(true);
|
|
587
|
+
expect(config.tcpEnabled).toBe(true);
|
|
588
|
+
expect(config.websocketEnabled).toBe(true);
|
|
589
|
+
expect(config.webtransportEnabled).toBe(true);
|
|
590
|
+
expect(config.webrtcEnabled).toBe(true);
|
|
591
|
+
expect(config.circuitRelayEnabled).toBe(true);
|
|
592
|
+
expect(config.perTransportTimeoutMs).toBe(10_000);
|
|
593
|
+
expect(config.stunServers.length).toBe(2);
|
|
594
|
+
expect(config.turnServers.length).toBe(0);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it('browser transport chain has 3 entries', () => {
|
|
598
|
+
expect(BROWSER_TRANSPORT_CHAIN.length).toBe(3);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it('Node.js transport chain has 9 entries', () => {
|
|
602
|
+
expect(NODEJS_TRANSPORT_CHAIN.length).toBe(9);
|
|
603
|
+
});
|
|
604
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"declaration": true,
|
|
11
|
+
"declarationMap": true,
|
|
12
|
+
"sourceMap": true,
|
|
13
|
+
"outDir": "./dist",
|
|
14
|
+
"rootDir": "./src",
|
|
15
|
+
"resolveJsonModule": true,
|
|
16
|
+
"isolatedModules": true
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*.ts"],
|
|
19
|
+
"exclude": ["node_modules", "dist", "tests"]
|
|
20
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
resolve: {
|
|
6
|
+
alias: {
|
|
7
|
+
'cairn-ts/src/': resolve(__dirname, 'src/'),
|
|
8
|
+
'cairn-ts': resolve(__dirname, 'src/index.ts'),
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
test: {
|
|
12
|
+
globals: true,
|
|
13
|
+
include: ['tests/**/*.test.ts', 'src/**/*.test.ts'],
|
|
14
|
+
},
|
|
15
|
+
});
|