@glion/mllp-client 0.16.0
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/LICENSE +21 -0
- package/README.md +326 -0
- package/dist/client.js +549 -0
- package/dist/client.js.map +1 -0
- package/dist/core/acknowledgment.d.ts +103 -0
- package/dist/core/acknowledgment.d.ts.map +1 -0
- package/dist/core/client.d.ts +256 -0
- package/dist/core/client.d.ts.map +1 -0
- package/dist/core/connect.d.ts +101 -0
- package/dist/core/connect.d.ts.map +1 -0
- package/dist/core/errors.d.ts +81 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/index.d.ts +23 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +2 -0
- package/dist/core/internal/ack-parser-stream.d.ts +22 -0
- package/dist/core/internal/ack-parser-stream.d.ts.map +1 -0
- package/dist/core/internal/subscribe-abort.d.ts +30 -0
- package/dist/core/internal/subscribe-abort.d.ts.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/node.js +214 -0
- package/dist/node.js.map +1 -0
- package/dist/runtimes/internal/node-error-mapping.d.ts +68 -0
- package/dist/runtimes/internal/node-error-mapping.d.ts.map +1 -0
- package/dist/runtimes/node.d.ts +47 -0
- package/dist/runtimes/node.d.ts.map +1 -0
- package/dist/runtimes/node.js +3 -0
- package/dist/runtimes/workers.d.ts +64 -0
- package/dist/runtimes/workers.d.ts.map +1 -0
- package/dist/runtimes/workers.js +143 -0
- package/dist/runtimes/workers.js.map +1 -0
- package/package.json +95 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
import { AckApplicationError, AckApplicationReject, AckCode, AckCommitError, AckCommitReject, AckException, Hl7ErrorCode, Severity } from "@glion/ack";
|
|
2
|
+
import { MllpError, createDecoderStream, encode } from "@glion/mllp-transport";
|
|
3
|
+
import { parseHL7v2 } from "@glion/parser";
|
|
4
|
+
import { value } from "@glion/util-query";
|
|
5
|
+
//#region src/core/errors.ts
|
|
6
|
+
/**
|
|
7
|
+
* Machine-readable codes for `MllpClient` transport failures.
|
|
8
|
+
*
|
|
9
|
+
* These cover everything that can go wrong between the moment
|
|
10
|
+
* `MllpClient.send()` is called and the moment a complete ACK frame
|
|
11
|
+
* has been decoded — i.e. everything that is not an HL7v2
|
|
12
|
+
* application-level NAK. NAK responses (MSA-1 ∈ {AE, AR, CE, CR})
|
|
13
|
+
* surface as `AckException` subclasses thrown directly from
|
|
14
|
+
* `@glion/ack`, not as transport errors.
|
|
15
|
+
*/
|
|
16
|
+
const MllpClientErrorCode = {
|
|
17
|
+
/** No ACK arrived within the configured `timeout`. */
|
|
18
|
+
/** The peer closed the connection before a complete ACK arrived. */
|
|
19
|
+
CONNECTION_CLOSED: "CONNECTION_CLOSED",
|
|
20
|
+
/** TCP connection could not be established (refused, DNS, routing). */
|
|
21
|
+
CONNECTION_REFUSED: "CONNECTION_REFUSED",
|
|
22
|
+
/**
|
|
23
|
+
* The caller passed a payload that could not be MLLP-encoded —
|
|
24
|
+
* typically because it was not a `string` or `Uint8Array`. Also
|
|
25
|
+
* surfaced when constructor options fail validation (bad
|
|
26
|
+
* host/port/timeout/etc). Thrown before any socket is opened, so
|
|
27
|
+
* no connection is made and no resources need cleanup.
|
|
28
|
+
*/
|
|
29
|
+
INVALID_INPUT: "INVALID_INPUT",
|
|
30
|
+
MALFORMED_ACK: "MALFORMED_ACK",
|
|
31
|
+
/** The remote sent bytes that did not form a valid MLLP frame. */
|
|
32
|
+
MALFORMED_FRAME: "MALFORMED_FRAME",
|
|
33
|
+
/** A frame was received but could not be parsed as a valid HL7v2 ACK. */
|
|
34
|
+
TIMEOUT: "TIMEOUT",
|
|
35
|
+
/**
|
|
36
|
+
* TLS handshake failed — typically because of certificate
|
|
37
|
+
* verification, hostname mismatch, expired cert, untrusted CA, or
|
|
38
|
+
* a TLS protocol error. Distinct from `CONNECTION_REFUSED` (the
|
|
39
|
+
* TCP connection succeeded but the TLS upgrade failed) so
|
|
40
|
+
* operators can identify TLS misconfiguration quickly in
|
|
41
|
+
* healthcare deployments where it is a recurring incident class.
|
|
42
|
+
*/
|
|
43
|
+
TLS_HANDSHAKE_FAILED: "TLS_HANDSHAKE_FAILED"
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Error thrown by `MllpClient.send()` for transport-level failures.
|
|
47
|
+
*
|
|
48
|
+
* Extends {@link MllpError} so callers can catch any MLLP error —
|
|
49
|
+
* server- or client-side — with a single `instanceof MllpError`
|
|
50
|
+
* check. The {@link MllpClientErrorCode | code} field discriminates
|
|
51
|
+
* the failure mode for typed handling.
|
|
52
|
+
*
|
|
53
|
+
* Application-level NAK responses are **not** thrown as
|
|
54
|
+
* `MllpClientError`; they are thrown as `AckException` subclasses
|
|
55
|
+
* from `@glion/ack`. A typical `catch` block handles both:
|
|
56
|
+
*
|
|
57
|
+
* ```ts
|
|
58
|
+
* import { AckException } from "@glion/ack";
|
|
59
|
+
* import { MllpClientError } from "@glion/mllp-client";
|
|
60
|
+
*
|
|
61
|
+
* try {
|
|
62
|
+
* await client.send(message);
|
|
63
|
+
* } catch (error) {
|
|
64
|
+
* if (error instanceof AckException) {
|
|
65
|
+
* // Application-level NAK.
|
|
66
|
+
* } else if (error instanceof MllpClientError) {
|
|
67
|
+
* // Transport-level failure.
|
|
68
|
+
* }
|
|
69
|
+
* }
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
var MllpClientError = class extends MllpError {
|
|
73
|
+
constructor(code, message, options) {
|
|
74
|
+
super(code, message, options);
|
|
75
|
+
this.name = "MllpClientError";
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/core/acknowledgment.ts
|
|
80
|
+
/**
|
|
81
|
+
* Acknowledgment parsing for the MLLP client.
|
|
82
|
+
*
|
|
83
|
+
* This module owns three things:
|
|
84
|
+
*
|
|
85
|
+
* 1. The {@link Acknowledgment} interface — a structured view of an ACK message
|
|
86
|
+
* exposing the fields callers most often need (MSA-1/2/3, ERR-3/4) without
|
|
87
|
+
* forcing them to walk the AST themselves.
|
|
88
|
+
* 2. {@link parseAck} — turns a raw wire-format ACK string into an
|
|
89
|
+
* {@link Acknowledgment}, or throws {@link MllpClientError} when the payload
|
|
90
|
+
* is not a recognisable ACK.
|
|
91
|
+
* 3. {@link throwOnNak} — inspects an {@link Acknowledgment} and throws the
|
|
92
|
+
* matching `AckException` subclass when MSA-1 indicates a NAK.
|
|
93
|
+
*
|
|
94
|
+
* Both helpers are internal to the package — only the {@link Acknowledgment}
|
|
95
|
+
* type is re-exported from `@glion/mllp-client`. Application code interacts
|
|
96
|
+
* with parsed acknowledgments through the return value (and thrown errors)
|
|
97
|
+
* of `MllpClient.send()`.
|
|
98
|
+
*
|
|
99
|
+
* The HL7v2 ACK structure used here (Section 2.9.2 of the HL7v2 standard):
|
|
100
|
+
*
|
|
101
|
+
* MSH | header (sender, receiver, timestamp, message control id) MSA |
|
|
102
|
+
* acknowledgment code (MSA-1), original control id (MSA-2), text (MSA-3) ERR |
|
|
103
|
+
* optional error segment with HL7 condition code (ERR-3) and severity (ERR-4)
|
|
104
|
+
*
|
|
105
|
+
* @module
|
|
106
|
+
*/
|
|
107
|
+
/**
|
|
108
|
+
* MSA-1 codes that mean "the receiver accepted the message". Anything
|
|
109
|
+
* outside this set is a NAK and produces a thrown `AckException`.
|
|
110
|
+
*
|
|
111
|
+
* See HL7v2 Table 0008 — Acknowledgment Codes.
|
|
112
|
+
*/
|
|
113
|
+
const SUCCESS_CODES = new Set([AckCode.ApplicationAccept, AckCode.CommitAccept]);
|
|
114
|
+
/**
|
|
115
|
+
* MSA-1 codes that are **intermediate** in HL7v2 enhanced
|
|
116
|
+
* acknowledgment mode — the receiver will follow up with a separate
|
|
117
|
+
* final acknowledgment later in the exchange. The MLLP client's read
|
|
118
|
+
* loop continues past these by default and only resolves on a final
|
|
119
|
+
* code.
|
|
120
|
+
*
|
|
121
|
+
* Per HL7v2 Section 2.9.2, only `CA` (Commit Accept) is intermediate;
|
|
122
|
+
* `CE` and `CR` are final at the commit level. `AA`, `AE`, `AR` are
|
|
123
|
+
* final at the application level.
|
|
124
|
+
*/
|
|
125
|
+
const INTERMEDIATE_ACK_CODES = new Set([AckCode.CommitAccept]);
|
|
126
|
+
/**
|
|
127
|
+
* Returns `true` for any acknowledgment code that should terminate the
|
|
128
|
+
* client's read loop. Intermediate codes (CA) return `false`; every
|
|
129
|
+
* other code — including vendor-specific values — is treated as final
|
|
130
|
+
* to avoid hanging on responses we do not recognise.
|
|
131
|
+
*/
|
|
132
|
+
function isFinalAckCode(code) {
|
|
133
|
+
return !INTERMEDIATE_ACK_CODES.has(code);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Parse a raw ACK frame string into an {@link Acknowledgment}.
|
|
137
|
+
*
|
|
138
|
+
* Field extraction is delegated to `@glion/util-query`'s `value()` helper —
|
|
139
|
+
* this is the same path used elsewhere in the Glion stack and avoids
|
|
140
|
+
* hand-rolling AST traversal here.
|
|
141
|
+
*
|
|
142
|
+
* Throws `MllpClientError(MALFORMED_ACK)` when:
|
|
143
|
+
*
|
|
144
|
+
* - The parser fails to produce a tree (malformed HL7v2), or
|
|
145
|
+
* - MSA-1 is missing (the message is not a valid acknowledgment).
|
|
146
|
+
*
|
|
147
|
+
* Other missing fields (MSA-3, ERR-3, ERR-4) are not fatal — they are
|
|
148
|
+
* exposed as `undefined` because real-world ACKs frequently omit them.
|
|
149
|
+
*/
|
|
150
|
+
function parseAck(raw) {
|
|
151
|
+
const tree = parseTreeOrThrow(raw);
|
|
152
|
+
const code = value(tree, "MSA-1")?.value;
|
|
153
|
+
if (!code) throw new MllpClientError(MllpClientErrorCode.MALFORMED_ACK, "ACK response is missing MSA-1 acknowledgment code");
|
|
154
|
+
return {
|
|
155
|
+
code,
|
|
156
|
+
controlId: value(tree, "MSA-2")?.value ?? "",
|
|
157
|
+
errorCode: value(tree, "ERR-3")?.value ?? void 0,
|
|
158
|
+
raw,
|
|
159
|
+
severity: value(tree, "ERR-4")?.value ?? void 0,
|
|
160
|
+
textMessage: value(tree, "MSA-3")?.value ?? void 0,
|
|
161
|
+
tree
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Run the HL7v2 parser, wrapping any thrown error in
|
|
166
|
+
* `MllpClientError(MALFORMED_ACK)` so callers see one error shape
|
|
167
|
+
* regardless of which lower-level component failed.
|
|
168
|
+
*/
|
|
169
|
+
function parseTreeOrThrow(raw) {
|
|
170
|
+
try {
|
|
171
|
+
return parseHL7v2(raw);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
throw new MllpClientError(MllpClientErrorCode.MALFORMED_ACK, `Failed to parse ACK response: ${formatCause(error)}`, { cause: error });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Inspect an {@link Acknowledgment} and throw the matching `AckException`
|
|
178
|
+
* subclass when MSA-1 indicates a NAK. Returns the acknowledgment unchanged
|
|
179
|
+
* on AA/CA.
|
|
180
|
+
*
|
|
181
|
+
* The thrown exception mirrors the receiver-side exception types — a
|
|
182
|
+
* caller that already handles `AckApplicationError` from a server's
|
|
183
|
+
* middleware can reuse the same `catch` blocks here.
|
|
184
|
+
*/
|
|
185
|
+
function throwOnNak(ack) {
|
|
186
|
+
if (SUCCESS_CODES.has(ack.code)) return ack;
|
|
187
|
+
throw buildAckException(ack);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Convert a NAK acknowledgment into an `AckException` subclass.
|
|
191
|
+
*
|
|
192
|
+
* The fallbacks below preserve a useful exception even when the receiver
|
|
193
|
+
* sent a sparse NAK: a missing MSA-3 yields a synthesised message; a
|
|
194
|
+
* missing ERR segment yields the catch-all `ApplicationInternalError`
|
|
195
|
+
* code (`207`) and severity Error (`E`). This matches the receiver-side
|
|
196
|
+
* conventions in `@glion/ack`.
|
|
197
|
+
*/
|
|
198
|
+
function buildAckException(ack) {
|
|
199
|
+
const message = ack.textMessage ?? `Acknowledgment ${ack.code} from receiver`;
|
|
200
|
+
const options = {
|
|
201
|
+
errorCode: coerceErrorCode(ack.errorCode),
|
|
202
|
+
raw: ack.raw,
|
|
203
|
+
severity: coerceSeverity(ack.severity)
|
|
204
|
+
};
|
|
205
|
+
switch (ack.code) {
|
|
206
|
+
case AckCode.ApplicationError: return new AckApplicationError(message, options);
|
|
207
|
+
case AckCode.ApplicationReject: return new AckApplicationReject(message, options);
|
|
208
|
+
case AckCode.CommitError: return new AckCommitError(message, options);
|
|
209
|
+
case AckCode.CommitReject: return new AckCommitReject(message, options);
|
|
210
|
+
default: return new AckApplicationError(message, options);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Set of HL7 condition codes (Table 0357) we recognise. Built once from
|
|
215
|
+
* the `Hl7ErrorCode` enum so the lookup stays cheap and the membership
|
|
216
|
+
* stays in sync with the source of truth in `@glion/ack`.
|
|
217
|
+
*/
|
|
218
|
+
const KNOWN_ERROR_CODES = new Set(Object.values(Hl7ErrorCode));
|
|
219
|
+
/**
|
|
220
|
+
* Set of severity codes (Table 0516) we recognise.
|
|
221
|
+
*/
|
|
222
|
+
const KNOWN_SEVERITIES = new Set(Object.values(Severity));
|
|
223
|
+
/**
|
|
224
|
+
* Map an ERR-3 string to a typed {@link Hl7ErrorCodeValue}. Unknown or
|
|
225
|
+
* missing values fall back to `ApplicationInternalError` (`207`) so the
|
|
226
|
+
* exception always carries a well-defined code, mirroring receiver-side
|
|
227
|
+
* conventions in `@glion/ack`.
|
|
228
|
+
*
|
|
229
|
+
* The widening cast is intentional and confined here — receivers
|
|
230
|
+
* occasionally send vendor-specific codes outside Table 0357, and the
|
|
231
|
+
* client must still produce a usable exception rather than reject the
|
|
232
|
+
* NAK over an unknown code.
|
|
233
|
+
*/
|
|
234
|
+
function coerceErrorCode(input) {
|
|
235
|
+
if (input !== void 0 && KNOWN_ERROR_CODES.has(input)) return input;
|
|
236
|
+
return Hl7ErrorCode.ApplicationInternalError;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Map an ERR-4 string to a typed {@link SeverityValue}. Unknown or
|
|
240
|
+
* missing values fall back to `Severity.Error` (`E`).
|
|
241
|
+
*/
|
|
242
|
+
function coerceSeverity(input) {
|
|
243
|
+
if (input !== void 0 && KNOWN_SEVERITIES.has(input)) return input;
|
|
244
|
+
return Severity.Error;
|
|
245
|
+
}
|
|
246
|
+
/** Render an unknown thrown value as a human-readable string for error messages. */
|
|
247
|
+
function formatCause(error) {
|
|
248
|
+
return error instanceof Error ? error.message : String(error);
|
|
249
|
+
}
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region src/core/internal/ack-parser-stream.ts
|
|
252
|
+
/**
|
|
253
|
+
* Build a transform that consumes decoded MLLP frames and produces
|
|
254
|
+
* parsed `Acknowledgment` objects. A parse failure errors the
|
|
255
|
+
* readable side rather than enqueuing — the consumer's `for await`
|
|
256
|
+
* loop rejects with the typed error.
|
|
257
|
+
*/
|
|
258
|
+
function createAckParserStream() {
|
|
259
|
+
return new TransformStream({ transform(frame, controller) {
|
|
260
|
+
try {
|
|
261
|
+
controller.enqueue(parseAck(frame.text));
|
|
262
|
+
} catch (error) {
|
|
263
|
+
controller.error(error);
|
|
264
|
+
}
|
|
265
|
+
} });
|
|
266
|
+
}
|
|
267
|
+
//#endregion
|
|
268
|
+
//#region src/core/internal/subscribe-abort.ts
|
|
269
|
+
/**
|
|
270
|
+
* Subscribe to an `AbortSignal` and run a callback once if it
|
|
271
|
+
* aborts. Returns a disposer that unsubscribes — call it from a
|
|
272
|
+
* `finally` block so we never leak listeners.
|
|
273
|
+
*
|
|
274
|
+
* If the signal is already aborted at subscription time, the
|
|
275
|
+
* callback fires synchronously and the returned disposer is a
|
|
276
|
+
* no-op. This collapses the "aborted vs aborting" branch every
|
|
277
|
+
* caller would otherwise need to write by hand.
|
|
278
|
+
*
|
|
279
|
+
* Internal to `@glion/mllp-client`.
|
|
280
|
+
*
|
|
281
|
+
* @module
|
|
282
|
+
*/
|
|
283
|
+
/**
|
|
284
|
+
* Subscribe `onAbort` to fire once when `signal` aborts. Returns a
|
|
285
|
+
* disposer that removes the listener.
|
|
286
|
+
*
|
|
287
|
+
* @example
|
|
288
|
+
* ```ts
|
|
289
|
+
* const dispose = subscribeAbort(signal, () => socket.destroy());
|
|
290
|
+
* try {
|
|
291
|
+
* await waitForReady(socket);
|
|
292
|
+
* } finally {
|
|
293
|
+
* dispose();
|
|
294
|
+
* }
|
|
295
|
+
* ```;
|
|
296
|
+
*/
|
|
297
|
+
function subscribeAbort(signal, onAbort) {
|
|
298
|
+
if (signal.aborted) {
|
|
299
|
+
onAbort();
|
|
300
|
+
return noop;
|
|
301
|
+
}
|
|
302
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
303
|
+
return () => signal.removeEventListener("abort", onAbort);
|
|
304
|
+
}
|
|
305
|
+
function noop() {}
|
|
306
|
+
//#endregion
|
|
307
|
+
//#region src/core/client.ts
|
|
308
|
+
/**
|
|
309
|
+
* Runtime-free core of `@glion/mllp-client`.
|
|
310
|
+
*
|
|
311
|
+
* This module owns {@link MllpClient}, the class application code
|
|
312
|
+
* instantiates to send HL7v2 messages and receive acknowledgments.
|
|
313
|
+
* The class is **transport-agnostic**: it composes a Web-Streams
|
|
314
|
+
* pipeline (`socket → decoder → ack-parser`) and consumes parsed
|
|
315
|
+
* acknowledgments from the readable side. It does **not** know how
|
|
316
|
+
* to open a TCP/TLS connection. That concern is supplied by a
|
|
317
|
+
* {@link MllpConnect} function passed in via
|
|
318
|
+
* {@link MllpClientOptions.connect}. Per-runtime adapters
|
|
319
|
+
* (`@glion/mllp-client/node`, `/deno`, `/workers`) ship pre-wired
|
|
320
|
+
* `MllpClient` subclasses so the common case stays a single import.
|
|
321
|
+
*
|
|
322
|
+
* Each call to `client.send()` performs one short-lived round trip:
|
|
323
|
+
*
|
|
324
|
+
* ┌─────────────┐ ① connect ┌──────────┐
|
|
325
|
+
* │ client │ ───────────────▶ │ duplex │
|
|
326
|
+
* │ │ └────┬─────┘
|
|
327
|
+
* │ │ ② write MLLP frame │
|
|
328
|
+
* │ │ ─────────────────────▶│ <VT> ... <FS><CR>
|
|
329
|
+
* │ │ │
|
|
330
|
+
* │ │ ③ read frames via │
|
|
331
|
+
* │ │ decoder + parser │
|
|
332
|
+
* │ │ ◀─────────────────────│ <VT> ACK <FS><CR> ...
|
|
333
|
+
* │ │ ┌────┴─────┐
|
|
334
|
+
* │ │ ④ close + parse │ duplex │ closed
|
|
335
|
+
* └─────────────┘ └──────────┘
|
|
336
|
+
*
|
|
337
|
+
* The four phases share a single deadline ({@link MllpClientOptions.timeout}).
|
|
338
|
+
* If any phase exceeds the budget, the duplex is closed and a typed
|
|
339
|
+
* `MllpClientError(TIMEOUT)` is thrown. Connections are deliberately
|
|
340
|
+
* **not** pooled or reused — each `send()` opens its own duplex.
|
|
341
|
+
*
|
|
342
|
+
* @module
|
|
343
|
+
*/
|
|
344
|
+
/** Default `timeout` when {@link MllpClientOptions.timeout} is omitted. */
|
|
345
|
+
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
346
|
+
/**
|
|
347
|
+
* Runtime-free MLLP client. Instantiate via a runtime adapter
|
|
348
|
+
* (`@glion/mllp-client/node`, `/deno`, `/workers`) for the common
|
|
349
|
+
* case.
|
|
350
|
+
*
|
|
351
|
+
* **TLS-on by default.** `MllpClientOptions.tls` defaults to `true`;
|
|
352
|
+
* pass `tls: false` for plain TCP. See {@link MllpClientOptions.tls}.
|
|
353
|
+
*
|
|
354
|
+
* @example
|
|
355
|
+
* Send a message and read the ACK on Node:
|
|
356
|
+
*
|
|
357
|
+
* ```ts
|
|
358
|
+
* import { MllpClient } from "@glion/mllp-client/node";
|
|
359
|
+
*
|
|
360
|
+
* const client = new MllpClient({ host: "127.0.0.1", port: 2575 });
|
|
361
|
+
* const ack = await client.send(rawHl7Message);
|
|
362
|
+
* console.log(ack.code, ack.controlId); // "AA" "MSG001"
|
|
363
|
+
* ```
|
|
364
|
+
*/
|
|
365
|
+
var MllpClient = class {
|
|
366
|
+
#host;
|
|
367
|
+
#port;
|
|
368
|
+
#timeout;
|
|
369
|
+
#maxAckSize;
|
|
370
|
+
#tls;
|
|
371
|
+
#connect;
|
|
372
|
+
constructor(options) {
|
|
373
|
+
validateOptions(options);
|
|
374
|
+
this.#host = options.host;
|
|
375
|
+
this.#port = options.port;
|
|
376
|
+
this.#connect = options.connect;
|
|
377
|
+
this.#timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
378
|
+
this.#maxAckSize = options.maxAckSize;
|
|
379
|
+
if (options.tls === false) this.#tls = void 0;
|
|
380
|
+
else if (options.tls === true || options.tls === void 0) this.#tls = {};
|
|
381
|
+
else this.#tls = options.tls;
|
|
382
|
+
}
|
|
383
|
+
/** The host this client connects to. */
|
|
384
|
+
get host() {
|
|
385
|
+
return this.#host;
|
|
386
|
+
}
|
|
387
|
+
/** The port this client connects to. */
|
|
388
|
+
get port() {
|
|
389
|
+
return this.#port;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Send a single HL7v2 message and resolve with the resolving accept ACK.
|
|
393
|
+
*
|
|
394
|
+
* Throws:
|
|
395
|
+
*
|
|
396
|
+
* - {@link MllpClientError} for transport failures. `code` identifies the mode:
|
|
397
|
+
* `INVALID_INPUT`, `CONNECTION_REFUSED`, `TIMEOUT`, `CONNECTION_CLOSED`,
|
|
398
|
+
* `MALFORMED_FRAME`, or `MALFORMED_ACK`.
|
|
399
|
+
* - An `AckException` subclass from `@glion/ack` (`AckApplicationError`,
|
|
400
|
+
* `AckApplicationReject`, `AckCommitError`, `AckCommitReject`) when MSA-1 ∈
|
|
401
|
+
* {AE, AR, CE, CR}. The thrown exception's `raw` attribute holds the
|
|
402
|
+
* wire-format ACK.
|
|
403
|
+
*
|
|
404
|
+
* To observe each accept ACK as it arrives (e.g. seeing the
|
|
405
|
+
* intermediate `CA` frame in HL7v2 enhanced mode), use
|
|
406
|
+
* {@link MllpClient.stream} instead.
|
|
407
|
+
*/
|
|
408
|
+
async send(message, options = {}) {
|
|
409
|
+
let last;
|
|
410
|
+
for await (const ack of this.#exchange(message, options)) last = ack;
|
|
411
|
+
if (!last) throw new MllpClientError(MllpClientErrorCode.CONNECTION_CLOSED, "Connection closed before a complete ACK was received");
|
|
412
|
+
return last;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Send a single HL7v2 message and yield each accept ACK as it
|
|
416
|
+
* arrives. The iterable completes after the resolving frame.
|
|
417
|
+
*
|
|
418
|
+
* In HL7v2 enhanced mode the iterable yields `CA` then the
|
|
419
|
+
* application-level ACK; in basic mode it yields a single
|
|
420
|
+
* application-level ACK. NAK codes (`AE`/`AR`/`CE`/`CR`) throw the
|
|
421
|
+
* matching `AckException` from the iterator with the wire-format
|
|
422
|
+
* ACK on `error.raw`.
|
|
423
|
+
*
|
|
424
|
+
* Use {@link MllpClient.send} for the simple "give me the resolving
|
|
425
|
+
* answer" case.
|
|
426
|
+
*/
|
|
427
|
+
stream(message, options = {}) {
|
|
428
|
+
return this.#exchange(message, options);
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Internal exchange generator shared by {@link send} and {@link stream}.
|
|
432
|
+
*
|
|
433
|
+
* Owns the connect → write → read loop. NAK codes throw
|
|
434
|
+
* `AckException` instead of yielding so both consumption shapes see
|
|
435
|
+
* the same exception. Cleanup (socket teardown, abort
|
|
436
|
+
* unsubscription) runs in the generator's `finally`, so a consumer
|
|
437
|
+
* that breaks out of iteration still releases the socket.
|
|
438
|
+
*
|
|
439
|
+
* @yields Each accept ACK in arrival order. `return`s after the
|
|
440
|
+
* resolving frame.
|
|
441
|
+
*/
|
|
442
|
+
async *#exchange(message, options) {
|
|
443
|
+
const frame = encodeOrThrow(message);
|
|
444
|
+
const mode = options.mode ?? "OnApplication";
|
|
445
|
+
const sources = [AbortSignal.timeout(this.#timeout)];
|
|
446
|
+
if (options.signal) sources.push(options.signal);
|
|
447
|
+
const signal = AbortSignal.any(sources);
|
|
448
|
+
const duplex = await this.#connect({
|
|
449
|
+
host: this.#host,
|
|
450
|
+
port: this.#port,
|
|
451
|
+
signal,
|
|
452
|
+
tls: this.#tls
|
|
453
|
+
});
|
|
454
|
+
const reader = duplex.readable.pipeThrough(createDecoderStream({
|
|
455
|
+
maxMessageSize: this.#maxAckSize,
|
|
456
|
+
onError: (frameError) => {
|
|
457
|
+
throw new MllpClientError(MllpClientErrorCode.MALFORMED_FRAME, `Invalid ACK frame: ${frameError.message}`, { cause: frameError });
|
|
458
|
+
}
|
|
459
|
+
})).pipeThrough(createAckParserStream()).getReader();
|
|
460
|
+
const writer = duplex.writable.getWriter();
|
|
461
|
+
const unsubscribe = subscribeAbort(signal, () => {
|
|
462
|
+
duplex.close();
|
|
463
|
+
});
|
|
464
|
+
try {
|
|
465
|
+
await writer.write(frame);
|
|
466
|
+
while (true) {
|
|
467
|
+
const { done, value: ack } = await reader.read();
|
|
468
|
+
if (done) throw new MllpClientError(MllpClientErrorCode.CONNECTION_CLOSED, "Connection closed before a complete ACK was received");
|
|
469
|
+
throwOnNak(ack);
|
|
470
|
+
yield ack;
|
|
471
|
+
if (mode === "OnCommit" || isFinalAckCode(ack.code)) return;
|
|
472
|
+
}
|
|
473
|
+
} catch (error) {
|
|
474
|
+
if (error instanceof AckException) throw error;
|
|
475
|
+
throw normaliseSendError(error, signal, this.#timeout);
|
|
476
|
+
} finally {
|
|
477
|
+
unsubscribe();
|
|
478
|
+
await duplex.close();
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
/**
|
|
483
|
+
* MLLP-encode a payload, mapping any failure to a typed
|
|
484
|
+
* {@link MllpClientError} with code `INVALID_INPUT`.
|
|
485
|
+
*/
|
|
486
|
+
function encodeOrThrow(message) {
|
|
487
|
+
try {
|
|
488
|
+
return encode(message);
|
|
489
|
+
} catch (error) {
|
|
490
|
+
throw new MllpClientError(MllpClientErrorCode.INVALID_INPUT, `Invalid message payload: ${error instanceof Error ? error.message : String(error)}`, { cause: error instanceof Error ? error : void 0 });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Translate a thrown value from any phase of {@link readAck} into a
|
|
495
|
+
* typed client error.
|
|
496
|
+
*
|
|
497
|
+
* Precedence:
|
|
498
|
+
*
|
|
499
|
+
* 1. If the signal aborted with a typed {@link MllpClientError} reason, surface it
|
|
500
|
+
* verbatim. (Connector or pipeline-internal failures keep their original
|
|
501
|
+
* code.)
|
|
502
|
+
* 2. Else if the signal aborted with the standard timeout `DOMException` produced
|
|
503
|
+
* by `AbortSignal.timeout(ms)`, wrap as `TIMEOUT` with the original budget
|
|
504
|
+
* in the message.
|
|
505
|
+
* 3. Else if the signal aborted with any other reason (a caller's own
|
|
506
|
+
* `AbortController.abort(reason)`), wrap as `TIMEOUT` with the caller's
|
|
507
|
+
* reason chained as `cause` — caller cancellation conceptually IS a timeout
|
|
508
|
+
* from the protocol's perspective.
|
|
509
|
+
* 4. Else if the underlying stream rejection is already typed, return it unchanged
|
|
510
|
+
* so adapter-specific failures are preserved.
|
|
511
|
+
* 5. Otherwise wrap as `CONNECTION_CLOSED`.
|
|
512
|
+
*/
|
|
513
|
+
function normaliseSendError(error, signal, timeoutMs) {
|
|
514
|
+
if (signal.aborted) {
|
|
515
|
+
if (signal.reason instanceof MllpClientError) return signal.reason;
|
|
516
|
+
if (isTimeoutAbort(signal.reason)) return new MllpClientError(MllpClientErrorCode.TIMEOUT, `MLLP round trip exceeded ${timeoutMs}ms`);
|
|
517
|
+
return new MllpClientError(MllpClientErrorCode.TIMEOUT, "Send aborted by caller", { cause: signal.reason instanceof Error ? signal.reason : void 0 });
|
|
518
|
+
}
|
|
519
|
+
if (error instanceof MllpClientError) return error;
|
|
520
|
+
const wrapped = error instanceof Error ? error : new Error(String(error));
|
|
521
|
+
return new MllpClientError(MllpClientErrorCode.CONNECTION_CLOSED, `Connection failed during MLLP exchange: ${wrapped.message}`, { cause: wrapped });
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Detect the standard `DOMException` produced by
|
|
525
|
+
* `AbortSignal.timeout(ms)` when the timer fires. The runtime
|
|
526
|
+
* convention is `name: "TimeoutError"` — duck-typed because
|
|
527
|
+
* `DOMException` exists in Node, Bun, Deno, and Workers but tests
|
|
528
|
+
* run in environments where the constructor identity may differ.
|
|
529
|
+
*/
|
|
530
|
+
function isTimeoutAbort(reason) {
|
|
531
|
+
return reason !== null && typeof reason === "object" && reason.name === "TimeoutError";
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Validate {@link MllpClientOptions} at construction time. Catches
|
|
535
|
+
* the sharp edges that would otherwise surface as confusing runtime
|
|
536
|
+
* symptoms.
|
|
537
|
+
*/
|
|
538
|
+
function validateOptions(options) {
|
|
539
|
+
if (typeof options.host !== "string" || options.host.length === 0) throw new MllpClientError(MllpClientErrorCode.INVALID_INPUT, "MllpClientOptions.host must be a non-empty string");
|
|
540
|
+
if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) throw new MllpClientError(MllpClientErrorCode.INVALID_INPUT, `MllpClientOptions.port must be an integer in 1..65535, got ${options.port}`);
|
|
541
|
+
if (typeof options.connect !== "function") throw new MllpClientError(MllpClientErrorCode.INVALID_INPUT, "MllpClientOptions.connect must be a function — import the adapter for your runtime (`@glion/mllp-client/node`, `/deno`, `/workers`)");
|
|
542
|
+
if (options.timeout !== void 0 && (!Number.isFinite(options.timeout) || options.timeout <= 0)) throw new MllpClientError(MllpClientErrorCode.INVALID_INPUT, `MllpClientOptions.timeout must be a positive finite number of milliseconds, got ${options.timeout}`);
|
|
543
|
+
if (options.maxAckSize !== void 0 && (!Number.isInteger(options.maxAckSize) || options.maxAckSize <= 0)) throw new MllpClientError(MllpClientErrorCode.INVALID_INPUT, `MllpClientOptions.maxAckSize must be a positive integer, got ${options.maxAckSize}`);
|
|
544
|
+
if (options.tls !== void 0 && typeof options.tls !== "boolean" && (typeof options.tls !== "object" || options.tls === null)) throw new MllpClientError(MllpClientErrorCode.INVALID_INPUT, "MllpClientOptions.tls must be `true`, `false`, or an MllpClientTlsOptions object");
|
|
545
|
+
}
|
|
546
|
+
//#endregion
|
|
547
|
+
export { MllpClientErrorCode as i, subscribeAbort as n, MllpClientError as r, MllpClient as t };
|
|
548
|
+
|
|
549
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","names":["#host","#port","#timeout","#maxAckSize","#tls","#connect","#exchange"],"sources":["../src/core/errors.ts","../src/core/acknowledgment.ts","../src/core/internal/ack-parser-stream.ts","../src/core/internal/subscribe-abort.ts","../src/core/client.ts"],"sourcesContent":["import { MllpError } from \"@glion/mllp-transport\";\nimport type { MllpErrorOptions } from \"@glion/mllp-transport\";\n\n/**\n * Machine-readable codes for `MllpClient` transport failures.\n *\n * These cover everything that can go wrong between the moment\n * `MllpClient.send()` is called and the moment a complete ACK frame\n * has been decoded — i.e. everything that is not an HL7v2\n * application-level NAK. NAK responses (MSA-1 ∈ {AE, AR, CE, CR})\n * surface as `AckException` subclasses thrown directly from\n * `@glion/ack`, not as transport errors.\n */\nexport const MllpClientErrorCode = {\n /** No ACK arrived within the configured `timeout`. */\n /** The peer closed the connection before a complete ACK arrived. */\n CONNECTION_CLOSED: \"CONNECTION_CLOSED\",\n /** TCP connection could not be established (refused, DNS, routing). */\n CONNECTION_REFUSED: \"CONNECTION_REFUSED\",\n /**\n * The caller passed a payload that could not be MLLP-encoded —\n * typically because it was not a `string` or `Uint8Array`. Also\n * surfaced when constructor options fail validation (bad\n * host/port/timeout/etc). Thrown before any socket is opened, so\n * no connection is made and no resources need cleanup.\n */\n INVALID_INPUT: \"INVALID_INPUT\",\n MALFORMED_ACK: \"MALFORMED_ACK\",\n /** The remote sent bytes that did not form a valid MLLP frame. */\n MALFORMED_FRAME: \"MALFORMED_FRAME\",\n /** A frame was received but could not be parsed as a valid HL7v2 ACK. */\n TIMEOUT: \"TIMEOUT\",\n /**\n * TLS handshake failed — typically because of certificate\n * verification, hostname mismatch, expired cert, untrusted CA, or\n * a TLS protocol error. Distinct from `CONNECTION_REFUSED` (the\n * TCP connection succeeded but the TLS upgrade failed) so\n * operators can identify TLS misconfiguration quickly in\n * healthcare deployments where it is a recurring incident class.\n */\n TLS_HANDSHAKE_FAILED: \"TLS_HANDSHAKE_FAILED\",\n} as const;\n\nexport type MllpClientErrorCode =\n (typeof MllpClientErrorCode)[keyof typeof MllpClientErrorCode];\n\n/**\n * Error thrown by `MllpClient.send()` for transport-level failures.\n *\n * Extends {@link MllpError} so callers can catch any MLLP error —\n * server- or client-side — with a single `instanceof MllpError`\n * check. The {@link MllpClientErrorCode | code} field discriminates\n * the failure mode for typed handling.\n *\n * Application-level NAK responses are **not** thrown as\n * `MllpClientError`; they are thrown as `AckException` subclasses\n * from `@glion/ack`. A typical `catch` block handles both:\n *\n * ```ts\n * import { AckException } from \"@glion/ack\";\n * import { MllpClientError } from \"@glion/mllp-client\";\n *\n * try {\n * await client.send(message);\n * } catch (error) {\n * if (error instanceof AckException) {\n * // Application-level NAK.\n * } else if (error instanceof MllpClientError) {\n * // Transport-level failure.\n * }\n * }\n * ```\n */\nexport class MllpClientError extends MllpError {\n /**\n * Narrowed view of the inherited `MllpError.code` field. `declare`\n * keeps the runtime value (set by the base constructor) while\n * exposing the discriminated {@link MllpClientErrorCode} union to\n * TypeScript — callers can `switch` on `error.code` with full\n * exhaustiveness checking.\n */\n declare readonly code: MllpClientErrorCode;\n\n constructor(\n code: MllpClientErrorCode,\n message: string,\n options?: MllpErrorOptions\n ) {\n super(code, message, options);\n this.name = \"MllpClientError\";\n }\n}\n","/**\n * Acknowledgment parsing for the MLLP client.\n *\n * This module owns three things:\n *\n * 1. The {@link Acknowledgment} interface — a structured view of an ACK message\n * exposing the fields callers most often need (MSA-1/2/3, ERR-3/4) without\n * forcing them to walk the AST themselves.\n * 2. {@link parseAck} — turns a raw wire-format ACK string into an\n * {@link Acknowledgment}, or throws {@link MllpClientError} when the payload\n * is not a recognisable ACK.\n * 3. {@link throwOnNak} — inspects an {@link Acknowledgment} and throws the\n * matching `AckException` subclass when MSA-1 indicates a NAK.\n *\n * Both helpers are internal to the package — only the {@link Acknowledgment}\n * type is re-exported from `@glion/mllp-client`. Application code interacts\n * with parsed acknowledgments through the return value (and thrown errors)\n * of `MllpClient.send()`.\n *\n * The HL7v2 ACK structure used here (Section 2.9.2 of the HL7v2 standard):\n *\n * MSH | header (sender, receiver, timestamp, message control id) MSA |\n * acknowledgment code (MSA-1), original control id (MSA-2), text (MSA-3) ERR |\n * optional error segment with HL7 condition code (ERR-3) and severity (ERR-4)\n *\n * @module\n */\n\nimport {\n AckApplicationError,\n AckApplicationReject,\n AckCode,\n AckCommitError,\n AckCommitReject,\n Hl7ErrorCode,\n Severity,\n} from \"@glion/ack\";\nimport type {\n AckCodeValue,\n AckException,\n AckExceptionOptions,\n Hl7ErrorCodeValue,\n SeverityValue,\n} from \"@glion/ack\";\nimport type { Root } from \"@glion/ast\";\nimport { parseHL7v2 } from \"@glion/parser\";\nimport { value } from \"@glion/util-query\";\n\nimport { MllpClientErrorCode, MllpClientError } from \"./errors\";\n\n// ---------------------------------------------------------------------------\n// Public type\n// ---------------------------------------------------------------------------\n\n/**\n * An HL7v2 acknowledgment message returned by an MLLP receiver, with\n * structured access to the fields callers most often need.\n *\n * Returned by `MllpClient.send()` on AA/CA. On NAK (AE/AR/CE/CR) an\n * `AckException` is thrown instead — the same shape is available there\n * via `error.message` (MSA-3), `error.errorCode` (ERR-3), `error.severity`\n * (ERR-4), and `error.raw` (the wire ACK).\n */\nexport interface Acknowledgment {\n /** The raw HL7v2 ACK message as received from the wire. */\n readonly raw: string;\n /**\n * The full ACK message AST, typed against `@glion/ast`. Use for\n * advanced inspection beyond the structured fields below; consumers\n * that walk the tree should install `@glion/ast` for the type.\n */\n readonly tree: Root;\n /**\n * MSA-1 acknowledgment code.\n *\n * Typed as the standard {@link AckCodeValue} union (`AA`, `AE`, `AR`,\n * `CA`, `CE`, `CR`) widened with an opaque `string` escape hatch — the\n * `(string & {})` form preserves auto-completion for the standard\n * codes while still accepting vendor-specific values that real\n * receivers occasionally send.\n */\n // oxlint-disable-next-line typescript-eslint/ban-types\n readonly code: AckCodeValue | (string & {});\n /** MSA-2 message control ID — references the original message's MSH-10. */\n readonly controlId: string;\n /** MSA-3 text message. Typically populated on NAK; optional on accept ACKs. */\n readonly textMessage: string | undefined;\n /**\n * ERR-3 HL7 error condition code (Table 0357). Present when an ERR segment is\n * included.\n */\n readonly errorCode: string | undefined;\n /** ERR-4 severity (Table 0516). Present when an ERR segment is included. */\n readonly severity: string | undefined;\n}\n\n// ---------------------------------------------------------------------------\n// Internal constants\n// ---------------------------------------------------------------------------\n\n/**\n * MSA-1 codes that mean \"the receiver accepted the message\". Anything\n * outside this set is a NAK and produces a thrown `AckException`.\n *\n * See HL7v2 Table 0008 — Acknowledgment Codes.\n */\nconst SUCCESS_CODES: ReadonlySet<string> = new Set<string>([\n AckCode.ApplicationAccept,\n AckCode.CommitAccept,\n]);\n\n/**\n * MSA-1 codes that are **intermediate** in HL7v2 enhanced\n * acknowledgment mode — the receiver will follow up with a separate\n * final acknowledgment later in the exchange. The MLLP client's read\n * loop continues past these by default and only resolves on a final\n * code.\n *\n * Per HL7v2 Section 2.9.2, only `CA` (Commit Accept) is intermediate;\n * `CE` and `CR` are final at the commit level. `AA`, `AE`, `AR` are\n * final at the application level.\n */\nconst INTERMEDIATE_ACK_CODES: ReadonlySet<string> = new Set<string>([\n AckCode.CommitAccept,\n]);\n\n/**\n * Returns `true` for any acknowledgment code that should terminate the\n * client's read loop. Intermediate codes (CA) return `false`; every\n * other code — including vendor-specific values — is treated as final\n * to avoid hanging on responses we do not recognise.\n */\nexport function isFinalAckCode(code: string): boolean {\n return !INTERMEDIATE_ACK_CODES.has(code);\n}\n\n// ---------------------------------------------------------------------------\n// parseAck\n// ---------------------------------------------------------------------------\n\n/**\n * Parse a raw ACK frame string into an {@link Acknowledgment}.\n *\n * Field extraction is delegated to `@glion/util-query`'s `value()` helper —\n * this is the same path used elsewhere in the Glion stack and avoids\n * hand-rolling AST traversal here.\n *\n * Throws `MllpClientError(MALFORMED_ACK)` when:\n *\n * - The parser fails to produce a tree (malformed HL7v2), or\n * - MSA-1 is missing (the message is not a valid acknowledgment).\n *\n * Other missing fields (MSA-3, ERR-3, ERR-4) are not fatal — they are\n * exposed as `undefined` because real-world ACKs frequently omit them.\n */\nexport function parseAck(raw: string): Acknowledgment {\n const tree = parseTreeOrThrow(raw);\n const code = value(tree, \"MSA-1\")?.value;\n if (!code) {\n throw new MllpClientError(\n MllpClientErrorCode.MALFORMED_ACK,\n \"ACK response is missing MSA-1 acknowledgment code\"\n );\n }\n\n return {\n code,\n controlId: value(tree, \"MSA-2\")?.value ?? \"\",\n errorCode: value(tree, \"ERR-3\")?.value ?? undefined,\n raw,\n severity: value(tree, \"ERR-4\")?.value ?? undefined,\n textMessage: value(tree, \"MSA-3\")?.value ?? undefined,\n tree,\n };\n}\n\n/**\n * Run the HL7v2 parser, wrapping any thrown error in\n * `MllpClientError(MALFORMED_ACK)` so callers see one error shape\n * regardless of which lower-level component failed.\n */\nfunction parseTreeOrThrow(raw: string): Root {\n try {\n return parseHL7v2(raw);\n } catch (error) {\n throw new MllpClientError(\n MllpClientErrorCode.MALFORMED_ACK,\n `Failed to parse ACK response: ${formatCause(error)}`,\n { cause: error }\n );\n }\n}\n\n// ---------------------------------------------------------------------------\n// throwOnNak\n// ---------------------------------------------------------------------------\n\n/**\n * Inspect an {@link Acknowledgment} and throw the matching `AckException`\n * subclass when MSA-1 indicates a NAK. Returns the acknowledgment unchanged\n * on AA/CA.\n *\n * The thrown exception mirrors the receiver-side exception types — a\n * caller that already handles `AckApplicationError` from a server's\n * middleware can reuse the same `catch` blocks here.\n */\nexport function throwOnNak(ack: Acknowledgment): Acknowledgment {\n if (SUCCESS_CODES.has(ack.code)) {\n return ack;\n }\n throw buildAckException(ack);\n}\n\n/**\n * Convert a NAK acknowledgment into an `AckException` subclass.\n *\n * The fallbacks below preserve a useful exception even when the receiver\n * sent a sparse NAK: a missing MSA-3 yields a synthesised message; a\n * missing ERR segment yields the catch-all `ApplicationInternalError`\n * code (`207`) and severity Error (`E`). This matches the receiver-side\n * conventions in `@glion/ack`.\n */\nfunction buildAckException(ack: Acknowledgment): AckException {\n const message = ack.textMessage ?? `Acknowledgment ${ack.code} from receiver`;\n const options: AckExceptionOptions = {\n errorCode: coerceErrorCode(ack.errorCode),\n raw: ack.raw,\n severity: coerceSeverity(ack.severity),\n };\n\n switch (ack.code) {\n case AckCode.ApplicationError: {\n return new AckApplicationError(message, options);\n }\n case AckCode.ApplicationReject: {\n return new AckApplicationReject(message, options);\n }\n case AckCode.CommitError: {\n return new AckCommitError(message, options);\n }\n case AckCode.CommitReject: {\n return new AckCommitReject(message, options);\n }\n default: {\n // Unreachable in practice — `throwOnNak` filters AA/CA before\n // calling us, and any other value of MSA-1 maps to one of the four\n // cases above. Map unknown codes to ApplicationError so the caller\n // still gets a typed exception rather than a silent return.\n return new AckApplicationError(message, options);\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Coercion helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Set of HL7 condition codes (Table 0357) we recognise. Built once from\n * the `Hl7ErrorCode` enum so the lookup stays cheap and the membership\n * stays in sync with the source of truth in `@glion/ack`.\n */\nconst KNOWN_ERROR_CODES: ReadonlySet<string> = new Set<string>(\n Object.values(Hl7ErrorCode)\n);\n\n/**\n * Set of severity codes (Table 0516) we recognise.\n */\nconst KNOWN_SEVERITIES: ReadonlySet<string> = new Set<string>(\n Object.values(Severity)\n);\n\n/**\n * Map an ERR-3 string to a typed {@link Hl7ErrorCodeValue}. Unknown or\n * missing values fall back to `ApplicationInternalError` (`207`) so the\n * exception always carries a well-defined code, mirroring receiver-side\n * conventions in `@glion/ack`.\n *\n * The widening cast is intentional and confined here — receivers\n * occasionally send vendor-specific codes outside Table 0357, and the\n * client must still produce a usable exception rather than reject the\n * NAK over an unknown code.\n */\nfunction coerceErrorCode(input: string | undefined): Hl7ErrorCodeValue {\n if (input !== undefined && KNOWN_ERROR_CODES.has(input)) {\n return input as Hl7ErrorCodeValue;\n }\n return Hl7ErrorCode.ApplicationInternalError;\n}\n\n/**\n * Map an ERR-4 string to a typed {@link SeverityValue}. Unknown or\n * missing values fall back to `Severity.Error` (`E`).\n */\nfunction coerceSeverity(input: string | undefined): SeverityValue {\n if (input !== undefined && KNOWN_SEVERITIES.has(input)) {\n return input as SeverityValue;\n }\n return Severity.Error;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/** Render an unknown thrown value as a human-readable string for error messages. */\nfunction formatCause(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n","/**\n * `TransformStream<DecodedMessage, Acknowledgment>` that parses each\n * decoded MLLP frame as an HL7v2 acknowledgment. Internal to\n * `@glion/mllp-client`.\n *\n * On a malformed ACK the transform errors the readable with the\n * typed `MllpClientError(MALFORMED_ACK)` from {@link parseAck}.\n * Downstream `for await ... of` consumers reject with that error;\n * `MllpClient.send()` translates it at its single catch site.\n *\n * @module\n */\n\nimport type { DecodedMessage } from \"@glion/mllp-transport\";\n\nimport type { Acknowledgment } from \"../acknowledgment\";\nimport { parseAck } from \"../acknowledgment\";\n\n/**\n * Build a transform that consumes decoded MLLP frames and produces\n * parsed `Acknowledgment` objects. A parse failure errors the\n * readable side rather than enqueuing — the consumer's `for await`\n * loop rejects with the typed error.\n */\nexport function createAckParserStream(): TransformStream<\n DecodedMessage,\n Acknowledgment\n> {\n return new TransformStream<DecodedMessage, Acknowledgment>({\n transform(frame, controller) {\n try {\n controller.enqueue(parseAck(frame.text));\n } catch (error) {\n controller.error(error);\n }\n },\n });\n}\n","/**\n * Subscribe to an `AbortSignal` and run a callback once if it\n * aborts. Returns a disposer that unsubscribes — call it from a\n * `finally` block so we never leak listeners.\n *\n * If the signal is already aborted at subscription time, the\n * callback fires synchronously and the returned disposer is a\n * no-op. This collapses the \"aborted vs aborting\" branch every\n * caller would otherwise need to write by hand.\n *\n * Internal to `@glion/mllp-client`.\n *\n * @module\n */\n\n/**\n * Subscribe `onAbort` to fire once when `signal` aborts. Returns a\n * disposer that removes the listener.\n *\n * @example\n * ```ts\n * const dispose = subscribeAbort(signal, () => socket.destroy());\n * try {\n * await waitForReady(socket);\n * } finally {\n * dispose();\n * }\n * ```;\n */\nexport function subscribeAbort(\n signal: AbortSignal,\n onAbort: () => void\n): () => void {\n if (signal.aborted) {\n onAbort();\n return noop;\n }\n signal.addEventListener(\"abort\", onAbort, { once: true });\n return () => signal.removeEventListener(\"abort\", onAbort);\n}\n\nfunction noop(): void {\n /* nothing to dispose */\n}\n","/**\n * Runtime-free core of `@glion/mllp-client`.\n *\n * This module owns {@link MllpClient}, the class application code\n * instantiates to send HL7v2 messages and receive acknowledgments.\n * The class is **transport-agnostic**: it composes a Web-Streams\n * pipeline (`socket → decoder → ack-parser`) and consumes parsed\n * acknowledgments from the readable side. It does **not** know how\n * to open a TCP/TLS connection. That concern is supplied by a\n * {@link MllpConnect} function passed in via\n * {@link MllpClientOptions.connect}. Per-runtime adapters\n * (`@glion/mllp-client/node`, `/deno`, `/workers`) ship pre-wired\n * `MllpClient` subclasses so the common case stays a single import.\n *\n * Each call to `client.send()` performs one short-lived round trip:\n *\n * ┌─────────────┐ ① connect ┌──────────┐\n * │ client │ ───────────────▶ │ duplex │\n * │ │ └────┬─────┘\n * │ │ ② write MLLP frame │\n * │ │ ─────────────────────▶│ <VT> ... <FS><CR>\n * │ │ │\n * │ │ ③ read frames via │\n * │ │ decoder + parser │\n * │ │ ◀─────────────────────│ <VT> ACK <FS><CR> ...\n * │ │ ┌────┴─────┐\n * │ │ ④ close + parse │ duplex │ closed\n * └─────────────┘ └──────────┘\n *\n * The four phases share a single deadline ({@link MllpClientOptions.timeout}).\n * If any phase exceeds the budget, the duplex is closed and a typed\n * `MllpClientError(TIMEOUT)` is thrown. Connections are deliberately\n * **not** pooled or reused — each `send()` opens its own duplex.\n *\n * @module\n */\n\nimport { AckException } from \"@glion/ack\";\nimport { createDecoderStream, encode } from \"@glion/mllp-transport\";\n\nimport type { Acknowledgment } from \"./acknowledgment\";\nimport { isFinalAckCode, throwOnNak } from \"./acknowledgment\";\nimport type { MllpConnect } from \"./connect\";\nimport { MllpClientError, MllpClientErrorCode } from \"./errors\";\nimport { createAckParserStream } from \"./internal/ack-parser-stream\";\nimport { subscribeAbort } from \"./internal/subscribe-abort\";\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\n/**\n * TLS configuration for an MLLP client connection.\n *\n * Mirrors the relevant subset of TLS options across runtimes. When\n * this object is provided to {@link MllpClientOptions.tls}, the\n * runtime adapter establishes a TLS connection (rather than plain\n * TCP). Server certificates are **always** verified unless\n * {@link insecure} is explicitly set to `true`.\n *\n * Not every runtime adapter supports every field — `passphrase` and\n * mutual-TLS material (`cert`/`key`) are honoured by the Node\n * adapter; Cloudflare Workers and Deno expose a smaller subset.\n * Adapters silently ignore fields they cannot use.\n */\nexport interface MllpClientTlsOptions {\n /** Trusted CA certificate(s) for verifying the server. */\n ca?: string | Uint8Array;\n /** Client certificate for mutual TLS. */\n cert?: string | Uint8Array;\n /** Client private key for mutual TLS. */\n key?: string | Uint8Array;\n /** Passphrase for the private key, if encrypted. */\n passphrase?: string;\n /**\n * Override the server name used for SNI and certificate\n * verification. Defaults to {@link MllpClientOptions.host}.\n *\n * Setting this to anything other than the real server hostname\n * effectively disables strict hostname verification — use only\n * when SNI multiplexing requires a different name from the\n * connection target.\n */\n servername?: string;\n /**\n * Disable server-certificate verification.\n *\n * **DO NOT enable this in production.** When set to `true` the\n * client accepts self-signed, expired, and otherwise invalid\n * certificates, making the connection vulnerable to active\n * man-in-the-middle attacks. The option exists so that local\n * development and integration tests can connect to throwaway\n * servers — never as a workaround for real certificate problems.\n *\n * Only the literal value `true` opts out. There is no `false`\n * form because the secure default is non-negotiable.\n */\n insecure?: true;\n}\n\n/**\n * Configuration for an {@link MllpClient}. All fields are immutable\n * once the client is constructed; create a new client to change\n * them.\n *\n * Application code that imports from `@glion/mllp-client/node`,\n * `/deno`, or `/workers` does **not** need to pass `connect` — the\n * runtime adapter provides it.\n */\nexport interface MllpClientOptions {\n /** Hostname or IP of the MLLP server. */\n host: string;\n /** TCP port of the MLLP server. */\n port: number;\n /**\n * Function that opens a connection to the receiver and returns a\n * Web-Streams duplex. Runtime adapters supply this for you.\n */\n connect: MllpConnect;\n /**\n * Maximum total milliseconds for the connect → write → read-ACK\n * round trip. The same budget covers all phases — if connecting\n * consumes 4s of a 10s budget, the remaining 6s is what's left to\n * receive the ACK.\n *\n * @default 30000\n */\n timeout?: number;\n /**\n * Maximum bytes accepted for an inbound ACK frame. When omitted,\n * no limit is enforced. Set this when receiving from untrusted\n * peers to cap memory use from a malicious or malformed stream.\n */\n maxAckSize?: number;\n /**\n * TLS configuration. **Defaults to `true`** — HL7v2 messages\n * commonly carry PHI, so the secure default is TLS-on. Callers\n * who genuinely want plain TCP (e.g. trusted hospital intranet,\n * or a TLS terminator hop in front of the receiver) must opt out\n * explicitly with `tls: false`.\n *\n * @example\n * ```ts\n * // TLS on, defaults (system trust, hostname verification)\n * new MllpClient({ host, port });\n *\n * // Same — explicit form\n * new MllpClient({ host, port, tls: true });\n *\n * // TLS with config (mutual TLS, custom CA, SNI override, ...)\n * new MllpClient({ host, port, tls: { servername: \"h\" } });\n *\n * // Plain TCP — caller takes the security trade-off explicitly\n * new MllpClient({ host, port, tls: false });\n * ```;\n *\n * @default true\n */\n tls?: boolean | MllpClientTlsOptions;\n}\n\n/**\n * Construction options accepted by the **runtime adapters'**\n * `MllpClient` subclasses (`@glion/mllp-client/node`, `/deno`,\n * `/workers`). Identical to {@link MllpClientOptions} except that\n * `connect` is omitted — the adapter binds it for you.\n */\nexport type BoundMllpClientOptions = Omit<MllpClientOptions, \"connect\">;\n\n/**\n * HL7v2 acknowledgment level the send waits for. Mirrors the two\n * acknowledgment levels in HL7v2 §2.9.2.\n *\n * - `\"OnApplication\"` (default) — return when the receiver's application-level\n * ACK arrives (`AA`/`AE`/`AR`). Intermediate `CA` (Commit Accept) frames sent\n * by enhanced-mode receivers are surfaced via iteration but do not resolve\n * the send. Use this when \"did the receiver successfully process my message?\"\n * is the question.\n * - `\"OnCommit\"` — return on the first frame regardless of code, typically a\n * commit-level ACK (`CA`/`CE`/`CR`). The send resolves as soon as the\n * receiver confirms receipt of the message; later processing-level frames are\n * not observed because the connection is closed once the resolving frame\n * arrives. Use this for receivers that only send commit-level ACKs (basic\n * mode) or when the caller wants the commit confirmation without waiting for\n * the processing result.\n */\nexport type SendMode = \"OnApplication\" | \"OnCommit\";\n\n/**\n * Per-call options accepted by {@link MllpClient.send}.\n */\nexport interface SendOptions {\n /**\n * Cancel the in-flight `send()` from outside. Composed with the\n * client's internal `timeout` deadline via `AbortSignal.any`, so\n * either source aborts the send.\n *\n * Typical use: tie a batch of `send()`s to an app-shutdown\n * `AbortController` so they all cancel cleanly when the process\n * exits.\n *\n * When this signal aborts, the resulting `MllpClientError` has\n * `code: TIMEOUT` and forwards the caller's `signal.reason` as\n * its `cause` (when the reason is itself an `Error`).\n */\n signal?: AbortSignal;\n /**\n * HL7v2 acknowledgment level to wait for. See {@link SendMode}.\n *\n * @default \"OnApplication\"\n */\n mode?: SendMode;\n}\n\n// ---------------------------------------------------------------------------\n// Defaults\n// ---------------------------------------------------------------------------\n\n/** Default `timeout` when {@link MllpClientOptions.timeout} is omitted. */\nconst DEFAULT_TIMEOUT_MS = 30_000;\n\n// ---------------------------------------------------------------------------\n// MllpClient\n// ---------------------------------------------------------------------------\n\n/**\n * Runtime-free MLLP client. Instantiate via a runtime adapter\n * (`@glion/mllp-client/node`, `/deno`, `/workers`) for the common\n * case.\n *\n * **TLS-on by default.** `MllpClientOptions.tls` defaults to `true`;\n * pass `tls: false` for plain TCP. See {@link MllpClientOptions.tls}.\n *\n * @example\n * Send a message and read the ACK on Node:\n *\n * ```ts\n * import { MllpClient } from \"@glion/mllp-client/node\";\n *\n * const client = new MllpClient({ host: \"127.0.0.1\", port: 2575 });\n * const ack = await client.send(rawHl7Message);\n * console.log(ack.code, ack.controlId); // \"AA\" \"MSG001\"\n * ```\n */\nexport class MllpClient {\n readonly #host: string;\n readonly #port: number;\n readonly #timeout: number;\n readonly #maxAckSize: number | undefined;\n readonly #tls: MllpClientTlsOptions | undefined;\n readonly #connect: MllpConnect;\n\n constructor(options: MllpClientOptions) {\n validateOptions(options);\n this.#host = options.host;\n this.#port = options.port;\n this.#connect = options.connect;\n this.#timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;\n this.#maxAckSize = options.maxAckSize;\n // Normalise the user-facing `boolean | TlsOptions | undefined` shape\n // down to `TlsOptions | undefined` so adapters only see the object\n // form. Default is TLS-on; only explicit `tls: false` opts out.\n if (options.tls === false) {\n this.#tls = undefined;\n } else if (options.tls === true || options.tls === undefined) {\n this.#tls = {};\n } else {\n this.#tls = options.tls;\n }\n }\n\n /** The host this client connects to. */\n get host(): string {\n return this.#host;\n }\n\n /** The port this client connects to. */\n get port(): number {\n return this.#port;\n }\n\n /**\n * Send a single HL7v2 message and resolve with the resolving accept ACK.\n *\n * Throws:\n *\n * - {@link MllpClientError} for transport failures. `code` identifies the mode:\n * `INVALID_INPUT`, `CONNECTION_REFUSED`, `TIMEOUT`, `CONNECTION_CLOSED`,\n * `MALFORMED_FRAME`, or `MALFORMED_ACK`.\n * - An `AckException` subclass from `@glion/ack` (`AckApplicationError`,\n * `AckApplicationReject`, `AckCommitError`, `AckCommitReject`) when MSA-1 ∈\n * {AE, AR, CE, CR}. The thrown exception's `raw` attribute holds the\n * wire-format ACK.\n *\n * To observe each accept ACK as it arrives (e.g. seeing the\n * intermediate `CA` frame in HL7v2 enhanced mode), use\n * {@link MllpClient.stream} instead.\n */\n async send(\n message: string | Uint8Array,\n options: SendOptions = {}\n ): Promise<Acknowledgment> {\n let last: Acknowledgment | undefined;\n for await (const ack of this.#exchange(message, options)) {\n last = ack;\n }\n if (!last) {\n // `#exchange` throws on every termination path before yielding,\n // so this branch should be unreachable. We keep it because the\n // alternative is a non-null assertion the type checker cannot\n // verify — surfacing a typed error if the generator ever breaks\n // its contract is cheaper than letting `undefined` propagate.\n throw new MllpClientError(\n MllpClientErrorCode.CONNECTION_CLOSED,\n \"Connection closed before a complete ACK was received\"\n );\n }\n return last;\n }\n\n /**\n * Send a single HL7v2 message and yield each accept ACK as it\n * arrives. The iterable completes after the resolving frame.\n *\n * In HL7v2 enhanced mode the iterable yields `CA` then the\n * application-level ACK; in basic mode it yields a single\n * application-level ACK. NAK codes (`AE`/`AR`/`CE`/`CR`) throw the\n * matching `AckException` from the iterator with the wire-format\n * ACK on `error.raw`.\n *\n * Use {@link MllpClient.send} for the simple \"give me the resolving\n * answer\" case.\n */\n stream(\n message: string | Uint8Array,\n options: SendOptions = {}\n ): AsyncIterable<Acknowledgment> {\n return this.#exchange(message, options);\n }\n\n /**\n * Internal exchange generator shared by {@link send} and {@link stream}.\n *\n * Owns the connect → write → read loop. NAK codes throw\n * `AckException` instead of yielding so both consumption shapes see\n * the same exception. Cleanup (socket teardown, abort\n * unsubscription) runs in the generator's `finally`, so a consumer\n * that breaks out of iteration still releases the socket.\n *\n * @yields Each accept ACK in arrival order. `return`s after the\n * resolving frame.\n */\n async *#exchange(\n message: string | Uint8Array,\n options: SendOptions\n ): AsyncGenerator<Acknowledgment, void, void> {\n // Encode eagerly so bad input fails fast as INVALID_INPUT, before\n // we open a connection.\n const frame = encodeOrThrow(message);\n const mode: SendMode = options.mode ?? \"OnApplication\";\n\n // Compose the deadline with the caller's signal. The runtime's\n // built-in `AbortSignal.timeout(ms)` uses an unref'd timer, so it\n // does not hold the event loop alive.\n const sources: AbortSignal[] = [AbortSignal.timeout(this.#timeout)];\n if (options.signal) {\n sources.push(options.signal);\n }\n const signal = AbortSignal.any(sources);\n\n const duplex = await this.#connect({\n host: this.#host,\n port: this.#port,\n signal,\n tls: this.#tls,\n });\n\n // Streams pipeline: socket bytes → MLLP frames → parsed ACKs. The\n // decoder runs in fatal mode (its `onError` throws), so a malformed\n // frame errors the readable; the parser stream errors the readable\n // on a malformed ACK. Both surface as `reader.read()` rejections.\n const ackStream = duplex.readable\n .pipeThrough(\n createDecoderStream({\n maxMessageSize: this.#maxAckSize,\n onError: (frameError) => {\n throw new MllpClientError(\n MllpClientErrorCode.MALFORMED_FRAME,\n `Invalid ACK frame: ${frameError.message}`,\n { cause: frameError }\n );\n },\n })\n )\n .pipeThrough(createAckParserStream());\n\n const reader = ackStream.getReader();\n const writer = duplex.writable.getWriter();\n\n // Wire the abort signal to interrupt pending I/O by tearing down\n // the underlying transport. `duplex.close()` destroys the socket;\n // that propagates through the streams and the pending\n // `reader.read()` resolves (done) or rejects (errored). The\n // generator's catch turns whichever outcome it is into the typed\n // `MllpClientError` via `normaliseSendError`. The abort path\n // fires-and-forgets the close — we don't need to await teardown\n // before the abort signal's listeners settle. Adapters MUST resolve\n // (never reject) from `close()` per the `MllpDuplexStream.close`\n // contract.\n const unsubscribe = subscribeAbort(signal, () => {\n void duplex.close();\n });\n\n try {\n await writer.write(frame);\n\n while (true) {\n const { done, value: ack } = await reader.read();\n if (done) {\n throw new MllpClientError(\n MllpClientErrorCode.CONNECTION_CLOSED,\n \"Connection closed before a complete ACK was received\"\n );\n }\n // NAK codes (AE/AR/CE/CR) throw the matching `AckException`;\n // the throw flies out of the generator and surfaces as the\n // consumer's rejection (whether they are `await`ing the\n // resolving frame in `send` or iterating via `stream`).\n throwOnNak(ack);\n yield ack;\n if (mode === \"OnCommit\" || isFinalAckCode(ack.code)) {\n return;\n }\n }\n } catch (error) {\n // `AckException` is the receiver's NAK — a successful exchange\n // with a negative result. Pass it through unchanged rather than\n // wrapping as a transport error.\n if (error instanceof AckException) {\n throw error;\n }\n throw normaliseSendError(error, signal, this.#timeout);\n } finally {\n unsubscribe();\n await duplex.close();\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * MLLP-encode a payload, mapping any failure to a typed\n * {@link MllpClientError} with code `INVALID_INPUT`.\n */\nfunction encodeOrThrow(message: string | Uint8Array): Uint8Array {\n try {\n return encode(message);\n } catch (error) {\n throw new MllpClientError(\n MllpClientErrorCode.INVALID_INPUT,\n `Invalid message payload: ${error instanceof Error ? error.message : String(error)}`,\n { cause: error instanceof Error ? error : undefined }\n );\n }\n}\n\n/**\n * Translate a thrown value from any phase of {@link readAck} into a\n * typed client error.\n *\n * Precedence:\n *\n * 1. If the signal aborted with a typed {@link MllpClientError} reason, surface it\n * verbatim. (Connector or pipeline-internal failures keep their original\n * code.)\n * 2. Else if the signal aborted with the standard timeout `DOMException` produced\n * by `AbortSignal.timeout(ms)`, wrap as `TIMEOUT` with the original budget\n * in the message.\n * 3. Else if the signal aborted with any other reason (a caller's own\n * `AbortController.abort(reason)`), wrap as `TIMEOUT` with the caller's\n * reason chained as `cause` — caller cancellation conceptually IS a timeout\n * from the protocol's perspective.\n * 4. Else if the underlying stream rejection is already typed, return it unchanged\n * so adapter-specific failures are preserved.\n * 5. Otherwise wrap as `CONNECTION_CLOSED`.\n */\nfunction normaliseSendError(\n error: unknown,\n signal: AbortSignal,\n timeoutMs: number\n): MllpClientError {\n if (signal.aborted) {\n if (signal.reason instanceof MllpClientError) {\n return signal.reason;\n }\n if (isTimeoutAbort(signal.reason)) {\n return new MllpClientError(\n MllpClientErrorCode.TIMEOUT,\n `MLLP round trip exceeded ${timeoutMs}ms`\n );\n }\n return new MllpClientError(\n MllpClientErrorCode.TIMEOUT,\n \"Send aborted by caller\",\n {\n cause: signal.reason instanceof Error ? signal.reason : undefined,\n }\n );\n }\n if (error instanceof MllpClientError) {\n return error;\n }\n const wrapped = error instanceof Error ? error : new Error(String(error));\n return new MllpClientError(\n MllpClientErrorCode.CONNECTION_CLOSED,\n `Connection failed during MLLP exchange: ${wrapped.message}`,\n { cause: wrapped }\n );\n}\n\n/**\n * Detect the standard `DOMException` produced by\n * `AbortSignal.timeout(ms)` when the timer fires. The runtime\n * convention is `name: \"TimeoutError\"` — duck-typed because\n * `DOMException` exists in Node, Bun, Deno, and Workers but tests\n * run in environments where the constructor identity may differ.\n */\nfunction isTimeoutAbort(reason: unknown): boolean {\n return (\n reason !== null &&\n typeof reason === \"object\" &&\n (reason as { name?: unknown }).name === \"TimeoutError\"\n );\n}\n\n/**\n * Validate {@link MllpClientOptions} at construction time. Catches\n * the sharp edges that would otherwise surface as confusing runtime\n * symptoms.\n */\nfunction validateOptions(options: MllpClientOptions): void {\n if (typeof options.host !== \"string\" || options.host.length === 0) {\n throw new MllpClientError(\n MllpClientErrorCode.INVALID_INPUT,\n \"MllpClientOptions.host must be a non-empty string\"\n );\n }\n if (\n !Number.isInteger(options.port) ||\n options.port < 1 ||\n options.port > 65_535\n ) {\n throw new MllpClientError(\n MllpClientErrorCode.INVALID_INPUT,\n `MllpClientOptions.port must be an integer in 1..65535, got ${options.port}`\n );\n }\n if (typeof options.connect !== \"function\") {\n throw new MllpClientError(\n MllpClientErrorCode.INVALID_INPUT,\n \"MllpClientOptions.connect must be a function — import the adapter for your runtime (`@glion/mllp-client/node`, `/deno`, `/workers`)\"\n );\n }\n if (\n options.timeout !== undefined &&\n (!Number.isFinite(options.timeout) || options.timeout <= 0)\n ) {\n throw new MllpClientError(\n MllpClientErrorCode.INVALID_INPUT,\n `MllpClientOptions.timeout must be a positive finite number of milliseconds, got ${options.timeout}`\n );\n }\n if (\n options.maxAckSize !== undefined &&\n (!Number.isInteger(options.maxAckSize) || options.maxAckSize <= 0)\n ) {\n throw new MllpClientError(\n MllpClientErrorCode.INVALID_INPUT,\n `MllpClientOptions.maxAckSize must be a positive integer, got ${options.maxAckSize}`\n );\n }\n if (\n options.tls !== undefined &&\n typeof options.tls !== \"boolean\" &&\n (typeof options.tls !== \"object\" || options.tls === null)\n ) {\n throw new MllpClientError(\n MllpClientErrorCode.INVALID_INPUT,\n \"MllpClientOptions.tls must be `true`, `false`, or an MllpClientTlsOptions object\"\n );\n }\n}\n"],"mappings":"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"}
|