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.
Files changed (76) hide show
  1. package/README.md +43 -0
  2. package/dist/index.cjs +1883 -0
  3. package/dist/index.d.cts +572 -0
  4. package/dist/index.d.ts +572 -0
  5. package/dist/index.js +1827 -0
  6. package/eslint.config.js +24 -0
  7. package/package.json +54 -0
  8. package/src/channel.ts +277 -0
  9. package/src/config.ts +161 -0
  10. package/src/crypto/aead.ts +80 -0
  11. package/src/crypto/double-ratchet.ts +355 -0
  12. package/src/crypto/exchange.ts +51 -0
  13. package/src/crypto/hkdf.ts +33 -0
  14. package/src/crypto/identity.ts +84 -0
  15. package/src/crypto/index.ts +20 -0
  16. package/src/crypto/noise.ts +415 -0
  17. package/src/crypto/sas.ts +36 -0
  18. package/src/crypto/spake2.ts +169 -0
  19. package/src/discovery/index.ts +38 -0
  20. package/src/discovery/manager.ts +138 -0
  21. package/src/discovery/rendezvous.ts +189 -0
  22. package/src/discovery/tracker.ts +251 -0
  23. package/src/errors.ts +166 -0
  24. package/src/index.ts +57 -0
  25. package/src/mesh/index.ts +48 -0
  26. package/src/mesh/relay.ts +100 -0
  27. package/src/mesh/routing-table.ts +196 -0
  28. package/src/node.ts +619 -0
  29. package/src/pairing/adapter.ts +51 -0
  30. package/src/pairing/index.ts +40 -0
  31. package/src/pairing/link.ts +127 -0
  32. package/src/pairing/payload.ts +98 -0
  33. package/src/pairing/pin.ts +115 -0
  34. package/src/pairing/psk.ts +49 -0
  35. package/src/pairing/qr.ts +52 -0
  36. package/src/pairing/rate-limit.ts +134 -0
  37. package/src/pairing/sas-flow.ts +45 -0
  38. package/src/pairing/state-machine.ts +438 -0
  39. package/src/pairing/unpairing.ts +50 -0
  40. package/src/protocol/custom-handler.ts +52 -0
  41. package/src/protocol/envelope.ts +138 -0
  42. package/src/protocol/index.ts +36 -0
  43. package/src/protocol/message-types.ts +74 -0
  44. package/src/protocol/version.ts +98 -0
  45. package/src/server/index.ts +67 -0
  46. package/src/server/management.ts +285 -0
  47. package/src/server/store-forward.ts +266 -0
  48. package/src/session/backoff.ts +58 -0
  49. package/src/session/heartbeat.ts +79 -0
  50. package/src/session/index.ts +26 -0
  51. package/src/session/message-queue.ts +133 -0
  52. package/src/session/network-monitor.ts +130 -0
  53. package/src/session/state-machine.ts +122 -0
  54. package/src/session.ts +223 -0
  55. package/src/transport/fallback.ts +475 -0
  56. package/src/transport/index.ts +46 -0
  57. package/src/transport/libp2p-node.ts +158 -0
  58. package/src/transport/nat.ts +348 -0
  59. package/tests/conformance/cbor-vectors.test.ts +250 -0
  60. package/tests/integration/pairing-session.test.ts +317 -0
  61. package/tests/unit/config-api.test.ts +310 -0
  62. package/tests/unit/crypto.test.ts +407 -0
  63. package/tests/unit/discovery.test.ts +618 -0
  64. package/tests/unit/double-ratchet.test.ts +185 -0
  65. package/tests/unit/mesh.test.ts +349 -0
  66. package/tests/unit/noise.test.ts +346 -0
  67. package/tests/unit/pairing-extras.test.ts +402 -0
  68. package/tests/unit/pairing.test.ts +572 -0
  69. package/tests/unit/protocol.test.ts +438 -0
  70. package/tests/unit/reconnection.test.ts +402 -0
  71. package/tests/unit/scaffolding.test.ts +142 -0
  72. package/tests/unit/server.test.ts +492 -0
  73. package/tests/unit/sessions.test.ts +595 -0
  74. package/tests/unit/transport.test.ts +604 -0
  75. package/tsconfig.json +20 -0
  76. 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
+ }
@@ -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
+ });