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