cairn-p2p 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,595 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import {
3
+ SessionStateMachine,
4
+ isValidTransition,
5
+ } from '../../src/session/state-machine.js';
6
+ import type {
7
+ ConnectionState,
8
+ StateChangedEvent,
9
+ } from '../../src/session/state-machine.js';
10
+ import {
11
+ Channel,
12
+ ChannelManager,
13
+ validateChannelName,
14
+ encodeChannelInit,
15
+ decodeChannelInit,
16
+ createDataMessage,
17
+ RESERVED_CHANNEL_PREFIX,
18
+ CHANNEL_FORWARD,
19
+ CHANNEL_INIT_TYPE,
20
+ } from '../../src/channel.js';
21
+ import type { ChannelEvent, DataMessage } from '../../src/channel.js';
22
+ import { Session, DEFAULT_SESSION_EXPIRY_MS } from '../../src/session.js';
23
+ import { CairnError } from '../../src/errors.js';
24
+
25
+ // --- isValidTransition ---
26
+
27
+ describe('isValidTransition', () => {
28
+ it('all 10 valid transitions', () => {
29
+ const valid: [ConnectionState, ConnectionState][] = [
30
+ ['connected', 'unstable'],
31
+ ['connected', 'disconnected'],
32
+ ['unstable', 'disconnected'],
33
+ ['unstable', 'connected'],
34
+ ['disconnected', 'reconnecting'],
35
+ ['reconnecting', 'reconnected'],
36
+ ['reconnecting', 'suspended'],
37
+ ['suspended', 'reconnecting'],
38
+ ['suspended', 'failed'],
39
+ ['reconnected', 'connected'],
40
+ ];
41
+ for (const [from, to] of valid) {
42
+ expect(isValidTransition(from, to)).toBe(true);
43
+ }
44
+ });
45
+
46
+ it('invalid transitions', () => {
47
+ const invalid: [ConnectionState, ConnectionState][] = [
48
+ ['connected', 'failed'],
49
+ ['connected', 'reconnecting'],
50
+ ['connected', 'reconnected'],
51
+ ['connected', 'suspended'],
52
+ ['disconnected', 'connected'],
53
+ ['disconnected', 'failed'],
54
+ ['reconnecting', 'connected'],
55
+ ['reconnecting', 'failed'],
56
+ ['reconnected', 'failed'],
57
+ ['reconnected', 'disconnected'],
58
+ ['failed', 'connected'],
59
+ ['failed', 'reconnecting'],
60
+ ];
61
+ for (const [from, to] of invalid) {
62
+ expect(isValidTransition(from, to)).toBe(false);
63
+ }
64
+ });
65
+
66
+ it('self-transitions are invalid', () => {
67
+ const states: ConnectionState[] = [
68
+ 'connected', 'unstable', 'disconnected', 'reconnecting',
69
+ 'suspended', 'reconnected', 'failed',
70
+ ];
71
+ for (const state of states) {
72
+ expect(isValidTransition(state, state)).toBe(false);
73
+ }
74
+ });
75
+ });
76
+
77
+ // --- SessionStateMachine ---
78
+
79
+ describe('SessionStateMachine', () => {
80
+ it('creates with default connected state', () => {
81
+ const sm = new SessionStateMachine('sess-1');
82
+ expect(sm.state).toBe('connected');
83
+ expect(sm.sessionId).toBe('sess-1');
84
+ });
85
+
86
+ it('creates with custom initial state', () => {
87
+ const sm = new SessionStateMachine('sess-1', 'disconnected');
88
+ expect(sm.state).toBe('disconnected');
89
+ });
90
+
91
+ it('valid transition: connected -> unstable', () => {
92
+ const sm = new SessionStateMachine('sess-1');
93
+ sm.transition('unstable');
94
+ expect(sm.state).toBe('unstable');
95
+ });
96
+
97
+ it('valid transition: connected -> disconnected', () => {
98
+ const sm = new SessionStateMachine('sess-1');
99
+ sm.transition('disconnected', 'abrupt loss');
100
+ expect(sm.state).toBe('disconnected');
101
+ });
102
+
103
+ it('valid transition: unstable -> connected (recovered)', () => {
104
+ const sm = new SessionStateMachine('sess-1', 'unstable');
105
+ sm.transition('connected');
106
+ expect(sm.state).toBe('connected');
107
+ });
108
+
109
+ it('invalid transition: connected -> failed', () => {
110
+ const sm = new SessionStateMachine('sess-1');
111
+ expect(() => sm.transition('failed')).toThrow('invalid session state transition');
112
+ });
113
+
114
+ it('invalid transition does not change state', () => {
115
+ const sm = new SessionStateMachine('sess-1');
116
+ try { sm.transition('failed'); } catch {}
117
+ expect(sm.state).toBe('connected');
118
+ });
119
+
120
+ it('emits state_changed event', () => {
121
+ const sm = new SessionStateMachine('sess-1');
122
+ const events: StateChangedEvent[] = [];
123
+ sm.onStateChanged((e) => events.push(e));
124
+
125
+ sm.transition('unstable', 'high latency');
126
+
127
+ expect(events.length).toBe(1);
128
+ expect(events[0].sessionId).toBe('sess-1');
129
+ expect(events[0].fromState).toBe('connected');
130
+ expect(events[0].toState).toBe('unstable');
131
+ expect(events[0].reason).toBe('high latency');
132
+ expect(events[0].timestamp).toBeGreaterThan(0);
133
+ });
134
+
135
+ it('emits multiple events', () => {
136
+ const sm = new SessionStateMachine('sess-1');
137
+ const events: StateChangedEvent[] = [];
138
+ sm.onStateChanged((e) => events.push(e));
139
+
140
+ sm.transition('unstable');
141
+ sm.transition('disconnected');
142
+ sm.transition('reconnecting');
143
+
144
+ expect(events.length).toBe(3);
145
+ expect(events[0].fromState).toBe('connected');
146
+ expect(events[1].fromState).toBe('unstable');
147
+ expect(events[2].fromState).toBe('disconnected');
148
+ });
149
+
150
+ it('full reconnection cycle', () => {
151
+ const sm = new SessionStateMachine('sess-1');
152
+ sm.transition('unstable');
153
+ sm.transition('disconnected');
154
+ sm.transition('reconnecting');
155
+ sm.transition('reconnected');
156
+ sm.transition('connected');
157
+ expect(sm.state).toBe('connected');
158
+ });
159
+
160
+ it('suspended retry then fail cycle', () => {
161
+ const sm = new SessionStateMachine('sess-1');
162
+ sm.transition('disconnected');
163
+ sm.transition('reconnecting');
164
+ sm.transition('suspended');
165
+ sm.transition('reconnecting');
166
+ sm.transition('suspended');
167
+ sm.transition('failed', 'max retries');
168
+ expect(sm.state).toBe('failed');
169
+ });
170
+ });
171
+
172
+ // --- Channel name validation ---
173
+
174
+ describe('Channel name validation', () => {
175
+ it('valid names', () => {
176
+ expect(() => validateChannelName('my-channel')).not.toThrow();
177
+ expect(() => validateChannelName('data')).not.toThrow();
178
+ expect(() => validateChannelName('chat_room_1')).not.toThrow();
179
+ });
180
+
181
+ it('reserved prefix rejected', () => {
182
+ expect(() => validateChannelName('__cairn_forward')).toThrow('reserved prefix');
183
+ expect(() => validateChannelName('__cairn_custom')).toThrow('reserved prefix');
184
+ expect(() => validateChannelName('__cairn_')).toThrow('reserved prefix');
185
+ });
186
+
187
+ it('empty name rejected', () => {
188
+ expect(() => validateChannelName('')).toThrow('must not be empty');
189
+ });
190
+
191
+ it('reserved constants', () => {
192
+ expect(RESERVED_CHANNEL_PREFIX).toBe('__cairn_');
193
+ expect(CHANNEL_FORWARD).toBe('__cairn_forward');
194
+ expect(CHANNEL_FORWARD.startsWith(RESERVED_CHANNEL_PREFIX)).toBe(true);
195
+ expect(CHANNEL_INIT_TYPE).toBe(0x0303);
196
+ });
197
+ });
198
+
199
+ // --- Channel state transitions ---
200
+
201
+ describe('Channel', () => {
202
+ it('new channel is in opening state', () => {
203
+ const ch = new Channel('test', 1);
204
+ expect(ch.state).toBe('opening');
205
+ expect(ch.name).toBe('test');
206
+ expect(ch.streamId).toBe(1);
207
+ expect(ch.isOpen()).toBe(false);
208
+ });
209
+
210
+ it('accept transitions to open', () => {
211
+ const ch = new Channel('test', 1);
212
+ ch.accept();
213
+ expect(ch.state).toBe('open');
214
+ expect(ch.isOpen()).toBe(true);
215
+ });
216
+
217
+ it('reject transitions to rejected', () => {
218
+ const ch = new Channel('test', 1);
219
+ ch.reject();
220
+ expect(ch.state).toBe('rejected');
221
+ expect(ch.isOpen()).toBe(false);
222
+ });
223
+
224
+ it('close from open', () => {
225
+ const ch = new Channel('test', 1);
226
+ ch.accept();
227
+ ch.close();
228
+ expect(ch.state).toBe('closed');
229
+ expect(ch.isOpen()).toBe(false);
230
+ });
231
+
232
+ it('close from opening', () => {
233
+ const ch = new Channel('test', 1);
234
+ ch.close();
235
+ expect(ch.state).toBe('closed');
236
+ });
237
+
238
+ it('double accept rejected', () => {
239
+ const ch = new Channel('test', 1);
240
+ ch.accept();
241
+ expect(() => ch.accept()).toThrow();
242
+ });
243
+
244
+ it('accept after reject rejected', () => {
245
+ const ch = new Channel('test', 1);
246
+ ch.reject();
247
+ expect(() => ch.accept()).toThrow();
248
+ });
249
+
250
+ it('double close rejected', () => {
251
+ const ch = new Channel('test', 1);
252
+ ch.close();
253
+ expect(() => ch.close()).toThrow();
254
+ });
255
+
256
+ it('channel with metadata', () => {
257
+ const meta = new Uint8Array([0xCA, 0xFE]);
258
+ const ch = new Channel('test', 1, meta);
259
+ expect(ch.metadata).toEqual(meta);
260
+ });
261
+ });
262
+
263
+ // --- ChannelInit serialization ---
264
+
265
+ describe('ChannelInit serialization', () => {
266
+ it('roundtrip without metadata', () => {
267
+ const init = { channelName: 'my-channel' };
268
+ const encoded = encodeChannelInit(init);
269
+ const decoded = decodeChannelInit(encoded);
270
+ expect(decoded.channelName).toBe('my-channel');
271
+ expect(decoded.metadata).toBeUndefined();
272
+ });
273
+
274
+ it('roundtrip with metadata', () => {
275
+ const init = { channelName: 'data-stream', metadata: new Uint8Array([0x01, 0x02, 0x03]) };
276
+ const encoded = encodeChannelInit(init);
277
+ const decoded = decodeChannelInit(encoded);
278
+ expect(decoded.channelName).toBe('data-stream');
279
+ expect(decoded.metadata).toEqual(new Uint8Array([0x01, 0x02, 0x03]));
280
+ });
281
+ });
282
+
283
+ // --- DataMessage ---
284
+
285
+ describe('DataMessage', () => {
286
+ it('creates with UUID v7', () => {
287
+ const msg = createDataMessage(new Uint8Array([0xDE, 0xAD]));
288
+ expect(msg.msgId.length).toBe(16);
289
+ expect(msg.payload).toEqual(new Uint8Array([0xDE, 0xAD]));
290
+ // Version bits (byte 6): should be 0x7x
291
+ expect((msg.msgId[6] & 0xf0)).toBe(0x70);
292
+ // Variant bits (byte 8): should be 0b10xx_xxxx
293
+ expect((msg.msgId[8] & 0xc0)).toBe(0x80);
294
+ });
295
+
296
+ it('unique msg_ids', () => {
297
+ const msg1 = createDataMessage(new Uint8Array([]));
298
+ const msg2 = createDataMessage(new Uint8Array([]));
299
+ expect(msg1.msgId).not.toEqual(msg2.msgId);
300
+ });
301
+ });
302
+
303
+ // --- ChannelManager ---
304
+
305
+ describe('ChannelManager', () => {
306
+ it('open channel', () => {
307
+ const mgr = new ChannelManager();
308
+ const init = mgr.openChannel('chat', 1);
309
+ expect(init.channelName).toBe('chat');
310
+ expect(mgr.channelCount).toBe(1);
311
+ expect(mgr.getChannel(1)!.state).toBe('opening');
312
+ });
313
+
314
+ it('open reserved channel rejected', () => {
315
+ const mgr = new ChannelManager();
316
+ expect(() => mgr.openChannel('__cairn_forward', 1)).toThrow('reserved prefix');
317
+ expect(mgr.channelCount).toBe(0);
318
+ });
319
+
320
+ it('open duplicate stream rejected', () => {
321
+ const mgr = new ChannelManager();
322
+ mgr.openChannel('chat', 1);
323
+ expect(() => mgr.openChannel('other', 1)).toThrow('already has a channel');
324
+ });
325
+
326
+ it('handle channel init emits opened event', () => {
327
+ const mgr = new ChannelManager();
328
+ const events: ChannelEvent[] = [];
329
+ mgr.onEvent((e) => events.push(e));
330
+
331
+ mgr.handleChannelInit(5, { channelName: 'remote-ch', metadata: new Uint8Array([0xAB]) });
332
+
333
+ expect(mgr.channelCount).toBe(1);
334
+ expect(mgr.getChannel(5)!.name).toBe('remote-ch');
335
+ expect(events.length).toBe(1);
336
+ expect(events[0].type).toBe('opened');
337
+ if (events[0].type === 'opened') {
338
+ expect(events[0].channelName).toBe('remote-ch');
339
+ expect(events[0].streamId).toBe(5);
340
+ expect(events[0].metadata).toEqual(new Uint8Array([0xAB]));
341
+ }
342
+ });
343
+
344
+ it('accept channel', () => {
345
+ const mgr = new ChannelManager();
346
+ const events: ChannelEvent[] = [];
347
+ mgr.onEvent((e) => events.push(e));
348
+
349
+ mgr.handleChannelInit(1, { channelName: 'ch' });
350
+ mgr.acceptChannel(1);
351
+
352
+ expect(mgr.getChannel(1)!.state).toBe('open');
353
+ expect(events.length).toBe(2); // opened + accepted
354
+ expect(events[1].type).toBe('accepted');
355
+ });
356
+
357
+ it('reject channel', () => {
358
+ const mgr = new ChannelManager();
359
+ const events: ChannelEvent[] = [];
360
+ mgr.onEvent((e) => events.push(e));
361
+
362
+ mgr.handleChannelInit(1, { channelName: 'ch' });
363
+ mgr.rejectChannel(1, 'not allowed');
364
+
365
+ expect(mgr.getChannel(1)!.state).toBe('rejected');
366
+ expect(events[1].type).toBe('rejected');
367
+ if (events[1].type === 'rejected') {
368
+ expect(events[1].reason).toBe('not allowed');
369
+ }
370
+ });
371
+
372
+ it('data on open channel', () => {
373
+ const mgr = new ChannelManager();
374
+ const events: ChannelEvent[] = [];
375
+ mgr.onEvent((e) => events.push(e));
376
+
377
+ mgr.handleChannelInit(1, { channelName: 'data' });
378
+ mgr.acceptChannel(1);
379
+
380
+ const msg = createDataMessage(new Uint8Array([0x42]));
381
+ mgr.handleData(1, msg);
382
+
383
+ const dataEvent = events.find((e) => e.type === 'data');
384
+ expect(dataEvent).toBeDefined();
385
+ if (dataEvent?.type === 'data') {
386
+ expect(dataEvent.message.payload).toEqual(new Uint8Array([0x42]));
387
+ }
388
+ });
389
+
390
+ it('data on non-open channel rejected', () => {
391
+ const mgr = new ChannelManager();
392
+ mgr.handleChannelInit(1, { channelName: 'data' });
393
+ // Channel still in opening state
394
+ expect(() => mgr.handleData(1, createDataMessage(new Uint8Array([0x42])))).toThrow('not open');
395
+ });
396
+
397
+ it('data on unknown stream rejected', () => {
398
+ const mgr = new ChannelManager();
399
+ expect(() => mgr.handleData(99, createDataMessage(new Uint8Array([0x42])))).toThrow('no channel');
400
+ });
401
+
402
+ it('close channel', () => {
403
+ const mgr = new ChannelManager();
404
+ const events: ChannelEvent[] = [];
405
+ mgr.onEvent((e) => events.push(e));
406
+
407
+ mgr.handleChannelInit(1, { channelName: 'ch' });
408
+ mgr.acceptChannel(1);
409
+ mgr.closeChannel(1);
410
+
411
+ expect(mgr.getChannel(1)!.state).toBe('closed');
412
+ expect(events.find((e) => e.type === 'closed')).toBeDefined();
413
+ });
414
+
415
+ it('multiple channels', () => {
416
+ const mgr = new ChannelManager();
417
+ mgr.openChannel('ch1', 1);
418
+ mgr.openChannel('ch2', 2);
419
+ mgr.openChannel('ch3', 3);
420
+ expect(mgr.channelCount).toBe(3);
421
+
422
+ expect(mgr.getChannel(1)!.name).toBe('ch1');
423
+ expect(mgr.getChannel(2)!.name).toBe('ch2');
424
+ expect(mgr.getChannel(3)!.name).toBe('ch3');
425
+ expect(mgr.getChannel(4)).toBeUndefined();
426
+ });
427
+ });
428
+
429
+ // --- Session ---
430
+
431
+ describe('Session', () => {
432
+ it('creates with connected state', () => {
433
+ const session = new Session(new Uint8Array(32).fill(0x01));
434
+ expect(session.connectionState).toBe('connected');
435
+ expect(session.sequenceTx).toBe(0);
436
+ expect(session.sequenceRx).toBe(0);
437
+ expect(session.ratchetEpoch).toBe(0);
438
+ expect(session.expiryMs).toBe(DEFAULT_SESSION_EXPIRY_MS);
439
+ expect(session.id).toBeTruthy();
440
+ });
441
+
442
+ it('unique session IDs', () => {
443
+ const s1 = new Session(new Uint8Array(32));
444
+ const s2 = new Session(new Uint8Array(32));
445
+ expect(s1.id).not.toBe(s2.id);
446
+ });
447
+
448
+ it('custom expiry', () => {
449
+ const session = new Session(new Uint8Array(32), 3600_000);
450
+ expect(session.expiryMs).toBe(3600_000);
451
+ });
452
+
453
+ it('not expired immediately', () => {
454
+ const session = new Session(new Uint8Array(32));
455
+ expect(session.isExpired).toBe(false);
456
+ });
457
+
458
+ it('state transition', () => {
459
+ const session = new Session(new Uint8Array(32));
460
+ session.transition('unstable');
461
+ expect(session.connectionState).toBe('unstable');
462
+ });
463
+
464
+ it('invalid state transition throws', () => {
465
+ const session = new Session(new Uint8Array(32));
466
+ expect(() => session.transition('failed')).toThrow();
467
+ expect(session.connectionState).toBe('connected');
468
+ });
469
+
470
+ it('state_changed listener', () => {
471
+ const session = new Session(new Uint8Array(32));
472
+ const events: StateChangedEvent[] = [];
473
+ session.onStateChanged((e) => events.push(e));
474
+
475
+ session.transition('unstable', 'latency spike');
476
+
477
+ expect(events.length).toBe(1);
478
+ expect(events[0].fromState).toBe('connected');
479
+ expect(events[0].toState).toBe('unstable');
480
+ expect(events[0].reason).toBe('latency spike');
481
+ });
482
+
483
+ it('sequence counters', () => {
484
+ const session = new Session(new Uint8Array(32));
485
+ expect(session.nextSequenceTx()).toBe(0);
486
+ expect(session.nextSequenceTx()).toBe(1);
487
+ expect(session.nextSequenceTx()).toBe(2);
488
+ expect(session.sequenceTx).toBe(3);
489
+
490
+ session.setSequenceRx(42);
491
+ expect(session.sequenceRx).toBe(42);
492
+ });
493
+
494
+ it('ratchet epoch', () => {
495
+ const session = new Session(new Uint8Array(32));
496
+ expect(session.ratchetEpoch).toBe(0);
497
+ session.advanceRatchetEpoch();
498
+ expect(session.ratchetEpoch).toBe(1);
499
+ session.advanceRatchetEpoch();
500
+ expect(session.ratchetEpoch).toBe(2);
501
+ });
502
+
503
+ it('open channel', () => {
504
+ const session = new Session(new Uint8Array(32));
505
+ const ch = session.openChannel('chat');
506
+ expect(ch.name).toBe('chat');
507
+ expect(ch.state).toBe('opening');
508
+ expect(session.channelCount).toBe(1);
509
+ });
510
+
511
+ it('open reserved channel rejected', () => {
512
+ const session = new Session(new Uint8Array(32));
513
+ expect(() => session.openChannel('__cairn_forward')).toThrow('reserved prefix');
514
+ });
515
+
516
+ it('handle incoming channel init', () => {
517
+ const session = new Session(new Uint8Array(32));
518
+ const events: Array<{ channelName: string; streamId: number }> = [];
519
+ session.onChannelOpened((e) => events.push(e));
520
+
521
+ session.handleChannelInit(10, 'remote-ch');
522
+
523
+ expect(session.channelCount).toBe(1);
524
+ expect(session.getChannel(10)!.name).toBe('remote-ch');
525
+ expect(events.length).toBe(1);
526
+ expect(events[0].channelName).toBe('remote-ch');
527
+ });
528
+
529
+ it('accept and send on channel', () => {
530
+ const session = new Session(new Uint8Array(32));
531
+ session.handleChannelInit(1, 'data');
532
+ session.acceptChannel(1);
533
+
534
+ const ch = session.getChannel(1)!;
535
+ expect(ch.isOpen()).toBe(true);
536
+
537
+ const msg = session.send(ch, new Uint8Array([0xDE, 0xAD]));
538
+ expect(msg.msgId.length).toBe(16);
539
+ expect(msg.payload).toEqual(new Uint8Array([0xDE, 0xAD]));
540
+ expect(session.sequenceTx).toBe(1);
541
+ });
542
+
543
+ it('send on non-open channel throws', () => {
544
+ const session = new Session(new Uint8Array(32));
545
+ const ch = session.openChannel('chat');
546
+ // Channel is in opening state
547
+ expect(() => session.send(ch, new Uint8Array([1]))).toThrow('cannot send');
548
+ });
549
+
550
+ it('handle incoming data emits message', () => {
551
+ const session = new Session(new Uint8Array(32));
552
+ session.handleChannelInit(1, 'data');
553
+ session.acceptChannel(1);
554
+
555
+ const messages: Array<{ data: Uint8Array }> = [];
556
+ session.onMessage((e) => messages.push({ data: e.data }));
557
+
558
+ const msg = createDataMessage(new Uint8Array([0x42]));
559
+ session.handleData(1, msg);
560
+
561
+ expect(messages.length).toBe(1);
562
+ expect(messages[0].data).toEqual(new Uint8Array([0x42]));
563
+ });
564
+
565
+ it('full reconnection cycle', () => {
566
+ const session = new Session(new Uint8Array(32));
567
+ const events: StateChangedEvent[] = [];
568
+ session.onStateChanged((e) => events.push(e));
569
+
570
+ session.transition('unstable');
571
+ session.transition('disconnected');
572
+ session.transition('reconnecting');
573
+ session.advanceRatchetEpoch();
574
+ session.transition('reconnected');
575
+ session.transition('connected');
576
+
577
+ expect(session.connectionState).toBe('connected');
578
+ expect(session.ratchetEpoch).toBe(1);
579
+ expect(events.length).toBe(5);
580
+ });
581
+
582
+ it('close channel', () => {
583
+ const session = new Session(new Uint8Array(32));
584
+ const ch = session.openChannel('chat');
585
+ session.closeChannel(ch.streamId);
586
+ expect(ch.state).toBe('closed');
587
+ });
588
+
589
+ it('reject channel', () => {
590
+ const session = new Session(new Uint8Array(32));
591
+ session.handleChannelInit(1, 'ch');
592
+ session.rejectChannel(1, 'denied');
593
+ expect(session.getChannel(1)!.state).toBe('rejected');
594
+ });
595
+ });