@cosyte/mllp 0.0.1

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/dist/index.mjs ADDED
@@ -0,0 +1,3428 @@
1
+ import { EventEmitter } from 'events';
2
+ import { randomUUID } from 'crypto';
3
+ import { createServer as createServer$2, createConnection, isIP, SocketAddress } from 'net';
4
+ import { createServer as createServer$1, connect } from 'tls';
5
+
6
+ // src/framing/registry.ts
7
+ function createWarning(code, byteOffset, message) {
8
+ return Object.freeze({
9
+ code,
10
+ message,
11
+ byteOffset,
12
+ connectionId: void 0,
13
+ timestamp: /* @__PURE__ */ new Date()
14
+ });
15
+ }
16
+
17
+ // src/framing/error.ts
18
+ var MAX_SNIPPET_BYTES = 64;
19
+ var MllpFramingError = class _MllpFramingError extends Error {
20
+ name = "MllpFramingError";
21
+ /** Stable warning code identifying the violation type. */
22
+ code;
23
+ /** Absolute byte offset in the stream where the error was detected. */
24
+ byteOffset;
25
+ /**
26
+ * Up to 64 bytes copied from around the anomaly.
27
+ *
28
+ * This is a **copied** Buffer — isolated from the source buffer so it remains
29
+ * valid after the underlying buffer is reused or overwritten.
30
+ *
31
+ * **PHI contract (MLLP-9):** the decoder only ever populates this with the single
32
+ * framing-boundary byte that violated the frame structure (whose hex value the
33
+ * `message` already discloses) — never a run of payload content bytes. For anomalies
34
+ * whose fault is not a specific byte (`MLLP_FRAME_TOO_LARGE` — the accumulated size),
35
+ * the snippet is **empty**: a payload slice on a public error field would leak a
36
+ * field-body slice of clinical content. Callers constructing this directly are
37
+ * responsible for the same discipline.
38
+ */
39
+ snippet;
40
+ /**
41
+ * Construct an MLLP framing error.
42
+ *
43
+ * @param code - The stable warning code classifying the violation.
44
+ * @param byteOffset - Absolute stream byte offset where the violation was detected.
45
+ * @param snippet - Raw bytes copied from around the anomaly (capped to 64 bytes).
46
+ * @param message - Optional human-readable override message.
47
+ */
48
+ constructor(code, byteOffset, snippet, message) {
49
+ super(message ?? `MLLP framing error: ${code} at byte offset ${byteOffset}`);
50
+ this.code = code;
51
+ this.byteOffset = byteOffset;
52
+ this.snippet = Buffer.from(snippet.subarray(0, MAX_SNIPPET_BYTES));
53
+ if (Error.captureStackTrace) {
54
+ Error.captureStackTrace(this, _MllpFramingError);
55
+ }
56
+ }
57
+ };
58
+
59
+ // src/framing/constants.ts
60
+ var VT = 11;
61
+ var FS = 28;
62
+ var CR = 13;
63
+ var LF = 10;
64
+ var DEFAULT_MAX_FRAME_SIZE = 16 * 1024 * 1024;
65
+
66
+ // src/framing/encoder.ts
67
+ function encodeFrame(payload, opts) {
68
+ const allow = opts?.allowDelimiterBytesInPayload === true;
69
+ const onWarning = opts?.onWarning;
70
+ for (let i = 0; i < payload.length; i++) {
71
+ const byte = payload[i];
72
+ if (byte === void 0) break;
73
+ if (byte === VT || byte === FS) {
74
+ const code = byte === VT ? "MLLP_PAYLOAD_CONTAINS_VT" : "MLLP_PAYLOAD_CONTAINS_FS";
75
+ if (!allow) {
76
+ throw new MllpFramingError(code, i, Buffer.from([byte]));
77
+ }
78
+ if (onWarning !== void 0) {
79
+ const byteHex = byte === VT ? "0x0B" : "0x1C";
80
+ const warning = createWarning(
81
+ code,
82
+ i,
83
+ `Payload contains ${byte === VT ? "VT" : "FS"} byte (${byteHex}) at offset ${i}; frame will be ambiguous`
84
+ );
85
+ try {
86
+ onWarning(warning);
87
+ } catch {
88
+ }
89
+ }
90
+ }
91
+ }
92
+ const frame = Buffer.allocUnsafe(payload.length + 3);
93
+ frame[0] = VT;
94
+ payload.copy(frame, 1);
95
+ frame[payload.length + 1] = FS;
96
+ frame[payload.length + 2] = CR;
97
+ return frame;
98
+ }
99
+
100
+ // src/framing/decoder.ts
101
+ var INITIAL_ACCUMULATOR_SIZE = 4096;
102
+ var EMPTY_SNIPPET = Buffer.alloc(0);
103
+ var FrameReader = class {
104
+ _opts;
105
+ _maxFrameSize;
106
+ _state = "SCANNING_FOR_VT";
107
+ /** Reusable accumulator buffer — grown by doubling as needed. */
108
+ _accumulator = Buffer.allocUnsafe(INITIAL_ACCUMULATOR_SIZE);
109
+ /** Number of payload bytes written to accumulator in current frame. */
110
+ _writePos = 0;
111
+ /** Monotonic absolute stream byte offset across the entire connection lifetime. */
112
+ _byteOffset = 0;
113
+ /** Byte offset where current leading-whitespace run started (FRAME-10). */
114
+ _wsStart = 0;
115
+ /** Count of leading whitespace bytes accumulated in current run (FRAME-10). */
116
+ _wsCount = 0;
117
+ /** Byte offset of the VT byte that started the current frame. */
118
+ _frameStartOffset = 0;
119
+ /** Per-frame warning accumulator — cleared after each _deliverFrame(). */
120
+ _frameWarnings = [];
121
+ /**
122
+ * Construct a chunked MLLP frame reader.
123
+ *
124
+ * @param opts - Reader options (`onFrame`/`onWarning` callbacks, tolerance, `maxFrameSizeBytes`).
125
+ */
126
+ constructor(opts) {
127
+ this._opts = opts;
128
+ this._maxFrameSize = opts.maxFrameSizeBytes !== void 0 ? opts.maxFrameSizeBytes : DEFAULT_MAX_FRAME_SIZE;
129
+ }
130
+ /**
131
+ * Feed a chunk of raw TCP bytes into the FSM. Frames fire synchronously via `onFrame`
132
+ * during this call. May throw `MllpFramingError` for unrecoverable framing violations
133
+ * when the matching tolerance is not enabled.
134
+ *
135
+ * @example
136
+ * ```typescript
137
+ * socket.on('data', (chunk) => reader.push(chunk));
138
+ * ```
139
+ */
140
+ push(chunk) {
141
+ for (let i = 0; i < chunk.length; i++, this._byteOffset++) {
142
+ const byte = chunk[i];
143
+ if (byte === void 0) break;
144
+ this._processByte(byte);
145
+ }
146
+ }
147
+ /**
148
+ * Clear internal accumulator state and reset byte offset to 0.
149
+ *
150
+ * Call on reconnect / connection reuse to start fresh without allocating a new reader (D-03).
151
+ * Pending partial frame state is discarded silently.
152
+ *
153
+ * @example
154
+ * ```typescript
155
+ * socket.on('close', () => reader.reset());
156
+ * ```
157
+ */
158
+ reset() {
159
+ this._state = "SCANNING_FOR_VT";
160
+ this._writePos = 0;
161
+ this._byteOffset = 0;
162
+ this._wsStart = 0;
163
+ this._wsCount = 0;
164
+ this._frameStartOffset = 0;
165
+ this._frameWarnings = [];
166
+ }
167
+ _processByte(byte) {
168
+ switch (this._state) {
169
+ case "SCANNING_FOR_VT":
170
+ this._scanForVt(byte);
171
+ break;
172
+ case "READING_PAYLOAD":
173
+ this._readPayload(byte);
174
+ break;
175
+ case "EXPECTING_CR":
176
+ this._expectCr(byte);
177
+ break;
178
+ }
179
+ }
180
+ _scanForVt(byte) {
181
+ if (byte === VT) {
182
+ if (this._wsCount > 0) {
183
+ this._emitWarning(
184
+ "MLLP_LEADING_WHITESPACE",
185
+ this._wsStart,
186
+ `${this._wsCount} leading whitespace byte(s) before VT at offset ${this._wsStart}`
187
+ );
188
+ this._wsStart = 0;
189
+ this._wsCount = 0;
190
+ }
191
+ this._frameStartOffset = this._byteOffset;
192
+ this._state = "READING_PAYLOAD";
193
+ return;
194
+ }
195
+ const isWhitespace = byte === 32 || byte === 9 || byte === LF || byte === CR;
196
+ if (isWhitespace) {
197
+ if (this._opts.allowLeadingWhitespace === true) {
198
+ if (this._opts.strict === true) {
199
+ const snippet3 = Buffer.from([byte]);
200
+ throw new MllpFramingError(
201
+ "MLLP_MISSING_LEADING_VT",
202
+ this._byteOffset,
203
+ snippet3,
204
+ `Strict mode: leading whitespace before VT at offset ${this._byteOffset}`
205
+ );
206
+ }
207
+ if (this._wsCount === 0) {
208
+ this._wsStart = this._byteOffset;
209
+ }
210
+ this._wsCount++;
211
+ return;
212
+ }
213
+ const snippet2 = Buffer.from([byte]);
214
+ throw new MllpFramingError(
215
+ "MLLP_MISSING_LEADING_VT",
216
+ this._byteOffset,
217
+ snippet2,
218
+ `Expected VT (0x0B) to start MLLP frame, got whitespace 0x${byte.toString(16).padStart(2, "0")} at offset ${this._byteOffset}`
219
+ );
220
+ }
221
+ if (this._wsCount > 0) {
222
+ this._emitWarning(
223
+ "MLLP_LEADING_WHITESPACE",
224
+ this._wsStart,
225
+ `${this._wsCount} leading whitespace byte(s) before VT at offset ${this._wsStart}`
226
+ );
227
+ this._wsStart = 0;
228
+ this._wsCount = 0;
229
+ }
230
+ if (this._opts.allowMissingLeadingVt === true) {
231
+ if (this._opts.strict === true) {
232
+ const snippet2 = Buffer.from([byte]);
233
+ throw new MllpFramingError(
234
+ "MLLP_MISSING_LEADING_VT",
235
+ this._byteOffset,
236
+ snippet2,
237
+ `Strict mode: missing leading VT at offset ${this._byteOffset}`
238
+ );
239
+ }
240
+ this._emitWarning(
241
+ "MLLP_MISSING_LEADING_VT",
242
+ this._byteOffset,
243
+ `Missing leading VT (0x0B) \u2014 treating byte 0x${byte.toString(16).padStart(2, "0")} at offset ${this._byteOffset} as payload start`
244
+ );
245
+ this._frameStartOffset = this._byteOffset;
246
+ this._state = "READING_PAYLOAD";
247
+ this._appendByte(byte);
248
+ return;
249
+ }
250
+ const snippet = Buffer.from([byte]);
251
+ throw new MllpFramingError(
252
+ "MLLP_MISSING_LEADING_VT",
253
+ this._byteOffset,
254
+ snippet,
255
+ `Expected VT (0x0B) to start MLLP frame, got 0x${byte.toString(16).padStart(2, "0")} at offset ${this._byteOffset}`
256
+ );
257
+ }
258
+ _readPayload(byte) {
259
+ if (byte === FS) {
260
+ if (this._writePos === 0) {
261
+ this._emitWarning(
262
+ "MLLP_EMPTY_PAYLOAD",
263
+ this._byteOffset,
264
+ `Empty payload between VT and FS at offset ${this._byteOffset}`
265
+ );
266
+ }
267
+ this._state = "EXPECTING_CR";
268
+ return;
269
+ }
270
+ if (byte === VT) {
271
+ this._emitWarning(
272
+ "MLLP_TRAILING_BYTES",
273
+ this._byteOffset,
274
+ `Unexpected VT (0x0B) mid-payload at offset ${this._byteOffset}; discarding ${this._writePos} accumulated bytes`
275
+ );
276
+ this._writePos = 0;
277
+ this._state = "READING_PAYLOAD";
278
+ return;
279
+ }
280
+ if (this._writePos >= this._maxFrameSize) {
281
+ throw new MllpFramingError(
282
+ "MLLP_FRAME_TOO_LARGE",
283
+ this._byteOffset,
284
+ EMPTY_SNIPPET,
285
+ `Frame payload exceeded maxFrameSizeBytes (${this._maxFrameSize}) at offset ${this._byteOffset}`
286
+ );
287
+ }
288
+ this._appendByte(byte);
289
+ }
290
+ _expectCr(byte) {
291
+ if (byte === CR) {
292
+ this._deliverFrame();
293
+ this._state = "SCANNING_FOR_VT";
294
+ return;
295
+ }
296
+ if (byte === LF) {
297
+ if (this._opts.allowLfAfterFs === true) {
298
+ if (this._opts.strict === true) {
299
+ const snippet3 = Buffer.from([byte]);
300
+ throw new MllpFramingError(
301
+ "MLLP_LF_AFTER_FS",
302
+ this._byteOffset,
303
+ snippet3,
304
+ `Strict mode: LF after FS at offset ${this._byteOffset}`
305
+ );
306
+ }
307
+ this._emitWarning(
308
+ "MLLP_LF_AFTER_FS",
309
+ this._byteOffset,
310
+ `LF (0x0A) after FS instead of CR (0x0D) at offset ${this._byteOffset}`
311
+ );
312
+ this._deliverFrame();
313
+ this._state = "SCANNING_FOR_VT";
314
+ return;
315
+ }
316
+ const snippet2 = Buffer.from([byte]);
317
+ throw new MllpFramingError(
318
+ "MLLP_LF_AFTER_FS",
319
+ this._byteOffset,
320
+ snippet2,
321
+ `Expected CR (0x0D) after FS, got LF (0x0A) at offset ${this._byteOffset}. Enable allowLfAfterFs to tolerate.`
322
+ );
323
+ }
324
+ if (byte === VT) {
325
+ if (this._opts.allowFsOnly === true) {
326
+ if (this._opts.strict === true) {
327
+ const snippet3 = Buffer.from([byte]);
328
+ throw new MllpFramingError(
329
+ "MLLP_FS_WITHOUT_CR",
330
+ this._byteOffset,
331
+ snippet3,
332
+ `Strict mode: FS without CR at offset ${this._byteOffset}`
333
+ );
334
+ }
335
+ this._emitWarning(
336
+ "MLLP_FS_WITHOUT_CR",
337
+ this._byteOffset,
338
+ `FS not followed by CR at offset ${this._byteOffset}; next frame VT found immediately`
339
+ );
340
+ this._deliverFrame();
341
+ this._state = "READING_PAYLOAD";
342
+ return;
343
+ }
344
+ const snippet2 = Buffer.from([byte]);
345
+ throw new MllpFramingError(
346
+ "MLLP_FS_WITHOUT_CR",
347
+ this._byteOffset,
348
+ snippet2,
349
+ `Expected CR (0x0D) after FS, got VT (0x0B) at offset ${this._byteOffset}. Enable allowFsOnly to tolerate.`
350
+ );
351
+ }
352
+ if (this._opts.allowFsOnly === true) {
353
+ if (this._opts.strict === true) {
354
+ const snippet2 = Buffer.from([byte]);
355
+ throw new MllpFramingError(
356
+ "MLLP_FS_WITHOUT_CR",
357
+ this._byteOffset,
358
+ snippet2,
359
+ `Strict mode: FS without CR at offset ${this._byteOffset}`
360
+ );
361
+ }
362
+ this._emitWarning(
363
+ "MLLP_FS_WITHOUT_CR",
364
+ this._byteOffset,
365
+ `FS not followed by CR at offset ${this._byteOffset}; got 0x${byte.toString(16).padStart(2, "0")} (dropped)`
366
+ );
367
+ this._deliverFrame();
368
+ this._state = "SCANNING_FOR_VT";
369
+ return;
370
+ }
371
+ const snippet = Buffer.from([byte]);
372
+ throw new MllpFramingError(
373
+ "MLLP_FS_WITHOUT_CR",
374
+ this._byteOffset,
375
+ snippet,
376
+ `Expected CR (0x0D) after FS, got 0x${byte.toString(16).padStart(2, "0")} at offset ${this._byteOffset}`
377
+ );
378
+ }
379
+ /** Grow the internal accumulator by doubling and write one byte. */
380
+ _appendByte(byte) {
381
+ if (this._writePos >= this._accumulator.length) {
382
+ const newSize = this._accumulator.length * 2;
383
+ const grown = Buffer.allocUnsafe(newSize);
384
+ this._accumulator.subarray(0, this._writePos).copy(grown);
385
+ this._accumulator = grown;
386
+ }
387
+ this._accumulator[this._writePos++] = byte;
388
+ }
389
+ /**
390
+ * Copy accumulated payload bytes into a new Buffer and deliver via `onFrame`.
391
+ * The copy isolates callers from internal accumulator reuse (T-02-03-03).
392
+ */
393
+ _deliverFrame() {
394
+ const payload = Buffer.from(this._accumulator.subarray(0, this._writePos));
395
+ this._writePos = 0;
396
+ const frameStart = this._frameStartOffset;
397
+ const frameWarnings = this._frameWarnings;
398
+ this._frameWarnings = [];
399
+ this._opts.onFrame(payload, frameStart, frameWarnings);
400
+ }
401
+ /** Emit a warning via the `onWarning` callback, swallowing any handler exceptions (WARN-06). */
402
+ _emitWarning(code, byteOffset, message) {
403
+ const warning = createWarning(code, byteOffset, message);
404
+ this._frameWarnings.push(warning);
405
+ if (this._opts.onWarning !== void 0) {
406
+ try {
407
+ this._opts.onWarning(warning);
408
+ } catch {
409
+ }
410
+ }
411
+ }
412
+ };
413
+
414
+ // src/transport/net-transport.ts
415
+ var NetTransport = class {
416
+ _socket;
417
+ /**
418
+ * Wrap an existing `net.Socket` (or `tls.TLSSocket`) as a `Transport`.
419
+ *
420
+ * @param socket - The connected (or connecting) socket to adapt.
421
+ */
422
+ constructor(socket) {
423
+ this._socket = socket;
424
+ }
425
+ /** See {@link Transport.write}. */
426
+ write(buf) {
427
+ return this._socket.write(buf);
428
+ }
429
+ /** See {@link Transport.close}. */
430
+ close() {
431
+ this._socket.end();
432
+ }
433
+ /** See {@link Transport.destroy}. */
434
+ destroy(reason) {
435
+ this._socket.destroy(reason);
436
+ }
437
+ /** See {@link Transport.onData}. */
438
+ onData(fn) {
439
+ this._socket.removeAllListeners("data");
440
+ this._socket.on("data", fn);
441
+ }
442
+ /** See {@link Transport.onConnect}. */
443
+ onConnect(fn) {
444
+ this._socket.removeAllListeners("connect");
445
+ this._socket.on("connect", fn);
446
+ }
447
+ /** See {@link Transport.onClose}. */
448
+ onClose(fn) {
449
+ this._socket.removeAllListeners("close");
450
+ this._socket.on("close", fn);
451
+ }
452
+ /** See {@link Transport.onError}. */
453
+ onError(fn) {
454
+ this._socket.removeAllListeners("error");
455
+ this._socket.on("error", fn);
456
+ }
457
+ };
458
+
459
+ // src/transport/tls-transport.ts
460
+ var TlsTransport = class {
461
+ _socket;
462
+ /**
463
+ * Wrap an existing `tls.TLSSocket` (connecting or already secure) as a `Transport`.
464
+ *
465
+ * @param socket - The TLS socket to adapt.
466
+ */
467
+ constructor(socket) {
468
+ this._socket = socket;
469
+ }
470
+ /** See {@link Transport.write}. */
471
+ write(buf) {
472
+ return this._socket.write(buf);
473
+ }
474
+ /** See {@link Transport.close}. */
475
+ close() {
476
+ this._socket.end();
477
+ }
478
+ /** See {@link Transport.destroy}. */
479
+ destroy(reason) {
480
+ this._socket.destroy(reason);
481
+ }
482
+ /** See {@link Transport.onData}. */
483
+ onData(fn) {
484
+ this._socket.removeAllListeners("data");
485
+ this._socket.on("data", fn);
486
+ }
487
+ /**
488
+ * See {@link Transport.onConnect}. Armed on `'secureConnect'` — the TLS
489
+ * handshake-complete event — not the raw TCP `'connect'` event.
490
+ */
491
+ onConnect(fn) {
492
+ this._socket.removeAllListeners("secureConnect");
493
+ this._socket.on("secureConnect", fn);
494
+ }
495
+ /** See {@link Transport.onClose}. */
496
+ onClose(fn) {
497
+ this._socket.removeAllListeners("close");
498
+ this._socket.on("close", fn);
499
+ }
500
+ /** See {@link Transport.onError}. */
501
+ onError(fn) {
502
+ this._socket.removeAllListeners("error");
503
+ this._socket.on("error", fn);
504
+ }
505
+ };
506
+
507
+ // src/transport/security-warnings.ts
508
+ var MLLP_TLS_VERIFY_DISABLED = "MLLP_TLS_VERIFY_DISABLED";
509
+ var MLLP_BIND_ALL_INTERFACES = "MLLP_BIND_ALL_INTERFACES";
510
+
511
+ // src/connection/error.ts
512
+ var MllpConnectionError = class _MllpConnectionError extends Error {
513
+ name = "MllpConnectionError";
514
+ /** The original OS or TLS error that caused this connection failure. */
515
+ cause;
516
+ /** Which connection lifecycle phase the failure occurred in. */
517
+ phase;
518
+ /**
519
+ * Optional stable cause code. Present on FIFO reconnect rejections
520
+ * (`'fifo-unsafe'` for queued sends, `'in-flight-orphan'` for in-flight sends).
521
+ *
522
+ * The set of values is a **public API**; see {@link ConnectionErrorCause}.
523
+ */
524
+ connectionCause;
525
+ /**
526
+ * Construct an MLLP connection error.
527
+ *
528
+ * @param message - Human-readable error message.
529
+ * @param opts - Error context (underlying `cause`, the lifecycle `phase`, optional stable cause code).
530
+ */
531
+ constructor(message, opts) {
532
+ super(message);
533
+ this.cause = opts.cause;
534
+ this.phase = opts.phase;
535
+ if (opts.connectionCause !== void 0) {
536
+ this.connectionCause = opts.connectionCause;
537
+ }
538
+ if (Error.captureStackTrace) {
539
+ Error.captureStackTrace(this, _MllpConnectionError);
540
+ }
541
+ }
542
+ };
543
+
544
+ // src/connection/connection.ts
545
+ var MAX_WARNINGS = 100;
546
+ var LEGAL_TRANSITIONS = /* @__PURE__ */ new Map([
547
+ ["CONNECTING", /* @__PURE__ */ new Set(["CONNECTED", "RECONNECTING", "CLOSED"])],
548
+ ["CONNECTED", /* @__PURE__ */ new Set(["DRAINING", "RECONNECTING", "DISCONNECTED", "CLOSED"])],
549
+ ["DRAINING", /* @__PURE__ */ new Set(["DISCONNECTED", "CLOSED"])],
550
+ ["RECONNECTING", /* @__PURE__ */ new Set(["CONNECTING", "CLOSED"])],
551
+ ["DISCONNECTED", /* @__PURE__ */ new Set(["CLOSED"])],
552
+ ["CLOSED", /* @__PURE__ */ new Set()]
553
+ ]);
554
+ var Connection = class extends EventEmitter {
555
+ /** Stable UUIDv4 identifier for this connection (D-11). */
556
+ connectionId;
557
+ _state = "CONNECTING";
558
+ _transport;
559
+ _reader;
560
+ _opts;
561
+ _remoteAddress = null;
562
+ _remotePort = null;
563
+ _connectedAt = null;
564
+ _bytesIn = 0;
565
+ _bytesOut = 0;
566
+ _lastByteInAt = null;
567
+ _lastByteOutAt = null;
568
+ /** Ring buffer: last MAX_WARNINGS entries (OBS-05). */
569
+ _warningBuffer = [];
570
+ /** Accurate total counts per code — unaffected by ring buffer truncation (OBS-05). */
571
+ _warningsByCode = /* @__PURE__ */ new Map();
572
+ /** Set to true once the ring buffer overflows (OBS-05). */
573
+ _warningsTruncated = false;
574
+ _onWarningFn = null;
575
+ _drainPromise = null;
576
+ /**
577
+ * beforeClose hook — no-op default that resolves immediately.
578
+ *
579
+ * Phase 4 (Server) and Phase 5 (Client) override this instance property to
580
+ * register ACK-drain and send-queue drain logic respectively (D-07/D-08).
581
+ * Connection calls this during `close()`, racing against `drainTimeoutMs`.
582
+ */
583
+ beforeClose = () => Promise.resolve();
584
+ /**
585
+ * Construct a connection over the given transport. Starts in `CONNECTING`; call
586
+ * {@link Connection.notifyConnect} once the transport handshake completes.
587
+ *
588
+ * @param opts - Connection options (the transport, message/warning handlers, drain timeout, framing).
589
+ */
590
+ constructor(opts) {
591
+ super();
592
+ this.connectionId = randomUUID();
593
+ this._opts = opts;
594
+ this._transport = opts.transport;
595
+ if (opts.onWarning !== void 0) {
596
+ this._onWarningFn = opts.onWarning;
597
+ }
598
+ this._reader = new FrameReader({
599
+ ...opts.framing ?? {},
600
+ onFrame: (payload, byteOffset, warnings) => {
601
+ this._onFrameDecoded(payload, byteOffset, warnings);
602
+ },
603
+ onWarning: (w) => {
604
+ this._onFramingWarning(w);
605
+ }
606
+ });
607
+ this._transport.onData((chunk) => {
608
+ this._bytesIn += chunk.length;
609
+ this._lastByteInAt = /* @__PURE__ */ new Date();
610
+ try {
611
+ this._reader.push(chunk);
612
+ } catch (err) {
613
+ if (err instanceof MllpFramingError) {
614
+ this._onFatalFramingError(err);
615
+ } else {
616
+ this._onInternalReceiveError(err instanceof Error ? err : new Error(String(err)));
617
+ }
618
+ }
619
+ });
620
+ this._transport.onClose(() => {
621
+ this._onTransportClose();
622
+ });
623
+ this._transport.onError((err) => {
624
+ this._onTransportError(err);
625
+ });
626
+ }
627
+ /**
628
+ * Current FSM state. One of the 6 `ConnectionState` values (LIFE-01).
629
+ *
630
+ * Subscribe to `'stateChange'` events for reactive state monitoring.
631
+ */
632
+ get state() {
633
+ return this._state;
634
+ }
635
+ /**
636
+ * Register or replace the per-connection warning subscriber (WARN-10).
637
+ *
638
+ * Subsequent calls replace the previous handler (set-once semantics prevent leaks).
639
+ * The handler is called synchronously and any exception it throws is swallowed (WARN-06).
640
+ *
641
+ * @example
642
+ * ```typescript
643
+ * conn.onWarning((w) => logger.warn({ code: w.code, connectionId: w.connectionId }));
644
+ * ```
645
+ */
646
+ onWarning(fn) {
647
+ this._onWarningFn = fn;
648
+ }
649
+ /**
650
+ * Notify the connection that the transport handshake has completed.
651
+ *
652
+ * Transitions `CONNECTING → CONNECTED` and emits the `'connect'` event.
653
+ * Called externally by Server/Client after the socket connects.
654
+ *
655
+ * @param remoteAddress - Remote peer IP address, or `null` if unavailable.
656
+ * @param remotePort - Remote peer port, or `null` if unavailable.
657
+ *
658
+ * @example
659
+ * ```typescript
660
+ * socket.on('connect', () =>
661
+ * conn.notifyConnect(socket.remoteAddress ?? null, socket.remotePort ?? null)
662
+ * );
663
+ * ```
664
+ */
665
+ notifyConnect(remoteAddress, remotePort) {
666
+ this._remoteAddress = remoteAddress;
667
+ this._remotePort = remotePort;
668
+ this._connectedAt = /* @__PURE__ */ new Date();
669
+ this._transition("CONNECTED");
670
+ this._dispatchContained(() => {
671
+ this.emit("connect", Object.freeze({ connectionId: this.connectionId }));
672
+ });
673
+ }
674
+ /**
675
+ * Write raw bytes to the transport (no framing applied).
676
+ *
677
+ * Phase 4 (Server) and Phase 5 (Client) wrap this with `encodeFrame()` to add MLLP framing.
678
+ * Returns `false` if the connection is CLOSED or DISCONNECTED (no bytes written).
679
+ *
680
+ * @returns `true` if bytes flushed immediately; `false` if buffered (backpressure) or not writable.
681
+ *
682
+ * @example
683
+ * ```typescript
684
+ * const flushed = conn.send(encodeFrame(payload));
685
+ * if (!flushed) logger.warn('backpressure detected');
686
+ * ```
687
+ */
688
+ send(data) {
689
+ if (this._state === "CLOSED" || this._state === "DISCONNECTED") return false;
690
+ const ok = this._transport.write(data);
691
+ this._bytesOut += data.length;
692
+ this._lastByteOutAt = /* @__PURE__ */ new Date();
693
+ return ok;
694
+ }
695
+ /**
696
+ * Initiate graceful close of the connection.
697
+ *
698
+ * - If `CONNECTING` or `RECONNECTING`: cancels the pending attempt, transitions
699
+ * directly to `CLOSED`, and calls `transport.destroy()` (LIFE-05).
700
+ * - If `CONNECTED`: transitions to `DRAINING`, calls `beforeClose(drainTimeoutMs)`,
701
+ * then `DRAINING → DISCONNECTED` once the hook resolves, or `DRAINING → CLOSED`
702
+ * if the drain timeout elapses first.
703
+ * - If `DRAINING`: waits for the existing drain to complete (idempotent).
704
+ * - If `CLOSED` or `DISCONNECTED`: no-op.
705
+ *
706
+ * @param opts.drainTimeoutMs - Override drain timeout (default: `opts.drainTimeoutMs ?? 30_000`).
707
+ *
708
+ * @example
709
+ * ```typescript
710
+ * await conn.close({ drainTimeoutMs: 5_000 });
711
+ * ```
712
+ */
713
+ async close(opts) {
714
+ const timeout = opts?.drainTimeoutMs ?? this._opts.drainTimeoutMs ?? 3e4;
715
+ if (this._state === "CLOSED" || this._state === "DISCONNECTED") return;
716
+ if (this._state === "CONNECTING" || this._state === "RECONNECTING") {
717
+ this._transition("CLOSED", "close() during " + this._state);
718
+ this._transport.destroy();
719
+ return;
720
+ }
721
+ if (this._state === "DRAINING") {
722
+ return this._drainPromise ?? Promise.resolve();
723
+ }
724
+ this._transition("DRAINING");
725
+ this._drainPromise = this._drainWithTimeout(timeout).finally(() => {
726
+ this._drainPromise = null;
727
+ });
728
+ return this._drainPromise;
729
+ }
730
+ /**
731
+ * Race `beforeClose()` against the drain timeout.
732
+ *
733
+ * On normal completion: `DRAINING → DISCONNECTED` + `transport.close()`.
734
+ * On timeout: `DRAINING → CLOSED` + `transport.destroy()` (T-03-04-01).
735
+ */
736
+ async _drainWithTimeout(timeoutMs) {
737
+ const drainPromise = this.beforeClose(timeoutMs);
738
+ const timeoutPromise = new Promise((resolve) => {
739
+ const handle = setTimeout(() => {
740
+ resolve("timeout");
741
+ }, timeoutMs);
742
+ handle.unref();
743
+ });
744
+ const result = await Promise.race([drainPromise.then(() => "done"), timeoutPromise]);
745
+ if (result === "timeout") {
746
+ if (this._state === "DRAINING") {
747
+ this._transition("CLOSED", "drain timeout");
748
+ this._transport.destroy();
749
+ }
750
+ } else {
751
+ if (this._state === "DRAINING") {
752
+ this._transition("DISCONNECTED");
753
+ this._transport.close();
754
+ }
755
+ }
756
+ }
757
+ /**
758
+ * Abruptly destroy the connection, discarding any pending writes.
759
+ *
760
+ * Transitions from any non-terminal state directly to `CLOSED` and calls
761
+ * `transport.destroy(reason)`. Emits `'close'` event. Idempotent — safe to
762
+ * call multiple times.
763
+ *
764
+ * @param reason - Optional error to propagate to the transport.
765
+ *
766
+ * @example
767
+ * ```typescript
768
+ * conn.destroy(new Error('Timeout exceeded'));
769
+ * ```
770
+ */
771
+ destroy(reason) {
772
+ if (this._state === "CLOSED") return;
773
+ this._transition("CLOSED", reason?.message ?? "destroy()");
774
+ this._transport.destroy(reason);
775
+ }
776
+ /**
777
+ * Return a JSON-serializable observability snapshot (OBS-03/04/05).
778
+ *
779
+ * All timestamp fields are `Date | null` (not epoch milliseconds). `JSON.stringify()`
780
+ * serialises them to ISO 8601 strings with no information loss.
781
+ *
782
+ * `warningsByCode` reflects every warning ever received regardless of ring buffer truncation.
783
+ * `warningsTruncated` is `true` if the 100-entry ring buffer has overflowed.
784
+ *
785
+ * @example
786
+ * ```typescript
787
+ * const stats = conn.getStats();
788
+ * console.log(JSON.stringify(stats)); // log-pipeline friendly
789
+ * ```
790
+ */
791
+ getStats() {
792
+ const warningsByCode = {};
793
+ for (const [code, count] of this._warningsByCode) {
794
+ warningsByCode[code] = count;
795
+ }
796
+ return {
797
+ state: this._state,
798
+ connectionId: this.connectionId,
799
+ remoteAddress: this._remoteAddress,
800
+ remotePort: this._remotePort,
801
+ warningsByCode,
802
+ bytesIn: this._bytesIn,
803
+ bytesOut: this._bytesOut,
804
+ lastByteInAt: this._lastByteInAt,
805
+ lastByteOutAt: this._lastByteOutAt,
806
+ connectedAt: this._connectedAt,
807
+ warningsTruncated: this._warningsTruncated
808
+ };
809
+ }
810
+ // ---------------------------------------------------------------------------
811
+ // Private FSM helpers
812
+ // ---------------------------------------------------------------------------
813
+ _transition(to, reason) {
814
+ const from = this._state;
815
+ const legal = LEGAL_TRANSITIONS.get(from);
816
+ if (legal === void 0 || !legal.has(to)) {
817
+ return;
818
+ }
819
+ this._state = to;
820
+ const event = Object.freeze(
821
+ reason !== void 0 ? { from, to, reason } : { from, to }
822
+ );
823
+ this._dispatchContained(() => {
824
+ this.emit("stateChange", event);
825
+ });
826
+ if (to === "DISCONNECTED") {
827
+ this._dispatchContained(() => {
828
+ this.emit("disconnect", Object.freeze({ connectionId: this.connectionId }));
829
+ });
830
+ }
831
+ if (to === "RECONNECTING") {
832
+ this._dispatchContained(() => {
833
+ this.emit("reconnecting", Object.freeze({ connectionId: this.connectionId }));
834
+ });
835
+ }
836
+ if (to === "CLOSED") {
837
+ this._dispatchContained(() => {
838
+ this.emit("close", Object.freeze({ connectionId: this.connectionId }));
839
+ });
840
+ }
841
+ }
842
+ _onTransportClose() {
843
+ if (this._state === "DRAINING") {
844
+ this._transition("DISCONNECTED");
845
+ return;
846
+ }
847
+ if (this._state === "CONNECTED") {
848
+ this._transition("DISCONNECTED", "peer closed");
849
+ return;
850
+ }
851
+ if (this._state === "CONNECTING" || this._state === "RECONNECTING") {
852
+ this._transition("CLOSED", "peer closed");
853
+ }
854
+ }
855
+ /**
856
+ * Emit on `'error'` only when a listener is attached.
857
+ *
858
+ * Node throws `ERR_UNHANDLED_ERROR` when `'error'` is emitted on an `EventEmitter` with no
859
+ * listener. Every `'error'` emission in this class is reached from inside a transport callback
860
+ * (the socket's `'data'`/`'error'` listener), so an unguarded emit would become an **uncaught
861
+ * exception that kills the process** — the exact failure the containment above exists to
862
+ * prevent, merely relocated one frame up the stack.
863
+ *
864
+ * `Connection` is a public export and can legitimately be driven with no `'error'` listener
865
+ * (`MllpServer` and `MllpClient` each attach one; a bare `Connection` need not). The connection
866
+ * is still torn down and `'stateChange'`/`'close'` still fire, so a caller is never blind —
867
+ * they simply do not get the typed error object they never asked for. Mirrors
868
+ * `MllpServer._emitErrorIfListened`.
869
+ */
870
+ _emitErrorIfListened(error) {
871
+ if (this.listenerCount("error") === 0) return;
872
+ try {
873
+ this.emit("error", Object.freeze({ connectionId: this.connectionId, error }));
874
+ } catch {
875
+ }
876
+ }
877
+ /**
878
+ * Handle a fatal framing error thrown out of `FrameReader.push` (FRAME-FATAL).
879
+ *
880
+ * Contract: surface it as a connection `'error'` (frozen payload, `phase: 'receive'`,
881
+ * `connectionCause: 'framing-fatal'`, the original `MllpFramingError` preserved as `cause` so
882
+ * consumers keep the stable `code` and `byteOffset`), then **destroy this connection only**.
883
+ *
884
+ * Why destroy rather than resynchronize: once `push` has thrown, the reader's position within
885
+ * the byte stream is no longer trustworthy — the next bytes could be the middle of a message.
886
+ * Guessing where the next frame starts is how a clinical message gets silently mis-split, so
887
+ * the connection is dropped instead. A server drops that one peer and keeps serving everyone
888
+ * else.
889
+ *
890
+ * `'framing-fatal'` is classified **permanent** by `isTransientConnectionError`, so a client
891
+ * does **not** auto-reconnect into it. That matters: a peer speaking something that is not MLLP
892
+ * (an HTTP probe, a health check, a wrong-port misconfiguration) would otherwise be retried
893
+ * forever, and the backoff would become an unbounded reconnect storm against an interface
894
+ * engine that is already unwell. A compatibility failure is not a network blip.
895
+ *
896
+ * If a peer's quirk is *expected* (trailing junk, keepalive bytes, a missing leading VT), the
897
+ * decoder's tolerance opt-ins are the supported answer — they turn the throw into a warning.
898
+ */
899
+ _onFatalFramingError(err) {
900
+ if (this._state === "CLOSED") return;
901
+ this._emitErrorIfListened(
902
+ new MllpConnectionError(err.message, {
903
+ cause: err,
904
+ phase: "receive",
905
+ connectionCause: "framing-fatal"
906
+ })
907
+ );
908
+ this.destroy(err);
909
+ }
910
+ /**
911
+ * Backstop for a NON-framing throw escaping `FrameReader.push` — i.e. our own bug, or a
912
+ * subscriber's, rather than the peer's bytes.
913
+ *
914
+ * `_onFrameDecoded` already contains subscriber throws at the dispatch site, so this should be
915
+ * unreachable. It exists because "unreachable" and "cannot kill the process from inside a
916
+ * socket `'data'` handler" are different claims, and only the second one is safe to bet a
917
+ * clinical interface on. Reported honestly as an internal receive error — never dressed up as
918
+ * a peer framing fault.
919
+ */
920
+ _onInternalReceiveError(err) {
921
+ if (this._state === "CLOSED") return;
922
+ this._emitErrorIfListened(
923
+ new MllpConnectionError(err.message, { cause: err, phase: "receive" })
924
+ );
925
+ this.destroy(err);
926
+ }
927
+ _onTransportError(err) {
928
+ const phase = this._state === "CONNECTING" ? "connect" : this._state === "RECONNECTING" ? "reconnect" : this._state === "DRAINING" ? "close" : "receive";
929
+ if (this._state === "CLOSED" || this._state === "DISCONNECTED") return;
930
+ this._emitErrorIfListened(new MllpConnectionError(err.message, { cause: err, phase }));
931
+ const target = this._state === "CONNECTING" || this._state === "RECONNECTING" ? "CLOSED" : "DISCONNECTED";
932
+ this._transition(target, `error: ${err.message}`);
933
+ }
934
+ _onFrameDecoded(payload, byteOffset, warnings) {
935
+ if (this._state !== "CONNECTED" && this._state !== "DRAINING") return;
936
+ const event = Object.freeze({ payload, connectionId: this.connectionId, byteOffset, warnings });
937
+ this._dispatchContained(() => {
938
+ this.emit("message", event);
939
+ });
940
+ this._dispatchContained(() => {
941
+ this._opts.onMessage?.(payload);
942
+ });
943
+ }
944
+ /**
945
+ * Run a subscriber callback, containing any throw as a connection `'error'` rather than letting
946
+ * it unwind into the socket callback that (transitively) invoked us.
947
+ */
948
+ _dispatchContained(fn) {
949
+ try {
950
+ fn();
951
+ } catch (err) {
952
+ const cause = err instanceof Error ? err : new Error(String(err));
953
+ this._emitErrorIfListened(
954
+ new MllpConnectionError(cause.message, { cause, phase: "receive" })
955
+ );
956
+ }
957
+ }
958
+ _onFramingWarning(w) {
959
+ const enriched = Object.freeze({ ...w, connectionId: this.connectionId });
960
+ this._warningsByCode.set(enriched.code, (this._warningsByCode.get(enriched.code) ?? 0) + 1);
961
+ if (this._warningBuffer.length >= MAX_WARNINGS) {
962
+ this._warningBuffer.shift();
963
+ this._warningsTruncated = true;
964
+ }
965
+ this._warningBuffer.push(enriched);
966
+ if (this._onWarningFn !== null) {
967
+ try {
968
+ this._onWarningFn(enriched);
969
+ } catch {
970
+ }
971
+ }
972
+ this._dispatchContained(() => {
973
+ this.emit("warning", enriched);
974
+ });
975
+ }
976
+ };
977
+
978
+ // src/internal/control-id.ts
979
+ var ASCII_M = 77;
980
+ var ASCII_S = 83;
981
+ var ASCII_H = 72;
982
+ var ASCII_A = 65;
983
+ var ASCII_B = 66;
984
+ var ASCII_F = 70;
985
+ var ASCII_T = 84;
986
+ var SEGMENT_SEPARATOR_CR = 13;
987
+ var SEGMENT_SEPARATOR_LF = 10;
988
+ var CONTROL_ID_ENCODING = "latin1";
989
+ var UNSAFE_FIELD_SEPARATORS = /* @__PURE__ */ new Set([
990
+ SEGMENT_SEPARATOR_CR,
991
+ SEGMENT_SEPARATOR_LF,
992
+ 11,
993
+ // VT — MLLP start block
994
+ 28
995
+ // FS — MLLP end block
996
+ ]);
997
+ function isMshSegmentAt(buf, i) {
998
+ return i + 3 < buf.length && buf[i] === ASCII_M && buf[i + 1] === ASCII_S && buf[i + 2] === ASCII_H && !UNSAFE_FIELD_SEPARATORS.has(buf[i + 3]);
999
+ }
1000
+ function segmentEnd(buf, i) {
1001
+ let end = i;
1002
+ while (end < buf.length && buf[end] !== SEGMENT_SEPARATOR_CR && buf[end] !== SEGMENT_SEPARATOR_LF) {
1003
+ end++;
1004
+ }
1005
+ return end;
1006
+ }
1007
+ function findMshSegment(buf) {
1008
+ let segStart = 0;
1009
+ while (segStart < buf.length) {
1010
+ const end = segmentEnd(buf, segStart);
1011
+ if (isMshSegmentAt(buf, segStart)) return { start: segStart, end };
1012
+ let next = end;
1013
+ while (next < buf.length && (buf[next] === SEGMENT_SEPARATOR_CR || buf[next] === SEGMENT_SEPARATOR_LF)) {
1014
+ next++;
1015
+ }
1016
+ if (next === segStart) return null;
1017
+ segStart = next;
1018
+ }
1019
+ return null;
1020
+ }
1021
+ function isBatchEnvelopeSegmentAt(buf, i) {
1022
+ if (i + 2 >= buf.length) return false;
1023
+ const a = buf[i];
1024
+ const b = buf[i + 1];
1025
+ const c = buf[i + 2];
1026
+ if (a === ASCII_F && (b === ASCII_H && c === ASCII_S || b === ASCII_T && c === ASCII_S)) {
1027
+ return true;
1028
+ }
1029
+ if (a === ASCII_B && (b === ASCII_H && c === ASCII_S || b === ASCII_T && c === ASCII_S)) {
1030
+ return true;
1031
+ }
1032
+ return false;
1033
+ }
1034
+ function containsBatchOrExtraMessage(buf) {
1035
+ let mshCount = 0;
1036
+ let segStart = 0;
1037
+ while (segStart < buf.length) {
1038
+ const end = segmentEnd(buf, segStart);
1039
+ if (isMshSegmentAt(buf, segStart)) {
1040
+ mshCount++;
1041
+ if (mshCount > 1) return true;
1042
+ } else if (isBatchEnvelopeSegmentAt(buf, segStart)) {
1043
+ return true;
1044
+ }
1045
+ let next = end;
1046
+ while (next < buf.length && (buf[next] === SEGMENT_SEPARATOR_CR || buf[next] === SEGMENT_SEPARATOR_LF)) {
1047
+ next++;
1048
+ }
1049
+ if (next === segStart) return false;
1050
+ segStart = next;
1051
+ }
1052
+ return false;
1053
+ }
1054
+ function readMshSegment(buf) {
1055
+ const at = findMshSegment(buf);
1056
+ if (at === null) return null;
1057
+ const fieldSep = String.fromCharCode(buf[at.start + 3]);
1058
+ const fields = buf.subarray(at.start, at.end).toString(CONTROL_ID_ENCODING).split(fieldSep);
1059
+ return { fieldSep, fields };
1060
+ }
1061
+ function extractMshControlId(buf) {
1062
+ const msh = readMshSegment(buf);
1063
+ if (msh === null) return null;
1064
+ const id = msh.fields[9];
1065
+ return id === void 0 || id === "" ? null : id;
1066
+ }
1067
+ function extractMsaControlId(buf) {
1068
+ const msh = readMshSegment(buf);
1069
+ if (msh === null) return null;
1070
+ const fieldSep = msh.fieldSep.charCodeAt(0);
1071
+ const end = buf.length;
1072
+ let segStart = 0;
1073
+ while (segStart < end) {
1074
+ let segEnd = segStart;
1075
+ while (segEnd < end && buf[segEnd] !== SEGMENT_SEPARATOR_CR && buf[segEnd] !== SEGMENT_SEPARATOR_LF) {
1076
+ segEnd++;
1077
+ }
1078
+ if (segEnd - segStart >= 4 && buf[segStart] === ASCII_M && buf[segStart + 1] === ASCII_S && buf[segStart + 2] === ASCII_A && buf[segStart + 3] === fieldSep) {
1079
+ let fieldIndex = 0;
1080
+ let fieldStart = 0;
1081
+ for (let i = segStart + 3; i <= segEnd; i++) {
1082
+ const b = i < segEnd ? buf[i] : fieldSep;
1083
+ if (b === fieldSep || b === SEGMENT_SEPARATOR_CR || b === SEGMENT_SEPARATOR_LF) {
1084
+ fieldIndex++;
1085
+ if (fieldIndex === 2) {
1086
+ fieldStart = i + 1;
1087
+ } else if (fieldIndex === 3) {
1088
+ if (fieldStart >= i) return null;
1089
+ return buf.subarray(fieldStart, i).toString(CONTROL_ID_ENCODING);
1090
+ }
1091
+ }
1092
+ }
1093
+ return null;
1094
+ }
1095
+ while (segEnd < end && (buf[segEnd] === SEGMENT_SEPARATOR_CR || buf[segEnd] === SEGMENT_SEPARATOR_LF)) {
1096
+ segEnd++;
1097
+ }
1098
+ if (segEnd === segStart) return null;
1099
+ segStart = segEnd;
1100
+ }
1101
+ return null;
1102
+ }
1103
+
1104
+ // src/server/ack.ts
1105
+ var MllpAckError = class extends Error {
1106
+ /** The negative acknowledgement code to return (`AE` or `AR`). */
1107
+ ackCode;
1108
+ /**
1109
+ * @param message - Diagnostic text for the thrower. Never placed on the wire (may carry PHI).
1110
+ * @param opts.ackCode - Negative code to return. Default `'AE'`.
1111
+ * @param opts.cause - Underlying error, preserved on `.cause`.
1112
+ */
1113
+ constructor(message, opts) {
1114
+ super(message, opts?.cause !== void 0 ? { cause: opts.cause } : void 0);
1115
+ this.name = "MllpAckError";
1116
+ this.ackCode = opts?.ackCode ?? "AE";
1117
+ }
1118
+ };
1119
+ function resolveNackCode(err) {
1120
+ if (err instanceof MllpAckError) return err.ackCode;
1121
+ if (typeof err === "object" && err !== null && "ackCode" in err && err.ackCode === "AR") {
1122
+ return "AR";
1123
+ }
1124
+ return "AE";
1125
+ }
1126
+ var NACK_TEXT = Object.freeze({
1127
+ AE: "message could not be processed",
1128
+ AR: "message rejected"
1129
+ });
1130
+ var DEFAULT_ENCODING_CHARACTERS = "^~\\&";
1131
+ var DEFAULT_FIELD_SEPARATOR = "|";
1132
+ var ENCODING_CHAR_SUBSTITUTE = "#";
1133
+ var UNSAFE_DELIMITER = /[\r\n\v\x1c]/;
1134
+ function rawAckUncorrelatable(payload) {
1135
+ const msh = readMshSegment(payload);
1136
+ if (msh === null) return true;
1137
+ const controlId = msh.fields[9];
1138
+ if (controlId === void 0 || controlId === "") return true;
1139
+ return containsBatchOrExtraMessage(payload);
1140
+ }
1141
+ function downgradePositiveAck(code) {
1142
+ if (code === "AA") return "AE";
1143
+ if (code === "CA") return "CE";
1144
+ return code;
1145
+ }
1146
+ function buildRawAck(payload, code) {
1147
+ const msh = readMshSegment(payload);
1148
+ if ((code === "AA" || code === "CA") && rawAckUncorrelatable(payload)) {
1149
+ code = downgradePositiveAck(code);
1150
+ }
1151
+ const newControlId = randomUUID().replace(/-/g, "").substring(0, 20);
1152
+ const now = timestamp14();
1153
+ if (msh === null) {
1154
+ const s2 = DEFAULT_FIELD_SEPARATOR;
1155
+ const e = DEFAULT_ENCODING_CHARACTERS;
1156
+ const tail = code === "AE" || code === "AR" ? `${s2}${NACK_TEXT[code]}` : "";
1157
+ return Buffer.from(
1158
+ `MSH${s2}${e}${s2}${s2}${s2}${s2}${s2}${now}${s2}${s2}ACK${s2}${newControlId}${s2}P${s2}2.3\rMSA${s2}${code}${s2}${tail}\r`,
1159
+ "latin1"
1160
+ );
1161
+ }
1162
+ const { fieldSep, fields } = msh;
1163
+ const declaredEnc = fields[1] ?? "";
1164
+ const usableEnc = declaredEnc !== "" && !UNSAFE_DELIMITER.test(declaredEnc) ? declaredEnc : DEFAULT_ENCODING_CHARACTERS;
1165
+ const s = fieldSep;
1166
+ const encodingChars = usableEnc.includes(fieldSep) ? usableEnc.replace(fieldSep, ENCODING_CHAR_SUBSTITUTE) : usableEnc;
1167
+ const sendingApp = fields[2] ?? "";
1168
+ const sendingFacility = fields[3] ?? "";
1169
+ const receivingApp = fields[4] ?? "";
1170
+ const receivingFacility = fields[5] ?? "";
1171
+ const inboundControlId = fields[9] ?? "";
1172
+ const processingId = fields[10] ?? "P";
1173
+ const version = fields[11] ?? "2.3";
1174
+ const msaTail = code === "AE" || code === "AR" ? `${s}${NACK_TEXT[code]}` : "";
1175
+ const ackStr = `MSH${s}${encodingChars}${s}${receivingApp}${s}${receivingFacility}${s}${sendingApp}${s}${sendingFacility}${s}${now}${s}${s}ACK${s}${newControlId}${s}${processingId}${s}${version}\rMSA${s}${code}${s}${inboundControlId}${msaTail}\r`;
1176
+ return Buffer.from(ackStr, "latin1");
1177
+ }
1178
+ function timestamp14() {
1179
+ const d = /* @__PURE__ */ new Date();
1180
+ const pad = (n, w = 2) => String(n).padStart(w, "0");
1181
+ return String(d.getUTCFullYear()) + pad(d.getUTCMonth() + 1) + pad(d.getUTCDate()) + pad(d.getUTCHours()) + pad(d.getUTCMinutes()) + pad(d.getUTCSeconds());
1182
+ }
1183
+
1184
+ // src/internal/safe-emit.ts
1185
+ function safeEmit(emitter, event, payload, onSubscriberThrow) {
1186
+ try {
1187
+ emitter.emit(event, payload);
1188
+ } catch (err) {
1189
+ onSubscriberThrow?.(err instanceof Error ? err : new Error(String(err)));
1190
+ }
1191
+ }
1192
+ function safeEmitError(emitter, payload) {
1193
+ if (emitter.listenerCount("error") === 0) return;
1194
+ try {
1195
+ emitter.emit("error", payload);
1196
+ } catch {
1197
+ }
1198
+ }
1199
+
1200
+ // src/server/server.ts
1201
+ function isWildcardHost(host) {
1202
+ if (host === "") return true;
1203
+ const family = isIP(host);
1204
+ if (family === 4) {
1205
+ return host.split(".").every((octet) => Number(octet) === 0);
1206
+ }
1207
+ if (family === 6) {
1208
+ let canonical;
1209
+ try {
1210
+ canonical = new SocketAddress({ address: host, family: "ipv6" }).address;
1211
+ } catch {
1212
+ return false;
1213
+ }
1214
+ return canonical === "::" || canonical === "::ffff:0.0.0.0";
1215
+ }
1216
+ return false;
1217
+ }
1218
+ var SERVER_DEFAULT_FRAMING = {
1219
+ allowFsOnly: true,
1220
+ allowLfAfterFs: true,
1221
+ allowLeadingWhitespace: true,
1222
+ allowMissingLeadingVt: false
1223
+ };
1224
+ var MllpServer = class extends EventEmitter {
1225
+ _netServer;
1226
+ _connections = /* @__PURE__ */ new Set();
1227
+ _opts;
1228
+ /** `true` when `ServerOptions.tls` is set — `_netServer` is actually a `tls.Server`. */
1229
+ _isTls;
1230
+ _listening = false;
1231
+ /** Phase 8 — single-flight guard: `true` while a `listen()` call is settling. */
1232
+ _listenInFlight = false;
1233
+ /**
1234
+ * Phase 8 — settle hook for an in-flight `listen()`. Non-null only while a
1235
+ * `listen()` is in flight; invoked by `close()` so a close racing startup
1236
+ * rejects the pending `listen()` (typed) instead of leaving it hung with
1237
+ * the single-flight guard stuck. Cleared on every settle path.
1238
+ */
1239
+ _pendingListenSettle = null;
1240
+ _port = null;
1241
+ _host = null;
1242
+ _acceptedTotal = 0;
1243
+ _closedTotal = 0;
1244
+ /** Phase 8 — count of `'tlsClientError'` events since listen(). */
1245
+ _tlsClientErrorsTotal = 0;
1246
+ /**
1247
+ * Construct an MLLP server. Created idle; call `listen()` (or use
1248
+ * {@link createServer}/{@link createStarterServer}) to begin accepting connections.
1249
+ *
1250
+ * @param opts - Server options (bind host/port, auto-ACK policy, message handler, framing, …).
1251
+ */
1252
+ constructor(opts) {
1253
+ super();
1254
+ this._opts = opts;
1255
+ this._isTls = opts.tls !== void 0;
1256
+ if (opts.tls !== void 0) {
1257
+ const tlsOpts = opts.tls;
1258
+ const clientAuth = tlsOpts.clientAuth ?? "NONE";
1259
+ const tlsServer = createServer$1({
1260
+ cert: tlsOpts.cert,
1261
+ key: tlsOpts.key,
1262
+ ...tlsOpts.ca !== void 0 ? { ca: tlsOpts.ca } : {},
1263
+ ...tlsOpts.passphrase !== void 0 ? { passphrase: tlsOpts.passphrase } : {},
1264
+ minVersion: tlsOpts.minVersion ?? "TLSv1.2",
1265
+ ...tlsOpts.maxVersion !== void 0 ? { maxVersion: tlsOpts.maxVersion } : {},
1266
+ ...tlsOpts.ciphers !== void 0 ? { ciphers: tlsOpts.ciphers } : {},
1267
+ requestCert: clientAuth !== "NONE",
1268
+ rejectUnauthorized: clientAuth === "MUST"
1269
+ });
1270
+ this._netServer = tlsServer;
1271
+ tlsServer.on("secureConnection", (socket) => {
1272
+ this._onSocketAccepted(socket);
1273
+ });
1274
+ tlsServer.on("tlsClientError", (err, socket) => {
1275
+ this._tlsClientErrorsTotal += 1;
1276
+ const nodeErr = err;
1277
+ const event = Object.freeze({
1278
+ remoteAddress: socket.remoteAddress ?? null,
1279
+ remotePort: socket.remotePort ?? null,
1280
+ message: err.message,
1281
+ code: typeof nodeErr.code === "string" ? nodeErr.code : null,
1282
+ timestamp: /* @__PURE__ */ new Date()
1283
+ });
1284
+ this._emitContained("tlsClientError", event);
1285
+ });
1286
+ } else {
1287
+ this._netServer = createServer$2();
1288
+ this._netServer.on("connection", (socket) => {
1289
+ this._onSocketAccepted(socket);
1290
+ });
1291
+ }
1292
+ this._netServer.on("error", (err) => {
1293
+ if (this.listenerCount("error") > 0) {
1294
+ safeEmitError(this, err);
1295
+ } else if (this._listening) {
1296
+ this.emit("error", err);
1297
+ }
1298
+ });
1299
+ }
1300
+ /**
1301
+ * Start listening on the given port.
1302
+ *
1303
+ * Resolves once the TCP socket is bound and emits `'listening'` with
1304
+ * `Object.freeze({ port: actualPort, host: actualHost })`.
1305
+ *
1306
+ * **Single-flight:** one `listen()` per server lifecycle at a time. A call
1307
+ * while the server is already listening — or while another `listen()` is
1308
+ * still in flight — rejects with a typed `MllpConnectionError` (concurrent
1309
+ * binds raced each other's post-bind safety checks). Call `close()` before
1310
+ * re-listening; sequential `listen()` → `close()` → `listen()` is fine.
1311
+ *
1312
+ * **`close()` during an in-flight `listen()`** rejects that `listen()` with
1313
+ * a typed `MllpConnectionError` (never a hang) and clears the single-flight
1314
+ * guard — a subsequent `listen()` on the same server works. This makes
1315
+ * `Symbol.asyncDispose` (which delegates to `close()`) safe even before a
1316
+ * `listen()` has settled. (Exception: `close({ signal })` with an
1317
+ * already-aborted signal is a no-op AbortError rejection — the in-flight
1318
+ * `listen()` is left to settle on its own bind outcome; see `close()`.)
1319
+ *
1320
+ * @param port - TCP port to bind. Use `0` to let the OS assign an ephemeral port.
1321
+ * @param hostOrOpts - Host string or object with `host` and optional `signal`.
1322
+ * Default host: `'127.0.0.1'` (Phase 8 bind-safety hardening). Wildcard hosts
1323
+ * are rejected unless `ServerOptions.allowWildcardBind: true` — literal
1324
+ * spellings pre-bind, resolver-only shorthands post-bind via the
1325
+ * OS-normalized bound address.
1326
+ *
1327
+ * @example
1328
+ * ```typescript
1329
+ * await server.listen(2575); // binds 127.0.0.1
1330
+ * await server.listen(0, '127.0.0.1');
1331
+ * await server.listen(0, { host: '127.0.0.1', signal: ac.signal });
1332
+ * ```
1333
+ */
1334
+ listen(port, hostOrOpts) {
1335
+ const host = typeof hostOrOpts === "string" ? hostOrOpts : hostOrOpts?.host ?? "127.0.0.1";
1336
+ const signal = typeof hostOrOpts === "object" && hostOrOpts !== null ? hostOrOpts.signal : void 0;
1337
+ if (signal?.aborted) {
1338
+ return Promise.reject(new DOMException("Aborted", "AbortError"));
1339
+ }
1340
+ if (this._listening || this._listenInFlight) {
1341
+ return Promise.reject(
1342
+ new MllpConnectionError(
1343
+ this._listening ? "listen() rejected: server is already listening \u2014 call close() before re-listening" : "listen() rejected: another listen() is already in flight on this server",
1344
+ {
1345
+ cause: new Error("listen already listening or in flight"),
1346
+ phase: "connect"
1347
+ }
1348
+ )
1349
+ );
1350
+ }
1351
+ if (isWildcardHost(host) && this._opts.allowWildcardBind !== true) {
1352
+ return Promise.reject(
1353
+ new MllpConnectionError(
1354
+ `refusing to bind wildcard host '${host}' \u2014 set ServerOptions.allowWildcardBind: true to bind all interfaces`,
1355
+ {
1356
+ cause: new Error("wildcard bind requires allowWildcardBind"),
1357
+ phase: "connect"
1358
+ }
1359
+ )
1360
+ );
1361
+ }
1362
+ this._listenInFlight = true;
1363
+ return new Promise((resolve, reject) => {
1364
+ let settled = false;
1365
+ const settle = (outcome, opts) => {
1366
+ if (settled) return;
1367
+ settled = true;
1368
+ this._pendingListenSettle = null;
1369
+ signal?.removeEventListener("abort", abortHandler);
1370
+ this._netServer.removeListener("listening", onListening);
1371
+ this._netServer.removeListener("error", onError);
1372
+ if (opts?.closeServer === true) {
1373
+ this._netServer.close();
1374
+ }
1375
+ this._listenInFlight = false;
1376
+ if (outcome.ok) {
1377
+ resolve();
1378
+ } else {
1379
+ reject(outcome.error);
1380
+ }
1381
+ };
1382
+ const abortHandler = () => {
1383
+ settle(
1384
+ { ok: false, error: new DOMException("Aborted", "AbortError") },
1385
+ {
1386
+ closeServer: true
1387
+ }
1388
+ );
1389
+ };
1390
+ if (signal !== void 0) {
1391
+ signal.addEventListener("abort", abortHandler, { once: true });
1392
+ }
1393
+ this._pendingListenSettle = () => {
1394
+ settle({
1395
+ ok: false,
1396
+ error: new MllpConnectionError(
1397
+ "listen() failed: server was closed while listen() was in flight",
1398
+ {
1399
+ cause: new Error("close() during in-flight listen()"),
1400
+ phase: "connect"
1401
+ }
1402
+ )
1403
+ });
1404
+ };
1405
+ const onListening = () => {
1406
+ if (settled) return;
1407
+ this._pendingListenSettle = null;
1408
+ signal?.removeEventListener("abort", abortHandler);
1409
+ const addr = this._netServer.address();
1410
+ if (addr === null || typeof addr === "string") {
1411
+ settle(
1412
+ {
1413
+ ok: false,
1414
+ error: new MllpConnectionError(
1415
+ "listen() failed: server was closed before the bind completed",
1416
+ {
1417
+ cause: new Error("server.address() returned no TCP address"),
1418
+ phase: "connect"
1419
+ }
1420
+ )
1421
+ },
1422
+ { closeServer: true }
1423
+ );
1424
+ return;
1425
+ }
1426
+ const actualPort = addr.port;
1427
+ const actualHost = addr.address;
1428
+ if (isWildcardHost(actualHost) && this._opts.allowWildcardBind !== true) {
1429
+ settle(
1430
+ {
1431
+ ok: false,
1432
+ error: new MllpConnectionError(
1433
+ `refusing to bind wildcard host '${host}' (bound address '${actualHost}') \u2014 set ServerOptions.allowWildcardBind: true to bind all interfaces`,
1434
+ {
1435
+ cause: new Error("wildcard bind requires allowWildcardBind"),
1436
+ phase: "connect"
1437
+ }
1438
+ )
1439
+ },
1440
+ { closeServer: true }
1441
+ );
1442
+ return;
1443
+ }
1444
+ this._listening = true;
1445
+ this._port = actualPort;
1446
+ this._host = actualHost;
1447
+ const emitSubscriberSafe = (event, payload) => {
1448
+ try {
1449
+ this.emit(event, payload);
1450
+ } catch (subscriberErr) {
1451
+ try {
1452
+ this._emitErrorIfListened(
1453
+ subscriberErr instanceof Error ? subscriberErr : new Error(String(subscriberErr))
1454
+ );
1455
+ } catch {
1456
+ }
1457
+ }
1458
+ };
1459
+ const wildcardBound = isWildcardHost(actualHost);
1460
+ const wildcardMessage = `MLLP server is bound to ALL network interfaces (host='${actualHost}') \u2014 set a specific bind address unless this is intentional.`;
1461
+ if (wildcardBound) {
1462
+ process.emitWarning(wildcardMessage, { code: MLLP_BIND_ALL_INTERFACES });
1463
+ }
1464
+ emitSubscriberSafe("listening", Object.freeze({ port: actualPort, host: actualHost }));
1465
+ if (wildcardBound) {
1466
+ const warning = Object.freeze({
1467
+ code: MLLP_BIND_ALL_INTERFACES,
1468
+ message: wildcardMessage,
1469
+ host: actualHost,
1470
+ port: actualPort,
1471
+ timestamp: /* @__PURE__ */ new Date()
1472
+ });
1473
+ emitSubscriberSafe("securityWarning", warning);
1474
+ }
1475
+ settle({ ok: true });
1476
+ };
1477
+ const onError = (err) => {
1478
+ settle({ ok: false, error: err });
1479
+ };
1480
+ this._netServer.once("listening", onListening);
1481
+ this._netServer.once("error", onError);
1482
+ this._netServer.listen(port, host);
1483
+ });
1484
+ }
1485
+ /**
1486
+ * Stop accepting new connections and gracefully close all active connections.
1487
+ *
1488
+ * Sequence (D-06):
1489
+ * 1. `net.Server.close()` — stops accepting new connections immediately
1490
+ * 2. If `_connections` is empty: resolves immediately (no drain needed)
1491
+ * 3. Calls `_drainAll(drainTimeoutMs)` — Promise.all + side-effect setTimeout
1492
+ * that force-destroys stragglers after the drain window
1493
+ *
1494
+ * Calling `close()` while a `listen()` is still in flight proactively
1495
+ * settles that `listen()` with a typed `MllpConnectionError` rejection
1496
+ * and clears the single-flight guard — the server is immediately
1497
+ * re-listenable. `Symbol.asyncDispose` (which delegates here) is
1498
+ * therefore safe before `listen()` settles. **Qualification:** a
1499
+ * `close({ signal })` whose signal is **already aborted** rejects
1500
+ * immediately with `AbortError` and performs no work — it does NOT close
1501
+ * the server and does NOT settle the in-flight `listen()`, which simply
1502
+ * continues and settles on its own bind outcome.
1503
+ *
1504
+ * @param opts.drainTimeoutMs - Override drain timeout (default: `opts.drainTimeoutMs ?? 30_000`).
1505
+ * @param opts.signal - AbortSignal to cancel the close operation. On abort, all
1506
+ * active connections are destroyed and the promise rejects with AbortError.
1507
+ *
1508
+ * @example
1509
+ * ```typescript
1510
+ * await server.close({ drainTimeoutMs: 5_000 });
1511
+ * ```
1512
+ */
1513
+ async close(opts) {
1514
+ const signal = opts?.signal;
1515
+ if (signal?.aborted) {
1516
+ return Promise.reject(new DOMException("Aborted", "AbortError"));
1517
+ }
1518
+ this._pendingListenSettle?.();
1519
+ this._netServer.close();
1520
+ this._listening = false;
1521
+ if (this._connections.size === 0) {
1522
+ this._emitContained("close", Object.freeze({}));
1523
+ return Promise.resolve();
1524
+ }
1525
+ let abortHandler;
1526
+ const abortPromise = signal !== void 0 ? new Promise((_resolve, reject) => {
1527
+ abortHandler = () => {
1528
+ for (const conn of this._connections) {
1529
+ conn.destroy();
1530
+ }
1531
+ reject(new DOMException("Aborted", "AbortError"));
1532
+ };
1533
+ signal.addEventListener("abort", abortHandler, { once: true });
1534
+ }) : null;
1535
+ try {
1536
+ const drainTimeoutMs = opts?.drainTimeoutMs ?? this._opts.drainTimeoutMs ?? 3e4;
1537
+ if (abortPromise !== null) {
1538
+ await Promise.race([this._drainAll(drainTimeoutMs), abortPromise]);
1539
+ } else {
1540
+ await this._drainAll(drainTimeoutMs);
1541
+ }
1542
+ } finally {
1543
+ if (abortHandler !== void 0 && signal !== void 0) {
1544
+ signal.removeEventListener("abort", abortHandler);
1545
+ }
1546
+ }
1547
+ this._emitContained("close", Object.freeze({}));
1548
+ }
1549
+ /**
1550
+ * Drain all active connections with a shared timeout.
1551
+ *
1552
+ * Uses Promise.all (not Promise.race) for coordination — the timeout is a
1553
+ * side effect that calls `conn.destroy()` on stragglers after `drainTimeoutMs`.
1554
+ * Promise.all resolves when all `conn.close()` promises settle (which happens
1555
+ * because `conn.destroy()` transitions connections to CLOSED).
1556
+ *
1557
+ * @param drainTimeoutMs - Maximum time to wait for connections to drain.
1558
+ */
1559
+ async _drainAll(drainTimeoutMs) {
1560
+ const snapshot = [...this._connections];
1561
+ const closePromises = snapshot.map((conn) => conn.close({ drainTimeoutMs }));
1562
+ const timeoutHandle = setTimeout(() => {
1563
+ for (const conn of this._connections) {
1564
+ conn.destroy();
1565
+ }
1566
+ }, drainTimeoutMs);
1567
+ timeoutHandle.unref();
1568
+ try {
1569
+ await Promise.all(closePromises);
1570
+ } finally {
1571
+ clearTimeout(timeoutHandle);
1572
+ }
1573
+ }
1574
+ /**
1575
+ * Async disposal — delegates to `close()` for `await using` support.
1576
+ *
1577
+ * @example
1578
+ * ```typescript
1579
+ * await using server = createServer({ onMessage: handler });
1580
+ * await server.listen(2575);
1581
+ * // server.close() is called automatically at end of block
1582
+ * ```
1583
+ */
1584
+ async [Symbol.asyncDispose]() {
1585
+ return this.close();
1586
+ }
1587
+ /**
1588
+ * Return a JSON-serializable observability snapshot (OBS-02).
1589
+ *
1590
+ * `totalBytesIn` and `totalBytesOut` aggregate from live connections at call time.
1591
+ * Plan 04 adds full aggregation; at Plan 01 scope they return 0.
1592
+ *
1593
+ * @example
1594
+ * ```typescript
1595
+ * const stats = server.getStats();
1596
+ * logger.info(JSON.stringify(stats));
1597
+ * ```
1598
+ */
1599
+ getStats() {
1600
+ let totalBytesIn = 0;
1601
+ let totalBytesOut = 0;
1602
+ for (const conn of this._connections) {
1603
+ const s = conn.getStats();
1604
+ totalBytesIn += s.bytesIn;
1605
+ totalBytesOut += s.bytesOut;
1606
+ }
1607
+ return {
1608
+ listening: this._listening,
1609
+ port: this._port,
1610
+ host: this._host,
1611
+ connections: this._connections.size,
1612
+ activeConnections: this._connections.size,
1613
+ totalBytesIn,
1614
+ totalBytesOut,
1615
+ acceptedTotal: this._acceptedTotal,
1616
+ closedTotal: this._closedTotal,
1617
+ tls: this._isTls,
1618
+ tlsClientErrorsTotal: this._tlsClientErrorsTotal
1619
+ };
1620
+ }
1621
+ /**
1622
+ * Emit on `'error'` only when a listener is attached — an unlistened
1623
+ * `EventEmitter` `'error'` emission throws (see the class JSDoc error
1624
+ * contract). The single guard shared by the optional-tap forwarding sites
1625
+ * in this class; the constructor's net.Server forwarder deliberately does
1626
+ * NOT use it (its while-serving branch re-emits unguarded to keep Node's
1627
+ * fail-loud convention).
1628
+ */
1629
+ /**
1630
+ * Emit a server event, containing a throwing subscriber.
1631
+ *
1632
+ * Every event this server emits is reachable from a callback we do not own — the `net.Server`
1633
+ * `'connection'` listener, the `tls.Server` `'tlsClientError'` listener, a socket's `'data'`
1634
+ * listener (via `Connection`), or the `catch` block of a `void`-ed async ACK task. A throwing
1635
+ * subscriber would therefore unwind into that callback and kill the process (or reject a void-ed
1636
+ * promise, which does the same and additionally skips the rest of the method).
1637
+ *
1638
+ * See `src/internal/safe-emit.ts` for the full rationale. The throw is re-surfaced on `'error'`.
1639
+ */
1640
+ _emitContained(event, payload) {
1641
+ safeEmit(this, event, payload, (err) => {
1642
+ this._emitErrorIfListened(err);
1643
+ });
1644
+ }
1645
+ _emitErrorIfListened(errEvent) {
1646
+ safeEmitError(this, errEvent);
1647
+ }
1648
+ // ---------------------------------------------------------------------------
1649
+ // Private: per-socket setup
1650
+ // ---------------------------------------------------------------------------
1651
+ _onSocketAccepted(socket) {
1652
+ if (this._opts.keepaliveIntervalMs !== void 0) {
1653
+ socket.setKeepAlive(true, this._opts.keepaliveIntervalMs);
1654
+ }
1655
+ const transport = this._isTls ? new TlsTransport(socket) : new NetTransport(socket);
1656
+ const mergedFraming = {
1657
+ ...SERVER_DEFAULT_FRAMING,
1658
+ ...this._opts.framing ?? {}
1659
+ };
1660
+ const connOpts = this._opts.onWarning !== void 0 ? { transport, framing: mergedFraming, onWarning: this._opts.onWarning } : { transport, framing: mergedFraming };
1661
+ const conn = new Connection(connOpts);
1662
+ conn.beforeClose = () => Promise.resolve();
1663
+ conn.on("error", (errEvent) => {
1664
+ this._emitErrorIfListened(errEvent);
1665
+ });
1666
+ this._acceptedTotal++;
1667
+ this._connections.add(conn);
1668
+ let ended = false;
1669
+ const _onConnEnded = () => {
1670
+ if (ended) return;
1671
+ ended = true;
1672
+ this._connections.delete(conn);
1673
+ this._closedTotal++;
1674
+ };
1675
+ conn.once("close", _onConnEnded);
1676
+ conn.once("disconnect", _onConnEnded);
1677
+ if (this._opts.deadPeerTimeoutMs !== void 0) {
1678
+ const timeoutMs = this._opts.deadPeerTimeoutMs;
1679
+ let deadPeerTimer = setTimeout(() => {
1680
+ conn.destroy(new Error("idle timeout"));
1681
+ }, timeoutMs);
1682
+ deadPeerTimer.unref();
1683
+ conn.on("message", () => {
1684
+ clearTimeout(deadPeerTimer);
1685
+ deadPeerTimer = setTimeout(() => {
1686
+ conn.destroy(new Error("idle timeout"));
1687
+ }, timeoutMs);
1688
+ deadPeerTimer.unref();
1689
+ });
1690
+ conn.once("close", () => {
1691
+ clearTimeout(deadPeerTimer);
1692
+ });
1693
+ }
1694
+ conn.notifyConnect(socket.remoteAddress ?? null, socket.remotePort ?? null);
1695
+ conn.on(
1696
+ "message",
1697
+ (event) => {
1698
+ const { payload, connectionId, byteOffset, warnings } = event;
1699
+ const meta = Object.freeze({
1700
+ connectionId,
1701
+ byteOffset,
1702
+ warnings
1703
+ });
1704
+ try {
1705
+ this.emit("message", Object.freeze({ payload, meta }));
1706
+ } catch (err) {
1707
+ this._emitErrorIfListened(
1708
+ Object.freeze({
1709
+ connectionId,
1710
+ error: err instanceof Error ? err : new Error(String(err))
1711
+ })
1712
+ );
1713
+ }
1714
+ const autoAck = this._opts.autoAck;
1715
+ if (autoAck === "AA") {
1716
+ void this._sendCommitAck(payload, meta, conn);
1717
+ } else if (autoAck !== void 0) {
1718
+ this._observeMessage(payload, meta, conn);
1719
+ void this._sendCustomAck(autoAck, payload, meta, conn);
1720
+ } else {
1721
+ this._observeMessage(payload, meta, conn);
1722
+ }
1723
+ }
1724
+ );
1725
+ const peerCertificate = this._isTls && (this._opts.tls?.clientAuth ?? "NONE") !== "NONE" ? this._extractPeerCertificateSummary(socket) : null;
1726
+ this._emitContained(
1727
+ "connection",
1728
+ Object.freeze({
1729
+ connectionId: conn.connectionId,
1730
+ remoteAddress: socket.remoteAddress ?? null,
1731
+ remotePort: socket.remotePort ?? null,
1732
+ peerCertificate
1733
+ })
1734
+ );
1735
+ }
1736
+ /**
1737
+ * Extract a minimal, content-free peer-certificate summary (Phase 8, ATNA
1738
+ * ITI-19 mutual authentication) — CN strings, expiry, and the verification
1739
+ * outcome (`authorized`) only; never the full certificate object. Returns
1740
+ * `null` when no peer certificate was presented (e.g. `clientAuth: 'WANT'`
1741
+ * with no client cert).
1742
+ */
1743
+ _extractPeerCertificateSummary(socket) {
1744
+ const cert = socket.getPeerCertificate();
1745
+ if (cert === void 0 || cert === null || Object.keys(cert).length === 0) {
1746
+ return null;
1747
+ }
1748
+ const asCn = (value) => {
1749
+ if (value === void 0) return null;
1750
+ return Array.isArray(value) ? value[0] ?? null : value;
1751
+ };
1752
+ return Object.freeze({
1753
+ subjectCN: asCn(cert.subject?.CN),
1754
+ issuerCN: asCn(cert.issuer?.CN),
1755
+ validTo: cert.valid_to ?? null,
1756
+ // Verification outcome — under 'WANT' a certificate can be present yet
1757
+ // UNVERIFIED (see PeerCertificateSummary.authorized JSDoc).
1758
+ authorized: socket.authorized === true
1759
+ });
1760
+ }
1761
+ /**
1762
+ * The fail-safe commit path for `autoAck: 'AA'` (HL7 v2.5.1 §2.9.2).
1763
+ *
1764
+ * With an `onMessage` handler this **awaits** it (the durable-commit step) and only
1765
+ * then dispatches: resolve ⇒ `AA`; throw/reject ⇒ a negative ACK (`AE` by default,
1766
+ * `AR` via {@link MllpAckError}). A positive ACK can never precede a successful commit.
1767
+ *
1768
+ * Without an `onMessage` handler, `'AA'` degrades to a **transport-accept** — `AA`
1769
+ * meaning "bytes received and framed", not "application-processed".
1770
+ *
1771
+ * A handler failure is **expected flow**, not a server error: it produces a negative
1772
+ * ACK and a `'nack'` event. The thrown error's message is never placed on the wire or
1773
+ * in the event (it may carry PHI) — only the static `ackCode` and the inbound control
1774
+ * metadata reach the peer.
1775
+ */
1776
+ async _sendCommitAck(payload, meta, conn) {
1777
+ const handler = this._opts.onMessage;
1778
+ if (handler === void 0) {
1779
+ this._dispatchAck(
1780
+ conn,
1781
+ buildRawAck(payload, this._resolveAutoAckPositive(payload, meta, conn))
1782
+ );
1783
+ return;
1784
+ }
1785
+ let code;
1786
+ try {
1787
+ await handler(payload, meta, conn);
1788
+ code = this._resolveAutoAckPositive(payload, meta, conn);
1789
+ } catch (err) {
1790
+ const nack = resolveNackCode(err);
1791
+ code = nack;
1792
+ this._emitContained(
1793
+ "nack",
1794
+ Object.freeze({
1795
+ connectionId: conn.connectionId,
1796
+ ackCode: nack,
1797
+ reason: "handler-rejected"
1798
+ })
1799
+ );
1800
+ }
1801
+ this._dispatchAck(conn, buildRawAck(payload, code));
1802
+ }
1803
+ /**
1804
+ * Resolve the auto-ACK code for a message the server would otherwise acknowledge
1805
+ * **positively** (`AA`) — the no-handler transport-accept and the post-commit success
1806
+ * paths. Fail-safe: downgrade to `AE` and emit a PHI-safe `'nack'` whenever the message
1807
+ * cannot carry a correlatable positive ACK, so `AA` never names a control ID the sender
1808
+ * cannot match (→ timeout → resend → **duplicate clinical message**).
1809
+ *
1810
+ * Two independent disqualifiers, one from the bytes and one from the frame:
1811
+ *
1812
+ * - **`rawAckUncorrelatable(payload)`** — no readable `MSH`, an empty MSH-10, or a
1813
+ * batch/concatenated-message shape. This is the SAME predicate `buildRawAck` enforces
1814
+ * on the wire; it is re-checked here so the downgrade is **observable** (a `'nack'`
1815
+ * event) rather than a silent code change inside the builder.
1816
+ * - **`MLLP_TRAILING_BYTES`** in the frame's warnings — a mid-payload `VT` made the
1817
+ * decoder discard accumulated bytes and deliver only the fragment after it. The
1818
+ * clinical message was destroyed in transit, so even a fragment that happens to carry
1819
+ * a readable MSH must not be positively acknowledged. This condition is invisible to
1820
+ * `buildRawAck` (it sees only the fragment), which is why it lives here. The code is
1821
+ * **reserved** for that discard (see the decoder's `_readPayload`) and is emitted while
1822
+ * accumulating the delivered frame, so it is frame-scoped: it names *this* payload's own
1823
+ * discard, never a neighbouring frame's trailing junk.
1824
+ *
1825
+ * `'discarded-bytes'` takes precedence in the reason when both hold — it is the root cause.
1826
+ */
1827
+ _resolveAutoAckPositive(payload, meta, conn) {
1828
+ const discarded = meta.warnings.some((w) => w.code === "MLLP_TRAILING_BYTES");
1829
+ const uncorrelatable = rawAckUncorrelatable(payload);
1830
+ if (!discarded && !uncorrelatable) return "AA";
1831
+ const reason = discarded ? "discarded-bytes" : "uncorrelatable-inbound";
1832
+ this._emitContained(
1833
+ "nack",
1834
+ Object.freeze({
1835
+ connectionId: conn.connectionId,
1836
+ ackCode: "AE",
1837
+ reason
1838
+ })
1839
+ );
1840
+ return "AE";
1841
+ }
1842
+ /**
1843
+ * Run `onMessage` for its side effects only (custom-ACK and manual modes), guarding
1844
+ * against a synchronous throw or a rejected promise. In these modes the handler does
1845
+ * **not** gate the ACK, so a failure is re-emitted as a PHI-safe `'error'` on the
1846
+ * connection (D-04: a handler error never crashes the server) rather than escaping as
1847
+ * an unhandled rejection. The error text is never placed on the wire.
1848
+ */
1849
+ _observeMessage(payload, meta, conn) {
1850
+ const handler = this._opts.onMessage;
1851
+ if (handler === void 0) return;
1852
+ void Promise.resolve().then(() => handler(payload, meta, conn)).catch((err) => {
1853
+ const connErr = err instanceof Error ? err : new Error(String(err));
1854
+ safeEmitError(conn, Object.freeze({ connectionId: conn.connectionId, error: connErr }));
1855
+ });
1856
+ }
1857
+ /**
1858
+ * The custom-builder path for `autoAck: fn`. Awaits the builder and dispatches its
1859
+ * Buffer as the ACK; the caller owns MSA-1. A builder error is re-emitted as `'error'`
1860
+ * on the connection — the server never crashes (D-04).
1861
+ */
1862
+ async _sendCustomAck(fn, payload, meta, conn) {
1863
+ try {
1864
+ const ackPayload = await Promise.resolve(fn(payload, meta, conn));
1865
+ this._dispatchAck(conn, ackPayload);
1866
+ } catch (err) {
1867
+ const connErr = err instanceof Error ? err : new Error(String(err));
1868
+ safeEmitError(conn, Object.freeze({ connectionId: conn.connectionId, error: connErr }));
1869
+ }
1870
+ }
1871
+ /**
1872
+ * Frame an ACK payload and write it to the connection. `Connection.send()` returns
1873
+ * `false` under backpressure (socket write buffer full); that drops the ACK and emits
1874
+ * a `MllpConnectionError({ phase: 'send' })` on the connection (D-04) — the peer will
1875
+ * time out waiting and may retry. The server never crashes.
1876
+ */
1877
+ _dispatchAck(conn, ackPayload) {
1878
+ let framed;
1879
+ try {
1880
+ framed = encodeFrame(ackPayload);
1881
+ } catch (err) {
1882
+ const cause = err instanceof Error ? err : new Error(String(err));
1883
+ safeEmitError(
1884
+ conn,
1885
+ Object.freeze({
1886
+ connectionId: conn.connectionId,
1887
+ error: new MllpConnectionError("ACK could not be framed", { cause, phase: "send" })
1888
+ })
1889
+ );
1890
+ return;
1891
+ }
1892
+ const sent = conn.send(framed);
1893
+ if (!sent) {
1894
+ safeEmitError(
1895
+ conn,
1896
+ Object.freeze({
1897
+ connectionId: conn.connectionId,
1898
+ error: new MllpConnectionError("ACK dropped: socket backpressure", {
1899
+ cause: new Error("backpressure"),
1900
+ phase: "send"
1901
+ })
1902
+ })
1903
+ );
1904
+ }
1905
+ }
1906
+ };
1907
+ function createServer(opts = {}) {
1908
+ return new MllpServer(opts);
1909
+ }
1910
+ async function createStarterServer(opts) {
1911
+ const server = createServer({
1912
+ ...opts,
1913
+ autoAck: opts.autoAck ?? "AA",
1914
+ drainTimeoutMs: opts.drainTimeoutMs ?? 3e4
1915
+ });
1916
+ if (opts.handleSignals === true) {
1917
+ const sigHandler = () => {
1918
+ void server.close().then(() => process.exit(0)).catch(() => process.exit(1));
1919
+ };
1920
+ process.once("SIGTERM", sigHandler);
1921
+ process.once("SIGINT", sigHandler);
1922
+ server.once("close", () => {
1923
+ process.removeListener("SIGTERM", sigHandler);
1924
+ process.removeListener("SIGINT", sigHandler);
1925
+ });
1926
+ }
1927
+ await server.listen(opts.port, opts.host ?? "127.0.0.1");
1928
+ return server;
1929
+ }
1930
+
1931
+ // src/client/correlator.ts
1932
+ var Correlator = class {
1933
+ _opts;
1934
+ _pending = /* @__PURE__ */ new Map();
1935
+ _graveyard = /* @__PURE__ */ new Map();
1936
+ _sendSeq = 0;
1937
+ _queueBytes = 0;
1938
+ /**
1939
+ * PLAN-06 — count of pending entries with `sentAt !== null` (D-26 / B-01).
1940
+ * Maintained at every site that mutates `entry.sentAt` or removes a
1941
+ * flushed entry: `markFlushed`, `remove`, `matchAck`, `expireDue`, `clear`.
1942
+ */
1943
+ _inFlight = 0;
1944
+ /**
1945
+ * Construct a send/ACK correlator.
1946
+ *
1947
+ * @param opts - Correlation options (FIFO vs control-id mode, ACK timeout, max in-flight).
1948
+ */
1949
+ constructor(opts) {
1950
+ this._opts = {
1951
+ mode: opts.mode ?? "fifo",
1952
+ ackTimeoutMs: opts.ackTimeoutMs ?? 3e4,
1953
+ maxInFlight: opts.maxInFlight ?? Number.POSITIVE_INFINITY,
1954
+ onWarning: opts.onWarning,
1955
+ onTimeout: opts.onTimeout,
1956
+ onUnmatchedAck: opts.onUnmatchedAck,
1957
+ now: opts.now ?? Date.now
1958
+ };
1959
+ }
1960
+ /** Number of live pending entries. */
1961
+ get size() {
1962
+ return this._pending.size;
1963
+ }
1964
+ /** Sum of `frame.length` across live entries. */
1965
+ get queueBytes() {
1966
+ return this._queueBytes;
1967
+ }
1968
+ /** Number of graveyard entries awaiting lazy eviction. */
1969
+ get graveyardSize() {
1970
+ return this._graveyard.size;
1971
+ }
1972
+ /**
1973
+ * Enqueue a new send awaiting its ACK. Returns the assigned
1974
+ * `correlationKey`, or `null` if `maxInFlight` is reached (caller awaits
1975
+ * drain — PLAN-05's `pipeline:false`).
1976
+ */
1977
+ enqueue(frame, controlIdOrNull, resolve, reject) {
1978
+ if (this._pending.size >= this._opts.maxInFlight) return null;
1979
+ const key = this._opts.mode === "controlId" ? controlIdOrNull ?? `__seq-${++this._sendSeq}` : ++this._sendSeq;
1980
+ const entry = {
1981
+ key,
1982
+ frame,
1983
+ controlId: controlIdOrNull,
1984
+ byteCount: frame.length,
1985
+ sentAt: null,
1986
+ resolve,
1987
+ reject
1988
+ };
1989
+ this._pending.set(key, entry);
1990
+ this._queueBytes += frame.length;
1991
+ return key;
1992
+ }
1993
+ /**
1994
+ * Record write-flush timestamp (CLIENT-04 — clock starts at flush, NOT
1995
+ * at `send()` call). No-op if key is unknown (e.g. removed by abort).
1996
+ */
1997
+ markFlushed(key, now) {
1998
+ const entry = this._pending.get(key);
1999
+ if (entry === void 0) return;
2000
+ if (entry.sentAt === null) this._inFlight += 1;
2001
+ entry.sentAt = now ?? this._opts.now();
2002
+ }
2003
+ /**
2004
+ * Match an inbound ACK against the live store.
2005
+ *
2006
+ * - FIFO: returns first pending entry by insertion order; entry is
2007
+ * removed from live store. Caller calls `entry.resolve(ackPayload)`.
2008
+ * - controlId: keyed lookup by `controlIdFromAck`. Live-store hit returns
2009
+ * the entry; graveyard hit fires `MLLP_ACK_AFTER_TIMEOUT` warning
2010
+ * (CLIENT-16, D-04) and returns `null`; otherwise fires
2011
+ * `onUnmatchedAck(controlIdFromAck)` (CLIENT-15, D-05) and returns `null`.
2012
+ *
2013
+ * Triggers lazy graveyard eviction (D-04).
2014
+ *
2015
+ * @param _payload Inbound ACK bytes (framing stripped). MSA-2 extraction
2016
+ * happens at MllpClient's `_onAckPayload` hook; this method takes the
2017
+ * already-extracted control ID as a parameter.
2018
+ * @param controlIdFromAck MSA-2 extracted from ACK (controlId mode only).
2019
+ * @param byteOffsetFromAck Stream offset; forwarded to `onWarning` (W-05).
2020
+ */
2021
+ matchAck(_payload, controlIdFromAck = null, byteOffsetFromAck = 0) {
2022
+ this._evictGraveyardDue(this._opts.now());
2023
+ if (this._opts.mode === "fifo") {
2024
+ const iter = this._pending.values().next();
2025
+ if (iter.done === true) return null;
2026
+ const entry = iter.value;
2027
+ this._pending.delete(entry.key);
2028
+ this._queueBytes -= entry.byteCount;
2029
+ if (entry.sentAt !== null) this._inFlight -= 1;
2030
+ return entry;
2031
+ }
2032
+ if (controlIdFromAck === null) {
2033
+ if (this._opts.onUnmatchedAck !== void 0) {
2034
+ this._opts.onUnmatchedAck("");
2035
+ }
2036
+ return null;
2037
+ }
2038
+ const live = this._pending.get(controlIdFromAck);
2039
+ if (live !== void 0) {
2040
+ this._pending.delete(controlIdFromAck);
2041
+ this._queueBytes -= live.byteCount;
2042
+ if (live.sentAt !== null) this._inFlight -= 1;
2043
+ return live;
2044
+ }
2045
+ const grave = this._graveyard.get(controlIdFromAck);
2046
+ if (grave !== void 0) {
2047
+ const elapsedSinceSendMs = this._opts.now() - grave.timedOutAt;
2048
+ this._opts.onWarning("MLLP_ACK_AFTER_TIMEOUT", {
2049
+ controlId: grave.controlId,
2050
+ elapsedSinceSendMs,
2051
+ byteOffset: byteOffsetFromAck
2052
+ });
2053
+ this._graveyard.delete(controlIdFromAck);
2054
+ return null;
2055
+ }
2056
+ if (this._opts.onUnmatchedAck !== void 0) {
2057
+ this._opts.onUnmatchedAck(controlIdFromAck);
2058
+ }
2059
+ return null;
2060
+ }
2061
+ /**
2062
+ * Sweep live entries; expire those past `sentAt + ackTimeoutMs`.
2063
+ * Fires `onTimeout(entry, elapsedMs)`; entries move to graveyard (D-04).
2064
+ * Driven externally by `MllpClient`'s periodic sweep tick — Correlator
2065
+ * itself owns no timers (D-03).
2066
+ */
2067
+ expireDue(now) {
2068
+ const t = now ?? this._opts.now();
2069
+ const ackTimeoutMs = this._opts.ackTimeoutMs;
2070
+ for (const [key, entry] of this._pending) {
2071
+ if (entry.sentAt !== null && entry.sentAt + ackTimeoutMs <= t) {
2072
+ const elapsed = t - entry.sentAt;
2073
+ this._pending.delete(key);
2074
+ this._queueBytes -= entry.byteCount;
2075
+ this._inFlight -= 1;
2076
+ this._graveyard.set(key, {
2077
+ timedOutAt: t,
2078
+ controlId: entry.controlId
2079
+ });
2080
+ this._opts.onTimeout(entry, elapsed);
2081
+ }
2082
+ }
2083
+ }
2084
+ /**
2085
+ * Reject every live entry with `reason` (insertion order) and clear
2086
+ * the live store. Graveyard is left intact (ages out via lazy eviction).
2087
+ * Used by FIFO reconnect-reject (D-07 — PLAN-04 wraps in MllpConnectionError)
2088
+ * and `MllpClient.close()` to cancel pending sends.
2089
+ */
2090
+ clear(reason) {
2091
+ for (const entry of this._pending.values()) entry.reject(reason);
2092
+ this._pending.clear();
2093
+ this._queueBytes = 0;
2094
+ this._inFlight = 0;
2095
+ }
2096
+ /** Iterate live entries in insertion order (PLAN-04 reconnect-resend, D-07). */
2097
+ *liveEntries() {
2098
+ for (const entry of this._pending.values()) yield entry;
2099
+ }
2100
+ /**
2101
+ * Remove a live entry by key WITHOUT resolving/rejecting. Returns the
2102
+ * removed entry, or `null` if no entry with that key exists.
2103
+ * Used by `MllpClient.send()` for AbortSignal cleanup and PLAN-04's
2104
+ * reconnect-reject FSM walk.
2105
+ */
2106
+ remove(key) {
2107
+ const entry = this._pending.get(key);
2108
+ if (entry === void 0) return null;
2109
+ this._pending.delete(key);
2110
+ this._queueBytes -= entry.byteCount;
2111
+ if (entry.sentAt !== null) this._inFlight -= 1;
2112
+ return entry;
2113
+ }
2114
+ /** JSON-serializable stats snapshot (no Buffers, no class instances). */
2115
+ getStats() {
2116
+ return {
2117
+ size: this._pending.size,
2118
+ queueBytes: this._queueBytes,
2119
+ graveyardSize: this._graveyard.size,
2120
+ sendSeq: this._sendSeq,
2121
+ inFlight: this._inFlight
2122
+ };
2123
+ }
2124
+ /** Lazy graveyard eviction (D-04): drop entries past `timedOutAt + 2 * ackTimeoutMs`. */
2125
+ _evictGraveyardDue(now) {
2126
+ const threshold = 2 * this._opts.ackTimeoutMs;
2127
+ for (const [key, entry] of this._graveyard) {
2128
+ if (entry.timedOutAt + threshold <= now) this._graveyard.delete(key);
2129
+ }
2130
+ }
2131
+ };
2132
+
2133
+ // src/client/error.ts
2134
+ var MllpTimeoutError = class _MllpTimeoutError extends Error {
2135
+ name = "MllpTimeoutError";
2136
+ /** MSH-10 control ID of the timed-out send (FIFO mode: `undefined`). */
2137
+ messageControlId;
2138
+ /** Milliseconds elapsed between write-flush and timeout fire. */
2139
+ elapsedMs;
2140
+ /** Epoch ms timestamp recorded at write-flush callback. */
2141
+ sentAt;
2142
+ /**
2143
+ * Construct an MLLP timeout error.
2144
+ *
2145
+ * @param message - Human-readable error message.
2146
+ * @param opts - Timeout context (originating message control id, elapsed time, flush timestamp).
2147
+ */
2148
+ constructor(message, opts) {
2149
+ super(message);
2150
+ this.messageControlId = opts.messageControlId;
2151
+ this.elapsedMs = opts.elapsedMs;
2152
+ this.sentAt = opts.sentAt;
2153
+ if (Error.captureStackTrace) {
2154
+ Error.captureStackTrace(this, _MllpTimeoutError);
2155
+ }
2156
+ }
2157
+ };
2158
+ function isTlsVerificationErrorCode(code) {
2159
+ switch (code) {
2160
+ case "UNABLE_TO_VERIFY_LEAF_SIGNATURE":
2161
+ case "DEPTH_ZERO_SELF_SIGNED_CERT":
2162
+ case "SELF_SIGNED_CERT_IN_CHAIN":
2163
+ case "UNABLE_TO_GET_ISSUER_CERT":
2164
+ case "UNABLE_TO_GET_ISSUER_CERT_LOCALLY":
2165
+ case "CERT_HAS_EXPIRED":
2166
+ case "CERT_NOT_YET_VALID":
2167
+ case "CERT_REVOKED":
2168
+ case "CERT_UNTRUSTED":
2169
+ case "CERT_REJECTED":
2170
+ case "CERT_SIGNATURE_FAILURE":
2171
+ case "HOSTNAME_MISMATCH":
2172
+ case "ERR_TLS_CERT_ALTNAME_INVALID":
2173
+ return true;
2174
+ default:
2175
+ return code.startsWith("CERT_");
2176
+ }
2177
+ }
2178
+ function isTlsProtocolError(err) {
2179
+ if (typeof err !== "object" || err === null) return false;
2180
+ const code = err.code;
2181
+ if (typeof code === "string") {
2182
+ if (code.startsWith("ERR_SSL_")) return true;
2183
+ if (code === "EPROTO") return true;
2184
+ }
2185
+ const message = err.message;
2186
+ if (typeof message === "string" && /\bssl\b|\balert\b/i.test(message)) return true;
2187
+ return false;
2188
+ }
2189
+ function isTransientConnectionError(err) {
2190
+ if (typeof err !== "object" || err === null) return true;
2191
+ const code = err.code;
2192
+ if (typeof code !== "string") return true;
2193
+ switch (code) {
2194
+ case "ENOTFOUND":
2195
+ case "EACCES":
2196
+ return false;
2197
+ case "ECONNREFUSED":
2198
+ case "ECONNRESET":
2199
+ case "ETIMEDOUT":
2200
+ case "EHOSTUNREACH":
2201
+ case "ENETUNREACH":
2202
+ case "EPIPE":
2203
+ return true;
2204
+ default:
2205
+ if (isTlsVerificationErrorCode(code)) return false;
2206
+ if (code.startsWith("ERR_SSL_")) return false;
2207
+ if (code.startsWith("MLLP_")) return false;
2208
+ return true;
2209
+ }
2210
+ }
2211
+ var MllpBackpressureError = class _MllpBackpressureError extends Error {
2212
+ name = "MllpBackpressureError";
2213
+ /** Number of in-flight + queued sends at the moment of rejection. */
2214
+ queueDepth;
2215
+ /** Total bytes of in-flight + queued frames at the moment of rejection. */
2216
+ queueBytes;
2217
+ /** The high-water-mark configuration that was triggered (D-23). */
2218
+ highWaterMark;
2219
+ /**
2220
+ * Construct an MLLP backpressure error.
2221
+ *
2222
+ * @param message - Human-readable error message.
2223
+ * @param opts - Backpressure context (queue depth, queued bytes, the high-water-mark hit).
2224
+ */
2225
+ constructor(message, opts) {
2226
+ super(message);
2227
+ this.queueDepth = opts.queueDepth;
2228
+ this.queueBytes = opts.queueBytes;
2229
+ this.highWaterMark = opts.highWaterMark;
2230
+ if (Error.captureStackTrace) {
2231
+ Error.captureStackTrace(this, _MllpBackpressureError);
2232
+ }
2233
+ }
2234
+ };
2235
+
2236
+ // src/client/client.ts
2237
+ var NEVER_ABORTING_SIGNAL = new AbortController().signal;
2238
+ var MllpClient = class extends EventEmitter {
2239
+ _opts;
2240
+ _connection = null;
2241
+ _socket = null;
2242
+ /**
2243
+ * Initial state for `get state()` before `_connection` exists. Once a Connection
2244
+ * is attached, `state` mirrors `_connection.state`.
2245
+ */
2246
+ _state = "DISCONNECTED";
2247
+ /** Per-message ACK timeout in ms (CLIENT-04). Resolved at construction. */
2248
+ _ackTimeoutMs;
2249
+ /** controlId-mode flag (CLIENT-03 branch). `false` → FIFO. */
2250
+ _correlateByControlId;
2251
+ /** Unified ACK correlator (D-03/A1). Built during `_attachConnection`. */
2252
+ _correlator = null;
2253
+ /**
2254
+ * Periodic ACK-timeout sweep timer. Drives `_correlator.expireDue()` because
2255
+ * the Correlator is timer-free per D-03. Cleared on close/destroy.
2256
+ */
2257
+ _ackSweepTimer = null;
2258
+ // ── Reconnect state (Plan 04, CLIENT-05/06/12/17/18) ─────────────────────
2259
+ _autoReconnect;
2260
+ _initialDelayMs;
2261
+ _maxDelayMs;
2262
+ _multiplier;
2263
+ _jitter;
2264
+ _retryStrategy;
2265
+ /** 0-indexed attempt counter for the current reconnect cycle. */
2266
+ _attempt = 0;
2267
+ /**
2268
+ * W-02 — total reconnect attempts since construction. Read by PLAN-06 for
2269
+ * `getStats().reconnectAttempts`. Incremented at the entry of every
2270
+ * `_handleDisconnect` invocation that proceeds to schedule a backoff.
2271
+ */
2272
+ _reconnectAttempts = 0;
2273
+ /** Epoch ms of the last successful ACK. Drives W-01 backoff-reset. */
2274
+ _lastSuccessAt = null;
2275
+ /** Epoch ms when the current reconnect cycle began. `null` outside a cycle. */
2276
+ _reconnectCycleStartedAt = null;
2277
+ /** Active backoff `setTimeout` handle. `null` when no backoff is armed. */
2278
+ _backoffTimer = null;
2279
+ /** Last delay (ms) used by the strategy. Surfaced via RetryContext.lastDelayMs. */
2280
+ _lastDelayMs = 0;
2281
+ // ── Backpressure + pipeline state (Plan 05, CLIENT-07/CLIENT-19, D-23) ───
2282
+ /** Count cap; `Number.POSITIVE_INFINITY` when only bytes configured. */
2283
+ _hwmCount;
2284
+ /** Byte cap; `Number.POSITIVE_INFINITY` when only count configured. */
2285
+ _hwmBytes;
2286
+ /** Backpressure policy; default `'reject'`. */
2287
+ _onBackpressure;
2288
+ /** Pipeline flag; default `true` (parallel up to highWaterMark). */
2289
+ _pipeline;
2290
+ /** Dead-peer idle timer (Plan 05 — D-11). `null` when not armed. */
2291
+ _deadPeerTimer = null;
2292
+ /** True once the self-'ack' listener that resets the dead-peer timer
2293
+ * has been attached. Guards against duplicate listeners on reconnect. */
2294
+ _ackResetWired = false;
2295
+ /** Most recently bound `connect()` signal. Reread on every RetryContext build (W-07). */
2296
+ _connectSignal;
2297
+ /** Set when close()/destroy()/abort fires; reconnect handler short-circuits. */
2298
+ _userClosed = false;
2299
+ /** Captured Connection error feeding `RetryContext.lastError`. */
2300
+ _lastError = null;
2301
+ /** Listener-removal handle for the abort listener bound in connect(). */
2302
+ _abortListener = null;
2303
+ /**
2304
+ * Test-only seam — when set, `_beginReconnectAttempt` builds the new
2305
+ * Connection through this factory instead of opening a real net.Socket.
2306
+ *
2307
+ * @internal
2308
+ */
2309
+ _reconnectFactory = null;
2310
+ // ── PLAN-06 — observability counters for getStats (OBS-01, D-26) ─────────
2311
+ /** Total successful `conn.send()` flushes since construction. */
2312
+ _sentTotal = 0;
2313
+ /** Total ACKs resolved since construction. */
2314
+ _ackedTotal = 0;
2315
+ /** Total ACK timeouts since construction. */
2316
+ _timedOutTotal = 0;
2317
+ /** Epoch ms of the most recent CONNECTED transition (null until first). */
2318
+ _lastConnectedAt = null;
2319
+ /** Epoch ms of the most recent successful ACK (null until first). */
2320
+ _lastAckAt = null;
2321
+ /**
2322
+ * Aggregated Correlator-emitted warning counts (MLLP_ACK_*). Connection-level
2323
+ * warnings are read directly from `_connection.getStats().warningsByCode`
2324
+ * at observation time and merged into the snapshot (D-26).
2325
+ */
2326
+ _aggregatedWarningsByCode = {};
2327
+ /**
2328
+ * Construct an MLLP client. Created idle; call `connect()` (or use
2329
+ * {@link createClient}/{@link createStarterClient}) to open the connection.
2330
+ *
2331
+ * @param opts - Client options (host/port, ACK timeout, reconnect/backpressure policy, …).
2332
+ */
2333
+ constructor(opts) {
2334
+ super();
2335
+ this._opts = opts;
2336
+ this._ackTimeoutMs = opts.ackTimeoutMs ?? 3e4;
2337
+ this._correlateByControlId = opts.correlateByControlId === true;
2338
+ this._autoReconnect = opts.autoReconnect === true;
2339
+ this._initialDelayMs = opts.initialDelayMs ?? 100;
2340
+ this._maxDelayMs = opts.maxDelayMs ?? 3e4;
2341
+ this._multiplier = opts.multiplier ?? 2;
2342
+ this._jitter = opts.jitter ?? 0.2;
2343
+ this._retryStrategy = opts.retryStrategy;
2344
+ const hwm = opts.highWaterMark ?? 64;
2345
+ if (typeof hwm === "number") {
2346
+ this._hwmCount = hwm;
2347
+ this._hwmBytes = Number.POSITIVE_INFINITY;
2348
+ } else {
2349
+ this._hwmCount = hwm.count ?? Number.POSITIVE_INFINITY;
2350
+ this._hwmBytes = hwm.bytes ?? Number.POSITIVE_INFINITY;
2351
+ }
2352
+ this._onBackpressure = opts.onBackpressure ?? "reject";
2353
+ this._pipeline = opts.pipeline !== false;
2354
+ }
2355
+ /**
2356
+ * Current FSM state. Mirrors the underlying Connection's state once attached;
2357
+ * before `connect()` (or after a `CLOSED` Connection is dropped) reports
2358
+ * the client-level baseline (`'DISCONNECTED'`).
2359
+ */
2360
+ get state() {
2361
+ return this._connection?.state ?? this._state;
2362
+ }
2363
+ /**
2364
+ * Open a TCP (or TLS — Phase 8) connection to the configured `host:port`
2365
+ * and attach a Phase 3 {@link Connection} to it. Resolves once the FSM
2366
+ * enters `CONNECTED` — for TLS, on `'secureConnect'` (handshake complete,
2367
+ * including certificate verification when it is on).
2368
+ *
2369
+ * Rejects with:
2370
+ * - `DOMException('Aborted', 'AbortError')` if `signal` is provided and aborts
2371
+ * before the connect resolves.
2372
+ * - `MllpConnectionError({ phase: 'connect' })` if the underlying socket emits
2373
+ * `error` before connecting, OR if the client is already connecting/connected.
2374
+ * TLS failures carry a `connectionCause`: `'tls-verify'` for certificate
2375
+ * verification failures, `'tls-handshake'` for TLS-protocol-shaped failures
2376
+ * ({@link isTlsProtocolError}); pure TCP failures carry none.
2377
+ *
2378
+ * **Dual failure signal on initial connect:** the Connection's transport
2379
+ * error handler is attached before this promise's own error listener, so a
2380
+ * pre-connect socket error produces BOTH the promise rejection AND a
2381
+ * client `'error'` event (when an `'error'` listener is attached). Handle
2382
+ * whichever fits your flow; they describe the same underlying failure.
2383
+ *
2384
+ * **TLS 1.3 + mutual TLS caveat (RFC 8446 §4.4.2):** `connect()` resolving
2385
+ * does NOT guarantee that a `clientAuth: 'MUST'` server accepted your
2386
+ * client certificate. Under TLS 1.3 the client's handshake — and its
2387
+ * `'secureConnect'` — can complete before the server finishes validating
2388
+ * the certificate; a rejection then surfaces moments later as a typed
2389
+ * post-connect error (`'error'` event with an `ERR_SSL_*`/alert cause,
2390
+ * classified **permanent** — no auto-reconnect loop). ACK correlation via
2391
+ * {@link MllpClient.send} remains the delivery guarantee: no send resolves
2392
+ * without its ACK, so a rejected session can never silently "deliver".
2393
+ *
2394
+ * @example
2395
+ * ```typescript
2396
+ * const ac = new AbortController();
2397
+ * setTimeout(() => ac.abort(), 5_000);
2398
+ * await client.connect({ signal: ac.signal });
2399
+ * ```
2400
+ */
2401
+ connect(opts) {
2402
+ const signal = opts?.signal;
2403
+ if (signal?.aborted) {
2404
+ return Promise.reject(new DOMException("Aborted", "AbortError"));
2405
+ }
2406
+ if (this._connection !== null && this._connection.state !== "CLOSED" && this._connection.state !== "DISCONNECTED") {
2407
+ return Promise.reject(
2408
+ new MllpConnectionError("already connected or connecting", {
2409
+ cause: new Error("already connected"),
2410
+ phase: "connect"
2411
+ })
2412
+ );
2413
+ }
2414
+ if (signal !== void 0) {
2415
+ this._captureConnectSignal(signal);
2416
+ }
2417
+ return new Promise((resolve, reject) => {
2418
+ let aborted = false;
2419
+ const { socket, transport } = this._createSocketAndTransport();
2420
+ if (this._opts.keepaliveIntervalMs !== void 0) {
2421
+ socket.setKeepAlive(true, this._opts.keepaliveIntervalMs);
2422
+ }
2423
+ this._socket = socket;
2424
+ const connOpts = this._opts.framing !== void 0 ? { transport, framing: this._opts.framing } : { transport };
2425
+ if (this._opts.drainTimeoutMs !== void 0) {
2426
+ connOpts.drainTimeoutMs = this._opts.drainTimeoutMs;
2427
+ }
2428
+ const conn = new Connection(connOpts);
2429
+ this._attachConnection(conn);
2430
+ const connectEventName = this._opts.tls !== void 0 ? "secureConnect" : "connect";
2431
+ const cleanup = () => {
2432
+ if (signal !== void 0) {
2433
+ signal.removeEventListener("abort", abortHandler);
2434
+ }
2435
+ socket.removeListener(connectEventName, onSocketConnect);
2436
+ socket.removeListener("error", onSocketError);
2437
+ };
2438
+ const abortHandler = () => {
2439
+ aborted = true;
2440
+ cleanup();
2441
+ this._teardownCorrelator(
2442
+ new MllpConnectionError("connect aborted", {
2443
+ cause: new Error("aborted"),
2444
+ phase: "connect"
2445
+ })
2446
+ );
2447
+ conn.destroy(new Error("aborted"));
2448
+ reject(new DOMException("Aborted", "AbortError"));
2449
+ };
2450
+ const onSocketConnect = () => {
2451
+ if (aborted) return;
2452
+ cleanup();
2453
+ conn.notifyConnect(socket.remoteAddress ?? null, socket.remotePort ?? null);
2454
+ this._emitInsecureWarningIfNeeded();
2455
+ resolve();
2456
+ };
2457
+ const onSocketError = (err) => {
2458
+ if (aborted) return;
2459
+ cleanup();
2460
+ reject(this._wrapConnectError(err, "connect"));
2461
+ };
2462
+ if (signal !== void 0) {
2463
+ signal.addEventListener("abort", abortHandler, { once: true });
2464
+ }
2465
+ socket.once(connectEventName, onSocketConnect);
2466
+ socket.once("error", onSocketError);
2467
+ });
2468
+ }
2469
+ /**
2470
+ * Build the raw socket + `Transport` pair for a connect / reconnect attempt.
2471
+ *
2472
+ * Plaintext (`ClientOptions.tls` unset): a `net.Socket` wrapped in
2473
+ * `NetTransport`. TLS (Phase 8): a `tls.TLSSocket` wrapped in
2474
+ * `TlsTransport` — verification defaults **on**
2475
+ * (`rejectUnauthorized: !allowUnverified`), floor `minVersion: 'TLSv1.2'`
2476
+ * (the IHE ATNA ITI-19 BCP195 floor), `servername` defaulting to
2477
+ * `ClientOptions.host`.
2478
+ */
2479
+ _createSocketAndTransport() {
2480
+ const tlsOpt = this._opts.tls;
2481
+ if (tlsOpt === void 0) {
2482
+ const socket2 = createConnection({ host: this._opts.host, port: this._opts.port });
2483
+ return { socket: socket2, transport: new NetTransport(socket2) };
2484
+ }
2485
+ const tlsOpts = tlsOpt === true ? {} : tlsOpt;
2486
+ const socket = connect({
2487
+ host: this._opts.host,
2488
+ port: this._opts.port,
2489
+ servername: tlsOpts.servername ?? this._opts.host,
2490
+ ...tlsOpts.ca !== void 0 ? { ca: tlsOpts.ca } : {},
2491
+ ...tlsOpts.cert !== void 0 ? { cert: tlsOpts.cert } : {},
2492
+ ...tlsOpts.key !== void 0 ? { key: tlsOpts.key } : {},
2493
+ ...tlsOpts.passphrase !== void 0 ? { passphrase: tlsOpts.passphrase } : {},
2494
+ minVersion: tlsOpts.minVersion ?? "TLSv1.2",
2495
+ ...tlsOpts.maxVersion !== void 0 ? { maxVersion: tlsOpts.maxVersion } : {},
2496
+ ...tlsOpts.ciphers !== void 0 ? { ciphers: tlsOpts.ciphers } : {},
2497
+ rejectUnauthorized: tlsOpts.allowUnverified !== true
2498
+ });
2499
+ return { socket, transport: new TlsTransport(socket) };
2500
+ }
2501
+ /**
2502
+ * Wrap a connect-phase socket error as `MllpConnectionError`, classifying
2503
+ * TLS failures (Phase 8) into the additive `connectionCause`:
2504
+ *
2505
+ * - `'tls-verify'` — certificate-verification failures
2506
+ * ({@link isTlsVerificationErrorCode}).
2507
+ * - `'tls-handshake'` — TLS-**protocol**-shaped failures only
2508
+ * ({@link isTlsProtocolError}): `ERR_SSL_*`, `EPROTO`, OpenSSL
2509
+ * alert-bearing errors.
2510
+ * - **No `connectionCause`** — pure TCP-level failures (`ECONNREFUSED`,
2511
+ * `ETIMEDOUT`, …) even on a TLS-configured connection; these carry the
2512
+ * same shape as plaintext connect failures.
2513
+ */
2514
+ _wrapConnectError(err, phase) {
2515
+ if (this._opts.tls === void 0) {
2516
+ return new MllpConnectionError(err.message, { cause: err, phase });
2517
+ }
2518
+ const code = err.code;
2519
+ if (typeof code === "string" && isTlsVerificationErrorCode(code)) {
2520
+ return new MllpConnectionError(err.message, {
2521
+ cause: err,
2522
+ phase,
2523
+ connectionCause: "tls-verify"
2524
+ });
2525
+ }
2526
+ if (isTlsProtocolError(err)) {
2527
+ return new MllpConnectionError(err.message, {
2528
+ cause: err,
2529
+ phase,
2530
+ connectionCause: "tls-handshake"
2531
+ });
2532
+ }
2533
+ return new MllpConnectionError(err.message, { cause: err, phase });
2534
+ }
2535
+ /**
2536
+ * Emit a client event, containing a throwing subscriber.
2537
+ *
2538
+ * Every event this client emits is reached from a callback we do not own — a socket's
2539
+ * `'connect'`/`'secureConnect'`/`'data'`/`'error'` listener, or a backoff timer. A throwing
2540
+ * subscriber would unwind into it and kill the process, and on several paths it would also skip
2541
+ * the work queued after the emit (the `resolve()` of `connect()`, the backoff scheduling).
2542
+ *
2543
+ * See `src/internal/safe-emit.ts`. The throw is re-surfaced on `'error'`.
2544
+ */
2545
+ _emitContained(event, payload) {
2546
+ safeEmit(this, event, payload, (err) => {
2547
+ safeEmitError(this, Object.freeze({ error: err }));
2548
+ });
2549
+ }
2550
+ /**
2551
+ * Emit the per-connection insecure-TLS warning (Phase 8) when
2552
+ * `tls.allowUnverified === true` — fires on EVERY successful
2553
+ * `secureConnect`, initial connect and every reconnect. Emits both a frozen
2554
+ * `'securityWarning'` event and `process.emitWarning`. No-op for plaintext
2555
+ * connections or when verification is on.
2556
+ */
2557
+ _emitInsecureWarningIfNeeded() {
2558
+ const tlsOpt = this._opts.tls;
2559
+ if (tlsOpt === void 0 || tlsOpt === true) return;
2560
+ if (tlsOpt.allowUnverified !== true) return;
2561
+ const message = "MLLP TLS certificate verification is DISABLED (allowUnverified: true) \u2014 this connection does not authenticate the peer.";
2562
+ const warning = Object.freeze({
2563
+ code: MLLP_TLS_VERIFY_DISABLED,
2564
+ message,
2565
+ host: this._opts.host,
2566
+ port: this._opts.port,
2567
+ timestamp: /* @__PURE__ */ new Date()
2568
+ });
2569
+ this._emitContained("securityWarning", warning);
2570
+ process.emitWarning(message, { code: MLLP_TLS_VERIFY_DISABLED });
2571
+ }
2572
+ /**
2573
+ * Wire a Connection's events through to this MllpClient. Every re-emitted
2574
+ * payload is `Object.freeze`'d before emission (D-25), even though the
2575
+ * Connection layer already freezes — defense-in-depth, harmless on
2576
+ * already-frozen objects.
2577
+ *
2578
+ * Builds the unified `Correlator` (D-03/A1) bound to this Connection and
2579
+ * arms the periodic ACK-timeout sweep. The Correlator is teardown-aware:
2580
+ * `close()` / `destroy()` clear the sweep timer and reject pending sends.
2581
+ *
2582
+ * @param conn - Connection to subscribe to.
2583
+ */
2584
+ _attachConnection(conn) {
2585
+ if (this._correlator === null) {
2586
+ this._correlator = new Correlator({
2587
+ mode: this._correlateByControlId ? "controlId" : "fifo",
2588
+ ackTimeoutMs: this._ackTimeoutMs,
2589
+ // Plan 05 — pipeline:false collapses the in-flight set to ≤1 (D-06).
2590
+ maxInFlight: this._pipeline ? Number.POSITIVE_INFINITY : 1,
2591
+ onWarning: (code, ctx) => {
2592
+ this._aggregatedWarningsByCode[code] = (this._aggregatedWarningsByCode[code] ?? 0) + 1;
2593
+ this._emitContained(
2594
+ "warning",
2595
+ Object.freeze({
2596
+ code,
2597
+ byteOffset: ctx.byteOffset,
2598
+ message: `${code}: controlId=${ctx.controlId} elapsed=${ctx.elapsedSinceSendMs}ms`,
2599
+ connectionId: this._connection?.connectionId ?? conn.connectionId,
2600
+ timestamp: /* @__PURE__ */ new Date()
2601
+ })
2602
+ );
2603
+ },
2604
+ onUnmatchedAck: (controlId) => {
2605
+ if (this.listenerCount("error") === 0) return;
2606
+ const err = new MllpFramingError(
2607
+ "MLLP_ACK_UNMATCHED_CONTROL_ID",
2608
+ 0,
2609
+ Buffer.alloc(0),
2610
+ `Unmatched ACK control ID${controlId === "" ? "" : `: ${controlId}`}`
2611
+ );
2612
+ safeEmitError(
2613
+ this,
2614
+ Object.freeze({
2615
+ connectionId: this._connection?.connectionId ?? conn.connectionId,
2616
+ error: err,
2617
+ controlId
2618
+ })
2619
+ );
2620
+ },
2621
+ onTimeout: (entry, elapsedMs) => {
2622
+ this._timedOutTotal += 1;
2623
+ entry.reject(
2624
+ new MllpTimeoutError(`ACK timeout after ${elapsedMs}ms`, {
2625
+ messageControlId: entry.controlId ?? void 0,
2626
+ elapsedMs,
2627
+ sentAt: entry.sentAt ?? 0
2628
+ })
2629
+ );
2630
+ this._maybeEmitDrain();
2631
+ }
2632
+ });
2633
+ }
2634
+ if (this._ackSweepTimer === null) {
2635
+ const sweepIntervalMs = Math.max(50, Math.min(1e3, Math.floor(this._ackTimeoutMs / 4)));
2636
+ this._ackSweepTimer = setInterval(() => {
2637
+ this._correlator?.expireDue();
2638
+ }, sweepIntervalMs);
2639
+ this._ackSweepTimer.unref();
2640
+ }
2641
+ conn.on("stateChange", (e) => {
2642
+ this._onStateChange(e);
2643
+ });
2644
+ conn.on(
2645
+ "message",
2646
+ (e) => {
2647
+ this._emitContained("message", Object.freeze({ ...e }));
2648
+ this._armDeadPeerTimer();
2649
+ this._onAckPayload(e.payload, e.byteOffset);
2650
+ }
2651
+ );
2652
+ conn.on("connect", (e) => {
2653
+ this._emitContained("connect", Object.freeze({ ...e }));
2654
+ });
2655
+ conn.on("disconnect", (e) => {
2656
+ this._emitContained("disconnect", Object.freeze({ ...e }));
2657
+ });
2658
+ conn.on("reconnecting", (e) => {
2659
+ this._emitContained("reconnecting", Object.freeze({ ...e }));
2660
+ });
2661
+ conn.on("close", (e) => {
2662
+ this._emitContained("close", Object.freeze({ ...e }));
2663
+ });
2664
+ conn.on("warning", (w) => {
2665
+ this._emitContained("warning", w);
2666
+ this._armDeadPeerTimer();
2667
+ });
2668
+ conn.on("error", (e) => {
2669
+ const wrapper = e instanceof Error ? e : e?.error;
2670
+ if (wrapper instanceof Error) {
2671
+ const inner = wrapper.cause;
2672
+ this._lastError = inner instanceof Error ? inner : wrapper;
2673
+ }
2674
+ safeEmitError(this, e);
2675
+ });
2676
+ this._connection = conn;
2677
+ if (!this._ackResetWired) {
2678
+ this._ackResetWired = true;
2679
+ this.on("ack", () => {
2680
+ this._armDeadPeerTimer();
2681
+ });
2682
+ }
2683
+ if (conn.state === "CONNECTED") {
2684
+ this._armDeadPeerTimer();
2685
+ }
2686
+ }
2687
+ /**
2688
+ * Arm (or re-arm) the dead-peer idle timer (Plan 05 — CLIENT-08, D-11).
2689
+ * No-op when `deadPeerTimeoutMs` is unset.
2690
+ */
2691
+ _armDeadPeerTimer() {
2692
+ if (this._opts.deadPeerTimeoutMs === void 0) return;
2693
+ if (this._deadPeerTimer !== null) {
2694
+ clearTimeout(this._deadPeerTimer);
2695
+ }
2696
+ this._deadPeerTimer = setTimeout(() => {
2697
+ this._connection?.destroy(new Error("dead peer timeout"));
2698
+ }, this._opts.deadPeerTimeoutMs);
2699
+ this._deadPeerTimer.unref();
2700
+ }
2701
+ /**
2702
+ * Clear the dead-peer idle timer (Plan 05 — D-14 timer cleanup on
2703
+ * every transition out of CONNECTED).
2704
+ */
2705
+ _clearDeadPeerTimer() {
2706
+ if (this._deadPeerTimer !== null) {
2707
+ clearTimeout(this._deadPeerTimer);
2708
+ this._deadPeerTimer = null;
2709
+ }
2710
+ }
2711
+ /**
2712
+ * Disconnect handler — Plan 04 reconnect FSM core.
2713
+ *
2714
+ * Implements:
2715
+ * - CLIENT-17 hybrid in-flight handling (D-08): controlId mode preserves
2716
+ * pending sends for resend; FIFO mode rejects in-flight with
2717
+ * `connectionCause: 'in-flight-orphan'` and queued with `'fifo-unsafe'`.
2718
+ * - CLIENT-18 classifier-first (Composition A — D-16): permanent errors
2719
+ * transition straight to CLOSED without invoking `retryStrategy`.
2720
+ * - W-01 backoff-reset on recent success: first disconnect after a
2721
+ * successful ACK on the prior session resets `_attempt` to 0.
2722
+ * - W-02 `_reconnectAttempts` counter increment.
2723
+ * - D-15 frozen RetryContext + D-17 null-return halts.
2724
+ * - D-19 default exponential strategy.
2725
+ *
2726
+ * Invoked from the SINGLE `_onStateChange` hook — no parallel listener
2727
+ * (B-04). Idempotent across same-cycle re-entry: cycle-start flag
2728
+ * coordinates first-disconnect vs subsequent-within-cycle behavior.
2729
+ */
2730
+ _handleDisconnect(err) {
2731
+ if (this._userClosed) return;
2732
+ if (this._correlator !== null) {
2733
+ if (this._correlateByControlId) ; else {
2734
+ const orphans = [];
2735
+ const queued = [];
2736
+ for (const entry of this._correlator.liveEntries()) {
2737
+ if (entry.sentAt !== null) orphans.push(entry);
2738
+ else queued.push(entry);
2739
+ }
2740
+ for (const o of orphans) {
2741
+ o.reject(
2742
+ new MllpConnectionError("in-flight send orphaned by reconnect", {
2743
+ cause: err,
2744
+ phase: "reconnect",
2745
+ connectionCause: "in-flight-orphan"
2746
+ })
2747
+ );
2748
+ }
2749
+ for (const q of queued) {
2750
+ q.reject(
2751
+ new MllpConnectionError("queued send rejected by FIFO reconnect", {
2752
+ cause: err,
2753
+ phase: "reconnect",
2754
+ connectionCause: "fifo-unsafe"
2755
+ })
2756
+ );
2757
+ }
2758
+ for (const entry of [...orphans, ...queued]) {
2759
+ this._correlator.remove(entry.key);
2760
+ }
2761
+ }
2762
+ }
2763
+ const tlsProtocolShaped = this._opts.tls !== void 0 && isTlsProtocolError(err);
2764
+ const classifiedAs = !tlsProtocolShaped && isTransientConnectionError(err) ? "transient" : "permanent";
2765
+ if (classifiedAs === "permanent") {
2766
+ this._userClosed = true;
2767
+ this._connection?.destroy(err);
2768
+ return;
2769
+ }
2770
+ this._reconnectAttempts += 1;
2771
+ if (this._reconnectCycleStartedAt === null && this._lastSuccessAt !== null) {
2772
+ this._attempt = 0;
2773
+ }
2774
+ if (this._reconnectCycleStartedAt === null) {
2775
+ this._reconnectCycleStartedAt = Date.now();
2776
+ }
2777
+ const ctx = Object.freeze({
2778
+ attempt: this._attempt,
2779
+ lastError: err,
2780
+ lastDelayMs: this._lastDelayMs,
2781
+ totalElapsedMs: Date.now() - this._reconnectCycleStartedAt,
2782
+ sinceLastSuccessMs: this._lastSuccessAt !== null ? Date.now() - this._lastSuccessAt : Number.POSITIVE_INFINITY,
2783
+ classifiedAs,
2784
+ signal: this._connectSignal ?? NEVER_ABORTING_SIGNAL
2785
+ });
2786
+ let delay;
2787
+ try {
2788
+ const strategy = this._retryStrategy ?? this._defaultRetryStrategy;
2789
+ delay = strategy(ctx);
2790
+ } catch (hookErr) {
2791
+ this._lastError = hookErr instanceof Error ? hookErr : new Error(String(hookErr));
2792
+ {
2793
+ safeEmitError(
2794
+ this,
2795
+ Object.freeze({
2796
+ connectionId: this._connection?.connectionId ?? "<none>",
2797
+ error: this._lastError
2798
+ })
2799
+ );
2800
+ }
2801
+ this._userClosed = true;
2802
+ this._connection?.destroy(this._lastError);
2803
+ return;
2804
+ }
2805
+ if (delay === null) {
2806
+ this._userClosed = true;
2807
+ this._connection?.destroy(err);
2808
+ return;
2809
+ }
2810
+ this._emitContained(
2811
+ "reconnecting",
2812
+ Object.freeze({
2813
+ connectionId: this._connection?.connectionId ?? "<none>",
2814
+ attempt: this._attempt,
2815
+ delayMs: delay
2816
+ })
2817
+ );
2818
+ this._lastDelayMs = delay;
2819
+ this._backoffTimer = setTimeout(() => {
2820
+ this._backoffTimer = null;
2821
+ this._attempt += 1;
2822
+ this._beginReconnectAttempt();
2823
+ }, delay);
2824
+ this._backoffTimer.unref();
2825
+ }
2826
+ /**
2827
+ * Default retry strategy (D-19): `min(maxDelay, initialDelay * multiplier^attempt)`
2828
+ * with ±jitter applied.
2829
+ */
2830
+ _defaultRetryStrategy = (ctx) => {
2831
+ const base = Math.min(
2832
+ this._maxDelayMs,
2833
+ this._initialDelayMs * Math.pow(this._multiplier, ctx.attempt)
2834
+ );
2835
+ const jitterFactor = 1 + (Math.random() * 2 - 1) * this._jitter;
2836
+ return Math.max(0, Math.floor(base * jitterFactor));
2837
+ };
2838
+ /**
2839
+ * Open a fresh Connection for the next reconnect attempt.
2840
+ *
2841
+ * In production, builds a new `net.Socket` + `NetTransport` + `Connection`.
2842
+ * In tests, the `_reconnectFactory` seam returns a pre-built Connection
2843
+ * over `InMemoryTransport.pair()`.
2844
+ *
2845
+ * On successful CONNECTED transition:
2846
+ * - controlId mode: re-transmits every live correlator entry via the
2847
+ * already-encoded `PendingAck.frame`, then `markFlushed`'s each one
2848
+ * so ACK timeouts restart from the new flush time.
2849
+ * - All modes: clears the cycle-start flag so the next disconnect
2850
+ * can re-enter `_handleDisconnect` cleanly.
2851
+ */
2852
+ _beginReconnectAttempt() {
2853
+ if (this._userClosed) return;
2854
+ try {
2855
+ let conn;
2856
+ let arm;
2857
+ if (this._reconnectFactory !== null) {
2858
+ ({ conn, arm } = this._reconnectFactory());
2859
+ } else {
2860
+ const { socket, transport } = this._createSocketAndTransport();
2861
+ if (this._opts.keepaliveIntervalMs !== void 0) {
2862
+ socket.setKeepAlive(true, this._opts.keepaliveIntervalMs);
2863
+ }
2864
+ this._socket = socket;
2865
+ const connOpts = this._opts.framing !== void 0 ? { transport, framing: this._opts.framing } : { transport };
2866
+ if (this._opts.drainTimeoutMs !== void 0) {
2867
+ connOpts.drainTimeoutMs = this._opts.drainTimeoutMs;
2868
+ }
2869
+ conn = new Connection(connOpts);
2870
+ arm = () => {
2871
+ conn.notifyConnect(socket.remoteAddress ?? null, socket.remotePort ?? null);
2872
+ this._emitInsecureWarningIfNeeded();
2873
+ };
2874
+ const connectEventName = this._opts.tls !== void 0 ? "secureConnect" : "connect";
2875
+ socket.once("error", (sErr) => {
2876
+ if (this._lastError === null || this._lastError.message !== sErr.message) {
2877
+ this._lastError = sErr;
2878
+ }
2879
+ });
2880
+ socket.once(connectEventName, () => {
2881
+ arm();
2882
+ this._afterReconnectArmed();
2883
+ });
2884
+ this._connection = null;
2885
+ this._attachConnection(conn);
2886
+ return;
2887
+ }
2888
+ this._connection = null;
2889
+ this._attachConnection(conn);
2890
+ arm();
2891
+ this._afterReconnectArmed();
2892
+ } catch (err) {
2893
+ this._lastError = err instanceof Error ? err : new Error(String(err));
2894
+ this._handleDisconnect(this._lastError);
2895
+ }
2896
+ }
2897
+ /**
2898
+ * Post-CONNECTED steps for a reconnect attempt:
2899
+ * - controlId mode: resend every live correlator entry's frame, then
2900
+ * re-stamp `markFlushed` so ACK timers reset from the new flush.
2901
+ * - Clear the cycle-start flag so the next disconnect re-enters cleanly.
2902
+ */
2903
+ _afterReconnectArmed() {
2904
+ if (this._correlateByControlId && this._correlator !== null && this._connection !== null) {
2905
+ const conn = this._connection;
2906
+ const corr = this._correlator;
2907
+ const entries = [...corr.liveEntries()];
2908
+ for (const entry of entries) conn.send(entry.frame);
2909
+ const now = Date.now();
2910
+ for (const entry of entries) corr.markFlushed(entry.key, now);
2911
+ }
2912
+ this._reconnectCycleStartedAt = null;
2913
+ }
2914
+ /**
2915
+ * Test seam — install a factory that produces the next reconnect Connection.
2916
+ *
2917
+ * @internal
2918
+ */
2919
+ _setReconnectFactory(factory) {
2920
+ this._reconnectFactory = factory;
2921
+ }
2922
+ /**
2923
+ * Test seam — capture or rebind the connect-signal mid-flight (W-07).
2924
+ *
2925
+ * @internal
2926
+ */
2927
+ _captureConnectSignal(signal) {
2928
+ this._connectSignal = signal;
2929
+ if (this._abortListener !== null) {
2930
+ this._abortListener.signal.removeEventListener("abort", this._abortListener.handler);
2931
+ this._abortListener = null;
2932
+ }
2933
+ const handler = () => {
2934
+ this._userClosed = true;
2935
+ if (this._backoffTimer !== null) {
2936
+ clearTimeout(this._backoffTimer);
2937
+ this._backoffTimer = null;
2938
+ }
2939
+ this._connection?.destroy(new DOMException("Aborted", "AbortError"));
2940
+ };
2941
+ signal.addEventListener("abort", handler, { once: true });
2942
+ this._abortListener = { signal, handler };
2943
+ }
2944
+ /**
2945
+ * Single source-of-truth for inbound ACK payload handling.
2946
+ *
2947
+ * - FIFO mode: passes `null` controlId so `matchAck` returns the head of
2948
+ * the live store.
2949
+ * - controlId mode: extracts MSA-2 at the `HOOK_EXTENSION_POINT: ack-payload`
2950
+ * anchor and passes it to `matchAck` for keyed lookup.
2951
+ *
2952
+ * Called from the SINGLE `'message'` listener registered in `_attachConnection`.
2953
+ * No parallel listener is registered — downstream plans extend at the named
2954
+ * anchor (B-04).
2955
+ */
2956
+ _onAckPayload(ackPayload, byteOffset) {
2957
+ if (this._correlator === null) return;
2958
+ const ackControlId = this._correlateByControlId ? extractMsaControlId(ackPayload) : null;
2959
+ const matched = this._correlator.matchAck(ackPayload, ackControlId, byteOffset);
2960
+ if (matched !== null) {
2961
+ this._onAckMatched(matched, ackPayload);
2962
+ }
2963
+ }
2964
+ /**
2965
+ * Single source-of-truth for a successfully matched ACK (live-store hit).
2966
+ *
2967
+ * PLAN-02: emit frozen 'ack' event, call matched.resolve().
2968
+ * Plan 04 extends at HOOK_EXTENSION_POINT: ack-matched to update _lastSuccessAt.
2969
+ * PLAN-06 extends at HOOK_EXTENSION_POINT: ack-matched to bump _ackedTotal and
2970
+ * set _lastAckAt.
2971
+ *
2972
+ * Called from _onAckPayload when matchAck() returns a non-null PendingAck.
2973
+ */
2974
+ _onAckMatched(matched, ackPayload) {
2975
+ const latencyMs = matched.sentAt !== null ? Date.now() - matched.sentAt : 0;
2976
+ this._emitContained(
2977
+ "ack",
2978
+ Object.freeze({
2979
+ payload: ackPayload,
2980
+ controlId: matched.controlId,
2981
+ latencyMs
2982
+ })
2983
+ );
2984
+ this._lastSuccessAt = Date.now();
2985
+ this._ackedTotal += 1;
2986
+ this._lastAckAt = Date.now();
2987
+ matched.resolve(ackPayload);
2988
+ this._maybeEmitDrain();
2989
+ }
2990
+ /**
2991
+ * Emit a frozen `'drain'` event when the queue depth and bytes fall below
2992
+ * both configured caps (Plan 05 — D-24). Called from `_onAckMatched`
2993
+ * (every successful ACK) and from the Correlator's `onTimeout` callback
2994
+ * (every expired send) — both code paths free a live-store slot.
2995
+ */
2996
+ _maybeEmitDrain() {
2997
+ const corr = this._correlator;
2998
+ if (corr === null) return;
2999
+ const belowCount = corr.size < this._hwmCount;
3000
+ const belowBytes = corr.queueBytes < this._hwmBytes;
3001
+ if (belowCount && belowBytes) {
3002
+ this._emitContained(
3003
+ "drain",
3004
+ Object.freeze({
3005
+ queueDepth: corr.size,
3006
+ queueBytes: corr.queueBytes
3007
+ })
3008
+ );
3009
+ }
3010
+ }
3011
+ /**
3012
+ * Single source-of-truth for Connection FSM transitions.
3013
+ *
3014
+ * PLAN-02: re-emit frozen 'stateChange' (was inline in PLAN-01; centralized
3015
+ * here so Plan 04 / Plan 05 can extend at named anchors).
3016
+ * Plan 04 extends at HOOK_EXTENSION_POINT: state-change to detect
3017
+ * CONNECTED → DISCONNECTED|RECONNECTING and trigger _handleDisconnect.
3018
+ * Plan 05 extends at HOOK_EXTENSION_POINT: state-change to clear/arm
3019
+ * dead-peer timer on transitions out of / into CONNECTED.
3020
+ *
3021
+ * Called from the SINGLE 'stateChange' listener registered in _attachConnection.
3022
+ */
3023
+ _onStateChange(e) {
3024
+ this._emitContained("stateChange", Object.freeze({ ...e }));
3025
+ if (e.to === "CONNECTED") {
3026
+ this._armDeadPeerTimer();
3027
+ this._lastConnectedAt = Date.now();
3028
+ }
3029
+ if (e.from === "CONNECTED" && e.to !== "CONNECTED") {
3030
+ this._clearDeadPeerTimer();
3031
+ }
3032
+ const isPostConnectedDrop = e.from === "CONNECTED" && (e.to === "DISCONNECTED" || e.to === "RECONNECTING" || e.to === "CLOSED");
3033
+ const isReconnectAttemptFailure = this._reconnectCycleStartedAt !== null && (e.from === "CONNECTING" || e.from === "RECONNECTING") && (e.to === "CLOSED" || e.to === "DISCONNECTED");
3034
+ if (isPostConnectedDrop || isReconnectAttemptFailure) {
3035
+ const cause = this._lastError ?? new Error(e.reason ?? "disconnect");
3036
+ if (this._userClosed) return;
3037
+ if (!this._autoReconnect) {
3038
+ this._teardownCorrelator(
3039
+ new MllpConnectionError("disconnected; autoReconnect disabled", {
3040
+ cause,
3041
+ phase: "send"
3042
+ })
3043
+ );
3044
+ return;
3045
+ }
3046
+ this._handleDisconnect(cause);
3047
+ }
3048
+ }
3049
+ /**
3050
+ * Send an MLLP-framed payload and await the inbound ACK (CLIENT-02).
3051
+ *
3052
+ * Resolves with the ACK Buffer (framing stripped). Rejects with:
3053
+ * - `DOMException('Aborted', 'AbortError')` if `signal` aborts before ACK
3054
+ * (CLIENT-11 send branch).
3055
+ * - `MllpTimeoutError` if no ACK arrives within `ackTimeoutMs` (ERR-02).
3056
+ * The clock starts at the underlying `write()` flush callback, NOT at
3057
+ * the `send()` call (CLIENT-04, D-19).
3058
+ * - `MllpConnectionError({ phase: 'send' })` if the client is not connected.
3059
+ *
3060
+ * Emits a frozen `'ack'` event on every successful match (D-25 + Specifics).
3061
+ *
3062
+ * @example
3063
+ * ```typescript
3064
+ * const ack = await client.send(payloadBuffer);
3065
+ * logger.info({ ack: ack.toString('utf8') });
3066
+ * ```
3067
+ *
3068
+ * @param payload Raw bytes; MLLP framing is added internally via `encodeFrame`.
3069
+ * @param opts.signal AbortSignal — aborting cancels the ACK wait (CLIENT-11).
3070
+ */
3071
+ send(payload, opts) {
3072
+ const signal = opts?.signal;
3073
+ if (signal?.aborted === true) {
3074
+ return Promise.reject(new DOMException("Aborted", "AbortError"));
3075
+ }
3076
+ if (this._connection === null || this._correlator === null || this._connection.state !== "CONNECTED") {
3077
+ return Promise.reject(
3078
+ new MllpConnectionError("send before connect", {
3079
+ cause: new Error(`client state is ${this.state}`),
3080
+ phase: "send"
3081
+ })
3082
+ );
3083
+ }
3084
+ const controlId = this._correlateByControlId ? extractMshControlId(payload) : null;
3085
+ const correlator = this._correlator;
3086
+ const conn = this._connection;
3087
+ const frame = encodeFrame(payload);
3088
+ const newQueueDepth = correlator.size + 1;
3089
+ const newQueueBytes = correlator.queueBytes + frame.length;
3090
+ const overCount = newQueueDepth > this._hwmCount;
3091
+ const overBytes = newQueueBytes > this._hwmBytes;
3092
+ if (overCount || overBytes) {
3093
+ const hwmDesc = {};
3094
+ if (this._hwmCount !== Number.POSITIVE_INFINITY) {
3095
+ hwmDesc.count = this._hwmCount;
3096
+ }
3097
+ if (this._hwmBytes !== Number.POSITIVE_INFINITY) {
3098
+ hwmDesc.bytes = this._hwmBytes;
3099
+ }
3100
+ if (this._onBackpressure === "reject") {
3101
+ return Promise.reject(
3102
+ new MllpBackpressureError(
3103
+ `queue at high-water mark (depth=${correlator.size}, bytes=${correlator.queueBytes})`,
3104
+ {
3105
+ queueDepth: correlator.size,
3106
+ queueBytes: correlator.queueBytes,
3107
+ highWaterMark: hwmDesc
3108
+ }
3109
+ )
3110
+ );
3111
+ }
3112
+ return this._waitThenSend(payload, opts);
3113
+ }
3114
+ return new Promise((resolve, reject) => {
3115
+ let abortListener = null;
3116
+ const wrappedResolve = (ack) => {
3117
+ if (signal !== void 0 && abortListener !== null) {
3118
+ signal.removeEventListener("abort", abortListener);
3119
+ }
3120
+ resolve(ack);
3121
+ };
3122
+ const wrappedReject = (err) => {
3123
+ if (signal !== void 0 && abortListener !== null) {
3124
+ signal.removeEventListener("abort", abortListener);
3125
+ }
3126
+ reject(err);
3127
+ };
3128
+ const key = correlator.enqueue(frame, controlId, wrappedResolve, wrappedReject);
3129
+ if (key === null) {
3130
+ const onDrain = () => {
3131
+ this.off("drain", onDrain);
3132
+ this.send(payload, opts).then(wrappedResolve, wrappedReject);
3133
+ };
3134
+ this.on("drain", onDrain);
3135
+ if (signal !== void 0) {
3136
+ abortListener = () => {
3137
+ this.off("drain", onDrain);
3138
+ wrappedReject(new DOMException("Aborted", "AbortError"));
3139
+ };
3140
+ signal.addEventListener("abort", abortListener, { once: true });
3141
+ }
3142
+ return;
3143
+ }
3144
+ if (signal !== void 0) {
3145
+ abortListener = () => {
3146
+ correlator.remove(key);
3147
+ wrappedReject(new DOMException("Aborted", "AbortError"));
3148
+ };
3149
+ signal.addEventListener("abort", abortListener, { once: true });
3150
+ }
3151
+ conn.send(frame);
3152
+ correlator.markFlushed(key, Date.now());
3153
+ this._sentTotal += 1;
3154
+ });
3155
+ }
3156
+ /**
3157
+ * 'wait'-mode backpressure handler (Plan 05 — CLIENT-07/CLIENT-11).
3158
+ *
3159
+ * Awaits one of three terminating signals, in order:
3160
+ * - `'drain'` event → re-enter `send()` (the gate will now pass).
3161
+ * - `ackTimeoutMs` elapses → reject with `MllpTimeoutError`.
3162
+ * - Caller's `signal` aborts → reject with `AbortError` (B-06). Cleanup
3163
+ * removes the drain listener AND the abort listener AND clears the
3164
+ * timer to prevent leaks.
3165
+ */
3166
+ _waitThenSend(payload, opts) {
3167
+ const signal = opts?.signal;
3168
+ return new Promise((resolve, reject) => {
3169
+ const ackTimeoutMs = opts?.ackTimeoutMs ?? this._ackTimeoutMs;
3170
+ let abortListener = null;
3171
+ const cleanup = () => {
3172
+ this.off("drain", onDrain);
3173
+ clearTimeout(timer);
3174
+ if (signal !== void 0 && abortListener !== null) {
3175
+ signal.removeEventListener("abort", abortListener);
3176
+ }
3177
+ };
3178
+ const onDrain = () => {
3179
+ cleanup();
3180
+ this.send(payload, opts).then(resolve, reject);
3181
+ };
3182
+ const timer = setTimeout(() => {
3183
+ cleanup();
3184
+ reject(
3185
+ new MllpTimeoutError(`waiting for drain timed out after ${ackTimeoutMs}ms`, {
3186
+ messageControlId: void 0,
3187
+ elapsedMs: ackTimeoutMs,
3188
+ sentAt: Date.now()
3189
+ })
3190
+ );
3191
+ }, ackTimeoutMs);
3192
+ timer.unref();
3193
+ this.on("drain", onDrain);
3194
+ if (signal !== void 0) {
3195
+ abortListener = () => {
3196
+ cleanup();
3197
+ reject(new DOMException("Aborted", "AbortError"));
3198
+ };
3199
+ signal.addEventListener("abort", abortListener, { once: true });
3200
+ }
3201
+ });
3202
+ }
3203
+ /**
3204
+ * Tear down per-connection state (sweep timer + correlator) when the
3205
+ * client is closing or destroying. Safe to call multiple times.
3206
+ */
3207
+ _teardownCorrelator(reason) {
3208
+ if (this._ackSweepTimer !== null) {
3209
+ clearInterval(this._ackSweepTimer);
3210
+ this._ackSweepTimer = null;
3211
+ }
3212
+ if (this._correlator !== null) {
3213
+ this._correlator.clear(reason);
3214
+ this._correlator = null;
3215
+ }
3216
+ this._clearDeadPeerTimer();
3217
+ }
3218
+ /**
3219
+ * **Test seam** — attach an externally-built {@link Connection} directly,
3220
+ * bypassing the `net.createConnection` + `NetTransport` path. Used by
3221
+ * lifecycle tests driving `InMemoryTransport.pair()` for determinism.
3222
+ *
3223
+ * @internal
3224
+ */
3225
+ _attachExistingConnection(conn) {
3226
+ if (this._connection !== null) {
3227
+ throw new MllpConnectionError("connection already attached", {
3228
+ cause: new Error("attach twice"),
3229
+ phase: "connect"
3230
+ });
3231
+ }
3232
+ this._attachConnection(conn);
3233
+ }
3234
+ /**
3235
+ * Gracefully close the client.
3236
+ *
3237
+ * Delegates to {@link Connection.close}, which transitions `CONNECTED → DRAINING
3238
+ * → DISCONNECTED` (or `CLOSED` on drain timeout). No-op if no Connection is
3239
+ * attached.
3240
+ *
3241
+ * Rejects with `DOMException('Aborted', 'AbortError')` if `signal` aborts mid-drain;
3242
+ * on abort, the underlying Connection is force-destroyed.
3243
+ *
3244
+ * @example
3245
+ * ```typescript
3246
+ * await client.close({ drainTimeoutMs: 5_000 });
3247
+ * ```
3248
+ */
3249
+ async close(opts) {
3250
+ const signal = opts?.signal;
3251
+ if (signal?.aborted) {
3252
+ return Promise.reject(new DOMException("Aborted", "AbortError"));
3253
+ }
3254
+ this._userClosed = true;
3255
+ if (this._backoffTimer !== null) {
3256
+ clearTimeout(this._backoffTimer);
3257
+ this._backoffTimer = null;
3258
+ }
3259
+ const conn = this._connection;
3260
+ if (conn === null) {
3261
+ this._teardownCorrelator(
3262
+ new MllpConnectionError("client closed", {
3263
+ cause: new Error("closed"),
3264
+ phase: "close"
3265
+ })
3266
+ );
3267
+ return;
3268
+ }
3269
+ this._teardownCorrelator(
3270
+ new MllpConnectionError("client closed", {
3271
+ cause: new Error("closed"),
3272
+ phase: "close"
3273
+ })
3274
+ );
3275
+ if (signal === void 0) {
3276
+ const closeOpts = opts?.drainTimeoutMs !== void 0 ? { drainTimeoutMs: opts.drainTimeoutMs } : void 0;
3277
+ await conn.close(closeOpts);
3278
+ return;
3279
+ }
3280
+ let abortHandler;
3281
+ const abortPromise = new Promise((_resolve, reject) => {
3282
+ abortHandler = () => {
3283
+ conn.destroy(new Error("aborted"));
3284
+ reject(new DOMException("Aborted", "AbortError"));
3285
+ };
3286
+ signal.addEventListener("abort", abortHandler, { once: true });
3287
+ });
3288
+ try {
3289
+ const closeOpts = opts?.drainTimeoutMs !== void 0 ? { drainTimeoutMs: opts.drainTimeoutMs } : void 0;
3290
+ await Promise.race([conn.close(closeOpts), abortPromise]);
3291
+ } finally {
3292
+ if (abortHandler !== void 0) {
3293
+ signal.removeEventListener("abort", abortHandler);
3294
+ }
3295
+ }
3296
+ }
3297
+ /**
3298
+ * Abruptly destroy the client — force-transitions the underlying Connection
3299
+ * to `CLOSED` immediately. No-op if no Connection is attached. Idempotent.
3300
+ *
3301
+ * @example
3302
+ * ```typescript
3303
+ * client.destroy(new Error('shutting down'));
3304
+ * ```
3305
+ */
3306
+ destroy(reason) {
3307
+ this._userClosed = true;
3308
+ if (this._backoffTimer !== null) {
3309
+ clearTimeout(this._backoffTimer);
3310
+ this._backoffTimer = null;
3311
+ }
3312
+ const teardownReason = reason ?? new MllpConnectionError("client destroyed", {
3313
+ cause: new Error("destroyed"),
3314
+ phase: "close"
3315
+ });
3316
+ this._teardownCorrelator(teardownReason);
3317
+ const conn = this._connection;
3318
+ if (conn === null) return;
3319
+ conn.destroy(reason);
3320
+ }
3321
+ /**
3322
+ * Returns a JSON-serializable observability snapshot (OBS-01, D-26).
3323
+ *
3324
+ * All fields are plain values — no Buffers, no class instances, no Maps,
3325
+ * no circular refs. Safe to `JSON.stringify` directly.
3326
+ *
3327
+ * `inFlight` is the count of correlator entries with `sentAt !== null`
3328
+ * (entries actually written to the wire and awaiting ACK), distinct from
3329
+ * `queueDepth` which counts ALL live correlator entries (including
3330
+ * pre-flush and serialization-queued sends).
3331
+ *
3332
+ * @example
3333
+ * ```typescript
3334
+ * setInterval(() => logger.info(JSON.stringify(client.getStats())), 60_000);
3335
+ * ```
3336
+ */
3337
+ getStats() {
3338
+ const connStats = this._connection?.getStats();
3339
+ const corrStats = this._correlator?.getStats();
3340
+ const merged = {
3341
+ ...this._aggregatedWarningsByCode
3342
+ };
3343
+ if (connStats !== void 0) {
3344
+ for (const [k, v] of Object.entries(connStats.warningsByCode)) {
3345
+ const code = k;
3346
+ merged[code] = (merged[code] ?? 0) + v;
3347
+ }
3348
+ }
3349
+ return {
3350
+ state: this.state,
3351
+ connectionId: connStats?.connectionId ?? null,
3352
+ queueDepth: corrStats?.size ?? 0,
3353
+ queueBytes: corrStats?.queueBytes ?? 0,
3354
+ inFlight: corrStats?.inFlight ?? 0,
3355
+ warningsByCode: merged,
3356
+ totalBytesIn: connStats?.bytesIn ?? 0,
3357
+ totalBytesOut: connStats?.bytesOut ?? 0,
3358
+ sentTotal: this._sentTotal,
3359
+ ackedTotal: this._ackedTotal,
3360
+ timedOutTotal: this._timedOutTotal,
3361
+ reconnectAttempts: this._reconnectAttempts,
3362
+ lastConnectedAt: this._lastConnectedAt,
3363
+ lastAckAt: this._lastAckAt,
3364
+ tls: this._opts.tls !== void 0
3365
+ };
3366
+ }
3367
+ /**
3368
+ * Async disposal — delegates to {@link MllpClient.close} for `await using` support.
3369
+ *
3370
+ * @example
3371
+ * ```typescript
3372
+ * await using client = createClient({ host: 'localhost', port: 2575 });
3373
+ * await client.connect();
3374
+ * // client.close() is called automatically at end of block
3375
+ * ```
3376
+ */
3377
+ async [Symbol.asyncDispose]() {
3378
+ return this.close();
3379
+ }
3380
+ };
3381
+ function createClient(opts) {
3382
+ return new MllpClient(opts);
3383
+ }
3384
+ async function createStarterClient(opts) {
3385
+ const clientOpts = {
3386
+ host: opts.host,
3387
+ port: opts.port,
3388
+ autoReconnect: opts.autoReconnect ?? true,
3389
+ ackTimeoutMs: opts.ackTimeoutMs ?? 3e4,
3390
+ correlateByControlId: opts.correlateByControlId ?? false,
3391
+ pipeline: opts.pipeline ?? true,
3392
+ highWaterMark: opts.highWaterMark ?? 64,
3393
+ onBackpressure: opts.onBackpressure ?? "reject",
3394
+ ...opts.drainTimeoutMs !== void 0 ? { drainTimeoutMs: opts.drainTimeoutMs } : {},
3395
+ ...opts.framing !== void 0 ? { framing: opts.framing } : {},
3396
+ ...opts.retryStrategy !== void 0 ? { retryStrategy: opts.retryStrategy } : {},
3397
+ ...opts.keepaliveIntervalMs !== void 0 ? { keepaliveIntervalMs: opts.keepaliveIntervalMs } : {},
3398
+ ...opts.deadPeerTimeoutMs !== void 0 ? { deadPeerTimeoutMs: opts.deadPeerTimeoutMs } : {},
3399
+ ...opts.tls !== void 0 ? { tls: opts.tls } : {}
3400
+ };
3401
+ const client = createClient(clientOpts);
3402
+ if (opts.onMessage !== void 0) {
3403
+ const handler = opts.onMessage;
3404
+ client.on("message", (e) => {
3405
+ handler(e.payload);
3406
+ });
3407
+ }
3408
+ if (opts.handleSignals === true) {
3409
+ const sigHandler = () => {
3410
+ void client.close().then(() => process.exit(0)).catch(() => process.exit(1));
3411
+ };
3412
+ process.once("SIGTERM", sigHandler);
3413
+ process.once("SIGINT", sigHandler);
3414
+ client.once("close", () => {
3415
+ process.removeListener("SIGTERM", sigHandler);
3416
+ process.removeListener("SIGINT", sigHandler);
3417
+ });
3418
+ }
3419
+ await client.connect();
3420
+ return client;
3421
+ }
3422
+
3423
+ // src/index.ts
3424
+ var VERSION = "0.0.1";
3425
+
3426
+ export { Connection, FrameReader, MLLP_BIND_ALL_INTERFACES, MLLP_TLS_VERIFY_DISABLED, MllpAckError, MllpBackpressureError, MllpClient, MllpConnectionError, MllpFramingError, MllpServer, MllpTimeoutError, NetTransport, TlsTransport, VERSION, buildRawAck, createClient, createServer, createStarterClient, createStarterServer, createWarning, encodeFrame, isTlsProtocolError, isTlsVerificationErrorCode, isTransientConnectionError, rawAckUncorrelatable, resolveNackCode };
3427
+ //# sourceMappingURL=index.mjs.map
3428
+ //# sourceMappingURL=index.mjs.map