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,475 @@
|
|
|
1
|
+
import { CairnError, TransportExhaustedError } from '../errors.js';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Transport type (9-level fallback chain)
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
/** Transport type in the 9-level fallback chain (spec section 2). */
|
|
8
|
+
export type FallbackTransportType =
|
|
9
|
+
| 'quic'
|
|
10
|
+
| 'stun-udp'
|
|
11
|
+
| 'tcp'
|
|
12
|
+
| 'turn-udp'
|
|
13
|
+
| 'turn-tcp'
|
|
14
|
+
| 'websocket-tls'
|
|
15
|
+
| 'webtransport'
|
|
16
|
+
| 'circuit-relay-v2'
|
|
17
|
+
| 'https-long-polling';
|
|
18
|
+
|
|
19
|
+
/** All transport types in priority order. */
|
|
20
|
+
const ALL_IN_ORDER: FallbackTransportType[] = [
|
|
21
|
+
'quic',
|
|
22
|
+
'stun-udp',
|
|
23
|
+
'tcp',
|
|
24
|
+
'turn-udp',
|
|
25
|
+
'turn-tcp',
|
|
26
|
+
'websocket-tls',
|
|
27
|
+
'webtransport',
|
|
28
|
+
'circuit-relay-v2',
|
|
29
|
+
'https-long-polling',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/** Priority number (1 = best, 9 = worst). */
|
|
33
|
+
export function transportPriority(t: FallbackTransportType): number {
|
|
34
|
+
return ALL_IN_ORDER.indexOf(t) + 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Human-readable display name. */
|
|
38
|
+
export function transportDisplayName(t: FallbackTransportType): string {
|
|
39
|
+
const names: Record<FallbackTransportType, string> = {
|
|
40
|
+
'quic': 'Direct QUIC v1',
|
|
41
|
+
'stun-udp': 'STUN-assisted UDP hole punch',
|
|
42
|
+
'tcp': 'Direct TCP',
|
|
43
|
+
'turn-udp': 'TURN relay (UDP)',
|
|
44
|
+
'turn-tcp': 'TURN relay (TCP)',
|
|
45
|
+
'websocket-tls': 'WebSocket/TLS (443)',
|
|
46
|
+
'webtransport': 'WebTransport/HTTP3 (443)',
|
|
47
|
+
'circuit-relay-v2': 'Circuit Relay v2',
|
|
48
|
+
'https-long-polling': 'HTTPS long-polling (443)',
|
|
49
|
+
};
|
|
50
|
+
return names[t];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Whether this transport is available in Tier 0 (zero-config). */
|
|
54
|
+
export function isTier0Available(t: FallbackTransportType): boolean {
|
|
55
|
+
return t === 'quic' || t === 'stun-udp' || t === 'tcp' || t === 'circuit-relay-v2';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Get all transport types in priority order. */
|
|
59
|
+
export function allTransportsInOrder(): FallbackTransportType[] {
|
|
60
|
+
return [...ALL_IN_ORDER];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// TransportAttempt — single entry in the fallback chain
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/** Configuration for a single transport attempt in the fallback chain. */
|
|
68
|
+
export interface TransportAttempt {
|
|
69
|
+
priority: number;
|
|
70
|
+
transportType: FallbackTransportType;
|
|
71
|
+
timeoutMs: number;
|
|
72
|
+
available: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Result of attempting a single transport in the fallback chain. */
|
|
76
|
+
export interface TransportAttemptResult {
|
|
77
|
+
transportType: FallbackTransportType;
|
|
78
|
+
error?: string;
|
|
79
|
+
skipped: boolean;
|
|
80
|
+
durationMs: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatAttemptResult(r: TransportAttemptResult): string {
|
|
84
|
+
const name = transportDisplayName(r.transportType);
|
|
85
|
+
if (r.skipped) {
|
|
86
|
+
return `${name}: skipped (not configured)`;
|
|
87
|
+
}
|
|
88
|
+
if (r.error) {
|
|
89
|
+
return `${name}: failed (${r.error}) [${r.durationMs}ms]`;
|
|
90
|
+
}
|
|
91
|
+
return `${name}: success [${r.durationMs}ms]`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// FallbackChain — the 9-level transport priority chain engine
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/** Default per-transport timeout in milliseconds. */
|
|
99
|
+
export const DEFAULT_TRANSPORT_TIMEOUT_MS = 10_000;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Executes the 9-level transport priority chain (spec section 2).
|
|
103
|
+
*
|
|
104
|
+
* Supports both sequential and parallel (ICE-style) probing modes.
|
|
105
|
+
* In parallel mode, multiple transports are attempted concurrently
|
|
106
|
+
* and the first success wins.
|
|
107
|
+
*/
|
|
108
|
+
export class FallbackChain {
|
|
109
|
+
private readonly _transports: TransportAttempt[];
|
|
110
|
+
private readonly _parallelMode: boolean;
|
|
111
|
+
|
|
112
|
+
private constructor(transports: TransportAttempt[], parallelMode: boolean) {
|
|
113
|
+
this._transports = transports;
|
|
114
|
+
this._parallelMode = parallelMode;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Create a new fallback chain with the full 9-level priority list.
|
|
119
|
+
*
|
|
120
|
+
* `hasTurn` / `hasRelay443` control whether TURN and port-443 relays
|
|
121
|
+
* are available (Tier 1+ infrastructure).
|
|
122
|
+
*/
|
|
123
|
+
static create(
|
|
124
|
+
perTransportTimeoutMs: number,
|
|
125
|
+
hasTurn: boolean,
|
|
126
|
+
hasRelay443: boolean,
|
|
127
|
+
parallelMode: boolean,
|
|
128
|
+
): FallbackChain {
|
|
129
|
+
const transports: TransportAttempt[] = ALL_IN_ORDER.map((tt) => {
|
|
130
|
+
let available: boolean;
|
|
131
|
+
if (tt === 'turn-udp' || tt === 'turn-tcp') {
|
|
132
|
+
available = hasTurn;
|
|
133
|
+
} else if (tt === 'websocket-tls' || tt === 'webtransport' || tt === 'https-long-polling') {
|
|
134
|
+
available = hasRelay443;
|
|
135
|
+
} else {
|
|
136
|
+
available = true;
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
priority: transportPriority(tt),
|
|
140
|
+
transportType: tt,
|
|
141
|
+
timeoutMs: perTransportTimeoutMs,
|
|
142
|
+
available,
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
return new FallbackChain(transports, parallelMode);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Create a Tier 0 (zero-config) fallback chain. Only priorities 1-3 and 8 available. */
|
|
149
|
+
static tier0(perTransportTimeoutMs: number = DEFAULT_TRANSPORT_TIMEOUT_MS): FallbackChain {
|
|
150
|
+
return FallbackChain.create(perTransportTimeoutMs, false, false, false);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Get the transport attempts in priority order. */
|
|
154
|
+
get transports(): readonly TransportAttempt[] {
|
|
155
|
+
return this._transports;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Whether parallel probing is enabled. */
|
|
159
|
+
get parallelMode(): boolean {
|
|
160
|
+
return this._parallelMode;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Execute the fallback chain, attempting each transport in order.
|
|
165
|
+
*
|
|
166
|
+
* The `attemptFn` is called for each available transport and should
|
|
167
|
+
* return a value on success or throw on failure. The first success is
|
|
168
|
+
* returned. If all fail, throws `TransportExhaustedError`.
|
|
169
|
+
*
|
|
170
|
+
* In parallel mode, available transports are attempted concurrently
|
|
171
|
+
* and the first success wins.
|
|
172
|
+
*/
|
|
173
|
+
async execute<T>(
|
|
174
|
+
attemptFn: (transportType: FallbackTransportType, timeoutMs: number) => Promise<T>,
|
|
175
|
+
): Promise<{ transportType: FallbackTransportType; value: T }> {
|
|
176
|
+
if (this._parallelMode) {
|
|
177
|
+
return this.executeParallel(attemptFn);
|
|
178
|
+
}
|
|
179
|
+
return this.executeSequential(attemptFn);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Sequential execution: attempt each transport in priority order. */
|
|
183
|
+
private async executeSequential<T>(
|
|
184
|
+
attemptFn: (transportType: FallbackTransportType, timeoutMs: number) => Promise<T>,
|
|
185
|
+
): Promise<{ transportType: FallbackTransportType; value: T }> {
|
|
186
|
+
const results: TransportAttemptResult[] = [];
|
|
187
|
+
|
|
188
|
+
for (const attempt of this._transports) {
|
|
189
|
+
if (!attempt.available) {
|
|
190
|
+
results.push({
|
|
191
|
+
transportType: attempt.transportType,
|
|
192
|
+
skipped: true,
|
|
193
|
+
durationMs: 0,
|
|
194
|
+
});
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const start = Date.now();
|
|
199
|
+
try {
|
|
200
|
+
const value = await attemptFn(attempt.transportType, attempt.timeoutMs);
|
|
201
|
+
return { transportType: attempt.transportType, value };
|
|
202
|
+
} catch (e) {
|
|
203
|
+
const elapsed = Date.now() - start;
|
|
204
|
+
results.push({
|
|
205
|
+
transportType: attempt.transportType,
|
|
206
|
+
error: e instanceof Error ? e.message : String(e),
|
|
207
|
+
skipped: false,
|
|
208
|
+
durationMs: elapsed,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
throw buildTransportExhaustedError(results);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Parallel (ICE-style) execution: attempt all available concurrently, first success wins. */
|
|
217
|
+
private async executeParallel<T>(
|
|
218
|
+
attemptFn: (transportType: FallbackTransportType, timeoutMs: number) => Promise<T>,
|
|
219
|
+
): Promise<{ transportType: FallbackTransportType; value: T }> {
|
|
220
|
+
const skippedResults: TransportAttemptResult[] = [];
|
|
221
|
+
const promises: Promise<{ transportType: FallbackTransportType; value: T }>[] = [];
|
|
222
|
+
const abortController = new AbortController();
|
|
223
|
+
|
|
224
|
+
for (const attempt of this._transports) {
|
|
225
|
+
if (!attempt.available) {
|
|
226
|
+
skippedResults.push({
|
|
227
|
+
transportType: attempt.transportType,
|
|
228
|
+
skipped: true,
|
|
229
|
+
durationMs: 0,
|
|
230
|
+
});
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const transportType = attempt.transportType;
|
|
235
|
+
const timeoutMs = attempt.timeoutMs;
|
|
236
|
+
|
|
237
|
+
promises.push(
|
|
238
|
+
(async () => {
|
|
239
|
+
const value = await attemptFn(transportType, timeoutMs);
|
|
240
|
+
return { transportType, value };
|
|
241
|
+
})(),
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (promises.length === 0) {
|
|
246
|
+
throw buildTransportExhaustedError(skippedResults);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Race all promises; collect failures
|
|
250
|
+
const failedResults: TransportAttemptResult[] = [];
|
|
251
|
+
const settled = await Promise.allSettled(promises);
|
|
252
|
+
|
|
253
|
+
// First check for any success
|
|
254
|
+
for (const result of settled) {
|
|
255
|
+
if (result.status === 'fulfilled') {
|
|
256
|
+
abortController.abort();
|
|
257
|
+
return result.value;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// All failed — collect errors
|
|
262
|
+
for (const result of settled) {
|
|
263
|
+
if (result.status === 'rejected') {
|
|
264
|
+
const err = result.reason;
|
|
265
|
+
failedResults.push({
|
|
266
|
+
transportType: 'quic', // placeholder — we reconstruct below
|
|
267
|
+
error: err instanceof Error ? err.message : String(err),
|
|
268
|
+
skipped: false,
|
|
269
|
+
durationMs: 0,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
throw buildTransportExhaustedError([...skippedResults, ...failedResults]);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Build a TransportExhaustedError with detailed diagnostics. */
|
|
279
|
+
function buildTransportExhaustedError(results: TransportAttemptResult[]): TransportExhaustedError {
|
|
280
|
+
const details = results.map(formatAttemptResult).join('; ');
|
|
281
|
+
const hasSkipped = results.some((r) => r.skipped);
|
|
282
|
+
const suggestion = hasSkipped
|
|
283
|
+
? 'deploy companion infrastructure (TURN relay, WebSocket relay on port 443) to enable additional transport fallbacks'
|
|
284
|
+
: 'check network connectivity and firewall rules';
|
|
285
|
+
|
|
286
|
+
return new TransportExhaustedError(
|
|
287
|
+
`all transports exhausted: ${details}`,
|
|
288
|
+
{ details, suggestion, results },
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// ConnectionQuality — metrics
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
/** Connection quality metrics (spec FR-4.5, spec section 6). */
|
|
297
|
+
export interface ConnectionQuality {
|
|
298
|
+
/** Round-trip latency in ms. */
|
|
299
|
+
latencyMs: number;
|
|
300
|
+
/** Jitter (latency variance) in ms. */
|
|
301
|
+
jitterMs: number;
|
|
302
|
+
/** Packet loss ratio (0.0 = none, 1.0 = total loss). */
|
|
303
|
+
packetLossRatio: number;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Default connection quality (no metrics yet). */
|
|
307
|
+
export function defaultConnectionQuality(): ConnectionQuality {
|
|
308
|
+
return { latencyMs: 0, jitterMs: 0, packetLossRatio: 0 };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Thresholds that trigger proactive transport migration (spec FR-4.5). */
|
|
312
|
+
export interface QualityThresholds {
|
|
313
|
+
maxLatencyMs: number;
|
|
314
|
+
maxJitterMs: number;
|
|
315
|
+
maxPacketLoss: number;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Default quality thresholds. */
|
|
319
|
+
export function defaultQualityThresholds(): QualityThresholds {
|
|
320
|
+
return { maxLatencyMs: 500, maxJitterMs: 100, maxPacketLoss: 0.05 };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// DegradationEvent
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
/** Which quality threshold was exceeded. */
|
|
328
|
+
export type DegradationReason = 'high_latency' | 'high_jitter' | 'high_packet_loss';
|
|
329
|
+
|
|
330
|
+
/** Degradation event emitted when connection quality drops below thresholds. */
|
|
331
|
+
export interface DegradationEvent {
|
|
332
|
+
quality: ConnectionQuality;
|
|
333
|
+
reason: DegradationReason;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// ConnectionQualityMonitor
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
export type DegradationListener = (event: DegradationEvent) => void;
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Monitors connection quality and emits degradation events (spec FR-4.5).
|
|
344
|
+
*
|
|
345
|
+
* When any metric exceeds its threshold, a DegradationEvent is emitted
|
|
346
|
+
* to trigger the TransportMigrator.
|
|
347
|
+
*/
|
|
348
|
+
export class ConnectionQualityMonitor {
|
|
349
|
+
private readonly _thresholds: QualityThresholds;
|
|
350
|
+
private readonly _sampleIntervalMs: number;
|
|
351
|
+
private readonly _listeners: DegradationListener[] = [];
|
|
352
|
+
|
|
353
|
+
constructor(thresholds?: QualityThresholds, sampleIntervalMs: number = 1000) {
|
|
354
|
+
this._thresholds = thresholds ?? defaultQualityThresholds();
|
|
355
|
+
this._sampleIntervalMs = sampleIntervalMs;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
get thresholds(): QualityThresholds {
|
|
359
|
+
return this._thresholds;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
get sampleIntervalMs(): number {
|
|
363
|
+
return this._sampleIntervalMs;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
onDegradation(listener: DegradationListener): void {
|
|
367
|
+
this._listeners.push(listener);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Report a new quality sample. Checks thresholds and emits degradation events. */
|
|
371
|
+
reportSample(quality: ConnectionQuality): void {
|
|
372
|
+
if (quality.latencyMs > this._thresholds.maxLatencyMs) {
|
|
373
|
+
this.emit({ quality, reason: 'high_latency' });
|
|
374
|
+
}
|
|
375
|
+
if (quality.jitterMs > this._thresholds.maxJitterMs) {
|
|
376
|
+
this.emit({ quality, reason: 'high_jitter' });
|
|
377
|
+
}
|
|
378
|
+
if (quality.packetLossRatio > this._thresholds.maxPacketLoss) {
|
|
379
|
+
this.emit({ quality, reason: 'high_packet_loss' });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Check whether a quality sample exceeds any threshold (without emitting). */
|
|
384
|
+
isDegraded(quality: ConnectionQuality): boolean {
|
|
385
|
+
return (
|
|
386
|
+
quality.latencyMs > this._thresholds.maxLatencyMs ||
|
|
387
|
+
quality.jitterMs > this._thresholds.maxJitterMs ||
|
|
388
|
+
quality.packetLossRatio > this._thresholds.maxPacketLoss
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private emit(event: DegradationEvent): void {
|
|
393
|
+
for (const listener of this._listeners) {
|
|
394
|
+
listener(event);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
// MigrationEvent
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
/** Migration event indicating a better transport is available. */
|
|
404
|
+
export interface MigrationEvent {
|
|
405
|
+
from: FallbackTransportType;
|
|
406
|
+
to: FallbackTransportType;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// TransportMigrator
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
export type MigrationListener = (event: MigrationEvent) => void;
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Probes for better transports and triggers mid-session migration
|
|
417
|
+
* (spec FR-4.3, spec section 3).
|
|
418
|
+
*
|
|
419
|
+
* "Transport migration is invisible to the application."
|
|
420
|
+
*/
|
|
421
|
+
export class TransportMigrator {
|
|
422
|
+
private readonly _probeIntervalMs: number;
|
|
423
|
+
private _currentTransport: FallbackTransportType;
|
|
424
|
+
private readonly _listeners: MigrationListener[] = [];
|
|
425
|
+
|
|
426
|
+
constructor(probeIntervalMs: number, currentTransport: FallbackTransportType) {
|
|
427
|
+
this._probeIntervalMs = probeIntervalMs;
|
|
428
|
+
this._currentTransport = currentTransport;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
get probeIntervalMs(): number {
|
|
432
|
+
return this._probeIntervalMs;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
get currentTransport(): FallbackTransportType {
|
|
436
|
+
return this._currentTransport;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Update the active transport after a successful migration. */
|
|
440
|
+
setCurrentTransport(transport: FallbackTransportType): void {
|
|
441
|
+
this._currentTransport = transport;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
onMigration(listener: MigrationListener): void {
|
|
445
|
+
this._listeners.push(listener);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Get the list of transports to probe (those with better priority). */
|
|
449
|
+
transportsToProbe(): FallbackTransportType[] {
|
|
450
|
+
const currentPriority = transportPriority(this._currentTransport);
|
|
451
|
+
return allTransportsInOrder().filter((t) => transportPriority(t) < currentPriority);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Report that a probe found a better transport. Emits a migration event. */
|
|
455
|
+
reportBetterTransport(betterTransport: FallbackTransportType): void {
|
|
456
|
+
const betterPriority = transportPriority(betterTransport);
|
|
457
|
+
const currentPriority = transportPriority(this._currentTransport);
|
|
458
|
+
|
|
459
|
+
if (betterPriority >= currentPriority) {
|
|
460
|
+
throw new CairnError(
|
|
461
|
+
'TRANSPORT',
|
|
462
|
+
`proposed transport ${transportDisplayName(betterTransport)} (priority ${betterPriority}) is not better than current ${transportDisplayName(this._currentTransport)} (priority ${currentPriority})`,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const event: MigrationEvent = {
|
|
467
|
+
from: this._currentTransport,
|
|
468
|
+
to: betterTransport,
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
for (const listener of this._listeners) {
|
|
472
|
+
listener(event);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Transport module — libp2p integration, fallback chain, NAT (task 035)
|
|
2
|
+
|
|
3
|
+
export type { FallbackTransportType } from './fallback.js';
|
|
4
|
+
export {
|
|
5
|
+
transportPriority,
|
|
6
|
+
transportDisplayName,
|
|
7
|
+
isTier0Available,
|
|
8
|
+
allTransportsInOrder,
|
|
9
|
+
DEFAULT_TRANSPORT_TIMEOUT_MS,
|
|
10
|
+
FallbackChain,
|
|
11
|
+
defaultConnectionQuality,
|
|
12
|
+
defaultQualityThresholds,
|
|
13
|
+
ConnectionQualityMonitor,
|
|
14
|
+
TransportMigrator,
|
|
15
|
+
} from './fallback.js';
|
|
16
|
+
export type {
|
|
17
|
+
TransportAttempt,
|
|
18
|
+
TransportAttemptResult,
|
|
19
|
+
ConnectionQuality,
|
|
20
|
+
QualityThresholds,
|
|
21
|
+
DegradationReason,
|
|
22
|
+
DegradationEvent,
|
|
23
|
+
DegradationListener,
|
|
24
|
+
MigrationEvent,
|
|
25
|
+
MigrationListener,
|
|
26
|
+
} from './fallback.js';
|
|
27
|
+
|
|
28
|
+
export type { NatType, NetworkInfo, StunMappedAddress, StunServerConfig } from './nat.js';
|
|
29
|
+
export {
|
|
30
|
+
defaultNetworkInfo,
|
|
31
|
+
buildBindingRequest,
|
|
32
|
+
parseBindingResponse,
|
|
33
|
+
classifyNat,
|
|
34
|
+
DEFAULT_STUN_SERVERS,
|
|
35
|
+
NatDetector,
|
|
36
|
+
} from './nat.js';
|
|
37
|
+
|
|
38
|
+
export type { TransportConfig, CreateNodeOptions } from './libp2p-node.js';
|
|
39
|
+
export {
|
|
40
|
+
defaultTransportConfig,
|
|
41
|
+
isNodeEnvironment,
|
|
42
|
+
isBrowserEnvironment,
|
|
43
|
+
createCairnNode,
|
|
44
|
+
BROWSER_TRANSPORT_CHAIN,
|
|
45
|
+
NODEJS_TRANSPORT_CHAIN,
|
|
46
|
+
} from './libp2p-node.js';
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { Libp2p } from 'libp2p';
|
|
2
|
+
|
|
3
|
+
import type { FallbackTransportType } from './fallback.js';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Transport config
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
/** Per-transport enable/disable flags and timeout settings. */
|
|
10
|
+
export interface TransportConfig {
|
|
11
|
+
/** Enable QUIC v1 — priority 1 in the fallback chain. (Node.js only) */
|
|
12
|
+
quicEnabled: boolean;
|
|
13
|
+
/** Enable TCP — priority 3 in the fallback chain. (Node.js only) */
|
|
14
|
+
tcpEnabled: boolean;
|
|
15
|
+
/** Enable WebSocket over TLS — priority 6 in the fallback chain. */
|
|
16
|
+
websocketEnabled: boolean;
|
|
17
|
+
/** Enable WebTransport over HTTP/3 — priority 7 in the fallback chain. */
|
|
18
|
+
webtransportEnabled: boolean;
|
|
19
|
+
/** Enable WebRTC — browser environment. */
|
|
20
|
+
webrtcEnabled: boolean;
|
|
21
|
+
/** Enable Circuit Relay v2 — priority 8. */
|
|
22
|
+
circuitRelayEnabled: boolean;
|
|
23
|
+
/** Per-transport connection timeout in ms. */
|
|
24
|
+
perTransportTimeoutMs: number;
|
|
25
|
+
/** STUN server URLs for NAT detection. */
|
|
26
|
+
stunServers: string[];
|
|
27
|
+
/** TURN server URLs (enables priorities 4-5). */
|
|
28
|
+
turnServers: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Default transport configuration. */
|
|
32
|
+
export function defaultTransportConfig(): TransportConfig {
|
|
33
|
+
return {
|
|
34
|
+
quicEnabled: true,
|
|
35
|
+
tcpEnabled: true,
|
|
36
|
+
websocketEnabled: true,
|
|
37
|
+
webtransportEnabled: true,
|
|
38
|
+
webrtcEnabled: true,
|
|
39
|
+
circuitRelayEnabled: true,
|
|
40
|
+
perTransportTimeoutMs: 10_000,
|
|
41
|
+
stunServers: [
|
|
42
|
+
'stun:stun.l.google.com:19302',
|
|
43
|
+
'stun:stun1.l.google.com:19302',
|
|
44
|
+
],
|
|
45
|
+
turnServers: [],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Environment detection
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/** Detect whether we are running in a Node.js environment. */
|
|
54
|
+
export function isNodeEnvironment(): boolean {
|
|
55
|
+
return (
|
|
56
|
+
typeof globalThis.process !== 'undefined' &&
|
|
57
|
+
typeof globalThis.process.versions !== 'undefined' &&
|
|
58
|
+
typeof globalThis.process.versions.node !== 'undefined'
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Detect whether we are running in a browser environment. */
|
|
63
|
+
export function isBrowserEnvironment(): boolean {
|
|
64
|
+
return typeof globalThis.window !== 'undefined' && typeof globalThis.document !== 'undefined';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// libp2p node creation
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/** Options for creating a cairn libp2p node. */
|
|
72
|
+
export interface CreateNodeOptions {
|
|
73
|
+
config?: Partial<TransportConfig>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create a libp2p node with environment-conditional transports.
|
|
78
|
+
*
|
|
79
|
+
* - Node.js: TCP, WebSocket, Circuit Relay v2 (yamux + noise)
|
|
80
|
+
* - Browser: WebRTC, WebSocket, WebTransport, Circuit Relay v2 (yamux + noise)
|
|
81
|
+
*
|
|
82
|
+
* Uses dynamic `import()` so browser bundles don't include Node.js-only packages.
|
|
83
|
+
*/
|
|
84
|
+
export async function createCairnNode(options?: CreateNodeOptions): Promise<Libp2p> {
|
|
85
|
+
const config = { ...defaultTransportConfig(), ...options?.config };
|
|
86
|
+
const { createLibp2p } = await import('libp2p');
|
|
87
|
+
const { yamux } = await import('@libp2p/yamux');
|
|
88
|
+
const { noise } = await import('@chainsafe/libp2p-noise');
|
|
89
|
+
|
|
90
|
+
const transports: unknown[] = [];
|
|
91
|
+
|
|
92
|
+
if (isNodeEnvironment()) {
|
|
93
|
+
// Node.js transports
|
|
94
|
+
if (config.tcpEnabled) {
|
|
95
|
+
const { tcp } = await import('@libp2p/tcp');
|
|
96
|
+
transports.push(tcp());
|
|
97
|
+
}
|
|
98
|
+
if (config.websocketEnabled) {
|
|
99
|
+
const { webSockets } = await import('@libp2p/websockets');
|
|
100
|
+
transports.push(webSockets());
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
// Browser transports
|
|
104
|
+
if (config.webrtcEnabled) {
|
|
105
|
+
const { webRTC } = await import('@libp2p/webrtc');
|
|
106
|
+
transports.push(webRTC());
|
|
107
|
+
}
|
|
108
|
+
if (config.websocketEnabled) {
|
|
109
|
+
const { webSockets } = await import('@libp2p/websockets');
|
|
110
|
+
transports.push(webSockets());
|
|
111
|
+
}
|
|
112
|
+
if (config.webtransportEnabled) {
|
|
113
|
+
const { webTransport } = await import('@libp2p/webtransport');
|
|
114
|
+
transports.push(webTransport());
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (config.circuitRelayEnabled) {
|
|
119
|
+
const { circuitRelayTransport } = await import('@libp2p/circuit-relay-v2');
|
|
120
|
+
transports.push(circuitRelayTransport());
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const node = await createLibp2p({
|
|
124
|
+
transports: transports as any[],
|
|
125
|
+
streamMuxers: [yamux()],
|
|
126
|
+
connectionEncrypters: [noise()],
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return node;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Browser transport chain (3 levels)
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
/** Browser transport chain: WebRTC -> WebSocket -> WebTransport. */
|
|
137
|
+
export const BROWSER_TRANSPORT_CHAIN: FallbackTransportType[] = [
|
|
138
|
+
'quic', // WebRTC direct maps conceptually to the "best effort direct" slot
|
|
139
|
+
'websocket-tls',
|
|
140
|
+
'webtransport',
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Node.js transport chain (9 levels)
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
/** Node.js transport chain: full 9-level priority. */
|
|
148
|
+
export const NODEJS_TRANSPORT_CHAIN: FallbackTransportType[] = [
|
|
149
|
+
'quic',
|
|
150
|
+
'stun-udp',
|
|
151
|
+
'tcp',
|
|
152
|
+
'turn-udp',
|
|
153
|
+
'turn-tcp',
|
|
154
|
+
'websocket-tls',
|
|
155
|
+
'webtransport',
|
|
156
|
+
'circuit-relay-v2',
|
|
157
|
+
'https-long-polling',
|
|
158
|
+
];
|