node-rtc-connection 1.0.18 → 2.0.4
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 +94 -85
- package/dist/index.cjs +20 -5421
- package/dist/index.mjs +25 -5413
- package/dist/types/crypto/der.d.ts +107 -0
- package/dist/types/crypto/x509.d.ts +56 -0
- package/dist/types/datachannel/RTCDataChannel.d.ts +179 -0
- package/dist/types/dtls/RTCCertificate.d.ts +163 -0
- package/dist/types/dtls/cipher.d.ts +81 -0
- package/dist/types/dtls/connection.d.ts +81 -0
- package/dist/types/dtls/prf.d.ts +29 -0
- package/dist/types/dtls/protocol.d.ts +127 -0
- package/dist/types/foundation/ByteBufferQueue.d.ts +71 -0
- package/dist/types/foundation/RTCError.d.ts +152 -0
- package/dist/types/ice/RTCIceCandidate.d.ts +161 -0
- package/dist/types/ice/ice-agent.d.ts +154 -0
- package/dist/types/ice/stun-message.d.ts +92 -0
- package/dist/types/index.d.ts +29 -0
- package/dist/types/peerconnection/RTCPeerConnection.d.ts +74 -0
- package/dist/types/sctp/association.d.ts +77 -0
- package/dist/types/sctp/chunks.d.ts +200 -0
- package/dist/types/sctp/crc32c.d.ts +24 -0
- package/dist/types/sctp/datachannel-manager.d.ts +51 -0
- package/dist/types/sctp/dcep.d.ts +56 -0
- package/dist/types/sdp/RTCSessionDescription.d.ts +73 -0
- package/dist/types/sdp/sdp-utils.d.ts +103 -0
- package/dist/types/stun/stun-client.d.ts +119 -0
- package/dist/types/transport-stack.d.ts +68 -0
- package/package.json +26 -21
- package/src/crypto/der.ts +205 -0
- package/src/crypto/x509.ts +146 -0
- package/src/datachannel/RTCDataChannel.ts +388 -0
- package/src/dtls/RTCCertificate.ts +396 -0
- package/src/dtls/cipher.ts +198 -0
- package/src/dtls/connection.ts +974 -0
- package/src/dtls/prf.ts +62 -0
- package/src/dtls/protocol.ts +204 -0
- package/src/foundation/{ByteBufferQueue.js → ByteBufferQueue.ts} +74 -72
- package/src/foundation/{RTCError.js → RTCError.ts} +110 -60
- package/src/ice/{RTCIceCandidate.js → RTCIceCandidate.ts} +140 -92
- package/src/ice/ice-agent.ts +609 -0
- package/src/ice/stun-message.ts +260 -0
- package/src/index.ts +72 -0
- package/src/peerconnection/RTCPeerConnection.ts +430 -0
- package/src/sctp/association.ts +523 -0
- package/src/sctp/chunks.ts +350 -0
- package/src/sctp/crc32c.ts +57 -0
- package/src/sctp/datachannel-manager.ts +187 -0
- package/src/sctp/dcep.ts +94 -0
- package/src/sdp/{RTCSessionDescription.js → RTCSessionDescription.ts} +42 -29
- package/src/sdp/sdp-utils.ts +229 -0
- package/src/stun/stun-client.ts +936 -0
- package/src/transport-stack.ts +165 -0
- package/dist/index.cjs.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/src/datachannel/RTCDataChannel.js +0 -354
- package/src/dtls/RTCCertificate.js +0 -310
- package/src/dtls/RTCDtlsTransport.js +0 -247
- package/src/ice/RTCIceTransport.js +0 -998
- package/src/index.d.ts +0 -400
- package/src/index.js +0 -92
- package/src/network/network-transport.js +0 -478
- package/src/peerconnection/RTCPeerConnection.js +0 -851
- package/src/sctp/RTCSctpTransport.js +0 -253
- package/src/sdp/sdp-utils.js +0 -224
- package/src/stun/stun-client.js +0 -643
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file association.ts
|
|
3
|
+
* @description Minimal SCTP association over a datagram channel (DTLS), scoped
|
|
4
|
+
* to the WebRTC data channel profile (RFC 8831).
|
|
5
|
+
* @module sctp/association
|
|
6
|
+
*
|
|
7
|
+
* Implements the four-way INIT/INIT-ACK/COOKIE-ECHO/COOKIE-ACK setup, DATA
|
|
8
|
+
* transmit/receive with TSN tracking and SACK, ordered and unordered delivery,
|
|
9
|
+
* and reassembly of fragmented user messages. Congestion control is
|
|
10
|
+
* intentionally simple (stop-and-go style with a generous rwnd) which is
|
|
11
|
+
* adequate for control/data-channel traffic and interoperates with usrsctp
|
|
12
|
+
* (the stack browsers use).
|
|
13
|
+
*
|
|
14
|
+
* The association rides on top of a reliable-ish datagram pipe provided by
|
|
15
|
+
* DTLS; SCTP still provides framing, ordering, multiplexing and ack'ing.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
import * as crypto from 'crypto';
|
|
21
|
+
import { EventEmitter } from 'events';
|
|
22
|
+
import * as C from './chunks';
|
|
23
|
+
import { applyChecksum, verifyChecksum } from './crc32c';
|
|
24
|
+
|
|
25
|
+
const SCTP_PORT = 5000; // WebRTC uses 5000 on both ends
|
|
26
|
+
const DEFAULT_RWND = 1024 * 1024;
|
|
27
|
+
const MAX_PAYLOAD = 1200; // fragment user messages above this (fits DTLS/MTU)
|
|
28
|
+
const RTO_INITIAL = 500;
|
|
29
|
+
const RTO_MAX = 5000;
|
|
30
|
+
|
|
31
|
+
const STATE = Object.freeze({
|
|
32
|
+
CLOSED: 'closed',
|
|
33
|
+
COOKIE_WAIT: 'cookie-wait',
|
|
34
|
+
COOKIE_ECHOED: 'cookie-echoed',
|
|
35
|
+
ESTABLISHED: 'established',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
type StateValue = (typeof STATE)[keyof typeof STATE];
|
|
39
|
+
|
|
40
|
+
/** Options for constructing an {@link SctpAssociation}. */
|
|
41
|
+
export interface SctpAssociationOptions {
|
|
42
|
+
/** the DTLS client initiates SCTP (RFC 8831) */
|
|
43
|
+
isClient?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A complete user message surfaced via the 'message' event. */
|
|
47
|
+
export interface SctpMessage {
|
|
48
|
+
streamId: number;
|
|
49
|
+
ppid: number;
|
|
50
|
+
data: Buffer;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A queued DATA chunk awaiting SACK. */
|
|
54
|
+
interface SentEntry {
|
|
55
|
+
chunk: Buffer;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** In-progress reassembly buffer for a fragmented user message. */
|
|
59
|
+
interface FragmentBuffer {
|
|
60
|
+
ppid: number;
|
|
61
|
+
parts: Buffer[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Serial number arithmetic (RFC 1982) for 32-bit TSNs. */
|
|
65
|
+
function snLt(a: number, b: number): boolean {
|
|
66
|
+
return ((a - b) & 0xffffffff) > 0x80000000;
|
|
67
|
+
}
|
|
68
|
+
function snLte(a: number, b: number): boolean {
|
|
69
|
+
return a === b || snLt(a, b);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @class SctpAssociation
|
|
74
|
+
* @extends EventEmitter
|
|
75
|
+
*
|
|
76
|
+
* Events:
|
|
77
|
+
* - 'established' association is up
|
|
78
|
+
* - 'message' ({streamId, ppid, data}) a complete user message arrived
|
|
79
|
+
* - 'output' (Buffer) an SCTP packet to hand to DTLS
|
|
80
|
+
* - 'close'
|
|
81
|
+
*/
|
|
82
|
+
class SctpAssociation extends EventEmitter {
|
|
83
|
+
isClient: boolean;
|
|
84
|
+
state: StateValue;
|
|
85
|
+
|
|
86
|
+
#localPort: number;
|
|
87
|
+
#remotePort: number;
|
|
88
|
+
|
|
89
|
+
#localTag: number;
|
|
90
|
+
#remoteTag: number;
|
|
91
|
+
|
|
92
|
+
// Transmit side.
|
|
93
|
+
#localTSN: number;
|
|
94
|
+
#nextSSN: Map<number, number>; // streamId -> next outbound stream sequence
|
|
95
|
+
#sentQueue: Map<number, SentEntry>; // tsn -> { chunk } awaiting SACK
|
|
96
|
+
|
|
97
|
+
// Receive side.
|
|
98
|
+
#peerCumulativeTSN: number | null; // highest contiguous TSN received
|
|
99
|
+
#receivedOutOfOrder: Map<number, C.ParsedDataBody>; // tsn -> dataChunk (gap storage)
|
|
100
|
+
#fragments: Map<string, FragmentBuffer>; // streamId -> array of partial DATA payloads
|
|
101
|
+
|
|
102
|
+
#initTimer: ReturnType<typeof setTimeout> | null;
|
|
103
|
+
|
|
104
|
+
#cookieSecret?: Buffer;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {Object} opts
|
|
108
|
+
* @param {boolean} opts.isClient - the DTLS client initiates SCTP (RFC 8831)
|
|
109
|
+
*/
|
|
110
|
+
constructor(opts: SctpAssociationOptions = {}) {
|
|
111
|
+
super();
|
|
112
|
+
this.isClient = !!opts.isClient;
|
|
113
|
+
this.state = STATE.CLOSED;
|
|
114
|
+
|
|
115
|
+
this.#localPort = SCTP_PORT;
|
|
116
|
+
this.#remotePort = SCTP_PORT;
|
|
117
|
+
|
|
118
|
+
this.#localTag = crypto.randomBytes(4).readUInt32BE(0) >>> 0 || 1;
|
|
119
|
+
this.#remoteTag = 0;
|
|
120
|
+
|
|
121
|
+
// Transmit side.
|
|
122
|
+
this.#localTSN = crypto.randomBytes(4).readUInt32BE(0) >>> 0;
|
|
123
|
+
this.#nextSSN = new Map(); // streamId -> next outbound stream sequence
|
|
124
|
+
this.#sentQueue = new Map(); // tsn -> { chunk } awaiting SACK
|
|
125
|
+
|
|
126
|
+
// Receive side.
|
|
127
|
+
this.#peerCumulativeTSN = null; // highest contiguous TSN received
|
|
128
|
+
this.#receivedOutOfOrder = new Map(); // tsn -> dataChunk (gap storage)
|
|
129
|
+
this.#fragments = new Map(); // streamId -> array of partial DATA payloads
|
|
130
|
+
|
|
131
|
+
this.#initTimer = null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Start the association (client sends INIT). */
|
|
135
|
+
start(): void {
|
|
136
|
+
if (this.state !== STATE.CLOSED) return;
|
|
137
|
+
if (this.isClient) {
|
|
138
|
+
this.#sendInit();
|
|
139
|
+
this.state = STATE.COOKIE_WAIT;
|
|
140
|
+
}
|
|
141
|
+
// Server waits for INIT.
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---- packet plumbing ----------------------------------------------------
|
|
145
|
+
|
|
146
|
+
#emitPacket(verificationTag: number, chunks: Buffer[]): void {
|
|
147
|
+
const header = C.encodeCommonHeader(this.#localPort, this.#remotePort, verificationTag);
|
|
148
|
+
const packet = Buffer.concat([header, ...chunks]);
|
|
149
|
+
applyChecksum(packet);
|
|
150
|
+
this.emit('output', packet);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Feed an inbound SCTP packet (decrypted from DTLS).
|
|
155
|
+
* @param {Buffer} packet
|
|
156
|
+
*/
|
|
157
|
+
receivePacket(packet: Buffer): void {
|
|
158
|
+
if (packet.length < 12) return;
|
|
159
|
+
if (!verifyChecksum(packet)) return; // drop corrupt
|
|
160
|
+
const header = C.parseCommonHeader(packet);
|
|
161
|
+
const chunks = C.parseChunks(packet);
|
|
162
|
+
for (const chunk of chunks) {
|
|
163
|
+
this.#handleChunk(chunk, header);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#handleChunk(chunk: C.ParsedChunk, _header: C.CommonHeader): void {
|
|
168
|
+
switch (chunk.type) {
|
|
169
|
+
case C.CHUNK_TYPE.INIT:
|
|
170
|
+
this.#handleInit(chunk);
|
|
171
|
+
break;
|
|
172
|
+
case C.CHUNK_TYPE.INIT_ACK:
|
|
173
|
+
this.#handleInitAck(chunk);
|
|
174
|
+
break;
|
|
175
|
+
case C.CHUNK_TYPE.COOKIE_ECHO:
|
|
176
|
+
this.#handleCookieEcho(chunk);
|
|
177
|
+
break;
|
|
178
|
+
case C.CHUNK_TYPE.COOKIE_ACK:
|
|
179
|
+
this.#handleCookieAck();
|
|
180
|
+
break;
|
|
181
|
+
case C.CHUNK_TYPE.DATA:
|
|
182
|
+
this.#handleData(chunk);
|
|
183
|
+
break;
|
|
184
|
+
case C.CHUNK_TYPE.SACK:
|
|
185
|
+
this.#handleSack(chunk);
|
|
186
|
+
break;
|
|
187
|
+
case C.CHUNK_TYPE.HEARTBEAT:
|
|
188
|
+
this.#handleHeartbeat(chunk);
|
|
189
|
+
break;
|
|
190
|
+
case C.CHUNK_TYPE.ABORT:
|
|
191
|
+
this.#abort('peer sent ABORT');
|
|
192
|
+
break;
|
|
193
|
+
case C.CHUNK_TYPE.SHUTDOWN:
|
|
194
|
+
this.#handleShutdown();
|
|
195
|
+
break;
|
|
196
|
+
default:
|
|
197
|
+
break; // ignore unknown
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---- setup handshake ----------------------------------------------------
|
|
202
|
+
|
|
203
|
+
#supportedExtParams(): Buffer[] {
|
|
204
|
+
// Advertise Forward-TSN support (FORWARD_TSN_SUPPORTED) like usrsctp does;
|
|
205
|
+
// we don't require it but including it improves interop.
|
|
206
|
+
return [
|
|
207
|
+
C.encodeParam(C.PARAM_TYPE.FORWARD_TSN_SUPPORTED, Buffer.alloc(0)),
|
|
208
|
+
];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#sendInit(): void {
|
|
212
|
+
const body = C.encodeInitBody({
|
|
213
|
+
initiateTag: this.#localTag,
|
|
214
|
+
a_rwnd: DEFAULT_RWND,
|
|
215
|
+
outStreams: 65535,
|
|
216
|
+
inStreams: 65535,
|
|
217
|
+
initialTSN: this.#localTSN,
|
|
218
|
+
});
|
|
219
|
+
const init = C.encodeChunk(
|
|
220
|
+
C.CHUNK_TYPE.INIT,
|
|
221
|
+
0,
|
|
222
|
+
Buffer.concat([body, ...this.#supportedExtParams()])
|
|
223
|
+
);
|
|
224
|
+
// INIT must be sent with verification tag 0.
|
|
225
|
+
this.#emitPacket(0, [init]);
|
|
226
|
+
this.#armInitRetransmit([init]);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
#armInitRetransmit(chunks: Buffer[]): void {
|
|
230
|
+
this.#clearInitTimer();
|
|
231
|
+
let rto = RTO_INITIAL;
|
|
232
|
+
let attempts = 0;
|
|
233
|
+
const fire = (): void => {
|
|
234
|
+
if (this.state === STATE.ESTABLISHED || this.state === STATE.CLOSED) return;
|
|
235
|
+
if (attempts >= 8) { this.#abort('SCTP setup timed out'); return; }
|
|
236
|
+
attempts++;
|
|
237
|
+
this.#emitPacket(this.state === STATE.COOKIE_ECHOED ? this.#remoteTag : 0, chunks);
|
|
238
|
+
rto = Math.min(rto * 2, RTO_MAX);
|
|
239
|
+
this.#initTimer = setTimeout(fire, rto);
|
|
240
|
+
if (this.#initTimer.unref) this.#initTimer.unref();
|
|
241
|
+
};
|
|
242
|
+
this.#initTimer = setTimeout(fire, rto);
|
|
243
|
+
if (this.#initTimer.unref) this.#initTimer.unref();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#clearInitTimer(): void {
|
|
247
|
+
if (this.#initTimer) { clearTimeout(this.#initTimer); this.#initTimer = null; }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#handleInit(chunk: C.ParsedChunk): void {
|
|
251
|
+
// Server side: reply with INIT_ACK carrying a state cookie.
|
|
252
|
+
const init = C.parseInitBody(chunk.body);
|
|
253
|
+
this.#remoteTag = init.initiateTag;
|
|
254
|
+
this.#peerCumulativeTSN = (init.initialTSN - 1) >>> 0;
|
|
255
|
+
|
|
256
|
+
// State cookie: an opaque blob the peer echoes back. We authenticate it
|
|
257
|
+
// with an HMAC over the parameters we need to resume.
|
|
258
|
+
if (!this.#cookieSecret) this.#cookieSecret = crypto.randomBytes(32);
|
|
259
|
+
const cookieData = Buffer.alloc(16);
|
|
260
|
+
cookieData.writeUInt32BE(this.#localTag, 0);
|
|
261
|
+
cookieData.writeUInt32BE(this.#remoteTag, 4);
|
|
262
|
+
cookieData.writeUInt32BE(this.#localTSN, 8);
|
|
263
|
+
cookieData.writeUInt32BE(init.initialTSN, 12);
|
|
264
|
+
const mac = crypto.createHmac('sha256', this.#cookieSecret).update(cookieData).digest();
|
|
265
|
+
const cookie = Buffer.concat([cookieData, mac]);
|
|
266
|
+
|
|
267
|
+
const ackBody = C.encodeInitBody({
|
|
268
|
+
initiateTag: this.#localTag,
|
|
269
|
+
a_rwnd: DEFAULT_RWND,
|
|
270
|
+
outStreams: 65535,
|
|
271
|
+
inStreams: 65535,
|
|
272
|
+
initialTSN: this.#localTSN,
|
|
273
|
+
});
|
|
274
|
+
const params = Buffer.concat([
|
|
275
|
+
C.encodeParam(C.PARAM_TYPE.STATE_COOKIE, cookie),
|
|
276
|
+
...this.#supportedExtParams(),
|
|
277
|
+
]);
|
|
278
|
+
const initAck = C.encodeChunk(C.CHUNK_TYPE.INIT_ACK, 0, Buffer.concat([ackBody, params]));
|
|
279
|
+
// INIT_ACK is sent with the peer's initiate tag as verification tag.
|
|
280
|
+
this.#emitPacket(this.#remoteTag, [initAck]);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
#handleInitAck(chunk: C.ParsedChunk): void {
|
|
284
|
+
if (this.state !== STATE.COOKIE_WAIT) return;
|
|
285
|
+
this.#clearInitTimer();
|
|
286
|
+
const initAck = C.parseInitBody(chunk.body);
|
|
287
|
+
this.#remoteTag = initAck.initiateTag;
|
|
288
|
+
this.#peerCumulativeTSN = (initAck.initialTSN - 1) >>> 0;
|
|
289
|
+
|
|
290
|
+
// Find the state cookie and echo it back.
|
|
291
|
+
const cookieParam = initAck.params.find((p) => p.type === C.PARAM_TYPE.STATE_COOKIE);
|
|
292
|
+
if (!cookieParam) { this.#abort('INIT_ACK missing state cookie'); return; }
|
|
293
|
+
|
|
294
|
+
const cookieEcho = C.encodeChunk(C.CHUNK_TYPE.COOKIE_ECHO, 0, cookieParam.value);
|
|
295
|
+
this.state = STATE.COOKIE_ECHOED;
|
|
296
|
+
this.#emitPacket(this.#remoteTag, [cookieEcho]);
|
|
297
|
+
this.#armInitRetransmit([cookieEcho]);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#handleCookieEcho(chunk: C.ParsedChunk): void {
|
|
301
|
+
// Server side: validate cookie, establish, reply COOKIE_ACK.
|
|
302
|
+
const cookie = chunk.body;
|
|
303
|
+
if (cookie.length >= 48 && this.#cookieSecret) {
|
|
304
|
+
const data = cookie.slice(0, 16);
|
|
305
|
+
const mac = cookie.slice(16, 48);
|
|
306
|
+
const expected = crypto.createHmac('sha256', this.#cookieSecret).update(data).digest();
|
|
307
|
+
if (!crypto.timingSafeEqual(mac, expected)) return; // bad cookie
|
|
308
|
+
// (tags/TSNs already set from the INIT we processed)
|
|
309
|
+
}
|
|
310
|
+
const cookieAck = C.encodeChunk(C.CHUNK_TYPE.COOKIE_ACK, 0, Buffer.alloc(0));
|
|
311
|
+
this.#emitPacket(this.#remoteTag, [cookieAck]);
|
|
312
|
+
this.#establish();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
#handleCookieAck(): void {
|
|
316
|
+
if (this.state !== STATE.COOKIE_ECHOED) return;
|
|
317
|
+
this.#clearInitTimer();
|
|
318
|
+
this.#establish();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#establish(): void {
|
|
322
|
+
if (this.state === STATE.ESTABLISHED) return;
|
|
323
|
+
this.state = STATE.ESTABLISHED;
|
|
324
|
+
this.emit('established');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---- data transfer ------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Send a user message on a stream.
|
|
331
|
+
* @param {number} streamId
|
|
332
|
+
* @param {number} ppid
|
|
333
|
+
* @param {Buffer} data
|
|
334
|
+
* @param {Object} [opts]
|
|
335
|
+
* @param {boolean} [opts.unordered=false]
|
|
336
|
+
*/
|
|
337
|
+
sendData(streamId: number, ppid: number, data: Buffer, opts: { unordered?: boolean } = {}): void {
|
|
338
|
+
if (this.state !== STATE.ESTABLISHED) {
|
|
339
|
+
throw new Error('SCTP association not established');
|
|
340
|
+
}
|
|
341
|
+
const unordered = !!opts.unordered;
|
|
342
|
+
|
|
343
|
+
// Fragment into <= MAX_PAYLOAD pieces; set B/E flags accordingly.
|
|
344
|
+
let ssn = 0;
|
|
345
|
+
if (!unordered) {
|
|
346
|
+
ssn = this.#nextSSN.get(streamId) || 0;
|
|
347
|
+
this.#nextSSN.set(streamId, (ssn + 1) & 0xffff);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const total = data.length;
|
|
351
|
+
let offset = 0;
|
|
352
|
+
const chunks: Buffer[] = [];
|
|
353
|
+
// An empty message still needs one DATA chunk (use EMPTY ppid variants).
|
|
354
|
+
do {
|
|
355
|
+
const slice = data.slice(offset, offset + MAX_PAYLOAD);
|
|
356
|
+
const beginning = offset === 0;
|
|
357
|
+
const ending = offset + slice.length >= total;
|
|
358
|
+
const tsn = this.#localTSN;
|
|
359
|
+
this.#localTSN = (this.#localTSN + 1) >>> 0;
|
|
360
|
+
const { flags, body } = C.encodeDataBody({
|
|
361
|
+
tsn, streamId, streamSeq: ssn, ppid, userData: slice,
|
|
362
|
+
unordered, beginning, ending,
|
|
363
|
+
});
|
|
364
|
+
const chunk = C.encodeChunk(C.CHUNK_TYPE.DATA, flags, body);
|
|
365
|
+
this.#sentQueue.set(tsn, { chunk });
|
|
366
|
+
chunks.push(chunk);
|
|
367
|
+
offset += slice.length;
|
|
368
|
+
} while (offset < total);
|
|
369
|
+
|
|
370
|
+
// Send each DATA chunk (one per packet keeps it simple and MTU-safe).
|
|
371
|
+
for (const chunk of chunks) {
|
|
372
|
+
this.#emitPacket(this.#remoteTag, [chunk]);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
#handleData(chunk: C.ParsedChunk): void {
|
|
377
|
+
const data = C.parseDataBody(chunk.flags, chunk.body);
|
|
378
|
+
|
|
379
|
+
// Always SACK what we've got (delayed-SACK simplified to immediate).
|
|
380
|
+
this.#deliverData(data);
|
|
381
|
+
this.#sendSack();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#deliverData(data: C.ParsedDataBody): void {
|
|
385
|
+
// Track cumulative TSN. Accept in-order and buffer out-of-order.
|
|
386
|
+
const expected = ((this.#peerCumulativeTSN as number) + 1) >>> 0;
|
|
387
|
+
if (snLt(data.tsn, expected)) {
|
|
388
|
+
return; // duplicate / already delivered
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (data.tsn === expected) {
|
|
392
|
+
this.#peerCumulativeTSN = data.tsn;
|
|
393
|
+
this.#consume(data);
|
|
394
|
+
// Drain any buffered contiguous TSNs.
|
|
395
|
+
let next = (this.#peerCumulativeTSN + 1) >>> 0;
|
|
396
|
+
while (this.#receivedOutOfOrder.has(next)) {
|
|
397
|
+
const buffered = this.#receivedOutOfOrder.get(next) as C.ParsedDataBody;
|
|
398
|
+
this.#receivedOutOfOrder.delete(next);
|
|
399
|
+
this.#peerCumulativeTSN = next;
|
|
400
|
+
this.#consume(buffered);
|
|
401
|
+
next = (this.#peerCumulativeTSN + 1) >>> 0;
|
|
402
|
+
}
|
|
403
|
+
} else {
|
|
404
|
+
// Out of order: buffer for later (gap).
|
|
405
|
+
if (!this.#receivedOutOfOrder.has(data.tsn)) {
|
|
406
|
+
this.#receivedOutOfOrder.set(data.tsn, data);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Reassemble fragments and emit complete user messages. */
|
|
412
|
+
#consume(data: C.ParsedDataBody): void {
|
|
413
|
+
const key = `${data.streamId}:${data.unordered ? 'u' : 'o'}`;
|
|
414
|
+
if (data.beginning && data.ending) {
|
|
415
|
+
this.emit('message', { streamId: data.streamId, ppid: data.ppid, data: data.userData });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
let buf = this.#fragments.get(key);
|
|
419
|
+
if (data.beginning) {
|
|
420
|
+
buf = { ppid: data.ppid, parts: [data.userData] };
|
|
421
|
+
this.#fragments.set(key, buf);
|
|
422
|
+
} else if (buf) {
|
|
423
|
+
buf.parts.push(data.userData);
|
|
424
|
+
} else {
|
|
425
|
+
return; // missing beginning; drop
|
|
426
|
+
}
|
|
427
|
+
if (data.ending && buf) {
|
|
428
|
+
this.#fragments.delete(key);
|
|
429
|
+
this.emit('message', {
|
|
430
|
+
streamId: data.streamId,
|
|
431
|
+
ppid: buf.ppid,
|
|
432
|
+
data: Buffer.concat(buf.parts),
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
#sendSack(): void {
|
|
438
|
+
// Build gap-ack blocks from buffered out-of-order TSNs.
|
|
439
|
+
const gapBlocks: Array<[number, number]> = [];
|
|
440
|
+
if (this.#receivedOutOfOrder.size > 0) {
|
|
441
|
+
const sorted = [...this.#receivedOutOfOrder.keys()].sort((a, b) => (snLt(a, b) ? -1 : 1));
|
|
442
|
+
const base = ((this.#peerCumulativeTSN as number) + 1) >>> 0;
|
|
443
|
+
let start: number | null = null;
|
|
444
|
+
let prev: number | null = null;
|
|
445
|
+
for (const tsn of sorted) {
|
|
446
|
+
if (start === null) { start = tsn; prev = tsn; continue; }
|
|
447
|
+
if (tsn === (((prev as number) + 1) >>> 0)) { prev = tsn; continue; }
|
|
448
|
+
gapBlocks.push([((start - base) & 0xffff) + 1, (((prev as number) - base) & 0xffff) + 1]);
|
|
449
|
+
start = tsn; prev = tsn;
|
|
450
|
+
}
|
|
451
|
+
if (start !== null) {
|
|
452
|
+
gapBlocks.push([((start - base) & 0xffff) + 1, (((prev as number) - base) & 0xffff) + 1]);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const body = C.encodeSackBody({
|
|
457
|
+
cumulativeTSNAck: (this.#peerCumulativeTSN as number) >>> 0,
|
|
458
|
+
a_rwnd: DEFAULT_RWND,
|
|
459
|
+
gapBlocks,
|
|
460
|
+
});
|
|
461
|
+
const sack = C.encodeChunk(C.CHUNK_TYPE.SACK, 0, body);
|
|
462
|
+
this.#emitPacket(this.#remoteTag, [sack]);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
#handleSack(chunk: C.ParsedChunk): void {
|
|
466
|
+
const sack = C.parseSackBody(chunk.body);
|
|
467
|
+
// Remove acknowledged TSNs from the retransmit queue.
|
|
468
|
+
for (const tsn of [...this.#sentQueue.keys()]) {
|
|
469
|
+
if (snLte(tsn, sack.cumulativeTSNAck)) {
|
|
470
|
+
this.#sentQueue.delete(tsn);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// Gap-acked blocks are relative to cumAck; mark those acked too.
|
|
474
|
+
const base = (sack.cumulativeTSNAck + 1) >>> 0;
|
|
475
|
+
for (const [start, end] of sack.gapBlocks) {
|
|
476
|
+
for (let i = start; i <= end; i++) {
|
|
477
|
+
this.#sentQueue.delete((base + i - 1) >>> 0);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
#handleHeartbeat(chunk: C.ParsedChunk): void {
|
|
483
|
+
// Echo the heartbeat info back as HEARTBEAT_ACK.
|
|
484
|
+
const ack = C.encodeChunk(C.CHUNK_TYPE.HEARTBEAT_ACK, 0, chunk.body);
|
|
485
|
+
this.#emitPacket(this.#remoteTag, [ack]);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
#handleShutdown(): void {
|
|
489
|
+
const sdAck = C.encodeChunk(C.CHUNK_TYPE.SHUTDOWN_ACK, 0, Buffer.alloc(0));
|
|
490
|
+
this.#emitPacket(this.#remoteTag, [sdAck]);
|
|
491
|
+
this.#close();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
#abort(reason?: string): void {
|
|
495
|
+
this.#clearInitTimer();
|
|
496
|
+
if (this.state !== STATE.CLOSED) {
|
|
497
|
+
this.state = STATE.CLOSED;
|
|
498
|
+
this.emit('error', new Error(reason || 'SCTP abort'));
|
|
499
|
+
this.emit('close');
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Gracefully close the association. */
|
|
504
|
+
shutdown(): void {
|
|
505
|
+
if (this.state !== STATE.ESTABLISHED) { this.#close(); return; }
|
|
506
|
+
const sd = C.encodeChunk(C.CHUNK_TYPE.SHUTDOWN, 0, (() => {
|
|
507
|
+
const b = Buffer.alloc(4);
|
|
508
|
+
b.writeUInt32BE((this.#peerCumulativeTSN as number) >>> 0, 0);
|
|
509
|
+
return b;
|
|
510
|
+
})());
|
|
511
|
+
this.#emitPacket(this.#remoteTag, [sd]);
|
|
512
|
+
this.#close();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
#close(): void {
|
|
516
|
+
this.#clearInitTimer();
|
|
517
|
+
if (this.state === STATE.CLOSED) return;
|
|
518
|
+
this.state = STATE.CLOSED;
|
|
519
|
+
this.emit('close');
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export { SctpAssociation, STATE, SCTP_PORT };
|