@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.d.cts
ADDED
|
@@ -0,0 +1,3112 @@
|
|
|
1
|
+
import { T as Transport } from './index-DjGQJtYE.cjs';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { Socket } from 'node:net';
|
|
4
|
+
import { TLSSocket } from 'node:tls';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* MLLP warning code registry and `MllpWarning` factory.
|
|
8
|
+
*
|
|
9
|
+
* `WarningCode` is a **stable public API**. Renaming or removing a code is a breaking change.
|
|
10
|
+
* New codes may only be added in minor versions.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* import { createWarning, type MllpWarning, type WarningCode } from '@cosyte/mllp';
|
|
15
|
+
* const w: MllpWarning = createWarning('MLLP_EMPTY_PAYLOAD', 128, 'Empty payload between VT and FS');
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* @packageDocumentation
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Union of all stable MLLP warning codes.
|
|
22
|
+
*
|
|
23
|
+
* These codes are a **public API** — they appear in `onWarning` handlers, log pipelines,
|
|
24
|
+
* monitoring dashboards, and error messages. Renaming is a breaking change.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* const code: WarningCode = 'MLLP_FS_WITHOUT_CR';
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
type WarningCode = "MLLP_MISSING_LEADING_VT" | "MLLP_FS_WITHOUT_CR" | "MLLP_LF_AFTER_FS" | "MLLP_LEADING_WHITESPACE" | "MLLP_TRAILING_BYTES" | "MLLP_PAYLOAD_CONTAINS_VT" | "MLLP_PAYLOAD_CONTAINS_FS" | "MLLP_EMPTY_PAYLOAD" | "MLLP_FRAME_TOO_LARGE" | "MLLP_ACK_UNMATCHED_CONTROL_ID" | "MLLP_ACK_AFTER_TIMEOUT";
|
|
32
|
+
/**
|
|
33
|
+
* A frozen warning object emitted when the decoder tolerates a framing deviation.
|
|
34
|
+
*
|
|
35
|
+
* `connectionId` is `undefined` when emitted by a standalone `FrameReader` (Phase 2).
|
|
36
|
+
* Phase 3 `Connection` enriches it to the real UUIDv4 before forwarding upstream.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```typescript
|
|
40
|
+
* const reader = new FrameReader({
|
|
41
|
+
* onFrame: (p) => process(p),
|
|
42
|
+
* onWarning: (w: MllpWarning) => logger.warn(w),
|
|
43
|
+
* allowFsOnly: true,
|
|
44
|
+
* });
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
interface MllpWarning {
|
|
48
|
+
readonly code: WarningCode;
|
|
49
|
+
/** Stable human-readable description. Never contains payload bytes or secrets. */
|
|
50
|
+
readonly message: string;
|
|
51
|
+
/** Absolute stream byte offset where the anomaly was detected. */
|
|
52
|
+
readonly byteOffset: number;
|
|
53
|
+
/**
|
|
54
|
+
* Connection identifier. `undefined` at the framing layer;
|
|
55
|
+
* enriched by Phase 3 `Connection` before emitting upstream.
|
|
56
|
+
*/
|
|
57
|
+
readonly connectionId: string | undefined;
|
|
58
|
+
/** Wall-clock time at point of emission. */
|
|
59
|
+
readonly timestamp: Date;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Create a frozen `MllpWarning` object. The returned object is `Object.freeze()`'d
|
|
63
|
+
* so subscribers cannot mutate shared warning state.
|
|
64
|
+
*
|
|
65
|
+
* `connectionId` is always `undefined` here — Phase 3 enriches via
|
|
66
|
+
* `{ ...w, connectionId: this.connectionId }` then re-freezes (D-08).
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```typescript
|
|
70
|
+
* const w = createWarning('MLLP_EMPTY_PAYLOAD', 64, 'Empty payload between VT and FS');
|
|
71
|
+
* // w.connectionId === undefined
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
declare function createWarning(code: WarningCode, byteOffset: number, message: string): MllpWarning;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Typed framing error class for unrecoverable MLLP wire-format violations.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```typescript
|
|
81
|
+
* import { MllpFramingError } from '@cosyte/mllp';
|
|
82
|
+
* try {
|
|
83
|
+
* encodeFrame(payloadWithVt);
|
|
84
|
+
* } catch (err) {
|
|
85
|
+
* if (err instanceof MllpFramingError) {
|
|
86
|
+
* logger.error({ code: err.code, offset: err.byteOffset });
|
|
87
|
+
* }
|
|
88
|
+
* }
|
|
89
|
+
* ```
|
|
90
|
+
*
|
|
91
|
+
* @packageDocumentation
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Thrown for unrecoverable MLLP wire-format violations.
|
|
96
|
+
*
|
|
97
|
+
* - `code` — stable `WarningCode` identifying the violation
|
|
98
|
+
* - `byteOffset` — absolute stream position where the violation was detected
|
|
99
|
+
* - `snippet` — the single framing-boundary byte that broke the structure, or empty when the
|
|
100
|
+
* anomaly is not a specific byte. **Never a run of payload content** (see the `snippet` PHI
|
|
101
|
+
* contract below); the constructor caps whatever it is given to 64 bytes as a backstop.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```typescript
|
|
105
|
+
* // Pass only the offending framing-boundary byte — never a slice of payload content.
|
|
106
|
+
* throw new MllpFramingError('MLLP_MISSING_LEADING_VT', byteOffset, Buffer.from([byte]));
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
declare class MllpFramingError extends Error {
|
|
110
|
+
readonly name: "MllpFramingError";
|
|
111
|
+
/** Stable warning code identifying the violation type. */
|
|
112
|
+
readonly code: WarningCode;
|
|
113
|
+
/** Absolute byte offset in the stream where the error was detected. */
|
|
114
|
+
readonly byteOffset: number;
|
|
115
|
+
/**
|
|
116
|
+
* Up to 64 bytes copied from around the anomaly.
|
|
117
|
+
*
|
|
118
|
+
* This is a **copied** Buffer — isolated from the source buffer so it remains
|
|
119
|
+
* valid after the underlying buffer is reused or overwritten.
|
|
120
|
+
*
|
|
121
|
+
* **PHI contract (MLLP-9):** the decoder only ever populates this with the single
|
|
122
|
+
* framing-boundary byte that violated the frame structure (whose hex value the
|
|
123
|
+
* `message` already discloses) — never a run of payload content bytes. For anomalies
|
|
124
|
+
* whose fault is not a specific byte (`MLLP_FRAME_TOO_LARGE` — the accumulated size),
|
|
125
|
+
* the snippet is **empty**: a payload slice on a public error field would leak a
|
|
126
|
+
* field-body slice of clinical content. Callers constructing this directly are
|
|
127
|
+
* responsible for the same discipline.
|
|
128
|
+
*/
|
|
129
|
+
readonly snippet: Buffer;
|
|
130
|
+
/**
|
|
131
|
+
* Construct an MLLP framing error.
|
|
132
|
+
*
|
|
133
|
+
* @param code - The stable warning code classifying the violation.
|
|
134
|
+
* @param byteOffset - Absolute stream byte offset where the violation was detected.
|
|
135
|
+
* @param snippet - Raw bytes copied from around the anomaly (capped to 64 bytes).
|
|
136
|
+
* @param message - Optional human-readable override message.
|
|
137
|
+
*/
|
|
138
|
+
constructor(code: WarningCode, byteOffset: number,
|
|
139
|
+
/** Raw bytes around the anomaly. Capped to MAX_SNIPPET_BYTES (64). */
|
|
140
|
+
snippet: Buffer, message?: string);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* MLLP frame encoder — canonical `VT + payload + FS + CR` wrapping.
|
|
145
|
+
*
|
|
146
|
+
* The encoder is a **strict emitter** (Postel's Law: conservative on write).
|
|
147
|
+
* There is no option to loosen the output format — it always emits canonical framing.
|
|
148
|
+
* Tolerance for delimiter bytes in the payload is available via `allowDelimiterBytesInPayload`,
|
|
149
|
+
* which passes bytes through verbatim and emits an `MllpWarning` for each offending byte.
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* ```typescript
|
|
153
|
+
* import { encodeFrame } from '@cosyte/mllp';
|
|
154
|
+
* const frame = encodeFrame(Buffer.from('MSH|^~\\&|...'));
|
|
155
|
+
* // frame[0] === 0x0B (VT), frame[frame.length - 2] === 0x1C (FS), frame[frame.length - 1] === 0x0D (CR)
|
|
156
|
+
* ```
|
|
157
|
+
*
|
|
158
|
+
* @packageDocumentation
|
|
159
|
+
*/
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Options for {@link encodeFrame}.
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```typescript
|
|
166
|
+
* const frame = encodeFrame(payload, {
|
|
167
|
+
* allowDelimiterBytesInPayload: true,
|
|
168
|
+
* onWarning: (w) => logger.warn(w),
|
|
169
|
+
* });
|
|
170
|
+
* ```
|
|
171
|
+
*/
|
|
172
|
+
interface EncoderOptions {
|
|
173
|
+
/**
|
|
174
|
+
* When `true`, VT (0x0B) or FS (0x1C) bytes within the payload are preserved
|
|
175
|
+
* verbatim in the output frame instead of throwing `MllpFramingError`.
|
|
176
|
+
* An `MllpWarning` is emitted per offending byte if `onWarning` is provided.
|
|
177
|
+
*
|
|
178
|
+
* Default: `false` (strict — throws on delimiter bytes).
|
|
179
|
+
*/
|
|
180
|
+
allowDelimiterBytesInPayload?: boolean;
|
|
181
|
+
/**
|
|
182
|
+
* Called for each offending delimiter byte when `allowDelimiterBytesInPayload` is `true`.
|
|
183
|
+
* Invocation is wrapped in try/catch — a throwing handler does not interrupt encoding (WARN-06).
|
|
184
|
+
*
|
|
185
|
+
* @example
|
|
186
|
+
* ```typescript
|
|
187
|
+
* const frame = encodeFrame(payload, {
|
|
188
|
+
* allowDelimiterBytesInPayload: true,
|
|
189
|
+
* onWarning: (w) => logger.warn({ code: w.code, offset: w.byteOffset }),
|
|
190
|
+
* });
|
|
191
|
+
* ```
|
|
192
|
+
*/
|
|
193
|
+
onWarning?: (w: MllpWarning) => void;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Encode `payload` in canonical MLLP framing: `VT (0x0B) + payload + FS (0x1C) + CR (0x0D)`.
|
|
197
|
+
*
|
|
198
|
+
* Strict by default: throws `MllpFramingError` if the payload contains VT or FS bytes.
|
|
199
|
+
* Set `{ allowDelimiterBytesInPayload: true }` to pass those bytes through verbatim
|
|
200
|
+
* and receive an `MllpWarning` per offending byte via `onWarning` instead.
|
|
201
|
+
*
|
|
202
|
+
* The returned Buffer has length `payload.length + 3`. Bytes at positions 1 through
|
|
203
|
+
* `payload.length` are an exact copy of the payload (independent of the input buffer).
|
|
204
|
+
*
|
|
205
|
+
* @throws {MllpFramingError} with code `MLLP_PAYLOAD_CONTAINS_VT` when payload contains
|
|
206
|
+
* byte `0x0B` and `allowDelimiterBytesInPayload` is `false` (default).
|
|
207
|
+
* @throws {MllpFramingError} with code `MLLP_PAYLOAD_CONTAINS_FS` when payload contains
|
|
208
|
+
* byte `0x1C` and `allowDelimiterBytesInPayload` is `false` (default).
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* ```typescript
|
|
212
|
+
* import { encodeFrame } from '@cosyte/mllp';
|
|
213
|
+
*
|
|
214
|
+
* // Strict (default) — throws on delimiter bytes in payload
|
|
215
|
+
* const frame = encodeFrame(Buffer.from('MSH|^~\\&|SEND|FAC|RECV|FAC|...'));
|
|
216
|
+
* socket.write(frame);
|
|
217
|
+
*
|
|
218
|
+
* // Tolerant — passes delimiter bytes through with a warning
|
|
219
|
+
* const frame2 = encodeFrame(dirtyPayload, {
|
|
220
|
+
* allowDelimiterBytesInPayload: true,
|
|
221
|
+
* onWarning: (w) => logger.warn(w),
|
|
222
|
+
* });
|
|
223
|
+
* ```
|
|
224
|
+
*/
|
|
225
|
+
declare function encodeFrame(payload: Buffer, opts?: EncoderOptions): Buffer;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* MLLP frame decoder — stateful 3-state FSM for chunked byte-stream parsing.
|
|
229
|
+
*
|
|
230
|
+
* Feed raw TCP chunks via `push(chunk)`. Complete MLLP frames fire synchronously
|
|
231
|
+
* via the `onFrame` callback during each `push()` call.
|
|
232
|
+
*
|
|
233
|
+
* The decoder is **strict by default**: any framing deviation throws `MllpFramingError`
|
|
234
|
+
* unless the matching tolerance option is explicitly enabled (Postel's Law — liberal receiver
|
|
235
|
+
* means explicit tolerance, not silent acceptance).
|
|
236
|
+
*
|
|
237
|
+
* @example
|
|
238
|
+
* ```typescript
|
|
239
|
+
* import { FrameReader } from '@cosyte/mllp';
|
|
240
|
+
* const reader = new FrameReader({
|
|
241
|
+
* onFrame: (payload) => handleMessage(payload),
|
|
242
|
+
* onWarning: (w) => logger.warn(w),
|
|
243
|
+
* allowFsOnly: true,
|
|
244
|
+
* });
|
|
245
|
+
* socket.on('data', (chunk) => reader.push(chunk));
|
|
246
|
+
* socket.on('close', () => reader.reset());
|
|
247
|
+
* ```
|
|
248
|
+
*
|
|
249
|
+
* @packageDocumentation
|
|
250
|
+
*/
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Options for `FrameReader`.
|
|
254
|
+
*
|
|
255
|
+
* All tolerance opts default to `false` — every framing deviation throws unless
|
|
256
|
+
* explicitly enabled. Server-level defaults (allowFsOnly, allowLfAfterFs, allowLeadingWhitespace)
|
|
257
|
+
* are applied by Phase 4 when constructing readers per SERVER-12.
|
|
258
|
+
*
|
|
259
|
+
* @example
|
|
260
|
+
* ```typescript
|
|
261
|
+
* const opts: FrameReaderOptions = {
|
|
262
|
+
* onFrame: (payload, byteOffset, warnings) => process(payload, byteOffset, warnings),
|
|
263
|
+
* onWarning: (w) => logger.warn(w),
|
|
264
|
+
* maxFrameSizeBytes: 4 * 1024 * 1024, // 4 MiB limit
|
|
265
|
+
* allowFsOnly: true,
|
|
266
|
+
* allowLfAfterFs: true,
|
|
267
|
+
* };
|
|
268
|
+
* ```
|
|
269
|
+
*/
|
|
270
|
+
interface FrameReaderOptions {
|
|
271
|
+
/**
|
|
272
|
+
* Called synchronously during `push()` for each complete MLLP payload.
|
|
273
|
+
*
|
|
274
|
+
* @param payload - Raw MLLP payload bytes (framing stripped).
|
|
275
|
+
* @param byteOffset - Stream byte offset of the VT byte that opened this frame.
|
|
276
|
+
* Monotonic across the connection lifetime; reset to 0 by `reset()`.
|
|
277
|
+
* @param warnings - Framing warnings emitted during decoding of this specific frame.
|
|
278
|
+
* Empty array when no tolerance deviations were detected.
|
|
279
|
+
*/
|
|
280
|
+
onFrame: (payload: Buffer, byteOffset: number, warnings: readonly MllpWarning[]) => void;
|
|
281
|
+
/**
|
|
282
|
+
* Called for each tolerated framing deviation. Wrapped in try/catch per WARN-06.
|
|
283
|
+
* A throwing handler will not corrupt FSM state.
|
|
284
|
+
*/
|
|
285
|
+
onWarning?: (w: MllpWarning) => void;
|
|
286
|
+
/**
|
|
287
|
+
* Maximum accumulated payload bytes before `MllpFramingError('MLLP_FRAME_TOO_LARGE')`.
|
|
288
|
+
* Default: 16 MiB (FRAME-11 DoS prevention).
|
|
289
|
+
*/
|
|
290
|
+
maxFrameSizeBytes?: number;
|
|
291
|
+
/** FRAME-07: Tolerate FS without trailing CR; emits `MLLP_FS_WITHOUT_CR`. */
|
|
292
|
+
allowFsOnly?: boolean;
|
|
293
|
+
/** FRAME-08: Tolerate FS+LF instead of FS+CR; emits `MLLP_LF_AFTER_FS`. */
|
|
294
|
+
allowLfAfterFs?: boolean;
|
|
295
|
+
/** FRAME-09: Tolerate missing leading VT; emits `MLLP_MISSING_LEADING_VT`. */
|
|
296
|
+
allowMissingLeadingVt?: boolean;
|
|
297
|
+
/** FRAME-10: Tolerate SP/TAB/LF/CR before VT; emits `MLLP_LEADING_WHITESPACE`. */
|
|
298
|
+
allowLeadingWhitespace?: boolean;
|
|
299
|
+
/**
|
|
300
|
+
* When `true`, escalates the following tolerances to thrown `MllpFramingError`
|
|
301
|
+
* even if individual opt-ins (`allowFsOnly`, `allowLfAfterFs`, `allowMissingLeadingVt`,
|
|
302
|
+
* `allowLeadingWhitespace`) are enabled (WARN-08):
|
|
303
|
+
* - `MLLP_MISSING_LEADING_VT`
|
|
304
|
+
* - `MLLP_FS_WITHOUT_CR`
|
|
305
|
+
* - `MLLP_LF_AFTER_FS`
|
|
306
|
+
* - `MLLP_LEADING_WHITESPACE` (leading whitespace escalates as `MLLP_MISSING_LEADING_VT`)
|
|
307
|
+
*
|
|
308
|
+
* `MLLP_EMPTY_PAYLOAD` and `MLLP_TRAILING_BYTES` remain warnings even in strict mode.
|
|
309
|
+
*
|
|
310
|
+
* @example
|
|
311
|
+
* ```typescript
|
|
312
|
+
* // Hardened enforcement — no tolerance, all violations throw
|
|
313
|
+
* const reader = new FrameReader({ onFrame: fn, strict: true });
|
|
314
|
+
* ```
|
|
315
|
+
*/
|
|
316
|
+
strict?: boolean;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Stateful MLLP frame decoder. Feed raw TCP byte chunks via `push(chunk)`.
|
|
320
|
+
* Complete frames fire synchronously via `onFrame` callback during `push()`.
|
|
321
|
+
*
|
|
322
|
+
* The decoder operates as a 3-state FSM:
|
|
323
|
+
* - `SCANNING_FOR_VT` — waiting for the 0x0B frame-start byte
|
|
324
|
+
* - `READING_PAYLOAD` — accumulating payload bytes until FS (0x1C)
|
|
325
|
+
* - `EXPECTING_CR` — received FS, waiting for CR (0x0D) to complete the frame
|
|
326
|
+
*
|
|
327
|
+
* @example
|
|
328
|
+
* ```typescript
|
|
329
|
+
* const reader = new FrameReader({
|
|
330
|
+
* onFrame: (payload, byteOffset, warnings) => process(payload, byteOffset, warnings),
|
|
331
|
+
* onWarning: (w) => logger.warn(w),
|
|
332
|
+
* });
|
|
333
|
+
* socket.on('data', (chunk) => reader.push(chunk));
|
|
334
|
+
* ```
|
|
335
|
+
*/
|
|
336
|
+
declare class FrameReader {
|
|
337
|
+
private readonly _opts;
|
|
338
|
+
private readonly _maxFrameSize;
|
|
339
|
+
private _state;
|
|
340
|
+
/** Reusable accumulator buffer — grown by doubling as needed. */
|
|
341
|
+
private _accumulator;
|
|
342
|
+
/** Number of payload bytes written to accumulator in current frame. */
|
|
343
|
+
private _writePos;
|
|
344
|
+
/** Monotonic absolute stream byte offset across the entire connection lifetime. */
|
|
345
|
+
private _byteOffset;
|
|
346
|
+
/** Byte offset where current leading-whitespace run started (FRAME-10). */
|
|
347
|
+
private _wsStart;
|
|
348
|
+
/** Count of leading whitespace bytes accumulated in current run (FRAME-10). */
|
|
349
|
+
private _wsCount;
|
|
350
|
+
/** Byte offset of the VT byte that started the current frame. */
|
|
351
|
+
private _frameStartOffset;
|
|
352
|
+
/** Per-frame warning accumulator — cleared after each _deliverFrame(). */
|
|
353
|
+
private _frameWarnings;
|
|
354
|
+
/**
|
|
355
|
+
* Construct a chunked MLLP frame reader.
|
|
356
|
+
*
|
|
357
|
+
* @param opts - Reader options (`onFrame`/`onWarning` callbacks, tolerance, `maxFrameSizeBytes`).
|
|
358
|
+
*/
|
|
359
|
+
constructor(opts: FrameReaderOptions);
|
|
360
|
+
/**
|
|
361
|
+
* Feed a chunk of raw TCP bytes into the FSM. Frames fire synchronously via `onFrame`
|
|
362
|
+
* during this call. May throw `MllpFramingError` for unrecoverable framing violations
|
|
363
|
+
* when the matching tolerance is not enabled.
|
|
364
|
+
*
|
|
365
|
+
* @example
|
|
366
|
+
* ```typescript
|
|
367
|
+
* socket.on('data', (chunk) => reader.push(chunk));
|
|
368
|
+
* ```
|
|
369
|
+
*/
|
|
370
|
+
push(chunk: Buffer): void;
|
|
371
|
+
/**
|
|
372
|
+
* Clear internal accumulator state and reset byte offset to 0.
|
|
373
|
+
*
|
|
374
|
+
* Call on reconnect / connection reuse to start fresh without allocating a new reader (D-03).
|
|
375
|
+
* Pending partial frame state is discarded silently.
|
|
376
|
+
*
|
|
377
|
+
* @example
|
|
378
|
+
* ```typescript
|
|
379
|
+
* socket.on('close', () => reader.reset());
|
|
380
|
+
* ```
|
|
381
|
+
*/
|
|
382
|
+
reset(): void;
|
|
383
|
+
private _processByte;
|
|
384
|
+
private _scanForVt;
|
|
385
|
+
private _readPayload;
|
|
386
|
+
private _expectCr;
|
|
387
|
+
/** Grow the internal accumulator by doubling and write one byte. */
|
|
388
|
+
private _appendByte;
|
|
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
|
+
private _deliverFrame;
|
|
394
|
+
/** Emit a warning via the `onWarning` callback, swallowing any handler exceptions (WARN-06). */
|
|
395
|
+
private _emitWarning;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* NetTransport — `Transport` implementation wrapping a `net.Socket`.
|
|
400
|
+
*
|
|
401
|
+
* This is the **only** place in the codebase that consumes `net.Socket`'s
|
|
402
|
+
* EventEmitter surface. All higher layers (Connection, Server, Client) work
|
|
403
|
+
* against the `Transport` interface, never against `net.Socket` directly.
|
|
404
|
+
*
|
|
405
|
+
* @example
|
|
406
|
+
* ```typescript
|
|
407
|
+
* import { createConnection } from 'node:net';
|
|
408
|
+
* import { NetTransport } from '@cosyte/mllp';
|
|
409
|
+
*
|
|
410
|
+
* const socket = createConnection({ host: 'mllp.example.com', port: 2575 });
|
|
411
|
+
* const transport = new NetTransport(socket);
|
|
412
|
+
* transport.onConnect(() => console.log('connected'));
|
|
413
|
+
* transport.onData((chunk) => reader.push(chunk));
|
|
414
|
+
* ```
|
|
415
|
+
*
|
|
416
|
+
* @packageDocumentation
|
|
417
|
+
*/
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Wraps a `net.Socket` as a `Transport`, mapping socket EventEmitter events
|
|
421
|
+
* to registered single-handler callbacks.
|
|
422
|
+
*
|
|
423
|
+
* Each `onXxx(fn)` call replaces the prior handler and re-registers on the socket
|
|
424
|
+
* (uses `socket.removeAllListeners(event)` + `socket.on(event, fn)`).
|
|
425
|
+
*
|
|
426
|
+
* @example
|
|
427
|
+
* ```typescript
|
|
428
|
+
* const t = new NetTransport(socket);
|
|
429
|
+
* t.onData((chunk) => process(chunk));
|
|
430
|
+
* t.onError((err) => handleError(err));
|
|
431
|
+
* ```
|
|
432
|
+
*/
|
|
433
|
+
declare class NetTransport implements Transport {
|
|
434
|
+
private readonly _socket;
|
|
435
|
+
/**
|
|
436
|
+
* Wrap an existing `net.Socket` (or `tls.TLSSocket`) as a `Transport`.
|
|
437
|
+
*
|
|
438
|
+
* @param socket - The connected (or connecting) socket to adapt.
|
|
439
|
+
*/
|
|
440
|
+
constructor(socket: Socket);
|
|
441
|
+
/** See {@link Transport.write}. */
|
|
442
|
+
write(buf: Buffer): boolean;
|
|
443
|
+
/** See {@link Transport.close}. */
|
|
444
|
+
close(): void;
|
|
445
|
+
/** See {@link Transport.destroy}. */
|
|
446
|
+
destroy(reason?: Error): void;
|
|
447
|
+
/** See {@link Transport.onData}. */
|
|
448
|
+
onData(fn: (chunk: Buffer) => void): void;
|
|
449
|
+
/** See {@link Transport.onConnect}. */
|
|
450
|
+
onConnect(fn: () => void): void;
|
|
451
|
+
/** See {@link Transport.onClose}. */
|
|
452
|
+
onClose(fn: () => void): void;
|
|
453
|
+
/** See {@link Transport.onError}. */
|
|
454
|
+
onError(fn: (err: Error) => void): void;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* TlsTransport — `Transport` implementation wrapping a `tls.TLSSocket` (Phase 8).
|
|
459
|
+
*
|
|
460
|
+
* Identical wiring to `NetTransport` with one deliberate difference: `onConnect`
|
|
461
|
+
* maps to the socket's `'secureConnect'` event (fires once the TLS handshake
|
|
462
|
+
* completes) rather than `'connect'` (fires once the raw TCP handshake
|
|
463
|
+
* completes). Consumers of `Transport` (Connection, Client, Server) never see
|
|
464
|
+
* the difference — they only ever observe "the transport is ready."
|
|
465
|
+
*
|
|
466
|
+
* @example
|
|
467
|
+
* ```typescript
|
|
468
|
+
* import { connect } from 'node:tls';
|
|
469
|
+
* import { TlsTransport } from '@cosyte/mllp';
|
|
470
|
+
*
|
|
471
|
+
* const socket = connect({ host: 'mllp.example.com', port: 2575, ca: caPem });
|
|
472
|
+
* const transport = new TlsTransport(socket);
|
|
473
|
+
* transport.onConnect(() => console.log('TLS handshake complete'));
|
|
474
|
+
* transport.onData((chunk) => reader.push(chunk));
|
|
475
|
+
* ```
|
|
476
|
+
*
|
|
477
|
+
* @packageDocumentation
|
|
478
|
+
*/
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Wraps a `tls.TLSSocket` as a `Transport`. `onConnect(fn)` is armed on the
|
|
482
|
+
* `'secureConnect'` event (handshake complete, including certificate
|
|
483
|
+
* verification when `rejectUnauthorized` is on) rather than the raw TCP
|
|
484
|
+
* `'connect'` event that `NetTransport` uses.
|
|
485
|
+
*
|
|
486
|
+
* Each `onXxx(fn)` call replaces the prior handler and re-registers on the
|
|
487
|
+
* socket (`socket.removeAllListeners(event)` + `socket.on(event, fn)`) —
|
|
488
|
+
* the same set-once semantics as `NetTransport`.
|
|
489
|
+
*
|
|
490
|
+
* @example
|
|
491
|
+
* ```typescript
|
|
492
|
+
* const t = new TlsTransport(tlsSocket);
|
|
493
|
+
* t.onConnect(() => logger.info('secure'));
|
|
494
|
+
* t.onError((err) => handleTlsError(err));
|
|
495
|
+
* ```
|
|
496
|
+
*/
|
|
497
|
+
declare class TlsTransport implements Transport {
|
|
498
|
+
private readonly _socket;
|
|
499
|
+
/**
|
|
500
|
+
* Wrap an existing `tls.TLSSocket` (connecting or already secure) as a `Transport`.
|
|
501
|
+
*
|
|
502
|
+
* @param socket - The TLS socket to adapt.
|
|
503
|
+
*/
|
|
504
|
+
constructor(socket: TLSSocket);
|
|
505
|
+
/** See {@link Transport.write}. */
|
|
506
|
+
write(buf: Buffer): boolean;
|
|
507
|
+
/** See {@link Transport.close}. */
|
|
508
|
+
close(): void;
|
|
509
|
+
/** See {@link Transport.destroy}. */
|
|
510
|
+
destroy(reason?: Error): void;
|
|
511
|
+
/** See {@link Transport.onData}. */
|
|
512
|
+
onData(fn: (chunk: Buffer) => void): void;
|
|
513
|
+
/**
|
|
514
|
+
* See {@link Transport.onConnect}. Armed on `'secureConnect'` — the TLS
|
|
515
|
+
* handshake-complete event — not the raw TCP `'connect'` event.
|
|
516
|
+
*/
|
|
517
|
+
onConnect(fn: () => void): void;
|
|
518
|
+
/** See {@link Transport.onClose}. */
|
|
519
|
+
onClose(fn: () => void): void;
|
|
520
|
+
/** See {@link Transport.onError}. */
|
|
521
|
+
onError(fn: (err: Error) => void): void;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Security-warning codes and payload shape shared by the client and server (Phase 8).
|
|
526
|
+
*
|
|
527
|
+
* These codes are a **public API** — they appear in `'securityWarning'` event
|
|
528
|
+
* handlers, log pipelines, and monitoring dashboards. Renaming or removing a
|
|
529
|
+
* code is a breaking change (mirrors the `WarningCode` guardrail in
|
|
530
|
+
* `src/framing/registry.ts`).
|
|
531
|
+
*
|
|
532
|
+
* @example
|
|
533
|
+
* ```typescript
|
|
534
|
+
* import { MLLP_TLS_VERIFY_DISABLED, MLLP_BIND_ALL_INTERFACES } from '@cosyte/mllp';
|
|
535
|
+
* client.on('securityWarning', (w) => {
|
|
536
|
+
* if (w.code === MLLP_TLS_VERIFY_DISABLED) logger.warn(w.message);
|
|
537
|
+
* });
|
|
538
|
+
* ```
|
|
539
|
+
*
|
|
540
|
+
* @packageDocumentation
|
|
541
|
+
*/
|
|
542
|
+
/**
|
|
543
|
+
* Emitted (client-side) on every successful `secureConnect` — initial connect
|
|
544
|
+
* AND every reconnect — when {@link TlsOptions.allowUnverified} is `true`.
|
|
545
|
+
* Certificate verification is disabled for the connection; this is the loud,
|
|
546
|
+
* per-connection reminder that the channel is not authenticated per IHE ATNA
|
|
547
|
+
* ITI-19 (https://profiles.ihe.net/ITI/TF/Volume2/ITI-19.html).
|
|
548
|
+
*
|
|
549
|
+
* @example
|
|
550
|
+
* ```typescript
|
|
551
|
+
* import { MLLP_TLS_VERIFY_DISABLED } from '@cosyte/mllp';
|
|
552
|
+
* client.on('securityWarning', (w) => {
|
|
553
|
+
* if (w.code === MLLP_TLS_VERIFY_DISABLED) metrics.increment('mllp.tls_verify_disabled');
|
|
554
|
+
* });
|
|
555
|
+
* ```
|
|
556
|
+
*/
|
|
557
|
+
declare const MLLP_TLS_VERIFY_DISABLED = "MLLP_TLS_VERIFY_DISABLED";
|
|
558
|
+
/**
|
|
559
|
+
* Emitted (server-side) once at `listen()` time when the server binds a
|
|
560
|
+
* wildcard host (`'0.0.0.0'` or `'::'`) with `ServerOptions.allowWildcardBind: true`.
|
|
561
|
+
* Binding all interfaces widens the network exposure of the listener; this is
|
|
562
|
+
* the loud, one-time reminder that the operator opted in.
|
|
563
|
+
*
|
|
564
|
+
* @example
|
|
565
|
+
* ```typescript
|
|
566
|
+
* import { MLLP_BIND_ALL_INTERFACES } from '@cosyte/mllp';
|
|
567
|
+
* server.on('securityWarning', (w) => {
|
|
568
|
+
* if (w.code === MLLP_BIND_ALL_INTERFACES) logger.warn(w.message);
|
|
569
|
+
* });
|
|
570
|
+
* ```
|
|
571
|
+
*/
|
|
572
|
+
declare const MLLP_BIND_ALL_INTERFACES = "MLLP_BIND_ALL_INTERFACES";
|
|
573
|
+
/**
|
|
574
|
+
* Union of the two stable security-warning codes.
|
|
575
|
+
*
|
|
576
|
+
* @example
|
|
577
|
+
* ```typescript
|
|
578
|
+
* const code: SecurityWarningCode = 'MLLP_TLS_VERIFY_DISABLED';
|
|
579
|
+
* ```
|
|
580
|
+
*/
|
|
581
|
+
type SecurityWarningCode = typeof MLLP_TLS_VERIFY_DISABLED | typeof MLLP_BIND_ALL_INTERFACES;
|
|
582
|
+
/**
|
|
583
|
+
* Frozen payload of the `'securityWarning'` event, emitted by both
|
|
584
|
+
* {@link MllpClient} (`MLLP_TLS_VERIFY_DISABLED`) and {@link MllpServer}
|
|
585
|
+
* (`MLLP_BIND_ALL_INTERFACES`).
|
|
586
|
+
*
|
|
587
|
+
* Never carries payload bytes or PHI — only routing metadata (host/port) and
|
|
588
|
+
* a fixed, static message string.
|
|
589
|
+
*
|
|
590
|
+
* @example
|
|
591
|
+
* ```typescript
|
|
592
|
+
* client.on('securityWarning', (w: SecurityWarning) => {
|
|
593
|
+
* logger.warn({ code: w.code, host: w.host, port: w.port });
|
|
594
|
+
* });
|
|
595
|
+
* ```
|
|
596
|
+
*/
|
|
597
|
+
interface SecurityWarning {
|
|
598
|
+
/** The stable security-warning code. */
|
|
599
|
+
readonly code: SecurityWarningCode;
|
|
600
|
+
/** Fixed, human-readable description. Never contains payload bytes or PHI. */
|
|
601
|
+
readonly message: string;
|
|
602
|
+
/** Host associated with the warning (the target host for clients; the bind host for servers). */
|
|
603
|
+
readonly host: string;
|
|
604
|
+
/** Port associated with the warning. */
|
|
605
|
+
readonly port: number;
|
|
606
|
+
/** Wall-clock time at point of emission. */
|
|
607
|
+
readonly timestamp: Date;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* TLS (MLLPS) option types for the client and server (Phase 8).
|
|
612
|
+
*
|
|
613
|
+
* Spec anchor: IHE ATNA, ITI-19 Authenticate Node
|
|
614
|
+
* (https://profiles.ihe.net/ITI/TF/Volume2/ITI-19.html), the "STX: TLS 1.2
|
|
615
|
+
* Floor using BCP195" option, ITI TF-2 §3.19.6.2.3 — TLS >= 1.2 required;
|
|
616
|
+
* mutual node authentication; certificate validation via chain-of-trust or
|
|
617
|
+
* direct comparison. ITI TF-2 §3.19.6.2.3 mandates four TLS 1.2 cipher
|
|
618
|
+
* suites: `TLS_DHE_RSA_WITH_AES_128_GCM_SHA256`,
|
|
619
|
+
* `TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256`, `TLS_DHE_RSA_WITH_AES_256_GCM_SHA384`,
|
|
620
|
+
* `TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384`. Node's default TLS 1.2 cipher list
|
|
621
|
+
* already includes both mandated ECDHE suites, so this package pins to Node
|
|
622
|
+
* defaults rather than bundling a cipher list — see `docs-content/tls.md`.
|
|
623
|
+
*
|
|
624
|
+
* @example
|
|
625
|
+
* ```typescript
|
|
626
|
+
* import type { TlsOptions } from '@cosyte/mllp';
|
|
627
|
+
* const tls: TlsOptions = { ca: caPem, servername: 'mllp.example.com' };
|
|
628
|
+
* ```
|
|
629
|
+
*
|
|
630
|
+
* @packageDocumentation
|
|
631
|
+
*/
|
|
632
|
+
/**
|
|
633
|
+
* A PEM-encoded credential (certificate, key, or CA), matching Node's
|
|
634
|
+
* `tls.connect`/`tls.createServer` input shape — a single PEM string/Buffer,
|
|
635
|
+
* or an array of them (chain / multiple trust anchors).
|
|
636
|
+
*
|
|
637
|
+
* @example
|
|
638
|
+
* ```typescript
|
|
639
|
+
* import { readFileSync } from 'node:fs';
|
|
640
|
+
* const ca: PemInput = readFileSync('ca.pem');
|
|
641
|
+
* ```
|
|
642
|
+
*/
|
|
643
|
+
type PemInput = string | Buffer | Array<string | Buffer>;
|
|
644
|
+
/**
|
|
645
|
+
* Client-side TLS options (Phase 8, `ClientOptions.tls`).
|
|
646
|
+
*
|
|
647
|
+
* Passing `true` for `ClientOptions.tls` is equivalent to `{}` — TLS enabled
|
|
648
|
+
* with all defaults, including certificate verification **on**.
|
|
649
|
+
*
|
|
650
|
+
* @example
|
|
651
|
+
* ```typescript
|
|
652
|
+
* import { createClient } from '@cosyte/mllp';
|
|
653
|
+
* const client = createClient({
|
|
654
|
+
* host: 'mllp.example.com',
|
|
655
|
+
* port: 2575,
|
|
656
|
+
* tls: { ca: caPem, minVersion: 'TLSv1.2' },
|
|
657
|
+
* });
|
|
658
|
+
* ```
|
|
659
|
+
*/
|
|
660
|
+
interface TlsOptions {
|
|
661
|
+
/** Trust anchor(s) for verifying the server's certificate chain. */
|
|
662
|
+
readonly ca?: PemInput;
|
|
663
|
+
/** Client certificate presented for mutual TLS (ATNA ITI-19). */
|
|
664
|
+
readonly cert?: PemInput;
|
|
665
|
+
/** Private key matching {@link TlsOptions.cert}. */
|
|
666
|
+
readonly key?: PemInput;
|
|
667
|
+
/** Passphrase for an encrypted {@link TlsOptions.key}. */
|
|
668
|
+
readonly passphrase?: string;
|
|
669
|
+
/**
|
|
670
|
+
* SNI hostname and the identity-check target (matched against the server
|
|
671
|
+
* certificate's Subject/SAN). Defaults to `ClientOptions.host` when unset.
|
|
672
|
+
*/
|
|
673
|
+
readonly servername?: string;
|
|
674
|
+
/**
|
|
675
|
+
* Minimum negotiated TLS protocol version.
|
|
676
|
+
*
|
|
677
|
+
* Default `'TLSv1.2'` — the IHE ATNA ITI-19 "TLS 1.2 Floor" (BCP195) floor
|
|
678
|
+
* (ITI TF-2 §3.19.6.2.3). `'TLSv1.0'`/`'TLSv1.1'` are intentionally **not**
|
|
679
|
+
* expressible by this type — the floor cannot be lowered through this API.
|
|
680
|
+
*
|
|
681
|
+
* @default 'TLSv1.2'
|
|
682
|
+
*/
|
|
683
|
+
readonly minVersion?: "TLSv1.2" | "TLSv1.3";
|
|
684
|
+
/** Maximum negotiated TLS protocol version. */
|
|
685
|
+
readonly maxVersion?: "TLSv1.2" | "TLSv1.3";
|
|
686
|
+
/**
|
|
687
|
+
* OpenSSL cipher-list string passthrough (`tls.connect`'s `ciphers`).
|
|
688
|
+
* Unset uses Node's compiled-in defaults, which already include both
|
|
689
|
+
* ATNA-mandated ECDHE suites (see the module doc comment). Set this to
|
|
690
|
+
* restrict to a stricter list (e.g. DHE suites) if your deployment requires it.
|
|
691
|
+
*
|
|
692
|
+
* @default undefined (Node defaults)
|
|
693
|
+
*/
|
|
694
|
+
readonly ciphers?: string;
|
|
695
|
+
/**
|
|
696
|
+
* Loud, explicit dev opt-out from certificate verification (maps to
|
|
697
|
+
* `tls.connect`'s `rejectUnauthorized: false`). There is deliberately no
|
|
698
|
+
* raw `rejectUnauthorized` surface on this type — this is the only door,
|
|
699
|
+
* and it is loud: every successful connection (initial + every reconnect)
|
|
700
|
+
* emits a `'securityWarning'` (`MLLP_TLS_VERIFY_DISABLED`) event and calls
|
|
701
|
+
* `process.emitWarning`.
|
|
702
|
+
*
|
|
703
|
+
* Never set this in production against an untrusted network.
|
|
704
|
+
*
|
|
705
|
+
* @default false
|
|
706
|
+
*/
|
|
707
|
+
readonly allowUnverified?: boolean;
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* ATNA ITI-19 mutual-authentication modes for {@link ServerTlsOptions.clientAuth}.
|
|
711
|
+
*
|
|
712
|
+
* Mirrors the IHE ATNA "Authenticate Node" mutual-auth requirement
|
|
713
|
+
* (https://profiles.ihe.net/ITI/TF/Volume2/ITI-19.html):
|
|
714
|
+
*
|
|
715
|
+
* - `'NONE'` — no client certificate requested (default).
|
|
716
|
+
* - `'WANT'` — client certificate requested but NOT required; an untrusted or
|
|
717
|
+
* absent client certificate does not reject the connection. The peer
|
|
718
|
+
* certificate (if any) is surfaced on the `'connection'` event.
|
|
719
|
+
* - `'MUST'` — client certificate required AND verified against
|
|
720
|
+
* {@link ServerTlsOptions.ca}; the ATNA mutual-authentication mode. A
|
|
721
|
+
* missing or untrusted client certificate rejects the handshake.
|
|
722
|
+
*
|
|
723
|
+
* @example
|
|
724
|
+
* ```typescript
|
|
725
|
+
* import type { ClientAuth } from '@cosyte/mllp';
|
|
726
|
+
* const mode: ClientAuth = 'MUST'; // ATNA ITI-19 mutual node authentication
|
|
727
|
+
* ```
|
|
728
|
+
*/
|
|
729
|
+
type ClientAuth = "NONE" | "WANT" | "MUST";
|
|
730
|
+
/**
|
|
731
|
+
* Server-side TLS options (Phase 8, `ServerOptions.tls`).
|
|
732
|
+
*
|
|
733
|
+
* @example
|
|
734
|
+
* ```typescript
|
|
735
|
+
* import { createServer } from '@cosyte/mllp';
|
|
736
|
+
* const server = createServer({
|
|
737
|
+
* tls: { cert: certPem, key: keyPem, clientAuth: 'MUST', ca: clientCaPem },
|
|
738
|
+
* });
|
|
739
|
+
* ```
|
|
740
|
+
*/
|
|
741
|
+
interface ServerTlsOptions {
|
|
742
|
+
/** Server certificate (PEM). Required. */
|
|
743
|
+
readonly cert: PemInput;
|
|
744
|
+
/** Private key matching {@link ServerTlsOptions.cert}. Required. */
|
|
745
|
+
readonly key: PemInput;
|
|
746
|
+
/** Trust anchor(s) for verifying client certificates under `WANT`/`MUST`. */
|
|
747
|
+
readonly ca?: PemInput;
|
|
748
|
+
/** Passphrase for an encrypted {@link ServerTlsOptions.key}. */
|
|
749
|
+
readonly passphrase?: string;
|
|
750
|
+
/**
|
|
751
|
+
* ATNA ITI-19 mutual-authentication mode. `'WANT'` requests a client
|
|
752
|
+
* certificate without rejecting unauthorized/absent ones (surfaced, not
|
|
753
|
+
* enforced); `'MUST'` requests AND enforces verification (ATNA mutual
|
|
754
|
+
* auth). See {@link ClientAuth}.
|
|
755
|
+
*
|
|
756
|
+
* @default 'NONE'
|
|
757
|
+
*/
|
|
758
|
+
readonly clientAuth?: ClientAuth;
|
|
759
|
+
/**
|
|
760
|
+
* Minimum negotiated TLS protocol version — the IHE ATNA ITI-19 "TLS 1.2
|
|
761
|
+
* Floor" (BCP195) floor (ITI TF-2 §3.19.6.2.3).
|
|
762
|
+
*
|
|
763
|
+
* @default 'TLSv1.2'
|
|
764
|
+
*/
|
|
765
|
+
readonly minVersion?: "TLSv1.2" | "TLSv1.3";
|
|
766
|
+
/** Maximum negotiated TLS protocol version. */
|
|
767
|
+
readonly maxVersion?: "TLSv1.2" | "TLSv1.3";
|
|
768
|
+
/**
|
|
769
|
+
* OpenSSL cipher-list string passthrough. Unset uses Node's defaults
|
|
770
|
+
* (includes both ATNA-mandated ECDHE suites).
|
|
771
|
+
*
|
|
772
|
+
* @default undefined (Node defaults)
|
|
773
|
+
*/
|
|
774
|
+
readonly ciphers?: string;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Connection — 6-state FSM over a Transport with lifecycle events, warning streams,
|
|
779
|
+
* and getStats() observability.
|
|
780
|
+
*
|
|
781
|
+
* @example
|
|
782
|
+
* ```typescript
|
|
783
|
+
* import { Connection, NetTransport } from '@cosyte/mllp';
|
|
784
|
+
* import { createConnection } from 'node:net';
|
|
785
|
+
*
|
|
786
|
+
* const socket = createConnection({ host: 'localhost', port: 2575 });
|
|
787
|
+
* const transport = new NetTransport(socket);
|
|
788
|
+
* const conn = new Connection({ transport });
|
|
789
|
+
* conn.on('message', ({ payload }) => conn.send(ackBuffer));
|
|
790
|
+
* conn.on('stateChange', ({ from, to }) => logger.info({ from, to }));
|
|
791
|
+
* socket.on('connect', () => conn.notifyConnect(socket.remoteAddress ?? null, socket.remotePort ?? null));
|
|
792
|
+
* ```
|
|
793
|
+
*
|
|
794
|
+
* @packageDocumentation
|
|
795
|
+
*/
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* The 6 connection states from LIFE-01.
|
|
799
|
+
*
|
|
800
|
+
* Transitions are validated against the LIFE-02 edge graph. Illegal transitions
|
|
801
|
+
* are silently ignored to preserve FSM integrity.
|
|
802
|
+
*/
|
|
803
|
+
type ConnectionState = "CONNECTING" | "CONNECTED" | "DRAINING" | "RECONNECTING" | "DISCONNECTED" | "CLOSED";
|
|
804
|
+
/**
|
|
805
|
+
* Payload for the 'stateChange' event. Always `Object.freeze`'d.
|
|
806
|
+
*
|
|
807
|
+
* @example
|
|
808
|
+
* ```typescript
|
|
809
|
+
* conn.on('stateChange', ({ from, to, reason }) => {
|
|
810
|
+
* logger.info({ from, to, reason });
|
|
811
|
+
* });
|
|
812
|
+
* ```
|
|
813
|
+
*/
|
|
814
|
+
interface StateChangeEvent {
|
|
815
|
+
readonly from: ConnectionState;
|
|
816
|
+
readonly to: ConnectionState;
|
|
817
|
+
readonly reason?: string;
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Payload for the 'reconnecting' event. Always `Object.freeze`'d.
|
|
821
|
+
*
|
|
822
|
+
* Phase 5 will populate `attempt` and `delayMs` once the reconnect backoff loop
|
|
823
|
+
* is implemented. For now only `connectionId` is present at the point of emission.
|
|
824
|
+
*
|
|
825
|
+
* @example
|
|
826
|
+
* ```typescript
|
|
827
|
+
* conn.on('reconnecting', ({ connectionId }) => {
|
|
828
|
+
* logger.info(`Reconnecting connection ${connectionId}`);
|
|
829
|
+
* });
|
|
830
|
+
* ```
|
|
831
|
+
*/
|
|
832
|
+
interface ReconnectingEvent {
|
|
833
|
+
readonly connectionId: string;
|
|
834
|
+
readonly attempt?: number;
|
|
835
|
+
readonly delayMs?: number;
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Return type of `connection.getStats()` — JSON-serializable per OBS-04.
|
|
839
|
+
*
|
|
840
|
+
* All timestamps are `Date | null` (not epoch milliseconds). `JSON.stringify()`
|
|
841
|
+
* serialises them to ISO 8601 strings by default with no information loss.
|
|
842
|
+
*
|
|
843
|
+
* @example
|
|
844
|
+
* ```typescript
|
|
845
|
+
* const stats = conn.getStats();
|
|
846
|
+
* logger.info(JSON.stringify(stats)); // safe: all values are JSON-serializable
|
|
847
|
+
* ```
|
|
848
|
+
*/
|
|
849
|
+
interface ConnectionStats {
|
|
850
|
+
readonly state: ConnectionState;
|
|
851
|
+
readonly connectionId: string;
|
|
852
|
+
readonly remoteAddress: string | null;
|
|
853
|
+
readonly remotePort: number | null;
|
|
854
|
+
readonly warningsByCode: Record<string, number>;
|
|
855
|
+
readonly bytesIn: number;
|
|
856
|
+
readonly bytesOut: number;
|
|
857
|
+
readonly lastByteInAt: Date | null;
|
|
858
|
+
readonly lastByteOutAt: Date | null;
|
|
859
|
+
readonly connectedAt: Date | null;
|
|
860
|
+
readonly warningsTruncated: boolean;
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Options for constructing a {@link Connection}.
|
|
864
|
+
*
|
|
865
|
+
* @example
|
|
866
|
+
* ```typescript
|
|
867
|
+
* const opts: ConnectionOptions = {
|
|
868
|
+
* transport: new NetTransport(socket),
|
|
869
|
+
* onMessage: (payload) => handleMessage(payload),
|
|
870
|
+
* onWarning: (w) => logger.warn(w),
|
|
871
|
+
* drainTimeoutMs: 10_000,
|
|
872
|
+
* };
|
|
873
|
+
* ```
|
|
874
|
+
*/
|
|
875
|
+
interface ConnectionOptions {
|
|
876
|
+
/** The transport this Connection will drive. */
|
|
877
|
+
transport: Transport;
|
|
878
|
+
/** Called for each decoded MLLP frame (raw payload bytes, framing stripped). */
|
|
879
|
+
onMessage?: (payload: Buffer) => void;
|
|
880
|
+
/** Per-connection warning subscriber (WARN-10). Replaces previous subscription. */
|
|
881
|
+
onWarning?: (w: MllpWarning) => void;
|
|
882
|
+
/** Drain timeout used by close() in Phase 4/5 (default: 30_000 ms). */
|
|
883
|
+
drainTimeoutMs?: number;
|
|
884
|
+
/** FrameReader options (tolerance, maxFrameSizeBytes). onFrame/onWarning are managed internally. */
|
|
885
|
+
framing?: Omit<FrameReaderOptions, "onFrame" | "onWarning">;
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* A single MLLP connection wrapping a {@link Transport} with a 6-state FSM,
|
|
889
|
+
* lifecycle events, per-connection warning streams, and `getStats()` observability.
|
|
890
|
+
*
|
|
891
|
+
* Emits events: `stateChange`, `connect`, `disconnect`, `reconnecting`, `close`,
|
|
892
|
+
* `message`, `warning`, `error`.
|
|
893
|
+
*
|
|
894
|
+
* @example
|
|
895
|
+
* ```typescript
|
|
896
|
+
* import { Connection, NetTransport } from '@cosyte/mllp';
|
|
897
|
+
* import { createConnection } from 'node:net';
|
|
898
|
+
*
|
|
899
|
+
* const socket = createConnection({ host: 'localhost', port: 2575 });
|
|
900
|
+
* const conn = new Connection({ transport: new NetTransport(socket) });
|
|
901
|
+
* conn.on('stateChange', ({ from, to }) => console.log(from, '->', to));
|
|
902
|
+
* conn.on('message', ({ payload }) => console.log('received', payload.length, 'bytes'));
|
|
903
|
+
* socket.on('connect', () =>
|
|
904
|
+
* conn.notifyConnect(socket.remoteAddress ?? null, socket.remotePort ?? null)
|
|
905
|
+
* );
|
|
906
|
+
* ```
|
|
907
|
+
*/
|
|
908
|
+
declare class Connection extends EventEmitter {
|
|
909
|
+
/** Stable UUIDv4 identifier for this connection (D-11). */
|
|
910
|
+
readonly connectionId: string;
|
|
911
|
+
private _state;
|
|
912
|
+
private readonly _transport;
|
|
913
|
+
private readonly _reader;
|
|
914
|
+
private readonly _opts;
|
|
915
|
+
private _remoteAddress;
|
|
916
|
+
private _remotePort;
|
|
917
|
+
private _connectedAt;
|
|
918
|
+
private _bytesIn;
|
|
919
|
+
private _bytesOut;
|
|
920
|
+
private _lastByteInAt;
|
|
921
|
+
private _lastByteOutAt;
|
|
922
|
+
/** Ring buffer: last MAX_WARNINGS entries (OBS-05). */
|
|
923
|
+
private _warningBuffer;
|
|
924
|
+
/** Accurate total counts per code — unaffected by ring buffer truncation (OBS-05). */
|
|
925
|
+
private readonly _warningsByCode;
|
|
926
|
+
/** Set to true once the ring buffer overflows (OBS-05). */
|
|
927
|
+
private _warningsTruncated;
|
|
928
|
+
private _onWarningFn;
|
|
929
|
+
private _drainPromise;
|
|
930
|
+
/**
|
|
931
|
+
* beforeClose hook — no-op default that resolves immediately.
|
|
932
|
+
*
|
|
933
|
+
* Phase 4 (Server) and Phase 5 (Client) override this instance property to
|
|
934
|
+
* register ACK-drain and send-queue drain logic respectively (D-07/D-08).
|
|
935
|
+
* Connection calls this during `close()`, racing against `drainTimeoutMs`.
|
|
936
|
+
*/
|
|
937
|
+
beforeClose: (drainTimeoutMs: number) => Promise<void>;
|
|
938
|
+
/**
|
|
939
|
+
* Construct a connection over the given transport. Starts in `CONNECTING`; call
|
|
940
|
+
* {@link Connection.notifyConnect} once the transport handshake completes.
|
|
941
|
+
*
|
|
942
|
+
* @param opts - Connection options (the transport, message/warning handlers, drain timeout, framing).
|
|
943
|
+
*/
|
|
944
|
+
constructor(opts: ConnectionOptions);
|
|
945
|
+
/**
|
|
946
|
+
* Current FSM state. One of the 6 `ConnectionState` values (LIFE-01).
|
|
947
|
+
*
|
|
948
|
+
* Subscribe to `'stateChange'` events for reactive state monitoring.
|
|
949
|
+
*/
|
|
950
|
+
get state(): ConnectionState;
|
|
951
|
+
/**
|
|
952
|
+
* Register or replace the per-connection warning subscriber (WARN-10).
|
|
953
|
+
*
|
|
954
|
+
* Subsequent calls replace the previous handler (set-once semantics prevent leaks).
|
|
955
|
+
* The handler is called synchronously and any exception it throws is swallowed (WARN-06).
|
|
956
|
+
*
|
|
957
|
+
* @example
|
|
958
|
+
* ```typescript
|
|
959
|
+
* conn.onWarning((w) => logger.warn({ code: w.code, connectionId: w.connectionId }));
|
|
960
|
+
* ```
|
|
961
|
+
*/
|
|
962
|
+
onWarning(fn: (w: MllpWarning) => void): void;
|
|
963
|
+
/**
|
|
964
|
+
* Notify the connection that the transport handshake has completed.
|
|
965
|
+
*
|
|
966
|
+
* Transitions `CONNECTING → CONNECTED` and emits the `'connect'` event.
|
|
967
|
+
* Called externally by Server/Client after the socket connects.
|
|
968
|
+
*
|
|
969
|
+
* @param remoteAddress - Remote peer IP address, or `null` if unavailable.
|
|
970
|
+
* @param remotePort - Remote peer port, or `null` if unavailable.
|
|
971
|
+
*
|
|
972
|
+
* @example
|
|
973
|
+
* ```typescript
|
|
974
|
+
* socket.on('connect', () =>
|
|
975
|
+
* conn.notifyConnect(socket.remoteAddress ?? null, socket.remotePort ?? null)
|
|
976
|
+
* );
|
|
977
|
+
* ```
|
|
978
|
+
*/
|
|
979
|
+
notifyConnect(remoteAddress: string | null, remotePort: number | null): void;
|
|
980
|
+
/**
|
|
981
|
+
* Write raw bytes to the transport (no framing applied).
|
|
982
|
+
*
|
|
983
|
+
* Phase 4 (Server) and Phase 5 (Client) wrap this with `encodeFrame()` to add MLLP framing.
|
|
984
|
+
* Returns `false` if the connection is CLOSED or DISCONNECTED (no bytes written).
|
|
985
|
+
*
|
|
986
|
+
* @returns `true` if bytes flushed immediately; `false` if buffered (backpressure) or not writable.
|
|
987
|
+
*
|
|
988
|
+
* @example
|
|
989
|
+
* ```typescript
|
|
990
|
+
* const flushed = conn.send(encodeFrame(payload));
|
|
991
|
+
* if (!flushed) logger.warn('backpressure detected');
|
|
992
|
+
* ```
|
|
993
|
+
*/
|
|
994
|
+
send(data: Buffer): boolean;
|
|
995
|
+
/**
|
|
996
|
+
* Initiate graceful close of the connection.
|
|
997
|
+
*
|
|
998
|
+
* - If `CONNECTING` or `RECONNECTING`: cancels the pending attempt, transitions
|
|
999
|
+
* directly to `CLOSED`, and calls `transport.destroy()` (LIFE-05).
|
|
1000
|
+
* - If `CONNECTED`: transitions to `DRAINING`, calls `beforeClose(drainTimeoutMs)`,
|
|
1001
|
+
* then `DRAINING → DISCONNECTED` once the hook resolves, or `DRAINING → CLOSED`
|
|
1002
|
+
* if the drain timeout elapses first.
|
|
1003
|
+
* - If `DRAINING`: waits for the existing drain to complete (idempotent).
|
|
1004
|
+
* - If `CLOSED` or `DISCONNECTED`: no-op.
|
|
1005
|
+
*
|
|
1006
|
+
* @param opts.drainTimeoutMs - Override drain timeout (default: `opts.drainTimeoutMs ?? 30_000`).
|
|
1007
|
+
*
|
|
1008
|
+
* @example
|
|
1009
|
+
* ```typescript
|
|
1010
|
+
* await conn.close({ drainTimeoutMs: 5_000 });
|
|
1011
|
+
* ```
|
|
1012
|
+
*/
|
|
1013
|
+
close(opts?: {
|
|
1014
|
+
drainTimeoutMs?: number;
|
|
1015
|
+
}): Promise<void>;
|
|
1016
|
+
/**
|
|
1017
|
+
* Race `beforeClose()` against the drain timeout.
|
|
1018
|
+
*
|
|
1019
|
+
* On normal completion: `DRAINING → DISCONNECTED` + `transport.close()`.
|
|
1020
|
+
* On timeout: `DRAINING → CLOSED` + `transport.destroy()` (T-03-04-01).
|
|
1021
|
+
*/
|
|
1022
|
+
private _drainWithTimeout;
|
|
1023
|
+
/**
|
|
1024
|
+
* Abruptly destroy the connection, discarding any pending writes.
|
|
1025
|
+
*
|
|
1026
|
+
* Transitions from any non-terminal state directly to `CLOSED` and calls
|
|
1027
|
+
* `transport.destroy(reason)`. Emits `'close'` event. Idempotent — safe to
|
|
1028
|
+
* call multiple times.
|
|
1029
|
+
*
|
|
1030
|
+
* @param reason - Optional error to propagate to the transport.
|
|
1031
|
+
*
|
|
1032
|
+
* @example
|
|
1033
|
+
* ```typescript
|
|
1034
|
+
* conn.destroy(new Error('Timeout exceeded'));
|
|
1035
|
+
* ```
|
|
1036
|
+
*/
|
|
1037
|
+
destroy(reason?: Error): void;
|
|
1038
|
+
/**
|
|
1039
|
+
* Return a JSON-serializable observability snapshot (OBS-03/04/05).
|
|
1040
|
+
*
|
|
1041
|
+
* All timestamp fields are `Date | null` (not epoch milliseconds). `JSON.stringify()`
|
|
1042
|
+
* serialises them to ISO 8601 strings with no information loss.
|
|
1043
|
+
*
|
|
1044
|
+
* `warningsByCode` reflects every warning ever received regardless of ring buffer truncation.
|
|
1045
|
+
* `warningsTruncated` is `true` if the 100-entry ring buffer has overflowed.
|
|
1046
|
+
*
|
|
1047
|
+
* @example
|
|
1048
|
+
* ```typescript
|
|
1049
|
+
* const stats = conn.getStats();
|
|
1050
|
+
* console.log(JSON.stringify(stats)); // log-pipeline friendly
|
|
1051
|
+
* ```
|
|
1052
|
+
*/
|
|
1053
|
+
getStats(): ConnectionStats;
|
|
1054
|
+
private _transition;
|
|
1055
|
+
private _onTransportClose;
|
|
1056
|
+
/**
|
|
1057
|
+
* Emit on `'error'` only when a listener is attached.
|
|
1058
|
+
*
|
|
1059
|
+
* Node throws `ERR_UNHANDLED_ERROR` when `'error'` is emitted on an `EventEmitter` with no
|
|
1060
|
+
* listener. Every `'error'` emission in this class is reached from inside a transport callback
|
|
1061
|
+
* (the socket's `'data'`/`'error'` listener), so an unguarded emit would become an **uncaught
|
|
1062
|
+
* exception that kills the process** — the exact failure the containment above exists to
|
|
1063
|
+
* prevent, merely relocated one frame up the stack.
|
|
1064
|
+
*
|
|
1065
|
+
* `Connection` is a public export and can legitimately be driven with no `'error'` listener
|
|
1066
|
+
* (`MllpServer` and `MllpClient` each attach one; a bare `Connection` need not). The connection
|
|
1067
|
+
* is still torn down and `'stateChange'`/`'close'` still fire, so a caller is never blind —
|
|
1068
|
+
* they simply do not get the typed error object they never asked for. Mirrors
|
|
1069
|
+
* `MllpServer._emitErrorIfListened`.
|
|
1070
|
+
*/
|
|
1071
|
+
private _emitErrorIfListened;
|
|
1072
|
+
/**
|
|
1073
|
+
* Handle a fatal framing error thrown out of `FrameReader.push` (FRAME-FATAL).
|
|
1074
|
+
*
|
|
1075
|
+
* Contract: surface it as a connection `'error'` (frozen payload, `phase: 'receive'`,
|
|
1076
|
+
* `connectionCause: 'framing-fatal'`, the original `MllpFramingError` preserved as `cause` so
|
|
1077
|
+
* consumers keep the stable `code` and `byteOffset`), then **destroy this connection only**.
|
|
1078
|
+
*
|
|
1079
|
+
* Why destroy rather than resynchronize: once `push` has thrown, the reader's position within
|
|
1080
|
+
* the byte stream is no longer trustworthy — the next bytes could be the middle of a message.
|
|
1081
|
+
* Guessing where the next frame starts is how a clinical message gets silently mis-split, so
|
|
1082
|
+
* the connection is dropped instead. A server drops that one peer and keeps serving everyone
|
|
1083
|
+
* else.
|
|
1084
|
+
*
|
|
1085
|
+
* `'framing-fatal'` is classified **permanent** by `isTransientConnectionError`, so a client
|
|
1086
|
+
* does **not** auto-reconnect into it. That matters: a peer speaking something that is not MLLP
|
|
1087
|
+
* (an HTTP probe, a health check, a wrong-port misconfiguration) would otherwise be retried
|
|
1088
|
+
* forever, and the backoff would become an unbounded reconnect storm against an interface
|
|
1089
|
+
* engine that is already unwell. A compatibility failure is not a network blip.
|
|
1090
|
+
*
|
|
1091
|
+
* If a peer's quirk is *expected* (trailing junk, keepalive bytes, a missing leading VT), the
|
|
1092
|
+
* decoder's tolerance opt-ins are the supported answer — they turn the throw into a warning.
|
|
1093
|
+
*/
|
|
1094
|
+
private _onFatalFramingError;
|
|
1095
|
+
/**
|
|
1096
|
+
* Backstop for a NON-framing throw escaping `FrameReader.push` — i.e. our own bug, or a
|
|
1097
|
+
* subscriber's, rather than the peer's bytes.
|
|
1098
|
+
*
|
|
1099
|
+
* `_onFrameDecoded` already contains subscriber throws at the dispatch site, so this should be
|
|
1100
|
+
* unreachable. It exists because "unreachable" and "cannot kill the process from inside a
|
|
1101
|
+
* socket `'data'` handler" are different claims, and only the second one is safe to bet a
|
|
1102
|
+
* clinical interface on. Reported honestly as an internal receive error — never dressed up as
|
|
1103
|
+
* a peer framing fault.
|
|
1104
|
+
*/
|
|
1105
|
+
private _onInternalReceiveError;
|
|
1106
|
+
private _onTransportError;
|
|
1107
|
+
private _onFrameDecoded;
|
|
1108
|
+
/**
|
|
1109
|
+
* Run a subscriber callback, containing any throw as a connection `'error'` rather than letting
|
|
1110
|
+
* it unwind into the socket callback that (transitively) invoked us.
|
|
1111
|
+
*/
|
|
1112
|
+
private _dispatchContained;
|
|
1113
|
+
private _onFramingWarning;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Typed error for socket-layer connection problems.
|
|
1118
|
+
*
|
|
1119
|
+
* @example
|
|
1120
|
+
* ```typescript
|
|
1121
|
+
* import { MllpConnectionError } from '@cosyte/mllp';
|
|
1122
|
+
* try {
|
|
1123
|
+
* await client.connect();
|
|
1124
|
+
* } catch (err) {
|
|
1125
|
+
* if (err instanceof MllpConnectionError) {
|
|
1126
|
+
* console.log(err.phase, err.cause);
|
|
1127
|
+
* }
|
|
1128
|
+
* }
|
|
1129
|
+
* ```
|
|
1130
|
+
*
|
|
1131
|
+
* @packageDocumentation
|
|
1132
|
+
*/
|
|
1133
|
+
/**
|
|
1134
|
+
* Connection lifecycle phase where the error occurred.
|
|
1135
|
+
*
|
|
1136
|
+
* All 5 phases are defined in Phase 3 even though `'reconnect'` is only
|
|
1137
|
+
* exercised in Phase 5 (CLIENT-17). Locking the full union now prevents a
|
|
1138
|
+
* breaking type change later (ERR-03).
|
|
1139
|
+
*/
|
|
1140
|
+
type ConnectionErrorPhase = "connect" | "send" | "receive" | "close" | "reconnect";
|
|
1141
|
+
/**
|
|
1142
|
+
* Stable cause codes for `MllpConnectionError`.
|
|
1143
|
+
*
|
|
1144
|
+
* These codes are a **public API** — they appear in error inspection
|
|
1145
|
+
* by callers, log pipelines, and monitoring dashboards. Renaming or
|
|
1146
|
+
* removing a member is a breaking change.
|
|
1147
|
+
*
|
|
1148
|
+
* - `'fifo-unsafe'` — A queued send was rejected during reconnect because
|
|
1149
|
+
* FIFO ordering cannot be safely resumed across sessions (CLIENT-17).
|
|
1150
|
+
* - `'in-flight-orphan'` — An in-flight send (already write-flushed,
|
|
1151
|
+
* ACK timer started) was rejected during reconnect in FIFO mode because
|
|
1152
|
+
* the at-most-once delivery contract cannot be preserved across a
|
|
1153
|
+
* socket drop (D-08, healthcare medication/orders semantics).
|
|
1154
|
+
* - `'tls-verify'` (Phase 8) — The TLS handshake failed certificate
|
|
1155
|
+
* verification (untrusted chain, expired/not-yet-valid cert, hostname
|
|
1156
|
+
* mismatch, revocation, …). See {@link isTlsVerificationErrorCode} for the
|
|
1157
|
+
* exact underlying error codes. Classified **permanent** by
|
|
1158
|
+
* `isTransientConnectionError` — never auto-reconnect-looped into a
|
|
1159
|
+
* misconfigured or MITM'd endpoint.
|
|
1160
|
+
* - `'tls-handshake'` (Phase 8) — A TLS-**protocol**-shaped handshake
|
|
1161
|
+
* failure observed before `'secureConnect'`: `ERR_SSL_*` codes, `EPROTO`,
|
|
1162
|
+
* or an OpenSSL alert-bearing error (protocol version mismatch, no shared
|
|
1163
|
+
* cipher, a required mutual-TLS client certificate rejected by the
|
|
1164
|
+
* server, …). See `isTlsProtocolError` for the boundary. Pure TCP-level
|
|
1165
|
+
* failures (`ECONNREFUSED`, `ETIMEDOUT`, …) on a TLS-configured
|
|
1166
|
+
* connection carry **no** `connectionCause` — the same shape as plaintext.
|
|
1167
|
+
*
|
|
1168
|
+
* Scope note: the `'tls-verify'`/`'tls-handshake'` values are attached on
|
|
1169
|
+
* the client's **initial `connect()` path**. Failures on the auto-reconnect
|
|
1170
|
+
* path surface as raw socket errors — their transient/permanent
|
|
1171
|
+
* classification still applies, but they do not (yet) carry a
|
|
1172
|
+
* `connectionCause`.
|
|
1173
|
+
*
|
|
1174
|
+
* @example
|
|
1175
|
+
* ```typescript
|
|
1176
|
+
* if (err instanceof MllpConnectionError && err.connectionCause === 'in-flight-orphan') {
|
|
1177
|
+
* // Treat as at-most-once: do NOT auto-retry; bubble up to caller for
|
|
1178
|
+
* // application-level dedupe / replay decisions.
|
|
1179
|
+
* }
|
|
1180
|
+
* ```
|
|
1181
|
+
*/
|
|
1182
|
+
type ConnectionErrorCause = "fifo-unsafe" | "in-flight-orphan" | "tls-verify" | "tls-handshake" | "framing-fatal";
|
|
1183
|
+
/**
|
|
1184
|
+
* Thrown (or emitted via `onError`) for socket-layer problems such as
|
|
1185
|
+
* connection refused, ECONNRESET, ETIMEDOUT, or DNS failure.
|
|
1186
|
+
*
|
|
1187
|
+
* - `cause` — the original OS or TLS error
|
|
1188
|
+
* - `phase` — which connection lifecycle phase the failure occurred in
|
|
1189
|
+
* - `connectionCause` — optional stable cause-code (e.g. for FIFO reconnect rejections)
|
|
1190
|
+
*
|
|
1191
|
+
* @example
|
|
1192
|
+
* ```typescript
|
|
1193
|
+
* throw new MllpConnectionError('Connection refused', {
|
|
1194
|
+
* cause: osError,
|
|
1195
|
+
* phase: 'connect',
|
|
1196
|
+
* });
|
|
1197
|
+
* ```
|
|
1198
|
+
*/
|
|
1199
|
+
declare class MllpConnectionError extends Error {
|
|
1200
|
+
readonly name: "MllpConnectionError";
|
|
1201
|
+
/** The original OS or TLS error that caused this connection failure. */
|
|
1202
|
+
readonly cause: Error;
|
|
1203
|
+
/** Which connection lifecycle phase the failure occurred in. */
|
|
1204
|
+
readonly phase: ConnectionErrorPhase;
|
|
1205
|
+
/**
|
|
1206
|
+
* Optional stable cause code. Present on FIFO reconnect rejections
|
|
1207
|
+
* (`'fifo-unsafe'` for queued sends, `'in-flight-orphan'` for in-flight sends).
|
|
1208
|
+
*
|
|
1209
|
+
* The set of values is a **public API**; see {@link ConnectionErrorCause}.
|
|
1210
|
+
*/
|
|
1211
|
+
readonly connectionCause?: ConnectionErrorCause;
|
|
1212
|
+
/**
|
|
1213
|
+
* Construct an MLLP connection error.
|
|
1214
|
+
*
|
|
1215
|
+
* @param message - Human-readable error message.
|
|
1216
|
+
* @param opts - Error context (underlying `cause`, the lifecycle `phase`, optional stable cause code).
|
|
1217
|
+
*/
|
|
1218
|
+
constructor(message: string, opts: {
|
|
1219
|
+
cause: Error;
|
|
1220
|
+
phase: ConnectionErrorPhase;
|
|
1221
|
+
connectionCause?: ConnectionErrorCause;
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Server-side MLLP acknowledgement construction and the **fail-safe commit contract**.
|
|
1227
|
+
*
|
|
1228
|
+
* This module owns the byte-level ACK builder used by {@link MllpServer}'s auto-ACK
|
|
1229
|
+
* path, the HL7 Table 0008 acknowledgement-code union, and the error type a message
|
|
1230
|
+
* handler throws to request a specific negative acknowledgement.
|
|
1231
|
+
*
|
|
1232
|
+
* ## Why this is safety-critical
|
|
1233
|
+
*
|
|
1234
|
+
* A positive acknowledgement (`AA`) tells the sender "you may forget this message — I
|
|
1235
|
+
* have it." If a server emits `AA` *before* the message is durably handled and then the
|
|
1236
|
+
* process crashes, the message is **silently lost** with no record on either side. For
|
|
1237
|
+
* clinical messages (an admit, an order, a result) that is a patient-safety failure.
|
|
1238
|
+
*
|
|
1239
|
+
* The contract this module enforces: a positive ACK (`AA`) is only built once the
|
|
1240
|
+
* application handler has resolved success. A handler that throws or rejects yields a
|
|
1241
|
+
* **negative** ACK (`AE` by default, `AR` when the handler asks for it) — never `AA`.
|
|
1242
|
+
*
|
|
1243
|
+
* Spec: HL7 v2.5.1 ch. 2 §2.9.2 (original acknowledgement mode), §2.9.3 (enhanced
|
|
1244
|
+
* accept/application acknowledgement), Table 0008 (Acknowledgment Code), §2.9.2.2
|
|
1245
|
+
* (MSA-2 echoes the inbound MSH-10 message control ID).
|
|
1246
|
+
*
|
|
1247
|
+
* @packageDocumentation
|
|
1248
|
+
*/
|
|
1249
|
+
/**
|
|
1250
|
+
* HL7 Table 0008 — Acknowledgment Code. A **stable public API**.
|
|
1251
|
+
*
|
|
1252
|
+
* Two families, by mode (HL7 v2.5.1 §2.9):
|
|
1253
|
+
*
|
|
1254
|
+
* - **Original mode (§2.9.2):** `AA` accept, `AE` application error, `AR` application
|
|
1255
|
+
* reject. The single ACK reports application-level outcome.
|
|
1256
|
+
* - **Enhanced mode (§2.9.3):** `CA` commit accept, `CE` commit error, `CR` commit
|
|
1257
|
+
* reject — the *accept* acknowledgement, distinct from a later application ACK.
|
|
1258
|
+
*
|
|
1259
|
+
* `AE` vs `AR`: `AE` is a processing **error** (the sender may resend later, e.g. a
|
|
1260
|
+
* transient downstream outage); `AR` is a **reject** (the sender should not resend the
|
|
1261
|
+
* message unchanged, e.g. it is structurally unacceptable). `@cosyte/mllp` builds
|
|
1262
|
+
* original-mode ACKs; the `C*` codes are surfaced in the type for completeness and for
|
|
1263
|
+
* callers that build their own enhanced-mode ACKs via `autoAck: fn`.
|
|
1264
|
+
*
|
|
1265
|
+
* @example
|
|
1266
|
+
* ```typescript
|
|
1267
|
+
* import type { AckCode } from '@cosyte/mllp';
|
|
1268
|
+
* const code: AckCode = 'AE';
|
|
1269
|
+
* ```
|
|
1270
|
+
*/
|
|
1271
|
+
type AckCode = "AA" | "AE" | "AR" | "CA" | "CE" | "CR";
|
|
1272
|
+
/**
|
|
1273
|
+
* The negative original-mode acknowledgement codes a failed handler can produce.
|
|
1274
|
+
*
|
|
1275
|
+
* @example
|
|
1276
|
+
* ```typescript
|
|
1277
|
+
* import type { NegativeAckCode } from '@cosyte/mllp';
|
|
1278
|
+
* const code: NegativeAckCode = 'AR';
|
|
1279
|
+
* ```
|
|
1280
|
+
*/
|
|
1281
|
+
type NegativeAckCode = "AE" | "AR";
|
|
1282
|
+
/**
|
|
1283
|
+
* Error a message handler throws to control the negative acknowledgement the server
|
|
1284
|
+
* returns. Throwing **any** error from a commit-gated handler yields `AE` by default;
|
|
1285
|
+
* throw an `MllpAckError` to choose `AR` (application reject) instead.
|
|
1286
|
+
*
|
|
1287
|
+
* The `message` is **never** copied into the ACK bytes or any emitted event — it may
|
|
1288
|
+
* carry PHI. Only the static, non-PHI `ackCode` influences the wire response.
|
|
1289
|
+
*
|
|
1290
|
+
* @example
|
|
1291
|
+
* ```typescript
|
|
1292
|
+
* import { createServer, MllpAckError } from '@cosyte/mllp';
|
|
1293
|
+
*
|
|
1294
|
+
* createServer({
|
|
1295
|
+
* autoAck: 'AA',
|
|
1296
|
+
* onMessage: async (payload) => {
|
|
1297
|
+
* if (!isAcceptable(payload)) {
|
|
1298
|
+
* // sender should NOT resend unchanged -> AR
|
|
1299
|
+
* throw new MllpAckError('unsupported message type', { ackCode: 'AR' });
|
|
1300
|
+
* }
|
|
1301
|
+
* await db.commit(payload); // throw here -> AE (resend may succeed)
|
|
1302
|
+
* },
|
|
1303
|
+
* });
|
|
1304
|
+
* ```
|
|
1305
|
+
*/
|
|
1306
|
+
declare class MllpAckError extends Error {
|
|
1307
|
+
/** The negative acknowledgement code to return (`AE` or `AR`). */
|
|
1308
|
+
readonly ackCode: NegativeAckCode;
|
|
1309
|
+
/**
|
|
1310
|
+
* @param message - Diagnostic text for the thrower. Never placed on the wire (may carry PHI).
|
|
1311
|
+
* @param opts.ackCode - Negative code to return. Default `'AE'`.
|
|
1312
|
+
* @param opts.cause - Underlying error, preserved on `.cause`.
|
|
1313
|
+
*/
|
|
1314
|
+
constructor(message: string, opts?: {
|
|
1315
|
+
ackCode?: NegativeAckCode;
|
|
1316
|
+
cause?: unknown;
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
1320
|
+
* Resolve the negative acknowledgement code for a handler failure.
|
|
1321
|
+
*
|
|
1322
|
+
* An {@link MllpAckError} carries an explicit `ackCode`; any other thrown value maps to
|
|
1323
|
+
* `AE` (application error — the default, since most handler failures are transient and
|
|
1324
|
+
* a resend may succeed).
|
|
1325
|
+
*
|
|
1326
|
+
* @example
|
|
1327
|
+
* ```typescript
|
|
1328
|
+
* import { resolveNackCode, MllpAckError } from '@cosyte/mllp';
|
|
1329
|
+
* resolveNackCode(new Error('boom')); // 'AE'
|
|
1330
|
+
* resolveNackCode(new MllpAckError('nope', { ackCode: 'AR' })); // 'AR'
|
|
1331
|
+
* ```
|
|
1332
|
+
*/
|
|
1333
|
+
declare function resolveNackCode(err: unknown): NegativeAckCode;
|
|
1334
|
+
/**
|
|
1335
|
+
* True iff a **positive** raw acknowledgement (`AA`/`CA`) cannot be safely correlated to
|
|
1336
|
+
* this payload, so {@link buildRawAck} (and the server's auto-ACK path) must **downgrade**
|
|
1337
|
+
* it to a non-positive `AE`/`CE` rather than tell the sender "I have it."
|
|
1338
|
+
*
|
|
1339
|
+
* The rule this enforces: **never answer `AA` for a message you could not correlate.** A
|
|
1340
|
+
* positive ACK is a promise the sender may forget the message; if it names a control ID
|
|
1341
|
+
* the sender cannot match — or names one of several messages it never read — the sender
|
|
1342
|
+
* times out and resends, committing a **duplicate clinical message** (or worse, believes a
|
|
1343
|
+
* destroyed message was delivered). Three payload-shaped reasons make a positive ACK
|
|
1344
|
+
* uncorrelatable, all peer-reachable off the wire:
|
|
1345
|
+
*
|
|
1346
|
+
* 1. **No readable `MSH`.** `readMshSegment` returns `null` — e.g. a `BOM`/`SP`/`TAB`
|
|
1347
|
+
* before `MSH` (which shares the `MSH`'s segment line, so `MSH` heads no segment), or
|
|
1348
|
+
* a bare fragment delivered after a mid-payload `VT` discard (`MLLP_TRAILING_BYTES`).
|
|
1349
|
+
* MSA-2 would be empty: an ACK that correlates to nothing.
|
|
1350
|
+
* 2. **Empty MSH-10.** The `MSH` is readable but carries no message control ID, so there
|
|
1351
|
+
* is nothing to echo — again `MSA|AA|` with an empty MSA-2.
|
|
1352
|
+
* 3. **A batch or concatenated messages.** An `FHS`/`BHS`/`BTS`/`FTS` envelope (§2.10.3)
|
|
1353
|
+
* or a second `MSH` in the same frame: a single MSA-2 can echo only ONE control ID, so
|
|
1354
|
+
* a positive ACK naming the first silently drops the rest (see
|
|
1355
|
+
* {@link containsBatchOrExtraMessage}). Batch ACK is its own feature (`MLLP-BATCH`) —
|
|
1356
|
+
* until it is designed, a batch must stay a **loud non-positive** answer.
|
|
1357
|
+
*
|
|
1358
|
+
* This is a **refusal**, not a tolerance widening: it never makes an unreadable message
|
|
1359
|
+
* readable, never re-bases on a located `MSH`, never parses a batch. It only recognizes
|
|
1360
|
+
* the shapes for which a positive disposition would be a lie, so the builder can fall back
|
|
1361
|
+
* to `AE`. A **negative** requested code (`AE`/`AR`/`CE`/`CR`) is unaffected — it is
|
|
1362
|
+
* already non-positive, and echoing whatever control ID it can find is still useful.
|
|
1363
|
+
*
|
|
1364
|
+
* Pure, byte-level, never throws.
|
|
1365
|
+
*
|
|
1366
|
+
* @example
|
|
1367
|
+
* ```typescript
|
|
1368
|
+
* import { rawAckUncorrelatable } from '@cosyte/mllp';
|
|
1369
|
+
* rawAckUncorrelatable(oneGoodMessage); // false → AA is safe
|
|
1370
|
+
* rawAckUncorrelatable(twoConcatenatedMsh); // true → downgrade AA to AE
|
|
1371
|
+
* ```
|
|
1372
|
+
*/
|
|
1373
|
+
declare function rawAckUncorrelatable(payload: Buffer): boolean;
|
|
1374
|
+
/**
|
|
1375
|
+
* Build a minimal original-mode HL7 v2 acknowledgement from raw inbound payload bytes,
|
|
1376
|
+
* **without a parser** (parser-driven ACKs are the `@cosyte/mllp/ack-from-hl7` subpath).
|
|
1377
|
+
*
|
|
1378
|
+
* Locates the `MSH` segment (the first `CR`/`LF`-delimited segment starting with `MSH` —
|
|
1379
|
+
* so a leading `CR` or an `FHS`/`BHS` batch header does not hide it), reads the field
|
|
1380
|
+
* separator from **MSH-1** and the encoding characters from **MSH-2**, then splits that
|
|
1381
|
+
* segment on that separator to read fields. The ACK swaps sender/receiver per HL7 ACK
|
|
1382
|
+
* rules, echoes the inbound MSH-10 into MSA-2 (§2.9.2.2), sets MSA-1 to `code`, and — for
|
|
1383
|
+
* negative codes — adds a **static, PHI-free** MSA-3 reason. A fresh control ID fills the
|
|
1384
|
+
* ACK's own MSH-10.
|
|
1385
|
+
*
|
|
1386
|
+
* ## Why the delimiters are read, not assumed
|
|
1387
|
+
*
|
|
1388
|
+
* MSH-1 **is** the field separator (HL7 v2.5.1 §2.5.4): the byte at offset 3 of the MSH
|
|
1389
|
+
* segment *defines* it for the whole message. `|` is overwhelmingly common but it is a
|
|
1390
|
+
* convention, not the spec. Assuming it was wrong in two compounding ways:
|
|
1391
|
+
*
|
|
1392
|
+
* 1. **Reading.** Splitting a `!`-delimited message on `|` yields ONE field, so every
|
|
1393
|
+
* echoed field — including MSH-10 — came back empty. The ACK went out as
|
|
1394
|
+
* `MSA|AA|` with **no correlation id at all**: the sender cannot match it, times
|
|
1395
|
+
* out, and resends → a duplicate clinical message. That is the exact failure this
|
|
1396
|
+
* package's correlator exists to prevent, manufactured by the ACK builder itself.
|
|
1397
|
+
* 2. **Writing.** The echoed MSH-3..6 and MSH-10 field *content* is copied verbatim
|
|
1398
|
+
* from the inbound, still escaped against the **inbound's** encoding characters. Re-
|
|
1399
|
+
* emitting that content under a different delimiter set silently reinterprets it:
|
|
1400
|
+
* an inbound whose component separator is `#` and whose MSH-10 is `ID#X` would be
|
|
1401
|
+
* re-emitted under `^~\&` as the literal `ID#X`, which the sender then reads as a
|
|
1402
|
+
* single component rather than two. Echoing MSH-1 and MSH-2 keeps the content and
|
|
1403
|
+
* the delimiters that define it together.
|
|
1404
|
+
*
|
|
1405
|
+
* ## It reads the MSH through the SHARED scanner
|
|
1406
|
+
*
|
|
1407
|
+
* The MSH read is `readMshSegment` from `src/internal/control-id.ts` — the same call the
|
|
1408
|
+
* client's correlator makes to derive the key it will later match this ACK against. That
|
|
1409
|
+
* is deliberate and it is the whole point: this builder used to re-derive the read itself
|
|
1410
|
+
* (`payload.toString("latin1").split("\r")`, hunting for an `MSH` anywhere in the
|
|
1411
|
+
* payload), and the two disagreed on real inputs. On a **truncated MSH** followed by a
|
|
1412
|
+
* `PID` the correlator keyed on the PID's MRN while this builder echoed an empty MSA-2. On
|
|
1413
|
+
* a payload with a **leading `CR`** — which the MLLP decoder passes straight through, and
|
|
1414
|
+
* which real senders emit — this builder found the `MSH` and echoed MSH-10 correctly while
|
|
1415
|
+
* the correlator, requiring `MSH` at byte 0, gave up. Every such disagreement is an ACK the
|
|
1416
|
+
* sender cannot match → timeout → resend → **duplicate clinical message**.
|
|
1417
|
+
*
|
|
1418
|
+
* The fix is one scan — but note *which* scan. The first attempt made them agree by
|
|
1419
|
+
* requiring `MSH` at byte 0 everywhere, which "resolved" the leading-`CR` disagreement by
|
|
1420
|
+
* degrading the side that had been **right**: `buildRawAck` began emitting a positive `AA`
|
|
1421
|
+
* with an empty MSA-2, silently, for a message whose MSH-10 was plainly present. Agreement
|
|
1422
|
+
* is not the goal; agreeing on the *correct*, *tolerant* answer is. A lenient reader may
|
|
1423
|
+
* never drop data that is there (Postel's Law — CLAUDE.md).
|
|
1424
|
+
*
|
|
1425
|
+
* ## The fail-safe downgrade: never a positive `AA`/`CA` it cannot correlate
|
|
1426
|
+
*
|
|
1427
|
+
* A positive acknowledgement is a promise the sender may forget the message. If it names a
|
|
1428
|
+
* control ID the sender cannot match — or names one message out of several it never read —
|
|
1429
|
+
* the sender times out and resends, committing a **duplicate clinical message**. So a
|
|
1430
|
+
* requested positive code is **downgraded** to its non-positive counterpart (`AA`→`AE`,
|
|
1431
|
+
* `CA`→`CE`) whenever the payload cannot carry a correlatable positive ACK: no readable
|
|
1432
|
+
* `MSH`, an empty MSH-10, or a batch/concatenated-message shape that a single MSA-2 cannot
|
|
1433
|
+
* acknowledge. See {@link rawAckUncorrelatable} for the exact conditions and why each is a
|
|
1434
|
+
* refusal rather than a widened reader. A requested **negative** code (`AE`/`AR`/`CE`/`CR`)
|
|
1435
|
+
* is never touched. This mirrors the parser-backed `buildMllpAck`, which downgrades and
|
|
1436
|
+
* warns on an unparseable inbound — the two builders' fail-safe semantics now agree.
|
|
1437
|
+
*
|
|
1438
|
+
* **Never throws** and **never copies payload content** beyond the routing/control
|
|
1439
|
+
* metadata above — `readMshSegment` stops at the MSH's segment terminator, so no field of
|
|
1440
|
+
* any later segment (PID and friends) can be reached, let alone echoed. On a missing or
|
|
1441
|
+
* unreadable `MSH` it returns a minimal well-formed ACK carrying the (downgraded) `code` so
|
|
1442
|
+
* the caller can still respond.
|
|
1443
|
+
*
|
|
1444
|
+
* @param payload - Raw decoded HL7 v2 payload bytes (MLLP framing already stripped).
|
|
1445
|
+
* @param code - Requested MSA-1 acknowledgement code. A positive `AA`/`CA` is downgraded to
|
|
1446
|
+
* `AE`/`CE` when the message cannot be correlated (see above).
|
|
1447
|
+
* @returns ACK payload bytes (no framing — the caller wraps with `encodeFrame`).
|
|
1448
|
+
*
|
|
1449
|
+
* @example
|
|
1450
|
+
* ```typescript
|
|
1451
|
+
* import { buildRawAck } from '@cosyte/mllp';
|
|
1452
|
+
* const ack = buildRawAck(inboundPayload, 'AE'); // MSA|AE|<inbound MSH-10>|message could not be processed
|
|
1453
|
+
* ```
|
|
1454
|
+
*/
|
|
1455
|
+
declare function buildRawAck(payload: Buffer, code: AckCode): Buffer;
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* MLLP Server — createServer(), createStarterServer(), and MllpServer class.
|
|
1459
|
+
*
|
|
1460
|
+
* Provides the server-side MLLP transport: listen for inbound TCP connections,
|
|
1461
|
+
* decode MLLP-framed messages, surface them as Buffer payloads, and support
|
|
1462
|
+
* optional auto-ACK, keepalive, graceful shutdown, and AbortSignal cancellation.
|
|
1463
|
+
*
|
|
1464
|
+
* @example
|
|
1465
|
+
* ```typescript
|
|
1466
|
+
* import { createServer } from '@cosyte/mllp';
|
|
1467
|
+
*
|
|
1468
|
+
* const server = createServer({
|
|
1469
|
+
* onMessage: (payload, meta, conn) => {
|
|
1470
|
+
* console.log('received', payload.length, 'bytes from', meta.connectionId);
|
|
1471
|
+
* },
|
|
1472
|
+
* });
|
|
1473
|
+
* await server.listen(2575);
|
|
1474
|
+
* ```
|
|
1475
|
+
*
|
|
1476
|
+
* @packageDocumentation
|
|
1477
|
+
*/
|
|
1478
|
+
|
|
1479
|
+
/**
|
|
1480
|
+
* Metadata attached to each decoded MLLP message (SERVER-03).
|
|
1481
|
+
*
|
|
1482
|
+
* All fields are `readonly` — the object is `Object.freeze()`'d before emission.
|
|
1483
|
+
*
|
|
1484
|
+
* @example
|
|
1485
|
+
* ```typescript
|
|
1486
|
+
* server.on('message', ({ payload, meta }) => {
|
|
1487
|
+
* console.log(meta.connectionId, '@', meta.byteOffset, 'warnings:', meta.warnings.length);
|
|
1488
|
+
* });
|
|
1489
|
+
* ```
|
|
1490
|
+
*/
|
|
1491
|
+
interface MessageMeta {
|
|
1492
|
+
/** Stable UUID identifying the connection that delivered this message. */
|
|
1493
|
+
readonly connectionId: string;
|
|
1494
|
+
/** Byte offset of the frame start in the connection's data stream. */
|
|
1495
|
+
readonly byteOffset: number;
|
|
1496
|
+
/** Framing warnings emitted during decoding of this frame. */
|
|
1497
|
+
readonly warnings: readonly MllpWarning[];
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Why the server sent a **negative** acknowledgement instead of `AA` on the auto-ACK path.
|
|
1501
|
+
* A stable, **PHI-free** enum (no payload bytes, no control ID) — safe to log and to key
|
|
1502
|
+
* metrics on.
|
|
1503
|
+
*
|
|
1504
|
+
* - `'handler-rejected'` — a commit-gated `onMessage` handler threw/rejected (the commit
|
|
1505
|
+
* contract: a positive ACK cannot precede a successful commit).
|
|
1506
|
+
* - `'uncorrelatable-inbound'` — the inbound could not carry a correlatable positive ACK
|
|
1507
|
+
* (no readable `MSH`, an empty MSH-10, or a batch/concatenated-message shape). A positive
|
|
1508
|
+
* `AA` here names a control ID the sender cannot match → timeout → resend → **duplicate
|
|
1509
|
+
* clinical message**. See {@link rawAckUncorrelatable}.
|
|
1510
|
+
* - `'discarded-bytes'` — the decoder flagged `MLLP_TRAILING_BYTES` for this frame: a
|
|
1511
|
+
* mid-payload `VT` made it **discard accumulated bytes** and deliver only the fragment
|
|
1512
|
+
* after it. The clinical message was destroyed in transit; a positive `AA` would tell the
|
|
1513
|
+
* sender a message we never received was delivered.
|
|
1514
|
+
*/
|
|
1515
|
+
type NackReason = "handler-rejected" | "uncorrelatable-inbound" | "discarded-bytes";
|
|
1516
|
+
/**
|
|
1517
|
+
* Payload of the server `'nack'` event — emitted whenever the server responds with a
|
|
1518
|
+
* **negative** acknowledgement instead of `AA` on the auto-ACK path (the fail-safe commit
|
|
1519
|
+
* contract). This fires both when a commit-gated `autoAck: 'AA'` handler throws/rejects and
|
|
1520
|
+
* when the server **downgrades** a positive auto-ACK because the inbound could not be
|
|
1521
|
+
* correlated ({@link NackReason}).
|
|
1522
|
+
*
|
|
1523
|
+
* **PHI-safe by construction:** carries only the connection ID, the resolved
|
|
1524
|
+
* acknowledgement code, and a static `reason` — never the payload, the inbound control ID,
|
|
1525
|
+
* or the thrown error's message (which may carry PHI). The object is `Object.freeze()`'d
|
|
1526
|
+
* before emission.
|
|
1527
|
+
*
|
|
1528
|
+
* @example
|
|
1529
|
+
* ```typescript
|
|
1530
|
+
* server.on('nack', ({ connectionId, ackCode, reason }) => {
|
|
1531
|
+
* metrics.increment('mllp.nack', { code: ackCode, reason }); // e.g. reason='discarded-bytes'
|
|
1532
|
+
* });
|
|
1533
|
+
* ```
|
|
1534
|
+
*/
|
|
1535
|
+
interface NackEvent {
|
|
1536
|
+
/** Connection that produced the negative acknowledgement. */
|
|
1537
|
+
readonly connectionId: string;
|
|
1538
|
+
/** The negative acknowledgement code sent to the peer (`AE` or `AR`). */
|
|
1539
|
+
readonly ackCode: NegativeAckCode;
|
|
1540
|
+
/** Why the negative acknowledgement was sent (PHI-free). */
|
|
1541
|
+
readonly reason: NackReason;
|
|
1542
|
+
}
|
|
1543
|
+
/**
|
|
1544
|
+
* Observability snapshot returned by `server.getStats()` (OBS-02).
|
|
1545
|
+
*
|
|
1546
|
+
* All fields are JSON-serializable. `connections` and `activeConnections` both
|
|
1547
|
+
* reflect the current live connection count (ROADMAP SC-5 / OBS-02).
|
|
1548
|
+
*
|
|
1549
|
+
* @example
|
|
1550
|
+
* ```typescript
|
|
1551
|
+
* const stats = server.getStats();
|
|
1552
|
+
* console.log(JSON.stringify(stats)); // log-pipeline friendly
|
|
1553
|
+
* ```
|
|
1554
|
+
*/
|
|
1555
|
+
interface ServerStats {
|
|
1556
|
+
/** Whether the server is currently accepting connections. */
|
|
1557
|
+
readonly listening: boolean;
|
|
1558
|
+
/** Bound port, or `null` before listen(). */
|
|
1559
|
+
readonly port: number | null;
|
|
1560
|
+
/** Bound host, or `null` before listen(). */
|
|
1561
|
+
readonly host: string | null;
|
|
1562
|
+
/**
|
|
1563
|
+
* Current live connection count (OBS-02, ROADMAP SC-5).
|
|
1564
|
+
* Same value as `activeConnections`.
|
|
1565
|
+
*/
|
|
1566
|
+
readonly connections: number;
|
|
1567
|
+
/** Current live connection count. Same value as `connections`. */
|
|
1568
|
+
readonly activeConnections: number;
|
|
1569
|
+
/** Aggregate bytes received across all current connections. */
|
|
1570
|
+
readonly totalBytesIn: number;
|
|
1571
|
+
/** Aggregate bytes sent across all current connections. */
|
|
1572
|
+
readonly totalBytesOut: number;
|
|
1573
|
+
/** Total connections accepted since listen() (monotonically increasing). */
|
|
1574
|
+
readonly acceptedTotal: number;
|
|
1575
|
+
/** Total connections closed since listen() (monotonically increasing). */
|
|
1576
|
+
readonly closedTotal: number;
|
|
1577
|
+
/** Whether this server is configured for TLS (Phase 8). Mirrors `ServerOptions.tls` being set. */
|
|
1578
|
+
readonly tls: boolean;
|
|
1579
|
+
/** Total `'tlsClientError'` events (failed TLS handshakes, incl. rejected client certs) since listen() (Phase 8). */
|
|
1580
|
+
readonly tlsClientErrorsTotal: number;
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1583
|
+
* Options for `createServer()`.
|
|
1584
|
+
*
|
|
1585
|
+
* @example
|
|
1586
|
+
* ```typescript
|
|
1587
|
+
* const opts: ServerOptions = {
|
|
1588
|
+
* onMessage: (payload, meta, conn) => {
|
|
1589
|
+
* const ack = buildAck(payload);
|
|
1590
|
+
* conn.send(ack);
|
|
1591
|
+
* },
|
|
1592
|
+
* framing: { maxFrameSizeBytes: 4 * 1024 * 1024 },
|
|
1593
|
+
* keepaliveIntervalMs: 60_000,
|
|
1594
|
+
* deadPeerTimeoutMs: 300_000,
|
|
1595
|
+
* drainTimeoutMs: 30_000,
|
|
1596
|
+
* };
|
|
1597
|
+
* ```
|
|
1598
|
+
*/
|
|
1599
|
+
interface ServerOptions {
|
|
1600
|
+
/**
|
|
1601
|
+
* Called for each decoded MLLP message.
|
|
1602
|
+
*
|
|
1603
|
+
* Its role depends on `autoAck` — this is the **commit contract** (HL7 v2.5.1 §2.9.2):
|
|
1604
|
+
*
|
|
1605
|
+
* - **`autoAck: 'AA'` + this handler ⇒ commit-gated (the safe default).** The handler
|
|
1606
|
+
* is the durable-commit step. The server **awaits** it and only then sends the ACK:
|
|
1607
|
+
* resolve ⇒ `AA`; **throw/reject ⇒ `AE`** (or `AR` via {@link MllpAckError}) — a
|
|
1608
|
+
* positive ACK can never precede a successful commit. Do **not** call `conn.send()`
|
|
1609
|
+
* here in this mode.
|
|
1610
|
+
* - **`autoAck` unset ⇒ manual mode.** The handler owns the response; build and send the
|
|
1611
|
+
* ACK yourself via `conn.send(encodeFrame(ackPayload))`. Its return value is ignored.
|
|
1612
|
+
* - **`autoAck: fn` ⇒ observation only.** `fn` builds the ACK; this handler runs first as
|
|
1613
|
+
* a side effect and its return value is ignored. Do not call `conn.send()` here.
|
|
1614
|
+
*
|
|
1615
|
+
* May be sync or async; an async handler is awaited in commit-gated mode.
|
|
1616
|
+
*/
|
|
1617
|
+
onMessage?: (payload: Buffer, meta: MessageMeta, conn: Connection) => void | Promise<void>;
|
|
1618
|
+
/**
|
|
1619
|
+
* FrameReader tolerance options applied to every accepted connection (SERVER-12).
|
|
1620
|
+
* Merged with SERVER_DEFAULT_FRAMING — caller-supplied values override defaults.
|
|
1621
|
+
* `onFrame` and `onWarning` are managed internally and must not be supplied here.
|
|
1622
|
+
*/
|
|
1623
|
+
framing?: Omit<FrameReaderOptions, "onFrame" | "onWarning">;
|
|
1624
|
+
/**
|
|
1625
|
+
* Per-connection warning subscriber. Called for every framing warning on every connection.
|
|
1626
|
+
*/
|
|
1627
|
+
onWarning?: (w: MllpWarning) => void;
|
|
1628
|
+
/**
|
|
1629
|
+
* Auto-ACK mode. When set, the server builds and sends the ACK for each message.
|
|
1630
|
+
*
|
|
1631
|
+
* - **`'AA'`** — auto-acknowledge with the fail-safe **commit contract**:
|
|
1632
|
+
* - **With an `onMessage` handler ⇒ commit-gated (recommended).** The server awaits
|
|
1633
|
+
* `onMessage` (the durable-commit step), then sends `AA` on success or a **negative**
|
|
1634
|
+
* ACK on failure (`AE` by default; `AR` via {@link MllpAckError}). The positive ACK
|
|
1635
|
+
* **cannot precede a successful commit** — a handler throw can never yield `AA`.
|
|
1636
|
+
* - **Without an `onMessage` handler ⇒ transport-accept.** `AA` is sent on frame
|
|
1637
|
+
* receipt. This `AA` means only **"bytes received and framed"** — *not*
|
|
1638
|
+
* "application-processed". ⚠️ For clinical messages this is unsafe on its own:
|
|
1639
|
+
* pair `'AA'` with an `onMessage` handler that durably commits, so the ACK reflects
|
|
1640
|
+
* real processing.
|
|
1641
|
+
* - **`fn`** — `fn(payload, meta, conn)` builds the ACK bytes the server sends; the
|
|
1642
|
+
* caller fully owns MSA-1 (e.g. to emit enhanced-mode `CA`/`CE`/`CR`).
|
|
1643
|
+
*
|
|
1644
|
+
* The `'message'` event always fires BEFORE the ACK is sent (D-03). Do NOT call
|
|
1645
|
+
* `conn.send()` in `onMessage` when `autoAck` is set — this results in two ACKs.
|
|
1646
|
+
*/
|
|
1647
|
+
autoAck?: "AA" | ((payload: Buffer, meta: MessageMeta, conn: Connection) => Buffer | Promise<Buffer>);
|
|
1648
|
+
/**
|
|
1649
|
+
* TCP keepalive probe interval in ms (D-10).
|
|
1650
|
+
* Calls `socket.setKeepAlive(true, ms)` on each accepted socket.
|
|
1651
|
+
* Uses OS TCP stack to detect dead peers (half-open, network partitions).
|
|
1652
|
+
* Distinct from `deadPeerTimeoutMs` (application-level idle close).
|
|
1653
|
+
*/
|
|
1654
|
+
keepaliveIntervalMs?: number;
|
|
1655
|
+
/**
|
|
1656
|
+
* Application-level idle close timeout in ms (D-11, ROADMAP SC-5).
|
|
1657
|
+
* If no HL7 messages are received on a connection for this interval, the connection
|
|
1658
|
+
* is destroyed via `conn.destroy(new Error('idle timeout'))`.
|
|
1659
|
+
* Resets on every `'message'` event. Distinct from `keepaliveIntervalMs` (OS TCP probe).
|
|
1660
|
+
*/
|
|
1661
|
+
deadPeerTimeoutMs?: number;
|
|
1662
|
+
/**
|
|
1663
|
+
* Graceful drain timeout passed to `conn.close()` during `server.close()` (D-06).
|
|
1664
|
+
* Default: 30 000 ms.
|
|
1665
|
+
*/
|
|
1666
|
+
drainTimeoutMs?: number;
|
|
1667
|
+
/**
|
|
1668
|
+
* Enable TLS (MLLPS) for this server (Phase 8). When set, the server binds a
|
|
1669
|
+
* `tls.Server` instead of a plain `net.Server`, consumes `'secureConnection'`
|
|
1670
|
+
* (post-handshake sockets) instead of `'connection'`, and surfaces failed
|
|
1671
|
+
* handshakes via the `'tlsClientError'` event rather than crashing.
|
|
1672
|
+
*
|
|
1673
|
+
* Spec anchor: IHE ATNA ITI-19 (https://profiles.ihe.net/ITI/TF/Volume2/ITI-19.html).
|
|
1674
|
+
*
|
|
1675
|
+
* @default undefined (plaintext TCP)
|
|
1676
|
+
*/
|
|
1677
|
+
tls?: ServerTlsOptions;
|
|
1678
|
+
/**
|
|
1679
|
+
* Opt-in required to bind a wildcard host — a bind-safety guardrail
|
|
1680
|
+
* (Phase 8). Without this flag, `listen()` rejects a wildcard host in
|
|
1681
|
+
* two tiers: **literal spellings** (`'0.0.0.0'`, `'::'`, `''`, `'::0'`,
|
|
1682
|
+
* `'0:0:0:0:0:0:0:0'`, `'::ffff:0.0.0.0'`, …) reject **pre-bind** (fast
|
|
1683
|
+
* path, nothing is ever bound); **resolver-only shorthands** (`'0'`,
|
|
1684
|
+
* `'0.0'`, `'0x0.0.0.0'`, hostnames resolving to the unspecified
|
|
1685
|
+
* address, …) are caught **post-bind** against the OS-normalized bound
|
|
1686
|
+
* address — the just-bound server closes before any connection can be
|
|
1687
|
+
* accepted and `listen()` rejects, with no listening state and no
|
|
1688
|
+
* `'listening'` event either way. When `true`, binding a wildcard host
|
|
1689
|
+
* emits a one-time `'securityWarning'` (`MLLP_BIND_ALL_INTERFACES`) at
|
|
1690
|
+
* listen time.
|
|
1691
|
+
*
|
|
1692
|
+
* @default false
|
|
1693
|
+
*/
|
|
1694
|
+
allowWildcardBind?: boolean;
|
|
1695
|
+
}
|
|
1696
|
+
/**
|
|
1697
|
+
* Options for `createStarterServer()` — the "three lines of code" factory (SERVER-08).
|
|
1698
|
+
*
|
|
1699
|
+
* Extends `ServerOptions` with `port`, `host`, and `handleSignals`. Defaults:
|
|
1700
|
+
* `autoAck: 'AA'`, `drainTimeoutMs: 30_000`, `Symbol.asyncDispose` wired.
|
|
1701
|
+
*
|
|
1702
|
+
* @example
|
|
1703
|
+
* ```typescript
|
|
1704
|
+
* import { createStarterServer } from '@cosyte/mllp';
|
|
1705
|
+
*
|
|
1706
|
+
* const server = await createStarterServer({
|
|
1707
|
+
* port: 2575,
|
|
1708
|
+
* onMessage: (buf) => buildAckBuffer(buf),
|
|
1709
|
+
* });
|
|
1710
|
+
* // server is listening, auto-ACK enabled, Symbol.asyncDispose wired
|
|
1711
|
+
* await using _ = server; // closes on scope exit
|
|
1712
|
+
* ```
|
|
1713
|
+
*/
|
|
1714
|
+
interface StarterServerOptions extends ServerOptions {
|
|
1715
|
+
/** Port to listen on. */
|
|
1716
|
+
port: number;
|
|
1717
|
+
/**
|
|
1718
|
+
* Host to bind to. Default `'127.0.0.1'` (Phase 8 bind-safety hardening —
|
|
1719
|
+
* was `'0.0.0.0'`; binding all interfaces now requires
|
|
1720
|
+
* `ServerOptions.allowWildcardBind: true`).
|
|
1721
|
+
*/
|
|
1722
|
+
host?: string;
|
|
1723
|
+
/**
|
|
1724
|
+
* Register `process.once('SIGTERM')` and `process.once('SIGINT')` handlers that
|
|
1725
|
+
* call `server.close()` then `process.exit(0)` (D-09). Default: `false`.
|
|
1726
|
+
*
|
|
1727
|
+
* Handlers are automatically removed when `server.close()` is called, so
|
|
1728
|
+
* `process.listenerCount('SIGTERM') === 0` after close() completes — preventing
|
|
1729
|
+
* handler accumulation across test instances or multiple server restarts.
|
|
1730
|
+
*/
|
|
1731
|
+
handleSignals?: boolean;
|
|
1732
|
+
}
|
|
1733
|
+
/**
|
|
1734
|
+
* MLLP TCP server — wraps `net.Server` without extending it (D-02).
|
|
1735
|
+
*
|
|
1736
|
+
* Each accepted socket is wrapped in a `NetTransport`, connected to a `Connection`
|
|
1737
|
+
* with server-level framing options, and added to `_connections`. Messages are
|
|
1738
|
+
* surfaced via the `'message'` event and the `onMessage` callback.
|
|
1739
|
+
*
|
|
1740
|
+
* Public events: `'listening'`, `'connection'`, `'message'`, `'nack'`, `'error'`, `'close'`.
|
|
1741
|
+
* The `'nack'` event ({@link NackEvent}) fires whenever the server returns a negative ACK
|
|
1742
|
+
* instead of `AA` on the auto-ACK path — a commit-gated `autoAck: 'AA'` handler failing, or
|
|
1743
|
+
* a positive auto-ACK **downgraded** because the inbound could not be correlated (an
|
|
1744
|
+
* unreadable/uncorrelatable message, or one whose bytes the decoder discarded). The
|
|
1745
|
+
* {@link NackReason} distinguishes the causes.
|
|
1746
|
+
*
|
|
1747
|
+
* **`'error'` contract:** underlying `net.Server`/`tls.Server` errors are forwarded to the
|
|
1748
|
+
* `'error'` event whenever a listener is attached. With **no** listener, the outcome depends on
|
|
1749
|
+
* server state: during a `listen()` (the bind window) the error **rejects the `listen()` promise**
|
|
1750
|
+
* — the primary error surface — and the process never crashes on a bind error; with no `listen()`
|
|
1751
|
+
* in flight and the server not serving (e.g. a stale async error after `close()`) the error is
|
|
1752
|
+
* dropped; but **while serving**, an unlistened runtime error (e.g. accept-loop `EMFILE`) keeps
|
|
1753
|
+
* Node's fail-loud crash-on-unlistened-`'error'` convention — a silent accept outage is
|
|
1754
|
+
* impossible. Caveat for `'error'` listeners: the forwarder runs **before** the internal
|
|
1755
|
+
* `listen()` rejection handler, so an `'error'` listener that synchronously calls `close()`
|
|
1756
|
+
* during the bind window changes the `listen()` rejection from the bind error to the typed
|
|
1757
|
+
* close-during-listen `MllpConnectionError` — match on the `'error'` event payload, not the
|
|
1758
|
+
* rejection, if you do that.
|
|
1759
|
+
* Phase 8 (TLS/MLLPS) adds two more: `'tlsClientError'` (a failed TLS handshake — the
|
|
1760
|
+
* server logs it and keeps serving other connections) and `'securityWarning'` (loud,
|
|
1761
|
+
* one-time notice when a wildcard host is bound via `allowWildcardBind: true`).
|
|
1762
|
+
* All event payloads are `Object.freeze()`'d before emission (SERVER-10).
|
|
1763
|
+
*
|
|
1764
|
+
* @example
|
|
1765
|
+
* ```typescript
|
|
1766
|
+
* import { createServer } from '@cosyte/mllp';
|
|
1767
|
+
*
|
|
1768
|
+
* const server = createServer({
|
|
1769
|
+
* onMessage: (payload, meta, conn) => {
|
|
1770
|
+
* console.log('received from', meta.connectionId);
|
|
1771
|
+
* },
|
|
1772
|
+
* });
|
|
1773
|
+
* await server.listen(2575);
|
|
1774
|
+
* // await using server = createServer({ ... }); // Symbol.asyncDispose
|
|
1775
|
+
* ```
|
|
1776
|
+
*/
|
|
1777
|
+
declare class MllpServer extends EventEmitter {
|
|
1778
|
+
private readonly _netServer;
|
|
1779
|
+
private readonly _connections;
|
|
1780
|
+
private readonly _opts;
|
|
1781
|
+
/** `true` when `ServerOptions.tls` is set — `_netServer` is actually a `tls.Server`. */
|
|
1782
|
+
private readonly _isTls;
|
|
1783
|
+
private _listening;
|
|
1784
|
+
/** Phase 8 — single-flight guard: `true` while a `listen()` call is settling. */
|
|
1785
|
+
private _listenInFlight;
|
|
1786
|
+
/**
|
|
1787
|
+
* Phase 8 — settle hook for an in-flight `listen()`. Non-null only while a
|
|
1788
|
+
* `listen()` is in flight; invoked by `close()` so a close racing startup
|
|
1789
|
+
* rejects the pending `listen()` (typed) instead of leaving it hung with
|
|
1790
|
+
* the single-flight guard stuck. Cleared on every settle path.
|
|
1791
|
+
*/
|
|
1792
|
+
private _pendingListenSettle;
|
|
1793
|
+
private _port;
|
|
1794
|
+
private _host;
|
|
1795
|
+
private _acceptedTotal;
|
|
1796
|
+
private _closedTotal;
|
|
1797
|
+
/** Phase 8 — count of `'tlsClientError'` events since listen(). */
|
|
1798
|
+
private _tlsClientErrorsTotal;
|
|
1799
|
+
/**
|
|
1800
|
+
* Construct an MLLP server. Created idle; call `listen()` (or use
|
|
1801
|
+
* {@link createServer}/{@link createStarterServer}) to begin accepting connections.
|
|
1802
|
+
*
|
|
1803
|
+
* @param opts - Server options (bind host/port, auto-ACK policy, message handler, framing, …).
|
|
1804
|
+
*/
|
|
1805
|
+
constructor(opts: ServerOptions);
|
|
1806
|
+
/**
|
|
1807
|
+
* Start listening on the given port.
|
|
1808
|
+
*
|
|
1809
|
+
* Resolves once the TCP socket is bound and emits `'listening'` with
|
|
1810
|
+
* `Object.freeze({ port: actualPort, host: actualHost })`.
|
|
1811
|
+
*
|
|
1812
|
+
* **Single-flight:** one `listen()` per server lifecycle at a time. A call
|
|
1813
|
+
* while the server is already listening — or while another `listen()` is
|
|
1814
|
+
* still in flight — rejects with a typed `MllpConnectionError` (concurrent
|
|
1815
|
+
* binds raced each other's post-bind safety checks). Call `close()` before
|
|
1816
|
+
* re-listening; sequential `listen()` → `close()` → `listen()` is fine.
|
|
1817
|
+
*
|
|
1818
|
+
* **`close()` during an in-flight `listen()`** rejects that `listen()` with
|
|
1819
|
+
* a typed `MllpConnectionError` (never a hang) and clears the single-flight
|
|
1820
|
+
* guard — a subsequent `listen()` on the same server works. This makes
|
|
1821
|
+
* `Symbol.asyncDispose` (which delegates to `close()`) safe even before a
|
|
1822
|
+
* `listen()` has settled. (Exception: `close({ signal })` with an
|
|
1823
|
+
* already-aborted signal is a no-op AbortError rejection — the in-flight
|
|
1824
|
+
* `listen()` is left to settle on its own bind outcome; see `close()`.)
|
|
1825
|
+
*
|
|
1826
|
+
* @param port - TCP port to bind. Use `0` to let the OS assign an ephemeral port.
|
|
1827
|
+
* @param hostOrOpts - Host string or object with `host` and optional `signal`.
|
|
1828
|
+
* Default host: `'127.0.0.1'` (Phase 8 bind-safety hardening). Wildcard hosts
|
|
1829
|
+
* are rejected unless `ServerOptions.allowWildcardBind: true` — literal
|
|
1830
|
+
* spellings pre-bind, resolver-only shorthands post-bind via the
|
|
1831
|
+
* OS-normalized bound address.
|
|
1832
|
+
*
|
|
1833
|
+
* @example
|
|
1834
|
+
* ```typescript
|
|
1835
|
+
* await server.listen(2575); // binds 127.0.0.1
|
|
1836
|
+
* await server.listen(0, '127.0.0.1');
|
|
1837
|
+
* await server.listen(0, { host: '127.0.0.1', signal: ac.signal });
|
|
1838
|
+
* ```
|
|
1839
|
+
*/
|
|
1840
|
+
listen(port: number, hostOrOpts?: string | {
|
|
1841
|
+
host?: string;
|
|
1842
|
+
signal?: AbortSignal;
|
|
1843
|
+
}): Promise<void>;
|
|
1844
|
+
/**
|
|
1845
|
+
* Stop accepting new connections and gracefully close all active connections.
|
|
1846
|
+
*
|
|
1847
|
+
* Sequence (D-06):
|
|
1848
|
+
* 1. `net.Server.close()` — stops accepting new connections immediately
|
|
1849
|
+
* 2. If `_connections` is empty: resolves immediately (no drain needed)
|
|
1850
|
+
* 3. Calls `_drainAll(drainTimeoutMs)` — Promise.all + side-effect setTimeout
|
|
1851
|
+
* that force-destroys stragglers after the drain window
|
|
1852
|
+
*
|
|
1853
|
+
* Calling `close()` while a `listen()` is still in flight proactively
|
|
1854
|
+
* settles that `listen()` with a typed `MllpConnectionError` rejection
|
|
1855
|
+
* and clears the single-flight guard — the server is immediately
|
|
1856
|
+
* re-listenable. `Symbol.asyncDispose` (which delegates here) is
|
|
1857
|
+
* therefore safe before `listen()` settles. **Qualification:** a
|
|
1858
|
+
* `close({ signal })` whose signal is **already aborted** rejects
|
|
1859
|
+
* immediately with `AbortError` and performs no work — it does NOT close
|
|
1860
|
+
* the server and does NOT settle the in-flight `listen()`, which simply
|
|
1861
|
+
* continues and settles on its own bind outcome.
|
|
1862
|
+
*
|
|
1863
|
+
* @param opts.drainTimeoutMs - Override drain timeout (default: `opts.drainTimeoutMs ?? 30_000`).
|
|
1864
|
+
* @param opts.signal - AbortSignal to cancel the close operation. On abort, all
|
|
1865
|
+
* active connections are destroyed and the promise rejects with AbortError.
|
|
1866
|
+
*
|
|
1867
|
+
* @example
|
|
1868
|
+
* ```typescript
|
|
1869
|
+
* await server.close({ drainTimeoutMs: 5_000 });
|
|
1870
|
+
* ```
|
|
1871
|
+
*/
|
|
1872
|
+
close(opts?: {
|
|
1873
|
+
drainTimeoutMs?: number;
|
|
1874
|
+
signal?: AbortSignal;
|
|
1875
|
+
}): Promise<void>;
|
|
1876
|
+
/**
|
|
1877
|
+
* Drain all active connections with a shared timeout.
|
|
1878
|
+
*
|
|
1879
|
+
* Uses Promise.all (not Promise.race) for coordination — the timeout is a
|
|
1880
|
+
* side effect that calls `conn.destroy()` on stragglers after `drainTimeoutMs`.
|
|
1881
|
+
* Promise.all resolves when all `conn.close()` promises settle (which happens
|
|
1882
|
+
* because `conn.destroy()` transitions connections to CLOSED).
|
|
1883
|
+
*
|
|
1884
|
+
* @param drainTimeoutMs - Maximum time to wait for connections to drain.
|
|
1885
|
+
*/
|
|
1886
|
+
private _drainAll;
|
|
1887
|
+
/**
|
|
1888
|
+
* Async disposal — delegates to `close()` for `await using` support.
|
|
1889
|
+
*
|
|
1890
|
+
* @example
|
|
1891
|
+
* ```typescript
|
|
1892
|
+
* await using server = createServer({ onMessage: handler });
|
|
1893
|
+
* await server.listen(2575);
|
|
1894
|
+
* // server.close() is called automatically at end of block
|
|
1895
|
+
* ```
|
|
1896
|
+
*/
|
|
1897
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
1898
|
+
/**
|
|
1899
|
+
* Return a JSON-serializable observability snapshot (OBS-02).
|
|
1900
|
+
*
|
|
1901
|
+
* `totalBytesIn` and `totalBytesOut` aggregate from live connections at call time.
|
|
1902
|
+
* Plan 04 adds full aggregation; at Plan 01 scope they return 0.
|
|
1903
|
+
*
|
|
1904
|
+
* @example
|
|
1905
|
+
* ```typescript
|
|
1906
|
+
* const stats = server.getStats();
|
|
1907
|
+
* logger.info(JSON.stringify(stats));
|
|
1908
|
+
* ```
|
|
1909
|
+
*/
|
|
1910
|
+
getStats(): ServerStats;
|
|
1911
|
+
/**
|
|
1912
|
+
* Emit on `'error'` only when a listener is attached — an unlistened
|
|
1913
|
+
* `EventEmitter` `'error'` emission throws (see the class JSDoc error
|
|
1914
|
+
* contract). The single guard shared by the optional-tap forwarding sites
|
|
1915
|
+
* in this class; the constructor's net.Server forwarder deliberately does
|
|
1916
|
+
* NOT use it (its while-serving branch re-emits unguarded to keep Node's
|
|
1917
|
+
* fail-loud convention).
|
|
1918
|
+
*/
|
|
1919
|
+
/**
|
|
1920
|
+
* Emit a server event, containing a throwing subscriber.
|
|
1921
|
+
*
|
|
1922
|
+
* Every event this server emits is reachable from a callback we do not own — the `net.Server`
|
|
1923
|
+
* `'connection'` listener, the `tls.Server` `'tlsClientError'` listener, a socket's `'data'`
|
|
1924
|
+
* listener (via `Connection`), or the `catch` block of a `void`-ed async ACK task. A throwing
|
|
1925
|
+
* subscriber would therefore unwind into that callback and kill the process (or reject a void-ed
|
|
1926
|
+
* promise, which does the same and additionally skips the rest of the method).
|
|
1927
|
+
*
|
|
1928
|
+
* See `src/internal/safe-emit.ts` for the full rationale. The throw is re-surfaced on `'error'`.
|
|
1929
|
+
*/
|
|
1930
|
+
private _emitContained;
|
|
1931
|
+
private _emitErrorIfListened;
|
|
1932
|
+
private _onSocketAccepted;
|
|
1933
|
+
/**
|
|
1934
|
+
* Extract a minimal, content-free peer-certificate summary (Phase 8, ATNA
|
|
1935
|
+
* ITI-19 mutual authentication) — CN strings, expiry, and the verification
|
|
1936
|
+
* outcome (`authorized`) only; never the full certificate object. Returns
|
|
1937
|
+
* `null` when no peer certificate was presented (e.g. `clientAuth: 'WANT'`
|
|
1938
|
+
* with no client cert).
|
|
1939
|
+
*/
|
|
1940
|
+
private _extractPeerCertificateSummary;
|
|
1941
|
+
/**
|
|
1942
|
+
* The fail-safe commit path for `autoAck: 'AA'` (HL7 v2.5.1 §2.9.2).
|
|
1943
|
+
*
|
|
1944
|
+
* With an `onMessage` handler this **awaits** it (the durable-commit step) and only
|
|
1945
|
+
* then dispatches: resolve ⇒ `AA`; throw/reject ⇒ a negative ACK (`AE` by default,
|
|
1946
|
+
* `AR` via {@link MllpAckError}). A positive ACK can never precede a successful commit.
|
|
1947
|
+
*
|
|
1948
|
+
* Without an `onMessage` handler, `'AA'` degrades to a **transport-accept** — `AA`
|
|
1949
|
+
* meaning "bytes received and framed", not "application-processed".
|
|
1950
|
+
*
|
|
1951
|
+
* A handler failure is **expected flow**, not a server error: it produces a negative
|
|
1952
|
+
* ACK and a `'nack'` event. The thrown error's message is never placed on the wire or
|
|
1953
|
+
* in the event (it may carry PHI) — only the static `ackCode` and the inbound control
|
|
1954
|
+
* metadata reach the peer.
|
|
1955
|
+
*/
|
|
1956
|
+
private _sendCommitAck;
|
|
1957
|
+
/**
|
|
1958
|
+
* Resolve the auto-ACK code for a message the server would otherwise acknowledge
|
|
1959
|
+
* **positively** (`AA`) — the no-handler transport-accept and the post-commit success
|
|
1960
|
+
* paths. Fail-safe: downgrade to `AE` and emit a PHI-safe `'nack'` whenever the message
|
|
1961
|
+
* cannot carry a correlatable positive ACK, so `AA` never names a control ID the sender
|
|
1962
|
+
* cannot match (→ timeout → resend → **duplicate clinical message**).
|
|
1963
|
+
*
|
|
1964
|
+
* Two independent disqualifiers, one from the bytes and one from the frame:
|
|
1965
|
+
*
|
|
1966
|
+
* - **`rawAckUncorrelatable(payload)`** — no readable `MSH`, an empty MSH-10, or a
|
|
1967
|
+
* batch/concatenated-message shape. This is the SAME predicate `buildRawAck` enforces
|
|
1968
|
+
* on the wire; it is re-checked here so the downgrade is **observable** (a `'nack'`
|
|
1969
|
+
* event) rather than a silent code change inside the builder.
|
|
1970
|
+
* - **`MLLP_TRAILING_BYTES`** in the frame's warnings — a mid-payload `VT` made the
|
|
1971
|
+
* decoder discard accumulated bytes and deliver only the fragment after it. The
|
|
1972
|
+
* clinical message was destroyed in transit, so even a fragment that happens to carry
|
|
1973
|
+
* a readable MSH must not be positively acknowledged. This condition is invisible to
|
|
1974
|
+
* `buildRawAck` (it sees only the fragment), which is why it lives here. The code is
|
|
1975
|
+
* **reserved** for that discard (see the decoder's `_readPayload`) and is emitted while
|
|
1976
|
+
* accumulating the delivered frame, so it is frame-scoped: it names *this* payload's own
|
|
1977
|
+
* discard, never a neighbouring frame's trailing junk.
|
|
1978
|
+
*
|
|
1979
|
+
* `'discarded-bytes'` takes precedence in the reason when both hold — it is the root cause.
|
|
1980
|
+
*/
|
|
1981
|
+
private _resolveAutoAckPositive;
|
|
1982
|
+
/**
|
|
1983
|
+
* Run `onMessage` for its side effects only (custom-ACK and manual modes), guarding
|
|
1984
|
+
* against a synchronous throw or a rejected promise. In these modes the handler does
|
|
1985
|
+
* **not** gate the ACK, so a failure is re-emitted as a PHI-safe `'error'` on the
|
|
1986
|
+
* connection (D-04: a handler error never crashes the server) rather than escaping as
|
|
1987
|
+
* an unhandled rejection. The error text is never placed on the wire.
|
|
1988
|
+
*/
|
|
1989
|
+
private _observeMessage;
|
|
1990
|
+
/**
|
|
1991
|
+
* The custom-builder path for `autoAck: fn`. Awaits the builder and dispatches its
|
|
1992
|
+
* Buffer as the ACK; the caller owns MSA-1. A builder error is re-emitted as `'error'`
|
|
1993
|
+
* on the connection — the server never crashes (D-04).
|
|
1994
|
+
*/
|
|
1995
|
+
private _sendCustomAck;
|
|
1996
|
+
/**
|
|
1997
|
+
* Frame an ACK payload and write it to the connection. `Connection.send()` returns
|
|
1998
|
+
* `false` under backpressure (socket write buffer full); that drops the ACK and emits
|
|
1999
|
+
* a `MllpConnectionError({ phase: 'send' })` on the connection (D-04) — the peer will
|
|
2000
|
+
* time out waiting and may retry. The server never crashes.
|
|
2001
|
+
*/
|
|
2002
|
+
private _dispatchAck;
|
|
2003
|
+
}
|
|
2004
|
+
/**
|
|
2005
|
+
* Factory function — creates a new `MllpServer` with the supplied options.
|
|
2006
|
+
*
|
|
2007
|
+
* Prefer `createServer()` over `new MllpServer()` for forward-compatible construction.
|
|
2008
|
+
*
|
|
2009
|
+
* @example
|
|
2010
|
+
* ```typescript
|
|
2011
|
+
* import { createServer } from '@cosyte/mllp';
|
|
2012
|
+
*
|
|
2013
|
+
* const server = createServer({
|
|
2014
|
+
* onMessage: (payload, meta, conn) => {
|
|
2015
|
+
* console.log('received', payload.length, 'bytes');
|
|
2016
|
+
* },
|
|
2017
|
+
* });
|
|
2018
|
+
* await server.listen(2575);
|
|
2019
|
+
* ```
|
|
2020
|
+
*/
|
|
2021
|
+
declare function createServer(opts?: ServerOptions): MllpServer;
|
|
2022
|
+
/**
|
|
2023
|
+
* Starter factory — creates, configures, and starts an `MllpServer` in one call.
|
|
2024
|
+
*
|
|
2025
|
+
* Provides the "three lines of code" north-star experience with sensible defaults:
|
|
2026
|
+
* `autoAck: 'AA'`, `drainTimeoutMs: 30_000`, `Symbol.asyncDispose` wired.
|
|
2027
|
+
*
|
|
2028
|
+
* NOTE: Stub at Plan 01 scope — Plan 04 fills in the full implementation.
|
|
2029
|
+
*
|
|
2030
|
+
* @example
|
|
2031
|
+
* ```typescript
|
|
2032
|
+
* import { createStarterServer } from '@cosyte/mllp';
|
|
2033
|
+
*
|
|
2034
|
+
* const server = await createStarterServer({
|
|
2035
|
+
* port: 2575,
|
|
2036
|
+
* onMessage: (payload) => buildAck(payload),
|
|
2037
|
+
* });
|
|
2038
|
+
* ```
|
|
2039
|
+
*/
|
|
2040
|
+
declare function createStarterServer(opts: StarterServerOptions): Promise<MllpServer>;
|
|
2041
|
+
|
|
2042
|
+
/**
|
|
2043
|
+
* MLLP Client — `createClient()` factory and `MllpClient` class.
|
|
2044
|
+
*
|
|
2045
|
+
* Provides the client-side MLLP transport: connect to an MLLP server over TCP,
|
|
2046
|
+
* encode and send framed messages, decode inbound ACKs, and surface lifecycle
|
|
2047
|
+
* events with frozen payloads. Supports `AbortSignal` cancellation on every
|
|
2048
|
+
* awaitable and `Symbol.asyncDispose` for `await using` ergonomics.
|
|
2049
|
+
*
|
|
2050
|
+
* Phase 5 PLAN-01 shipped the lifecycle scaffolding — `connect()`, `close()`,
|
|
2051
|
+
* `destroy()`, event re-emission. PLAN-02 added `send()` + `MllpTimeoutError`.
|
|
2052
|
+
* The `correlateByControlId` option (MSH-10 → MSA-2 ACK matching) lights up
|
|
2053
|
+
* out-of-order ACK handling. Subsequent plans add auto-reconnect (Plan 04),
|
|
2054
|
+
* backpressure (Plan 05), and `createStarterClient` + `getStats()` (PLAN-06).
|
|
2055
|
+
*
|
|
2056
|
+
* @example
|
|
2057
|
+
* ```typescript
|
|
2058
|
+
* import { createClient } from '@cosyte/mllp';
|
|
2059
|
+
*
|
|
2060
|
+
* const client = createClient({ host: 'localhost', port: 2575 });
|
|
2061
|
+
* await client.connect();
|
|
2062
|
+
* // PLAN-02 will add: const ack = await client.send(payloadBuffer);
|
|
2063
|
+
* await client.close();
|
|
2064
|
+
* ```
|
|
2065
|
+
*
|
|
2066
|
+
* @packageDocumentation
|
|
2067
|
+
*/
|
|
2068
|
+
|
|
2069
|
+
/**
|
|
2070
|
+
* Context passed to a custom `retryStrategy` hook on each reconnect attempt
|
|
2071
|
+
* (CLIENT-12, D-15).
|
|
2072
|
+
*
|
|
2073
|
+
* Frozen via `Object.freeze` before invocation — handlers cannot mutate
|
|
2074
|
+
* (T-05-04-04 mitigation).
|
|
2075
|
+
*
|
|
2076
|
+
* @example
|
|
2077
|
+
* ```typescript
|
|
2078
|
+
* const retryStrategy: RetryStrategy = (ctx) => {
|
|
2079
|
+
* if (ctx.attempt >= 5) return null;
|
|
2080
|
+
* if (ctx.classifiedAs === 'permanent') return null;
|
|
2081
|
+
* return Math.min(30_000, 1000 * (ctx.attempt + 1));
|
|
2082
|
+
* };
|
|
2083
|
+
* ```
|
|
2084
|
+
*/
|
|
2085
|
+
interface RetryContext {
|
|
2086
|
+
/** 0-indexed attempt counter for the current reconnect cycle. */
|
|
2087
|
+
readonly attempt: number;
|
|
2088
|
+
/** The error that triggered the disconnect. */
|
|
2089
|
+
readonly lastError: Error;
|
|
2090
|
+
/** Delay used for the previous attempt (ms). 0 on the first attempt. */
|
|
2091
|
+
readonly lastDelayMs: number;
|
|
2092
|
+
/** Total wall-clock ms elapsed since the disconnect that started this cycle. */
|
|
2093
|
+
readonly totalElapsedMs: number;
|
|
2094
|
+
/** Ms since the last successful ACK. `Infinity` if no success seen. */
|
|
2095
|
+
readonly sinceLastSuccessMs: number;
|
|
2096
|
+
/** CLIENT-18 classification (Composition A — D-16). */
|
|
2097
|
+
readonly classifiedAs: "transient" | "permanent";
|
|
2098
|
+
/**
|
|
2099
|
+
* The same `AbortSignal` passed into `connect()`. If no signal was
|
|
2100
|
+
* supplied, the module-level `NEVER_ABORTING_SIGNAL` sentinel is provided
|
|
2101
|
+
* so handlers always have a real `AbortSignal` to inspect. (D-18, W-07)
|
|
2102
|
+
*/
|
|
2103
|
+
readonly signal: AbortSignal;
|
|
2104
|
+
}
|
|
2105
|
+
/**
|
|
2106
|
+
* Custom reconnect-backoff hook (CLIENT-12). Return `null` to halt
|
|
2107
|
+
* reconnection (D-17) — the FSM transitions to `CLOSED`.
|
|
2108
|
+
*/
|
|
2109
|
+
type RetryStrategy = (ctx: RetryContext) => number | null;
|
|
2110
|
+
/**
|
|
2111
|
+
* Combined count + byte-based queue cap. Stricter-of-two wins (D-23).
|
|
2112
|
+
*
|
|
2113
|
+
* - `number` — count cap only (default 64).
|
|
2114
|
+
* - `{ bytes }` — byte cap only.
|
|
2115
|
+
* - `{ count, bytes }` — both caps; whichever trips first wins.
|
|
2116
|
+
*
|
|
2117
|
+
* @example
|
|
2118
|
+
* ```typescript
|
|
2119
|
+
* const opts: ClientOptions = {
|
|
2120
|
+
* host: 'localhost', port: 2575,
|
|
2121
|
+
* highWaterMark: { count: 100, bytes: 1_000_000 },
|
|
2122
|
+
* };
|
|
2123
|
+
* ```
|
|
2124
|
+
*/
|
|
2125
|
+
type HighWaterMark = number | {
|
|
2126
|
+
readonly count?: number;
|
|
2127
|
+
readonly bytes?: number;
|
|
2128
|
+
};
|
|
2129
|
+
/**
|
|
2130
|
+
* Options for {@link createClient} and the {@link MllpClient} constructor.
|
|
2131
|
+
*
|
|
2132
|
+
* Phase 5 plans extend this incrementally — new fields are additive and optional.
|
|
2133
|
+
*
|
|
2134
|
+
* @example
|
|
2135
|
+
* ```typescript
|
|
2136
|
+
* const opts: ClientOptions = { host: 'localhost', port: 2575, drainTimeoutMs: 10_000 };
|
|
2137
|
+
* ```
|
|
2138
|
+
*/
|
|
2139
|
+
interface ClientOptions {
|
|
2140
|
+
/** Host to connect to (e.g. `'localhost'` or `'mllp.example.com'`). */
|
|
2141
|
+
readonly host: string;
|
|
2142
|
+
/** TCP port. */
|
|
2143
|
+
readonly port: number;
|
|
2144
|
+
/** FrameReader tolerance / size options. `onFrame` and `onWarning` are managed internally. */
|
|
2145
|
+
readonly framing?: Omit<FrameReaderOptions, "onFrame" | "onWarning">;
|
|
2146
|
+
/** Drain timeout for {@link MllpClient.close} (default: `30_000` ms). */
|
|
2147
|
+
readonly drainTimeoutMs?: number;
|
|
2148
|
+
/**
|
|
2149
|
+
* Per-message ACK timeout in milliseconds (CLIENT-04). The clock starts at
|
|
2150
|
+
* the underlying `write()` flush callback, NOT at the `send()` call —
|
|
2151
|
+
* pre-flush queue time is not charged to the peer. Default: `30_000`.
|
|
2152
|
+
*/
|
|
2153
|
+
readonly ackTimeoutMs?: number;
|
|
2154
|
+
/**
|
|
2155
|
+
* If `true`, ACKs are matched against outgoing sends by MSH-10 → MSA-2
|
|
2156
|
+
* (CLIENT-03 controlId branch). Default `false` (FIFO mode).
|
|
2157
|
+
*
|
|
2158
|
+
* Out-of-order ACKs from the peer are supported in this mode. MSH-10 is
|
|
2159
|
+
* extracted from the outbound payload before send; MSA-2 is extracted from
|
|
2160
|
+
* the inbound ACK payload. An ACK whose MSA-2 matches no pending send
|
|
2161
|
+
* (and is not in the late-ACK graveyard) emits a frozen
|
|
2162
|
+
* `MllpFramingError('MLLP_ACK_UNMATCHED_CONTROL_ID')` to the `'error'` event
|
|
2163
|
+
* (CLIENT-15). A late ACK whose MSA-2 matches a graveyard entry emits a
|
|
2164
|
+
* `MLLP_ACK_AFTER_TIMEOUT` warning (CLIENT-16) and is dropped.
|
|
2165
|
+
*
|
|
2166
|
+
* @default false
|
|
2167
|
+
*/
|
|
2168
|
+
readonly correlateByControlId?: boolean;
|
|
2169
|
+
/**
|
|
2170
|
+
* Auto-reconnect on transient disconnect (CLIENT-05). Default `false`.
|
|
2171
|
+
*
|
|
2172
|
+
* When `true`, dropped connections caused by transient errors (per
|
|
2173
|
+
* {@link isTransientConnectionError}) trigger the FSM cycle
|
|
2174
|
+
* `CONNECTED → DISCONNECTED → RECONNECTING → CONNECTING → CONNECTED`
|
|
2175
|
+
* with exponential backoff per D-19 unless overridden by
|
|
2176
|
+
* {@link ClientOptions.retryStrategy}. Permanent errors halt and
|
|
2177
|
+
* transition directly to `CLOSED` (Composition A — D-16).
|
|
2178
|
+
*/
|
|
2179
|
+
readonly autoReconnect?: boolean;
|
|
2180
|
+
/**
|
|
2181
|
+
* Custom reconnect-backoff hook (CLIENT-12, D-15). Return `null` to halt
|
|
2182
|
+
* reconnection (D-17). Receives a frozen {@link RetryContext}. Defaults
|
|
2183
|
+
* to the exponential strategy described in D-19.
|
|
2184
|
+
*/
|
|
2185
|
+
readonly retryStrategy?: RetryStrategy;
|
|
2186
|
+
/** First delay (ms) on auto-reconnect; default 100. (CLIENT-05, D-19) */
|
|
2187
|
+
readonly initialDelayMs?: number;
|
|
2188
|
+
/** Maximum backoff cap (ms); default 30_000. (CLIENT-05, D-19) */
|
|
2189
|
+
readonly maxDelayMs?: number;
|
|
2190
|
+
/** Backoff multiplier; default 2. (CLIENT-05, D-19) */
|
|
2191
|
+
readonly multiplier?: number;
|
|
2192
|
+
/** Jitter fraction, e.g. 0.2 = ±20%; default 0.2. (CLIENT-05, D-19) */
|
|
2193
|
+
readonly jitter?: number;
|
|
2194
|
+
/**
|
|
2195
|
+
* Application-level high-water mark on the in-flight + queued send set
|
|
2196
|
+
* (CLIENT-07, D-23). `number` configures a count cap (default 64);
|
|
2197
|
+
* `{ bytes }` configures a byte cap; `{ count, bytes }` configures
|
|
2198
|
+
* both, with the stricter-of-two trigger winning.
|
|
2199
|
+
*
|
|
2200
|
+
* When the cap is exceeded, behavior is governed by
|
|
2201
|
+
* {@link ClientOptions.onBackpressure}.
|
|
2202
|
+
*
|
|
2203
|
+
* @default 64
|
|
2204
|
+
*/
|
|
2205
|
+
readonly highWaterMark?: HighWaterMark;
|
|
2206
|
+
/**
|
|
2207
|
+
* Behavior when the high-water mark is exceeded (CLIENT-07).
|
|
2208
|
+
*
|
|
2209
|
+
* - `'reject'` (default) — `send()` rejects with `MllpBackpressureError`.
|
|
2210
|
+
* - `'wait'` — `send()` awaits the `'drain'` event OR the per-message
|
|
2211
|
+
* `ackTimeoutMs` OR `signal` abort, whichever fires first (CLIENT-11).
|
|
2212
|
+
*
|
|
2213
|
+
* @default 'reject'
|
|
2214
|
+
*/
|
|
2215
|
+
readonly onBackpressure?: "reject" | "wait";
|
|
2216
|
+
/**
|
|
2217
|
+
* Strict serialization send → await-ACK → send (CLIENT-19, D-06).
|
|
2218
|
+
*
|
|
2219
|
+
* - `true` (default) — concurrent in-flight sends up to
|
|
2220
|
+
* {@link ClientOptions.highWaterMark}.
|
|
2221
|
+
* - `false` — collapses the in-flight set to ≤1 (the unified Correlator's
|
|
2222
|
+
* `maxInFlight=1`); the next `send()` waits for the prior ACK before
|
|
2223
|
+
* reaching the wire.
|
|
2224
|
+
*
|
|
2225
|
+
* @default true
|
|
2226
|
+
*/
|
|
2227
|
+
readonly pipeline?: boolean;
|
|
2228
|
+
/**
|
|
2229
|
+
* TCP keepalive interval (ms). Sets `socket.setKeepAlive(true, ms)` on
|
|
2230
|
+
* the underlying `net.Socket` BEFORE wrapping in `NetTransport`. OS-level
|
|
2231
|
+
* half-open detection (network partitions, NAT-table eviction). Independent
|
|
2232
|
+
* of {@link ClientOptions.deadPeerTimeoutMs} (CLIENT-08, D-11/A3).
|
|
2233
|
+
*
|
|
2234
|
+
* @default undefined (off)
|
|
2235
|
+
*/
|
|
2236
|
+
readonly keepaliveIntervalMs?: number;
|
|
2237
|
+
/**
|
|
2238
|
+
* Application-idle timeout (ms) keyed on last inbound bytes / ACK / warning
|
|
2239
|
+
* (CLIENT-08, D-11). On trip, calls `connection.destroy(new Error('dead
|
|
2240
|
+
* peer timeout'))` which surfaces as `MllpConnectionError({ phase: 'receive' })`.
|
|
2241
|
+
* Trip honors {@link ClientOptions.autoReconnect} (D-13). Independent of
|
|
2242
|
+
* {@link ClientOptions.keepaliveIntervalMs} (D-11/A3).
|
|
2243
|
+
*
|
|
2244
|
+
* @default undefined (off)
|
|
2245
|
+
*/
|
|
2246
|
+
readonly deadPeerTimeoutMs?: number;
|
|
2247
|
+
/**
|
|
2248
|
+
* Enable TLS (MLLPS) for this connection (Phase 8). `true` enables TLS with
|
|
2249
|
+
* all defaults — including certificate verification **on**. Pass a
|
|
2250
|
+
* {@link TlsOptions} object to customize (`ca`/`cert`/`key`, minimum
|
|
2251
|
+
* version, ciphers, `allowUnverified`, …).
|
|
2252
|
+
*
|
|
2253
|
+
* Spec anchor: IHE ATNA ITI-19 (https://profiles.ihe.net/ITI/TF/Volume2/ITI-19.html).
|
|
2254
|
+
*
|
|
2255
|
+
* @default undefined (plaintext TCP)
|
|
2256
|
+
*/
|
|
2257
|
+
readonly tls?: TlsOptions | true;
|
|
2258
|
+
}
|
|
2259
|
+
/**
|
|
2260
|
+
* Observability snapshot returned by {@link MllpClient.getStats} (OBS-01, D-26).
|
|
2261
|
+
*
|
|
2262
|
+
* All fields are JSON-serializable (OBS-04) — no Buffers, no class instances,
|
|
2263
|
+
* no Maps, no circular references. `lastConnectedAt` and `lastAckAt` are
|
|
2264
|
+
* **epoch milliseconds** (numbers), NOT `Date` instances — log-pipeline
|
|
2265
|
+
* friendly per D-26.
|
|
2266
|
+
*
|
|
2267
|
+
* `warningsByCode` keys are constrained to the public {@link WarningCode}
|
|
2268
|
+
* union — adding/removing a code is a breaking change (CLAUDE.md
|
|
2269
|
+
* stable-codes guardrail enforced at the type boundary, B-05).
|
|
2270
|
+
*
|
|
2271
|
+
* @example
|
|
2272
|
+
* ```typescript
|
|
2273
|
+
* const stats = client.getStats();
|
|
2274
|
+
* logger.info(JSON.stringify(stats));
|
|
2275
|
+
* // {"state":"CONNECTED","connectionId":"…","queueDepth":0, … }
|
|
2276
|
+
* ```
|
|
2277
|
+
*/
|
|
2278
|
+
interface ClientStats {
|
|
2279
|
+
/** Current FSM state — mirrors `client.state`. */
|
|
2280
|
+
readonly state: ConnectionState;
|
|
2281
|
+
/** Live Connection's id, or `null` before the first connect (or post-CLOSED). */
|
|
2282
|
+
readonly connectionId: string | null;
|
|
2283
|
+
/** Total live correlator entries (in-flight + pre-flush + serialization-queued). */
|
|
2284
|
+
readonly queueDepth: number;
|
|
2285
|
+
/** Sum of `frame.length` across live correlator entries. */
|
|
2286
|
+
readonly queueBytes: number;
|
|
2287
|
+
/** Entries with `sentAt !== null` — actually written to the wire / awaiting ACK. */
|
|
2288
|
+
readonly inFlight: number;
|
|
2289
|
+
/**
|
|
2290
|
+
* Aggregated warning counts. Keys are constrained to the public
|
|
2291
|
+
* {@link WarningCode} union (B-05). Connection-level warnings + Correlator
|
|
2292
|
+
* `MLLP_ACK_*` warnings are merged.
|
|
2293
|
+
*/
|
|
2294
|
+
readonly warningsByCode: Partial<Record<WarningCode, number>>;
|
|
2295
|
+
/** Bytes received from the peer (current Connection). */
|
|
2296
|
+
readonly totalBytesIn: number;
|
|
2297
|
+
/** Bytes written to the peer (current Connection). */
|
|
2298
|
+
readonly totalBytesOut: number;
|
|
2299
|
+
/** Total successful `connection.send()` calls since construction. */
|
|
2300
|
+
readonly sentTotal: number;
|
|
2301
|
+
/** Total ACKs matched + resolved since construction. */
|
|
2302
|
+
readonly ackedTotal: number;
|
|
2303
|
+
/** Total ACK timeouts since construction. */
|
|
2304
|
+
readonly timedOutTotal: number;
|
|
2305
|
+
/** Total reconnect attempts since construction (W-02). */
|
|
2306
|
+
readonly reconnectAttempts: number;
|
|
2307
|
+
/** Epoch ms of the last `CONNECTED` transition. `null` until first connect. */
|
|
2308
|
+
readonly lastConnectedAt: number | null;
|
|
2309
|
+
/** Epoch ms of the most recent successful ACK. `null` until first ACK. */
|
|
2310
|
+
readonly lastAckAt: number | null;
|
|
2311
|
+
/** Whether this client is configured for TLS (Phase 8). Mirrors `ClientOptions.tls` being set. */
|
|
2312
|
+
readonly tls: boolean;
|
|
2313
|
+
}
|
|
2314
|
+
/**
|
|
2315
|
+
* MLLP client — composes a single Phase 3 {@link Connection} over a {@link NetTransport}
|
|
2316
|
+
* (production) or any other `Transport` (testing via {@link InMemoryTransport}).
|
|
2317
|
+
*
|
|
2318
|
+
* Public events — every payload `Object.freeze`'d before emission (D-25):
|
|
2319
|
+
* - `'stateChange'` — `{ from, to, reason? }` from the underlying Connection FSM
|
|
2320
|
+
* - `'connect'` — `{ connectionId }` once the FSM enters `CONNECTED`
|
|
2321
|
+
* - `'disconnect'` — `{ connectionId }` once the FSM enters `DISCONNECTED`
|
|
2322
|
+
* - `'reconnecting'` — `{ connectionId, attempt?, delayMs? }` (Plan 04 populates)
|
|
2323
|
+
* - `'close'` — `{ connectionId }` once the FSM enters terminal `CLOSED`
|
|
2324
|
+
* - `'message'` — `{ payload, connectionId, byteOffset, warnings }` for every inbound frame
|
|
2325
|
+
* - `'warning'` — `MllpWarning` enriched with `connectionId` from the Connection layer
|
|
2326
|
+
* - `'securityWarning'` — `SecurityWarning` (Phase 8). Emitted on every successful
|
|
2327
|
+
* `secureConnect` (initial + every reconnect) when `tls.allowUnverified` is `true`
|
|
2328
|
+
* — code `MLLP_TLS_VERIFY_DISABLED`. Also mirrored to `process.emitWarning`.
|
|
2329
|
+
* - `'error'` — re-emitted from Connection. Guarded by `listenerCount('error') > 0` so
|
|
2330
|
+
* absence of a listener does NOT crash the process (server precedent).
|
|
2331
|
+
*
|
|
2332
|
+
* @example
|
|
2333
|
+
* ```typescript
|
|
2334
|
+
* const client = createClient({ host: 'localhost', port: 2575 });
|
|
2335
|
+
* client.on('stateChange', ({ from, to }) => logger.info({ from, to }));
|
|
2336
|
+
* client.on('message', ({ payload }) => logger.info({ bytes: payload.length }));
|
|
2337
|
+
* await client.connect();
|
|
2338
|
+
* // PLAN-02 will add: const ack = await client.send(payloadBuffer);
|
|
2339
|
+
* await client.close();
|
|
2340
|
+
* ```
|
|
2341
|
+
*/
|
|
2342
|
+
declare class MllpClient extends EventEmitter {
|
|
2343
|
+
private readonly _opts;
|
|
2344
|
+
private _connection;
|
|
2345
|
+
private _socket;
|
|
2346
|
+
/**
|
|
2347
|
+
* Initial state for `get state()` before `_connection` exists. Once a Connection
|
|
2348
|
+
* is attached, `state` mirrors `_connection.state`.
|
|
2349
|
+
*/
|
|
2350
|
+
private _state;
|
|
2351
|
+
/** Per-message ACK timeout in ms (CLIENT-04). Resolved at construction. */
|
|
2352
|
+
private readonly _ackTimeoutMs;
|
|
2353
|
+
/** controlId-mode flag (CLIENT-03 branch). `false` → FIFO. */
|
|
2354
|
+
private readonly _correlateByControlId;
|
|
2355
|
+
/** Unified ACK correlator (D-03/A1). Built during `_attachConnection`. */
|
|
2356
|
+
private _correlator;
|
|
2357
|
+
/**
|
|
2358
|
+
* Periodic ACK-timeout sweep timer. Drives `_correlator.expireDue()` because
|
|
2359
|
+
* the Correlator is timer-free per D-03. Cleared on close/destroy.
|
|
2360
|
+
*/
|
|
2361
|
+
private _ackSweepTimer;
|
|
2362
|
+
private readonly _autoReconnect;
|
|
2363
|
+
private readonly _initialDelayMs;
|
|
2364
|
+
private readonly _maxDelayMs;
|
|
2365
|
+
private readonly _multiplier;
|
|
2366
|
+
private readonly _jitter;
|
|
2367
|
+
private readonly _retryStrategy;
|
|
2368
|
+
/** 0-indexed attempt counter for the current reconnect cycle. */
|
|
2369
|
+
private _attempt;
|
|
2370
|
+
/**
|
|
2371
|
+
* W-02 — total reconnect attempts since construction. Read by PLAN-06 for
|
|
2372
|
+
* `getStats().reconnectAttempts`. Incremented at the entry of every
|
|
2373
|
+
* `_handleDisconnect` invocation that proceeds to schedule a backoff.
|
|
2374
|
+
*/
|
|
2375
|
+
private _reconnectAttempts;
|
|
2376
|
+
/** Epoch ms of the last successful ACK. Drives W-01 backoff-reset. */
|
|
2377
|
+
private _lastSuccessAt;
|
|
2378
|
+
/** Epoch ms when the current reconnect cycle began. `null` outside a cycle. */
|
|
2379
|
+
private _reconnectCycleStartedAt;
|
|
2380
|
+
/** Active backoff `setTimeout` handle. `null` when no backoff is armed. */
|
|
2381
|
+
private _backoffTimer;
|
|
2382
|
+
/** Last delay (ms) used by the strategy. Surfaced via RetryContext.lastDelayMs. */
|
|
2383
|
+
private _lastDelayMs;
|
|
2384
|
+
/** Count cap; `Number.POSITIVE_INFINITY` when only bytes configured. */
|
|
2385
|
+
private readonly _hwmCount;
|
|
2386
|
+
/** Byte cap; `Number.POSITIVE_INFINITY` when only count configured. */
|
|
2387
|
+
private readonly _hwmBytes;
|
|
2388
|
+
/** Backpressure policy; default `'reject'`. */
|
|
2389
|
+
private readonly _onBackpressure;
|
|
2390
|
+
/** Pipeline flag; default `true` (parallel up to highWaterMark). */
|
|
2391
|
+
private readonly _pipeline;
|
|
2392
|
+
/** Dead-peer idle timer (Plan 05 — D-11). `null` when not armed. */
|
|
2393
|
+
private _deadPeerTimer;
|
|
2394
|
+
/** True once the self-'ack' listener that resets the dead-peer timer
|
|
2395
|
+
* has been attached. Guards against duplicate listeners on reconnect. */
|
|
2396
|
+
private _ackResetWired;
|
|
2397
|
+
/** Most recently bound `connect()` signal. Reread on every RetryContext build (W-07). */
|
|
2398
|
+
private _connectSignal;
|
|
2399
|
+
/** Set when close()/destroy()/abort fires; reconnect handler short-circuits. */
|
|
2400
|
+
private _userClosed;
|
|
2401
|
+
/** Captured Connection error feeding `RetryContext.lastError`. */
|
|
2402
|
+
private _lastError;
|
|
2403
|
+
/** Listener-removal handle for the abort listener bound in connect(). */
|
|
2404
|
+
private _abortListener;
|
|
2405
|
+
/**
|
|
2406
|
+
* Test-only seam — when set, `_beginReconnectAttempt` builds the new
|
|
2407
|
+
* Connection through this factory instead of opening a real net.Socket.
|
|
2408
|
+
*
|
|
2409
|
+
* @internal
|
|
2410
|
+
*/
|
|
2411
|
+
private _reconnectFactory;
|
|
2412
|
+
/** Total successful `conn.send()` flushes since construction. */
|
|
2413
|
+
private _sentTotal;
|
|
2414
|
+
/** Total ACKs resolved since construction. */
|
|
2415
|
+
private _ackedTotal;
|
|
2416
|
+
/** Total ACK timeouts since construction. */
|
|
2417
|
+
private _timedOutTotal;
|
|
2418
|
+
/** Epoch ms of the most recent CONNECTED transition (null until first). */
|
|
2419
|
+
private _lastConnectedAt;
|
|
2420
|
+
/** Epoch ms of the most recent successful ACK (null until first). */
|
|
2421
|
+
private _lastAckAt;
|
|
2422
|
+
/**
|
|
2423
|
+
* Aggregated Correlator-emitted warning counts (MLLP_ACK_*). Connection-level
|
|
2424
|
+
* warnings are read directly from `_connection.getStats().warningsByCode`
|
|
2425
|
+
* at observation time and merged into the snapshot (D-26).
|
|
2426
|
+
*/
|
|
2427
|
+
private _aggregatedWarningsByCode;
|
|
2428
|
+
/**
|
|
2429
|
+
* Construct an MLLP client. Created idle; call `connect()` (or use
|
|
2430
|
+
* {@link createClient}/{@link createStarterClient}) to open the connection.
|
|
2431
|
+
*
|
|
2432
|
+
* @param opts - Client options (host/port, ACK timeout, reconnect/backpressure policy, …).
|
|
2433
|
+
*/
|
|
2434
|
+
constructor(opts: ClientOptions);
|
|
2435
|
+
/**
|
|
2436
|
+
* Current FSM state. Mirrors the underlying Connection's state once attached;
|
|
2437
|
+
* before `connect()` (or after a `CLOSED` Connection is dropped) reports
|
|
2438
|
+
* the client-level baseline (`'DISCONNECTED'`).
|
|
2439
|
+
*/
|
|
2440
|
+
get state(): ConnectionState;
|
|
2441
|
+
/**
|
|
2442
|
+
* Open a TCP (or TLS — Phase 8) connection to the configured `host:port`
|
|
2443
|
+
* and attach a Phase 3 {@link Connection} to it. Resolves once the FSM
|
|
2444
|
+
* enters `CONNECTED` — for TLS, on `'secureConnect'` (handshake complete,
|
|
2445
|
+
* including certificate verification when it is on).
|
|
2446
|
+
*
|
|
2447
|
+
* Rejects with:
|
|
2448
|
+
* - `DOMException('Aborted', 'AbortError')` if `signal` is provided and aborts
|
|
2449
|
+
* before the connect resolves.
|
|
2450
|
+
* - `MllpConnectionError({ phase: 'connect' })` if the underlying socket emits
|
|
2451
|
+
* `error` before connecting, OR if the client is already connecting/connected.
|
|
2452
|
+
* TLS failures carry a `connectionCause`: `'tls-verify'` for certificate
|
|
2453
|
+
* verification failures, `'tls-handshake'` for TLS-protocol-shaped failures
|
|
2454
|
+
* ({@link isTlsProtocolError}); pure TCP failures carry none.
|
|
2455
|
+
*
|
|
2456
|
+
* **Dual failure signal on initial connect:** the Connection's transport
|
|
2457
|
+
* error handler is attached before this promise's own error listener, so a
|
|
2458
|
+
* pre-connect socket error produces BOTH the promise rejection AND a
|
|
2459
|
+
* client `'error'` event (when an `'error'` listener is attached). Handle
|
|
2460
|
+
* whichever fits your flow; they describe the same underlying failure.
|
|
2461
|
+
*
|
|
2462
|
+
* **TLS 1.3 + mutual TLS caveat (RFC 8446 §4.4.2):** `connect()` resolving
|
|
2463
|
+
* does NOT guarantee that a `clientAuth: 'MUST'` server accepted your
|
|
2464
|
+
* client certificate. Under TLS 1.3 the client's handshake — and its
|
|
2465
|
+
* `'secureConnect'` — can complete before the server finishes validating
|
|
2466
|
+
* the certificate; a rejection then surfaces moments later as a typed
|
|
2467
|
+
* post-connect error (`'error'` event with an `ERR_SSL_*`/alert cause,
|
|
2468
|
+
* classified **permanent** — no auto-reconnect loop). ACK correlation via
|
|
2469
|
+
* {@link MllpClient.send} remains the delivery guarantee: no send resolves
|
|
2470
|
+
* without its ACK, so a rejected session can never silently "deliver".
|
|
2471
|
+
*
|
|
2472
|
+
* @example
|
|
2473
|
+
* ```typescript
|
|
2474
|
+
* const ac = new AbortController();
|
|
2475
|
+
* setTimeout(() => ac.abort(), 5_000);
|
|
2476
|
+
* await client.connect({ signal: ac.signal });
|
|
2477
|
+
* ```
|
|
2478
|
+
*/
|
|
2479
|
+
connect(opts?: {
|
|
2480
|
+
signal?: AbortSignal;
|
|
2481
|
+
}): Promise<void>;
|
|
2482
|
+
/**
|
|
2483
|
+
* Build the raw socket + `Transport` pair for a connect / reconnect attempt.
|
|
2484
|
+
*
|
|
2485
|
+
* Plaintext (`ClientOptions.tls` unset): a `net.Socket` wrapped in
|
|
2486
|
+
* `NetTransport`. TLS (Phase 8): a `tls.TLSSocket` wrapped in
|
|
2487
|
+
* `TlsTransport` — verification defaults **on**
|
|
2488
|
+
* (`rejectUnauthorized: !allowUnverified`), floor `minVersion: 'TLSv1.2'`
|
|
2489
|
+
* (the IHE ATNA ITI-19 BCP195 floor), `servername` defaulting to
|
|
2490
|
+
* `ClientOptions.host`.
|
|
2491
|
+
*/
|
|
2492
|
+
private _createSocketAndTransport;
|
|
2493
|
+
/**
|
|
2494
|
+
* Wrap a connect-phase socket error as `MllpConnectionError`, classifying
|
|
2495
|
+
* TLS failures (Phase 8) into the additive `connectionCause`:
|
|
2496
|
+
*
|
|
2497
|
+
* - `'tls-verify'` — certificate-verification failures
|
|
2498
|
+
* ({@link isTlsVerificationErrorCode}).
|
|
2499
|
+
* - `'tls-handshake'` — TLS-**protocol**-shaped failures only
|
|
2500
|
+
* ({@link isTlsProtocolError}): `ERR_SSL_*`, `EPROTO`, OpenSSL
|
|
2501
|
+
* alert-bearing errors.
|
|
2502
|
+
* - **No `connectionCause`** — pure TCP-level failures (`ECONNREFUSED`,
|
|
2503
|
+
* `ETIMEDOUT`, …) even on a TLS-configured connection; these carry the
|
|
2504
|
+
* same shape as plaintext connect failures.
|
|
2505
|
+
*/
|
|
2506
|
+
private _wrapConnectError;
|
|
2507
|
+
/**
|
|
2508
|
+
* Emit a client event, containing a throwing subscriber.
|
|
2509
|
+
*
|
|
2510
|
+
* Every event this client emits is reached from a callback we do not own — a socket's
|
|
2511
|
+
* `'connect'`/`'secureConnect'`/`'data'`/`'error'` listener, or a backoff timer. A throwing
|
|
2512
|
+
* subscriber would unwind into it and kill the process, and on several paths it would also skip
|
|
2513
|
+
* the work queued after the emit (the `resolve()` of `connect()`, the backoff scheduling).
|
|
2514
|
+
*
|
|
2515
|
+
* See `src/internal/safe-emit.ts`. The throw is re-surfaced on `'error'`.
|
|
2516
|
+
*/
|
|
2517
|
+
private _emitContained;
|
|
2518
|
+
/**
|
|
2519
|
+
* Emit the per-connection insecure-TLS warning (Phase 8) when
|
|
2520
|
+
* `tls.allowUnverified === true` — fires on EVERY successful
|
|
2521
|
+
* `secureConnect`, initial connect and every reconnect. Emits both a frozen
|
|
2522
|
+
* `'securityWarning'` event and `process.emitWarning`. No-op for plaintext
|
|
2523
|
+
* connections or when verification is on.
|
|
2524
|
+
*/
|
|
2525
|
+
private _emitInsecureWarningIfNeeded;
|
|
2526
|
+
/**
|
|
2527
|
+
* Wire a Connection's events through to this MllpClient. Every re-emitted
|
|
2528
|
+
* payload is `Object.freeze`'d before emission (D-25), even though the
|
|
2529
|
+
* Connection layer already freezes — defense-in-depth, harmless on
|
|
2530
|
+
* already-frozen objects.
|
|
2531
|
+
*
|
|
2532
|
+
* Builds the unified `Correlator` (D-03/A1) bound to this Connection and
|
|
2533
|
+
* arms the periodic ACK-timeout sweep. The Correlator is teardown-aware:
|
|
2534
|
+
* `close()` / `destroy()` clear the sweep timer and reject pending sends.
|
|
2535
|
+
*
|
|
2536
|
+
* @param conn - Connection to subscribe to.
|
|
2537
|
+
*/
|
|
2538
|
+
private _attachConnection;
|
|
2539
|
+
/**
|
|
2540
|
+
* Arm (or re-arm) the dead-peer idle timer (Plan 05 — CLIENT-08, D-11).
|
|
2541
|
+
* No-op when `deadPeerTimeoutMs` is unset.
|
|
2542
|
+
*/
|
|
2543
|
+
private _armDeadPeerTimer;
|
|
2544
|
+
/**
|
|
2545
|
+
* Clear the dead-peer idle timer (Plan 05 — D-14 timer cleanup on
|
|
2546
|
+
* every transition out of CONNECTED).
|
|
2547
|
+
*/
|
|
2548
|
+
private _clearDeadPeerTimer;
|
|
2549
|
+
/**
|
|
2550
|
+
* Disconnect handler — Plan 04 reconnect FSM core.
|
|
2551
|
+
*
|
|
2552
|
+
* Implements:
|
|
2553
|
+
* - CLIENT-17 hybrid in-flight handling (D-08): controlId mode preserves
|
|
2554
|
+
* pending sends for resend; FIFO mode rejects in-flight with
|
|
2555
|
+
* `connectionCause: 'in-flight-orphan'` and queued with `'fifo-unsafe'`.
|
|
2556
|
+
* - CLIENT-18 classifier-first (Composition A — D-16): permanent errors
|
|
2557
|
+
* transition straight to CLOSED without invoking `retryStrategy`.
|
|
2558
|
+
* - W-01 backoff-reset on recent success: first disconnect after a
|
|
2559
|
+
* successful ACK on the prior session resets `_attempt` to 0.
|
|
2560
|
+
* - W-02 `_reconnectAttempts` counter increment.
|
|
2561
|
+
* - D-15 frozen RetryContext + D-17 null-return halts.
|
|
2562
|
+
* - D-19 default exponential strategy.
|
|
2563
|
+
*
|
|
2564
|
+
* Invoked from the SINGLE `_onStateChange` hook — no parallel listener
|
|
2565
|
+
* (B-04). Idempotent across same-cycle re-entry: cycle-start flag
|
|
2566
|
+
* coordinates first-disconnect vs subsequent-within-cycle behavior.
|
|
2567
|
+
*/
|
|
2568
|
+
private _handleDisconnect;
|
|
2569
|
+
/**
|
|
2570
|
+
* Default retry strategy (D-19): `min(maxDelay, initialDelay * multiplier^attempt)`
|
|
2571
|
+
* with ±jitter applied.
|
|
2572
|
+
*/
|
|
2573
|
+
private _defaultRetryStrategy;
|
|
2574
|
+
/**
|
|
2575
|
+
* Open a fresh Connection for the next reconnect attempt.
|
|
2576
|
+
*
|
|
2577
|
+
* In production, builds a new `net.Socket` + `NetTransport` + `Connection`.
|
|
2578
|
+
* In tests, the `_reconnectFactory` seam returns a pre-built Connection
|
|
2579
|
+
* over `InMemoryTransport.pair()`.
|
|
2580
|
+
*
|
|
2581
|
+
* On successful CONNECTED transition:
|
|
2582
|
+
* - controlId mode: re-transmits every live correlator entry via the
|
|
2583
|
+
* already-encoded `PendingAck.frame`, then `markFlushed`'s each one
|
|
2584
|
+
* so ACK timeouts restart from the new flush time.
|
|
2585
|
+
* - All modes: clears the cycle-start flag so the next disconnect
|
|
2586
|
+
* can re-enter `_handleDisconnect` cleanly.
|
|
2587
|
+
*/
|
|
2588
|
+
private _beginReconnectAttempt;
|
|
2589
|
+
/**
|
|
2590
|
+
* Post-CONNECTED steps for a reconnect attempt:
|
|
2591
|
+
* - controlId mode: resend every live correlator entry's frame, then
|
|
2592
|
+
* re-stamp `markFlushed` so ACK timers reset from the new flush.
|
|
2593
|
+
* - Clear the cycle-start flag so the next disconnect re-enters cleanly.
|
|
2594
|
+
*/
|
|
2595
|
+
private _afterReconnectArmed;
|
|
2596
|
+
/**
|
|
2597
|
+
* Test seam — install a factory that produces the next reconnect Connection.
|
|
2598
|
+
*
|
|
2599
|
+
* @internal
|
|
2600
|
+
*/
|
|
2601
|
+
_setReconnectFactory(factory: () => {
|
|
2602
|
+
conn: Connection;
|
|
2603
|
+
arm: () => void;
|
|
2604
|
+
}): void;
|
|
2605
|
+
/**
|
|
2606
|
+
* Test seam — capture or rebind the connect-signal mid-flight (W-07).
|
|
2607
|
+
*
|
|
2608
|
+
* @internal
|
|
2609
|
+
*/
|
|
2610
|
+
_captureConnectSignal(signal: AbortSignal): void;
|
|
2611
|
+
/**
|
|
2612
|
+
* Single source-of-truth for inbound ACK payload handling.
|
|
2613
|
+
*
|
|
2614
|
+
* - FIFO mode: passes `null` controlId so `matchAck` returns the head of
|
|
2615
|
+
* the live store.
|
|
2616
|
+
* - controlId mode: extracts MSA-2 at the `HOOK_EXTENSION_POINT: ack-payload`
|
|
2617
|
+
* anchor and passes it to `matchAck` for keyed lookup.
|
|
2618
|
+
*
|
|
2619
|
+
* Called from the SINGLE `'message'` listener registered in `_attachConnection`.
|
|
2620
|
+
* No parallel listener is registered — downstream plans extend at the named
|
|
2621
|
+
* anchor (B-04).
|
|
2622
|
+
*/
|
|
2623
|
+
private _onAckPayload;
|
|
2624
|
+
/**
|
|
2625
|
+
* Single source-of-truth for a successfully matched ACK (live-store hit).
|
|
2626
|
+
*
|
|
2627
|
+
* PLAN-02: emit frozen 'ack' event, call matched.resolve().
|
|
2628
|
+
* Plan 04 extends at HOOK_EXTENSION_POINT: ack-matched to update _lastSuccessAt.
|
|
2629
|
+
* PLAN-06 extends at HOOK_EXTENSION_POINT: ack-matched to bump _ackedTotal and
|
|
2630
|
+
* set _lastAckAt.
|
|
2631
|
+
*
|
|
2632
|
+
* Called from _onAckPayload when matchAck() returns a non-null PendingAck.
|
|
2633
|
+
*/
|
|
2634
|
+
private _onAckMatched;
|
|
2635
|
+
/**
|
|
2636
|
+
* Emit a frozen `'drain'` event when the queue depth and bytes fall below
|
|
2637
|
+
* both configured caps (Plan 05 — D-24). Called from `_onAckMatched`
|
|
2638
|
+
* (every successful ACK) and from the Correlator's `onTimeout` callback
|
|
2639
|
+
* (every expired send) — both code paths free a live-store slot.
|
|
2640
|
+
*/
|
|
2641
|
+
private _maybeEmitDrain;
|
|
2642
|
+
/**
|
|
2643
|
+
* Single source-of-truth for Connection FSM transitions.
|
|
2644
|
+
*
|
|
2645
|
+
* PLAN-02: re-emit frozen 'stateChange' (was inline in PLAN-01; centralized
|
|
2646
|
+
* here so Plan 04 / Plan 05 can extend at named anchors).
|
|
2647
|
+
* Plan 04 extends at HOOK_EXTENSION_POINT: state-change to detect
|
|
2648
|
+
* CONNECTED → DISCONNECTED|RECONNECTING and trigger _handleDisconnect.
|
|
2649
|
+
* Plan 05 extends at HOOK_EXTENSION_POINT: state-change to clear/arm
|
|
2650
|
+
* dead-peer timer on transitions out of / into CONNECTED.
|
|
2651
|
+
*
|
|
2652
|
+
* Called from the SINGLE 'stateChange' listener registered in _attachConnection.
|
|
2653
|
+
*/
|
|
2654
|
+
private _onStateChange;
|
|
2655
|
+
/**
|
|
2656
|
+
* Send an MLLP-framed payload and await the inbound ACK (CLIENT-02).
|
|
2657
|
+
*
|
|
2658
|
+
* Resolves with the ACK Buffer (framing stripped). Rejects with:
|
|
2659
|
+
* - `DOMException('Aborted', 'AbortError')` if `signal` aborts before ACK
|
|
2660
|
+
* (CLIENT-11 send branch).
|
|
2661
|
+
* - `MllpTimeoutError` if no ACK arrives within `ackTimeoutMs` (ERR-02).
|
|
2662
|
+
* The clock starts at the underlying `write()` flush callback, NOT at
|
|
2663
|
+
* the `send()` call (CLIENT-04, D-19).
|
|
2664
|
+
* - `MllpConnectionError({ phase: 'send' })` if the client is not connected.
|
|
2665
|
+
*
|
|
2666
|
+
* Emits a frozen `'ack'` event on every successful match (D-25 + Specifics).
|
|
2667
|
+
*
|
|
2668
|
+
* @example
|
|
2669
|
+
* ```typescript
|
|
2670
|
+
* const ack = await client.send(payloadBuffer);
|
|
2671
|
+
* logger.info({ ack: ack.toString('utf8') });
|
|
2672
|
+
* ```
|
|
2673
|
+
*
|
|
2674
|
+
* @param payload Raw bytes; MLLP framing is added internally via `encodeFrame`.
|
|
2675
|
+
* @param opts.signal AbortSignal — aborting cancels the ACK wait (CLIENT-11).
|
|
2676
|
+
*/
|
|
2677
|
+
send(payload: Buffer, opts?: {
|
|
2678
|
+
signal?: AbortSignal;
|
|
2679
|
+
ackTimeoutMs?: number;
|
|
2680
|
+
}): Promise<Buffer>;
|
|
2681
|
+
/**
|
|
2682
|
+
* 'wait'-mode backpressure handler (Plan 05 — CLIENT-07/CLIENT-11).
|
|
2683
|
+
*
|
|
2684
|
+
* Awaits one of three terminating signals, in order:
|
|
2685
|
+
* - `'drain'` event → re-enter `send()` (the gate will now pass).
|
|
2686
|
+
* - `ackTimeoutMs` elapses → reject with `MllpTimeoutError`.
|
|
2687
|
+
* - Caller's `signal` aborts → reject with `AbortError` (B-06). Cleanup
|
|
2688
|
+
* removes the drain listener AND the abort listener AND clears the
|
|
2689
|
+
* timer to prevent leaks.
|
|
2690
|
+
*/
|
|
2691
|
+
private _waitThenSend;
|
|
2692
|
+
/**
|
|
2693
|
+
* Tear down per-connection state (sweep timer + correlator) when the
|
|
2694
|
+
* client is closing or destroying. Safe to call multiple times.
|
|
2695
|
+
*/
|
|
2696
|
+
private _teardownCorrelator;
|
|
2697
|
+
/**
|
|
2698
|
+
* **Test seam** — attach an externally-built {@link Connection} directly,
|
|
2699
|
+
* bypassing the `net.createConnection` + `NetTransport` path. Used by
|
|
2700
|
+
* lifecycle tests driving `InMemoryTransport.pair()` for determinism.
|
|
2701
|
+
*
|
|
2702
|
+
* @internal
|
|
2703
|
+
*/
|
|
2704
|
+
_attachExistingConnection(conn: Connection): void;
|
|
2705
|
+
/**
|
|
2706
|
+
* Gracefully close the client.
|
|
2707
|
+
*
|
|
2708
|
+
* Delegates to {@link Connection.close}, which transitions `CONNECTED → DRAINING
|
|
2709
|
+
* → DISCONNECTED` (or `CLOSED` on drain timeout). No-op if no Connection is
|
|
2710
|
+
* attached.
|
|
2711
|
+
*
|
|
2712
|
+
* Rejects with `DOMException('Aborted', 'AbortError')` if `signal` aborts mid-drain;
|
|
2713
|
+
* on abort, the underlying Connection is force-destroyed.
|
|
2714
|
+
*
|
|
2715
|
+
* @example
|
|
2716
|
+
* ```typescript
|
|
2717
|
+
* await client.close({ drainTimeoutMs: 5_000 });
|
|
2718
|
+
* ```
|
|
2719
|
+
*/
|
|
2720
|
+
close(opts?: {
|
|
2721
|
+
drainTimeoutMs?: number;
|
|
2722
|
+
signal?: AbortSignal;
|
|
2723
|
+
}): Promise<void>;
|
|
2724
|
+
/**
|
|
2725
|
+
* Abruptly destroy the client — force-transitions the underlying Connection
|
|
2726
|
+
* to `CLOSED` immediately. No-op if no Connection is attached. Idempotent.
|
|
2727
|
+
*
|
|
2728
|
+
* @example
|
|
2729
|
+
* ```typescript
|
|
2730
|
+
* client.destroy(new Error('shutting down'));
|
|
2731
|
+
* ```
|
|
2732
|
+
*/
|
|
2733
|
+
destroy(reason?: Error): void;
|
|
2734
|
+
/**
|
|
2735
|
+
* Returns a JSON-serializable observability snapshot (OBS-01, D-26).
|
|
2736
|
+
*
|
|
2737
|
+
* All fields are plain values — no Buffers, no class instances, no Maps,
|
|
2738
|
+
* no circular refs. Safe to `JSON.stringify` directly.
|
|
2739
|
+
*
|
|
2740
|
+
* `inFlight` is the count of correlator entries with `sentAt !== null`
|
|
2741
|
+
* (entries actually written to the wire and awaiting ACK), distinct from
|
|
2742
|
+
* `queueDepth` which counts ALL live correlator entries (including
|
|
2743
|
+
* pre-flush and serialization-queued sends).
|
|
2744
|
+
*
|
|
2745
|
+
* @example
|
|
2746
|
+
* ```typescript
|
|
2747
|
+
* setInterval(() => logger.info(JSON.stringify(client.getStats())), 60_000);
|
|
2748
|
+
* ```
|
|
2749
|
+
*/
|
|
2750
|
+
getStats(): ClientStats;
|
|
2751
|
+
/**
|
|
2752
|
+
* Async disposal — delegates to {@link MllpClient.close} for `await using` support.
|
|
2753
|
+
*
|
|
2754
|
+
* @example
|
|
2755
|
+
* ```typescript
|
|
2756
|
+
* await using client = createClient({ host: 'localhost', port: 2575 });
|
|
2757
|
+
* await client.connect();
|
|
2758
|
+
* // client.close() is called automatically at end of block
|
|
2759
|
+
* ```
|
|
2760
|
+
*/
|
|
2761
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
2762
|
+
}
|
|
2763
|
+
/**
|
|
2764
|
+
* Create an {@link MllpClient}. Equivalent to `new MllpClient(opts)`.
|
|
2765
|
+
*
|
|
2766
|
+
* @example
|
|
2767
|
+
* ```typescript
|
|
2768
|
+
* import { createClient } from '@cosyte/mllp';
|
|
2769
|
+
*
|
|
2770
|
+
* const client = createClient({ host: 'localhost', port: 2575 });
|
|
2771
|
+
* await client.connect();
|
|
2772
|
+
* // PLAN-02 will add: const ack = await client.send(payloadBuffer);
|
|
2773
|
+
* await client.close();
|
|
2774
|
+
* ```
|
|
2775
|
+
*/
|
|
2776
|
+
declare function createClient(opts: ClientOptions): MllpClient;
|
|
2777
|
+
/**
|
|
2778
|
+
* Options for {@link createStarterClient} (PLAN-06, CLIENT-10).
|
|
2779
|
+
*
|
|
2780
|
+
* The starter applies opinionated D-22 defaults on top of `ClientOptions`,
|
|
2781
|
+
* so every override here is optional except `host` + `port`. The
|
|
2782
|
+
* starter-specific addition is `handleSignals` (mirrors `createStarterServer`).
|
|
2783
|
+
*
|
|
2784
|
+
* @example
|
|
2785
|
+
* ```typescript
|
|
2786
|
+
* const opts: StarterClientOptions = {
|
|
2787
|
+
* host: 'localhost',
|
|
2788
|
+
* port: 2575,
|
|
2789
|
+
* onMessage: (payload) => logger.info({ bytes: payload.length }),
|
|
2790
|
+
* handleSignals: true,
|
|
2791
|
+
* };
|
|
2792
|
+
* ```
|
|
2793
|
+
*/
|
|
2794
|
+
interface StarterClientOptions {
|
|
2795
|
+
/** Host to connect to. */
|
|
2796
|
+
readonly host: string;
|
|
2797
|
+
/** TCP port. */
|
|
2798
|
+
readonly port: number;
|
|
2799
|
+
/**
|
|
2800
|
+
* Inbound-message callback (any framed payload from the peer, including
|
|
2801
|
+
* non-ACK messages on bidirectional channels). Mirrors the server-side
|
|
2802
|
+
* `onMessage` ergonomics.
|
|
2803
|
+
*/
|
|
2804
|
+
readonly onMessage?: (payload: Buffer) => void;
|
|
2805
|
+
/** Override default `30_000`. */
|
|
2806
|
+
readonly ackTimeoutMs?: number;
|
|
2807
|
+
/** Override default `false` (FIFO mode). */
|
|
2808
|
+
readonly correlateByControlId?: boolean;
|
|
2809
|
+
/** Override default `true` (parallel up to highWaterMark). */
|
|
2810
|
+
readonly pipeline?: boolean;
|
|
2811
|
+
/** Override default `64`. */
|
|
2812
|
+
readonly highWaterMark?: HighWaterMark;
|
|
2813
|
+
/** Override default `'reject'`. */
|
|
2814
|
+
readonly onBackpressure?: "reject" | "wait";
|
|
2815
|
+
/** Override default `true` (auto-reconnect on transient errors). */
|
|
2816
|
+
readonly autoReconnect?: boolean;
|
|
2817
|
+
/** Custom reconnect-backoff hook (CLIENT-12). */
|
|
2818
|
+
readonly retryStrategy?: RetryStrategy;
|
|
2819
|
+
/** Drain timeout for `close()` (default `30_000`). */
|
|
2820
|
+
readonly drainTimeoutMs?: number;
|
|
2821
|
+
/** FrameReader options (passthrough). */
|
|
2822
|
+
readonly framing?: ClientOptions["framing"];
|
|
2823
|
+
/** TCP keepalive interval ms (CLIENT-08). */
|
|
2824
|
+
readonly keepaliveIntervalMs?: number;
|
|
2825
|
+
/** Application-idle dead-peer timeout ms (CLIENT-08). */
|
|
2826
|
+
readonly deadPeerTimeoutMs?: number;
|
|
2827
|
+
/** Enable TLS (MLLPS) for this connection (Phase 8). Passthrough to `ClientOptions.tls`. */
|
|
2828
|
+
readonly tls?: TlsOptions | true;
|
|
2829
|
+
/**
|
|
2830
|
+
* Register process SIGTERM/SIGINT handlers that close the client. Default
|
|
2831
|
+
* `false` (D-22). When `true`, SIGTERM/SIGINT both call `client.close()`
|
|
2832
|
+
* and exit the process. Handlers self-deregister on `'close'` (T-05-06-01).
|
|
2833
|
+
*/
|
|
2834
|
+
readonly handleSignals?: boolean;
|
|
2835
|
+
}
|
|
2836
|
+
/**
|
|
2837
|
+
* Three-line MLLP client with batteries-included defaults (PLAN-06,
|
|
2838
|
+
* CLIENT-10, D-22). The returned client is already CONNECTED — `connect()`
|
|
2839
|
+
* has been awaited.
|
|
2840
|
+
*
|
|
2841
|
+
* D-22 defaults:
|
|
2842
|
+
* - `autoReconnect: true`
|
|
2843
|
+
* - `ackTimeoutMs: 30_000`
|
|
2844
|
+
* - `correlateByControlId: false` (FIFO mode — simplest mental model)
|
|
2845
|
+
* - `pipeline: true`
|
|
2846
|
+
* - `highWaterMark: 64`
|
|
2847
|
+
* - `onBackpressure: 'reject'`
|
|
2848
|
+
* - `handleSignals: false` (opt-in)
|
|
2849
|
+
*
|
|
2850
|
+
* The factory is **async**, so the literal three-line north-star snippet
|
|
2851
|
+
* has an explicit `await` BEFORE `createStarterClient(...)` — without it,
|
|
2852
|
+
* the `using` declaration would receive a `Promise`, not an `MllpClient`,
|
|
2853
|
+
* and `Symbol.asyncDispose` would not run at scope exit.
|
|
2854
|
+
*
|
|
2855
|
+
* @example
|
|
2856
|
+
* ```typescript
|
|
2857
|
+
* import { createStarterClient } from '@cosyte/mllp';
|
|
2858
|
+
* await using c = await createStarterClient({ host: 'localhost', port: 2575 });
|
|
2859
|
+
* const ack = await c.send(payloadBuffer);
|
|
2860
|
+
* ```
|
|
2861
|
+
*/
|
|
2862
|
+
declare function createStarterClient(opts: StarterClientOptions): Promise<MllpClient>;
|
|
2863
|
+
|
|
2864
|
+
/**
|
|
2865
|
+
* MLLP Client typed errors and classifiers.
|
|
2866
|
+
*
|
|
2867
|
+
* Exports:
|
|
2868
|
+
* - `MllpTimeoutError` (PLAN-02) — ACK timeout (ERR-02)
|
|
2869
|
+
* - `MllpBackpressureError` — high-water mark exceeded (ERR-04, plan 05)
|
|
2870
|
+
* - `isTransientConnectionError` (PLAN-04) — transient/permanent classifier (CLIENT-18)
|
|
2871
|
+
*
|
|
2872
|
+
* Re-exported from `src/client/index.ts` and `src/index.ts`.
|
|
2873
|
+
*
|
|
2874
|
+
* @packageDocumentation
|
|
2875
|
+
*/
|
|
2876
|
+
/**
|
|
2877
|
+
* Thrown (or rejects the `send()` promise) when an ACK does not arrive within
|
|
2878
|
+
* the configured `ackTimeoutMs` (ERR-02).
|
|
2879
|
+
*
|
|
2880
|
+
* The timeout clock starts at the underlying `write()` flush callback, NOT
|
|
2881
|
+
* at the `send()` call — pre-flush queue time is not charged to the peer
|
|
2882
|
+
* (CLIENT-04).
|
|
2883
|
+
*
|
|
2884
|
+
* @example
|
|
2885
|
+
* ```typescript
|
|
2886
|
+
* try {
|
|
2887
|
+
* await client.send(payload);
|
|
2888
|
+
* } catch (err) {
|
|
2889
|
+
* if (err instanceof MllpTimeoutError) {
|
|
2890
|
+
* logger.warn({ elapsedMs: err.elapsedMs, controlId: err.messageControlId });
|
|
2891
|
+
* }
|
|
2892
|
+
* }
|
|
2893
|
+
* ```
|
|
2894
|
+
*/
|
|
2895
|
+
declare class MllpTimeoutError extends Error {
|
|
2896
|
+
readonly name: "MllpTimeoutError";
|
|
2897
|
+
/** MSH-10 control ID of the timed-out send (FIFO mode: `undefined`). */
|
|
2898
|
+
readonly messageControlId: string | undefined;
|
|
2899
|
+
/** Milliseconds elapsed between write-flush and timeout fire. */
|
|
2900
|
+
readonly elapsedMs: number;
|
|
2901
|
+
/** Epoch ms timestamp recorded at write-flush callback. */
|
|
2902
|
+
readonly sentAt: number;
|
|
2903
|
+
/**
|
|
2904
|
+
* Construct an MLLP timeout error.
|
|
2905
|
+
*
|
|
2906
|
+
* @param message - Human-readable error message.
|
|
2907
|
+
* @param opts - Timeout context (originating message control id, elapsed time, flush timestamp).
|
|
2908
|
+
*/
|
|
2909
|
+
constructor(message: string, opts: {
|
|
2910
|
+
messageControlId: string | undefined;
|
|
2911
|
+
elapsedMs: number;
|
|
2912
|
+
sentAt: number;
|
|
2913
|
+
});
|
|
2914
|
+
}
|
|
2915
|
+
/**
|
|
2916
|
+
* Set of Node/OpenSSL error codes that indicate a **certificate-verification**
|
|
2917
|
+
* failure (as opposed to some other TLS handshake failure) — untrusted chain,
|
|
2918
|
+
* expired/not-yet-valid certificate, hostname mismatch, revocation, etc.
|
|
2919
|
+
*
|
|
2920
|
+
* Used by `MllpClient` (Phase 8) to classify a TLS connect failure's
|
|
2921
|
+
* `connectionCause` as `'tls-verify'` (this set) vs `'tls-handshake'`
|
|
2922
|
+
* (everything else observed before `'secureConnect'`). Exported so callers
|
|
2923
|
+
* can apply the same classification to their own error handling.
|
|
2924
|
+
*
|
|
2925
|
+
* @example
|
|
2926
|
+
* ```typescript
|
|
2927
|
+
* import { isTlsVerificationErrorCode } from '@cosyte/mllp';
|
|
2928
|
+
* if (isTlsVerificationErrorCode('CERT_HAS_EXPIRED')) {
|
|
2929
|
+
* // definitely a verification failure, not a protocol/cipher mismatch
|
|
2930
|
+
* }
|
|
2931
|
+
* ```
|
|
2932
|
+
*/
|
|
2933
|
+
declare function isTlsVerificationErrorCode(code: string): boolean;
|
|
2934
|
+
/**
|
|
2935
|
+
* Detects **TLS-protocol-shaped** errors — failures of the TLS protocol
|
|
2936
|
+
* itself, as opposed to plain TCP-level network failures (Phase 8).
|
|
2937
|
+
*
|
|
2938
|
+
* Apply this only to errors raised on a **TLS** connection; `MllpClient`
|
|
2939
|
+
* does exactly that (the predicate is consulted only when `ClientOptions.tls`
|
|
2940
|
+
* is set). The boundary:
|
|
2941
|
+
*
|
|
2942
|
+
* **TLS-protocol-shaped (`true`):**
|
|
2943
|
+
* - `code` starting `ERR_SSL_` (Node's TLS alert codes, e.g.
|
|
2944
|
+
* `ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED` — a `clientAuth: 'MUST'`
|
|
2945
|
+
* server rejecting the client's certificate).
|
|
2946
|
+
* - `code === 'EPROTO'` — on a TLS connection this is OpenSSL failing the
|
|
2947
|
+
* handshake (protocol version mismatch, no shared cipher, a TLS ≤1.2
|
|
2948
|
+
* mTLS rejection).
|
|
2949
|
+
* - `message` containing `ssl` or `alert` (`/\bssl\b|\balert\b/i` — "SSL
|
|
2950
|
+
* routines", "tlsv13 alert certificate required", …). This message check
|
|
2951
|
+
* is a **heuristic backstop** over the code-based checks above, not a
|
|
2952
|
+
* precise boundary: it exists to catch OpenSSL errors that surface without
|
|
2953
|
+
* a usable `code`, and `MllpClient` consults it only on connections where
|
|
2954
|
+
* TLS is configured. An arbitrary non-TLS error whose message happens to
|
|
2955
|
+
* contain those words would also match.
|
|
2956
|
+
*
|
|
2957
|
+
* **NOT TLS-protocol-shaped (`false`) — plain network failures, which stay
|
|
2958
|
+
* transient for the reconnect classifier:** `ECONNREFUSED`, `ETIMEDOUT`,
|
|
2959
|
+
* `EHOSTUNREACH`, `ENETUNREACH`, `EPIPE`, and a plain `ECONNRESET` carrying
|
|
2960
|
+
* no TLS alert context — a network blip during (or after) a handshake
|
|
2961
|
+
* should still auto-heal.
|
|
2962
|
+
*
|
|
2963
|
+
* Certificate-**verification** failures are a separate class — see
|
|
2964
|
+
* {@link isTlsVerificationErrorCode}; `MllpClient` checks that first and
|
|
2965
|
+
* labels those `connectionCause: 'tls-verify'`.
|
|
2966
|
+
*
|
|
2967
|
+
* Why this matters: under TLS 1.3 (RFC 8446 §4.4.2) a `clientAuth: 'MUST'`
|
|
2968
|
+
* server can reject the client's certificate AFTER the client's own
|
|
2969
|
+
* `'secureConnect'` — the rejection then surfaces as a post-connect socket
|
|
2970
|
+
* error. A misconfigured mTLS client must never auto-reconnect-loop against
|
|
2971
|
+
* a server that will always reject it, so `MllpClient` classifies
|
|
2972
|
+
* TLS-protocol-shaped errors as **permanent**.
|
|
2973
|
+
*
|
|
2974
|
+
* @example
|
|
2975
|
+
* ```typescript
|
|
2976
|
+
* import { isTlsProtocolError, MllpConnectionError } from '@cosyte/mllp';
|
|
2977
|
+
* client.on('error', ({ error }) => {
|
|
2978
|
+
* if (error instanceof MllpConnectionError && isTlsProtocolError(error.cause)) {
|
|
2979
|
+
* // TLS protocol failure — a configuration problem, not a network blip.
|
|
2980
|
+
* }
|
|
2981
|
+
* });
|
|
2982
|
+
* ```
|
|
2983
|
+
*/
|
|
2984
|
+
declare function isTlsProtocolError(err: unknown): boolean;
|
|
2985
|
+
/**
|
|
2986
|
+
* Classifies a connection error as transient (eligible for auto-reconnect)
|
|
2987
|
+
* or permanent (halts auto-reconnect, transitions to CLOSED).
|
|
2988
|
+
*
|
|
2989
|
+
* Used internally by `MllpClient` BEFORE invoking `retryStrategy` (Composition A
|
|
2990
|
+
* — see `RetryContext.classifiedAs`). Re-exported so callers can implement
|
|
2991
|
+
* their own retry policies.
|
|
2992
|
+
*
|
|
2993
|
+
* Classification table (CLIENT-18, D-16):
|
|
2994
|
+
* - `ENOTFOUND`, `EACCES` → **permanent** (`false`)
|
|
2995
|
+
* - `ECONNREFUSED`, `ECONNRESET`, `ETIMEDOUT`, `EHOSTUNREACH`,
|
|
2996
|
+
* `ENETUNREACH`, `EPIPE` → **transient** (`true`)
|
|
2997
|
+
* - `CERT_*` and `UNABLE_TO_VERIFY_LEAF_SIGNATURE` /
|
|
2998
|
+
* `DEPTH_ZERO_SELF_SIGNED_CERT` / `SELF_SIGNED_CERT_IN_CHAIN`
|
|
2999
|
+
* (any {@link isTlsVerificationErrorCode} code) → **permanent** (`false`) —
|
|
3000
|
+
* never auto-reconnect-loop into a misconfigured or MITM'd endpoint (Phase 8).
|
|
3001
|
+
* - `ERR_SSL_*` (Node TLS alert codes) → **permanent** (`false`) — a TLS
|
|
3002
|
+
* protocol failure such as a `clientAuth: 'MUST'` server rejecting the
|
|
3003
|
+
* client certificate recurs on every attempt (Phase 8). On TLS-configured
|
|
3004
|
+
* connections `MllpClient` additionally consults {@link isTlsProtocolError},
|
|
3005
|
+
* which also catches `EPROTO`/alert-bearing OpenSSL errors that this
|
|
3006
|
+
* generic classifier (which cannot know the connection was TLS) leaves
|
|
3007
|
+
* transient.
|
|
3008
|
+
* - `MLLP_*` (any {@link MllpFramingError} code — a fatal decoder throw, surfaced with
|
|
3009
|
+
* `connectionCause: 'framing-fatal'`) → **permanent** (`false`) (MLLP-10). The peer is not
|
|
3010
|
+
* speaking MLLP — an HTTP probe, a health check, a wrong-port misconfiguration — or is emitting
|
|
3011
|
+
* frames past `maxFrameSizeBytes`. Every reconnect meets the same bytes, so retrying is an
|
|
3012
|
+
* unbounded storm against a peer that is already misconfigured. If a peer's quirk is *expected*,
|
|
3013
|
+
* the decoder's tolerance opt-ins are the supported answer — they make it a warning, not a fatal.
|
|
3014
|
+
* - non-Error / unknown / no-code → **transient** (`true`) — Postel's Law
|
|
3015
|
+
* default. Reconnect attempts are bounded by `retryStrategy` and the
|
|
3016
|
+
* 30s backoff cap, so the default is safe.
|
|
3017
|
+
*
|
|
3018
|
+
* @example
|
|
3019
|
+
* ```typescript
|
|
3020
|
+
* import { isTransientConnectionError } from '@cosyte/mllp';
|
|
3021
|
+
* client.on('error', (err) => {
|
|
3022
|
+
* if (isTransientConnectionError(err)) {
|
|
3023
|
+
* metrics.increment('mllp.transient_error');
|
|
3024
|
+
* } else {
|
|
3025
|
+
* metrics.increment('mllp.permanent_error');
|
|
3026
|
+
* }
|
|
3027
|
+
* });
|
|
3028
|
+
* ```
|
|
3029
|
+
*/
|
|
3030
|
+
declare function isTransientConnectionError(err: unknown): boolean;
|
|
3031
|
+
/**
|
|
3032
|
+
* Thrown (or rejects the `send()` promise) when the in-flight queue exceeds
|
|
3033
|
+
* the configured high-water mark and `onBackpressure: 'reject'` is set
|
|
3034
|
+
* (CLIENT-07, ERR-04).
|
|
3035
|
+
*
|
|
3036
|
+
* `highWaterMark` accepts a count cap, a byte cap, or both — when both are
|
|
3037
|
+
* present, the stricter-of-two trigger wins (D-23).
|
|
3038
|
+
*
|
|
3039
|
+
* @example
|
|
3040
|
+
* ```typescript
|
|
3041
|
+
* try {
|
|
3042
|
+
* await client.send(payload);
|
|
3043
|
+
* } catch (err) {
|
|
3044
|
+
* if (err instanceof MllpBackpressureError) {
|
|
3045
|
+
* logger.warn({
|
|
3046
|
+
* queueDepth: err.queueDepth,
|
|
3047
|
+
* queueBytes: err.queueBytes,
|
|
3048
|
+
* cap: err.highWaterMark,
|
|
3049
|
+
* });
|
|
3050
|
+
* }
|
|
3051
|
+
* }
|
|
3052
|
+
* ```
|
|
3053
|
+
*/
|
|
3054
|
+
declare class MllpBackpressureError extends Error {
|
|
3055
|
+
readonly name: "MllpBackpressureError";
|
|
3056
|
+
/** Number of in-flight + queued sends at the moment of rejection. */
|
|
3057
|
+
readonly queueDepth: number;
|
|
3058
|
+
/** Total bytes of in-flight + queued frames at the moment of rejection. */
|
|
3059
|
+
readonly queueBytes: number;
|
|
3060
|
+
/** The high-water-mark configuration that was triggered (D-23). */
|
|
3061
|
+
readonly highWaterMark: {
|
|
3062
|
+
readonly count?: number;
|
|
3063
|
+
readonly bytes?: number;
|
|
3064
|
+
};
|
|
3065
|
+
/**
|
|
3066
|
+
* Construct an MLLP backpressure error.
|
|
3067
|
+
*
|
|
3068
|
+
* @param message - Human-readable error message.
|
|
3069
|
+
* @param opts - Backpressure context (queue depth, queued bytes, the high-water-mark hit).
|
|
3070
|
+
*/
|
|
3071
|
+
constructor(message: string, opts: {
|
|
3072
|
+
queueDepth: number;
|
|
3073
|
+
queueBytes: number;
|
|
3074
|
+
highWaterMark: {
|
|
3075
|
+
count?: number;
|
|
3076
|
+
bytes?: number;
|
|
3077
|
+
};
|
|
3078
|
+
});
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
/**
|
|
3082
|
+
* `@cosyte/mllp` — Production-grade MLLP client and server for Node.js.
|
|
3083
|
+
*
|
|
3084
|
+
* Transport-only sibling to `@cosyte/hl7`. Handles framing, ACKs, reconnects,
|
|
3085
|
+
* backpressure, and TLS without requiring knowledge of the MLLP spec.
|
|
3086
|
+
*
|
|
3087
|
+
* @example
|
|
3088
|
+
* ```typescript
|
|
3089
|
+
* import { createStarterServer } from '@cosyte/mllp';
|
|
3090
|
+
* const server = await createStarterServer({ port: 2575, onMessage: (buf) => buf });
|
|
3091
|
+
* ```
|
|
3092
|
+
*
|
|
3093
|
+
* @packageDocumentation
|
|
3094
|
+
*/
|
|
3095
|
+
/**
|
|
3096
|
+
* Package version marker exported from the `@cosyte/mllp` root.
|
|
3097
|
+
*
|
|
3098
|
+
* Kept in lockstep with `package.json` by `scripts/sync-version.mjs`, which the `version` script
|
|
3099
|
+
* runs immediately after `changeset version`. The `: string` annotation is deliberate — without it
|
|
3100
|
+
* TypeScript infers the *literal* type (`declare const VERSION = "0.0.0"`), which leaks the current
|
|
3101
|
+
* release into consumers' types and makes an equality check against any other version a compile
|
|
3102
|
+
* error.
|
|
3103
|
+
*
|
|
3104
|
+
* @example
|
|
3105
|
+
* ```typescript
|
|
3106
|
+
* import { VERSION } from '@cosyte/mllp';
|
|
3107
|
+
* console.log(VERSION);
|
|
3108
|
+
* ```
|
|
3109
|
+
*/
|
|
3110
|
+
declare const VERSION: string;
|
|
3111
|
+
|
|
3112
|
+
export { type AckCode, type ClientAuth, type ClientOptions, type ClientStats, Connection, type ConnectionErrorCause, type ConnectionErrorPhase, type ConnectionOptions, type ConnectionState, type ConnectionStats, type EncoderOptions, FrameReader, type FrameReaderOptions, MLLP_BIND_ALL_INTERFACES, MLLP_TLS_VERIFY_DISABLED, type MessageMeta, MllpAckError, MllpBackpressureError, MllpClient, MllpConnectionError, MllpFramingError, MllpServer, MllpTimeoutError, type MllpWarning, type NackEvent, type NackReason, type NegativeAckCode, NetTransport, type PemInput, type ReconnectingEvent, type RetryContext, type RetryStrategy, type SecurityWarning, type SecurityWarningCode, type ServerOptions, type ServerStats, type ServerTlsOptions, type StarterClientOptions, type StarterServerOptions, type StateChangeEvent, type TlsOptions, TlsTransport, Transport, VERSION, type WarningCode, buildRawAck, createClient, createServer, createStarterClient, createStarterServer, createWarning, encodeFrame, isTlsProtocolError, isTlsVerificationErrorCode, isTransientConnectionError, rawAckUncorrelatable, resolveNackCode };
|