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