@glion/mllp 0.14.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/LICENSE +21 -0
- package/README.md +486 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/index.js.map +1 -0
- package/dist/mllp.js +791 -0
- package/dist/mllp.js.map +1 -0
- package/dist/node/adapter.d.ts +75 -0
- package/dist/node/adapter.d.ts.map +1 -0
- package/dist/node/index.d.ts +15 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/serve.d.ts +150 -0
- package/dist/node/serve.d.ts.map +1 -0
- package/dist/node.js +324 -0
- package/dist/node.js.map +1 -0
- package/dist/server/adapter.d.ts +64 -0
- package/dist/server/adapter.d.ts.map +1 -0
- package/dist/server/compose.d.ts +15 -0
- package/dist/server/compose.d.ts.map +1 -0
- package/dist/server/context.d.ts +33 -0
- package/dist/server/context.d.ts.map +1 -0
- package/dist/server/mllp.d.ts +102 -0
- package/dist/server/mllp.d.ts.map +1 -0
- package/dist/server/router.d.ts +49 -0
- package/dist/server/router.d.ts.map +1 -0
- package/dist/server/types.d.ts +139 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/transport/constants.d.ts +24 -0
- package/dist/transport/constants.d.ts.map +1 -0
- package/dist/transport/decoder-stream.d.ts +49 -0
- package/dist/transport/decoder-stream.d.ts.map +1 -0
- package/dist/transport/decoder.d.ts +18 -0
- package/dist/transport/decoder.d.ts.map +1 -0
- package/dist/transport/dynamic-buffer.d.ts +28 -0
- package/dist/transport/dynamic-buffer.d.ts.map +1 -0
- package/dist/transport/encoder.d.ts +14 -0
- package/dist/transport/encoder.d.ts.map +1 -0
- package/dist/transport/errors.d.ts +13 -0
- package/dist/transport/errors.d.ts.map +1 -0
- package/dist/transport/index.d.ts +8 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/types.d.ts +49 -0
- package/dist/transport/types.d.ts.map +1 -0
- package/package.json +68 -0
package/dist/mllp.js
ADDED
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
import { value } from "@glion/util-query";
|
|
2
|
+
import { VFile } from "vfile";
|
|
3
|
+
//#region src/transport/constants.ts
|
|
4
|
+
/**
|
|
5
|
+
* MLLP Start Byte - Vertical Tab (VT)
|
|
6
|
+
* Marks the beginning of an MLLP frame
|
|
7
|
+
*/
|
|
8
|
+
const MLLP_START_BYTE = 11;
|
|
9
|
+
/**
|
|
10
|
+
* MLLP End Byte 1 - File Separator (FS)
|
|
11
|
+
* First byte of the end sequence
|
|
12
|
+
*/
|
|
13
|
+
const MLLP_END_BYTE_1 = 28;
|
|
14
|
+
/**
|
|
15
|
+
* MLLP End Byte 2 - Carriage Return (CR)
|
|
16
|
+
* Second byte of the end sequence
|
|
17
|
+
*/
|
|
18
|
+
const MLLP_END_BYTE_2 = 13;
|
|
19
|
+
/**
|
|
20
|
+
* MLLP header as a Uint8Array containing the start byte
|
|
21
|
+
*/
|
|
22
|
+
const MLLP_HEADER = new Uint8Array([11]);
|
|
23
|
+
/**
|
|
24
|
+
* MLLP trailer as a Uint8Array containing the end sequence
|
|
25
|
+
*/
|
|
26
|
+
const MLLP_TRAILER = new Uint8Array([28, 13]);
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/errors.ts
|
|
29
|
+
/**
|
|
30
|
+
* Base error class for all MLLP errors.
|
|
31
|
+
*
|
|
32
|
+
* All error types in the MLLP package extend this class.
|
|
33
|
+
* Provides a common `code` field so that any MLLP error can be
|
|
34
|
+
* programmatically identified and translated (e.g., into ACK/NAK responses).
|
|
35
|
+
*/
|
|
36
|
+
var MllpError = class extends Error {
|
|
37
|
+
/** Machine-readable error code identifying the error type */
|
|
38
|
+
code;
|
|
39
|
+
/** Error condition string (e.g., for ERR segment in ACK) */
|
|
40
|
+
errorCondition;
|
|
41
|
+
constructor(code, message, options) {
|
|
42
|
+
super(message, options);
|
|
43
|
+
this.name = "MllpError";
|
|
44
|
+
this.code = code;
|
|
45
|
+
this.errorCondition = options?.errorCondition;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region src/transport/errors.ts
|
|
50
|
+
/**
|
|
51
|
+
* Transport-level error for MLLP framing issues (invalid framing, incomplete
|
|
52
|
+
* messages, etc.)
|
|
53
|
+
*/
|
|
54
|
+
var TransportError = class extends MllpError {
|
|
55
|
+
/** Position in the data where the error occurred (if applicable) */
|
|
56
|
+
position;
|
|
57
|
+
constructor(code, message, position) {
|
|
58
|
+
super(code, message);
|
|
59
|
+
this.name = "TransportError";
|
|
60
|
+
this.position = position;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/transport/types.ts
|
|
65
|
+
/**
|
|
66
|
+
* Error codes for MLLP frame-level errors
|
|
67
|
+
*/
|
|
68
|
+
const TransportErrorCode = {
|
|
69
|
+
INCOMPLETE_MESSAGE: "INCOMPLETE_MESSAGE",
|
|
70
|
+
INVALID_END_SEQUENCE: "INVALID_END_SEQUENCE",
|
|
71
|
+
INVALID_START_BYTE: "INVALID_START_BYTE",
|
|
72
|
+
MESSAGE_TOO_LARGE: "MESSAGE_TOO_LARGE"
|
|
73
|
+
};
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/transport/dynamic-buffer.ts
|
|
76
|
+
/**
|
|
77
|
+
* A growable byte buffer with geometric capacity doubling.
|
|
78
|
+
*
|
|
79
|
+
* Designed for protocol decoders that accumulate bytes from arbitrarily
|
|
80
|
+
* chunked TCP streams. Amortizes copy cost to O(n) total — the same
|
|
81
|
+
* strategy used by pg-protocol, NATS.js, and Java's ArrayList.
|
|
82
|
+
*
|
|
83
|
+
* The buffer maintains a read offset so consumed bytes can be skipped
|
|
84
|
+
* without shifting data. When the wasted prefix exceeds half the
|
|
85
|
+
* capacity, the buffer auto-compacts by shifting remaining bytes to
|
|
86
|
+
* the front.
|
|
87
|
+
*
|
|
88
|
+
* @internal
|
|
89
|
+
*/
|
|
90
|
+
var DynamicBuffer = class {
|
|
91
|
+
_buffer;
|
|
92
|
+
_length = 0;
|
|
93
|
+
_offset = 0;
|
|
94
|
+
constructor(initialCapacity = 4096) {
|
|
95
|
+
this._buffer = new Uint8Array(initialCapacity);
|
|
96
|
+
}
|
|
97
|
+
get capacity() {
|
|
98
|
+
return this._buffer.length;
|
|
99
|
+
}
|
|
100
|
+
get available() {
|
|
101
|
+
return this._length - this._offset;
|
|
102
|
+
}
|
|
103
|
+
append(chunk) {
|
|
104
|
+
const needed = this._length + chunk.length;
|
|
105
|
+
if (needed > this._buffer.length) {
|
|
106
|
+
const newCapacity = Math.max(this._buffer.length * 2, needed);
|
|
107
|
+
const newBuffer = new Uint8Array(newCapacity);
|
|
108
|
+
newBuffer.set(this._buffer.subarray(0, this._length));
|
|
109
|
+
this._buffer = newBuffer;
|
|
110
|
+
}
|
|
111
|
+
this._buffer.set(chunk, this._length);
|
|
112
|
+
this._length += chunk.length;
|
|
113
|
+
}
|
|
114
|
+
view() {
|
|
115
|
+
return this._buffer.subarray(this._offset, this._length);
|
|
116
|
+
}
|
|
117
|
+
consume(n) {
|
|
118
|
+
this._offset += n;
|
|
119
|
+
if (this._offset >= this._length) {
|
|
120
|
+
this._offset = 0;
|
|
121
|
+
this._length = 0;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (this._offset > this._buffer.length / 2) this._compact();
|
|
125
|
+
}
|
|
126
|
+
reset() {
|
|
127
|
+
this._offset = 0;
|
|
128
|
+
this._length = 0;
|
|
129
|
+
}
|
|
130
|
+
_compact() {
|
|
131
|
+
const remaining = this._length - this._offset;
|
|
132
|
+
this._buffer.copyWithin(0, this._offset, this._length);
|
|
133
|
+
this._offset = 0;
|
|
134
|
+
this._length = remaining;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/transport/decoder-stream.ts
|
|
139
|
+
function defaultOnError(error) {
|
|
140
|
+
console.warn(`MLLP decode error: [${error.code}] ${error.message}`);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Default decoder options, merged with user-provided values via spread.
|
|
144
|
+
*
|
|
145
|
+
* - `encoding` — `"utf8"`, the standard encoding for HL7v2.
|
|
146
|
+
* - `onError` — logs to `console.warn`; the stream continues processing.
|
|
147
|
+
*/
|
|
148
|
+
const defaultDecoderOptions = {
|
|
149
|
+
encoding: "utf8",
|
|
150
|
+
onError: defaultOnError
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* Build the {@link Transformer} that powers both {@link createDecoderStream}
|
|
154
|
+
* and {@link MLLPDecoderStream}.
|
|
155
|
+
*
|
|
156
|
+
* ## How MLLP framing works
|
|
157
|
+
*
|
|
158
|
+
* Every HL7v2 message on the wire is wrapped in a minimal envelope:
|
|
159
|
+
*
|
|
160
|
+
* <VT> message-payload <FS><CR>
|
|
161
|
+
* 0x0B 0x1C 0x0D
|
|
162
|
+
*
|
|
163
|
+
* Multiple frames can arrive back-to-back within the same TCP stream.
|
|
164
|
+
* The decoder's job is to find each `<VT>…<FS><CR>` envelope, strip
|
|
165
|
+
* the framing bytes, and emit the inner payload as a {@link DecodedMessage}.
|
|
166
|
+
*
|
|
167
|
+
* ## State machine
|
|
168
|
+
*
|
|
169
|
+
* The decoder alternates between two states:
|
|
170
|
+
*
|
|
171
|
+
* ┌──────────────┐ VT found ┌────────────┐
|
|
172
|
+
* │ WAITING_START │──────────────▶│ IN_MESSAGE │
|
|
173
|
+
* └──────┬───────┘ └─────┬──────┘
|
|
174
|
+
* ▲ garbage → onError │
|
|
175
|
+
* │ │ FS CR found → emit message
|
|
176
|
+
* └─────────────────────────────┘
|
|
177
|
+
*
|
|
178
|
+
* 1. **WAITING_START** — Scans byte-by-byte for a VT start byte. Any bytes
|
|
179
|
+
* encountered before the VT are garbage (e.g. noise on the line or a partial
|
|
180
|
+
* connection reset); they are reported via `onError` and discarded.
|
|
181
|
+
* 2. **IN_MESSAGE** — Scans for the two-byte FS CR end sequence. Once found, the
|
|
182
|
+
* bytes between VT and FS CR are the message payload. The decoder emits the
|
|
183
|
+
* message and transitions back to WAITING_START.
|
|
184
|
+
*
|
|
185
|
+
* ## Chunk boundaries
|
|
186
|
+
*
|
|
187
|
+
* TCP delivers data in arbitrarily-sized chunks that rarely align with
|
|
188
|
+
* MLLP frame boundaries. A single chunk may contain:
|
|
189
|
+
*
|
|
190
|
+
* - A fraction of one frame (the rest arrives in the next chunk)
|
|
191
|
+
* - Exactly one frame
|
|
192
|
+
* - Multiple complete frames
|
|
193
|
+
* - A mix of complete frames and a trailing partial frame
|
|
194
|
+
*
|
|
195
|
+
* The transformer handles all of these by appending every incoming
|
|
196
|
+
* chunk to an internal `buffer`, then running `processBuffer()` to
|
|
197
|
+
* extract as many complete frames as possible. Any leftover bytes
|
|
198
|
+
* (a partial frame) stay in the buffer until the next chunk arrives.
|
|
199
|
+
*
|
|
200
|
+
* ## Error recovery
|
|
201
|
+
*
|
|
202
|
+
* The stream is **resilient** — it never throws. All framing errors
|
|
203
|
+
* are reported via the `onError` callback, and the decoder continues
|
|
204
|
+
* scanning for the next valid frame. Error scenarios:
|
|
205
|
+
*
|
|
206
|
+
* - **Garbage before VT** — bytes are skipped, `INVALID_START_BYTE` reported.
|
|
207
|
+
* - **Oversized message** — frame is abandoned, `MESSAGE_TOO_LARGE` reported,
|
|
208
|
+
* scanning resumes.
|
|
209
|
+
* - **Incomplete frame at stream end** — `INCOMPLETE_MESSAGE` reported during
|
|
210
|
+
* `flush()`. This is the only error that can't be recovered from, since there
|
|
211
|
+
* is no more data coming.
|
|
212
|
+
*
|
|
213
|
+
* ## Size enforcement
|
|
214
|
+
*
|
|
215
|
+
* When `maxMessageSize` is set, the decoder checks the payload length
|
|
216
|
+
* in two places to bound memory usage:
|
|
217
|
+
*
|
|
218
|
+
* 1. **Mid-accumulation** — while waiting for the end sequence, if the buffered
|
|
219
|
+
* payload already exceeds the limit, the frame is abandoned immediately.
|
|
220
|
+
* This prevents a missing end sequence from causing unbounded buffer
|
|
221
|
+
* growth.
|
|
222
|
+
* 2. **On completion** — once the end sequence is found, the final payload length
|
|
223
|
+
* is checked before the message is enqueued.
|
|
224
|
+
*/
|
|
225
|
+
function createDecoderTransformer(options) {
|
|
226
|
+
const opts = {
|
|
227
|
+
...defaultDecoderOptions,
|
|
228
|
+
...options
|
|
229
|
+
};
|
|
230
|
+
const textDecoder = new TextDecoder(opts.encoding);
|
|
231
|
+
/**
|
|
232
|
+
* Accumulation buffer — grows as chunks arrive, shrinks as frames are
|
|
233
|
+
* consumed.
|
|
234
|
+
*/
|
|
235
|
+
const dynBuffer = new DynamicBuffer();
|
|
236
|
+
/** Current state machine position. */
|
|
237
|
+
let state = "WAITING_START";
|
|
238
|
+
/**
|
|
239
|
+
* Byte offset within the buffer view where the current frame's VT start byte
|
|
240
|
+
* lives. Only meaningful when `state === "IN_MESSAGE"`.
|
|
241
|
+
*/
|
|
242
|
+
let messageStartPos = 0;
|
|
243
|
+
/**
|
|
244
|
+
* Scan `view` for the next VT (0x0B) at or after `startPos`.
|
|
245
|
+
*
|
|
246
|
+
* @returns The index of the VT byte, or -1 if not found.
|
|
247
|
+
*/
|
|
248
|
+
function findStartByte(view, startPos) {
|
|
249
|
+
for (let i = startPos; i < view.length; i++) if (view[i] === 11) return i;
|
|
250
|
+
return -1;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Scan `view` for the two-byte FS CR (0x1C 0x0D) end sequence
|
|
254
|
+
* at or after `startPos`.
|
|
255
|
+
*
|
|
256
|
+
* @returns The index of the FS byte (first byte of the pair), or -1 if not
|
|
257
|
+
* found.
|
|
258
|
+
*/
|
|
259
|
+
function findEndSequence(view, startPos) {
|
|
260
|
+
for (let i = startPos; i < view.length - 1; i++) if (view[i] === 28 && view[i + 1] === 13) return i;
|
|
261
|
+
return -1;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Drain as many complete frames from the buffer as possible, enqueuing
|
|
265
|
+
* each decoded message into `controller`.
|
|
266
|
+
*
|
|
267
|
+
* Returns when the buffer contains only an incomplete frame (or is
|
|
268
|
+
* empty). The incomplete bytes are kept for the next `transform()` call.
|
|
269
|
+
*
|
|
270
|
+
* The loop uses a local `position` cursor that always resets to 0
|
|
271
|
+
* after a `consume()` call, since consumption shifts the view.
|
|
272
|
+
*/
|
|
273
|
+
function processBuffer(controller) {
|
|
274
|
+
let position = 0;
|
|
275
|
+
while (true) {
|
|
276
|
+
const view = dynBuffer.view();
|
|
277
|
+
if (position >= view.length) break;
|
|
278
|
+
if (state === "WAITING_START") {
|
|
279
|
+
const startPos = findStartByte(view, position);
|
|
280
|
+
if (startPos === -1) {
|
|
281
|
+
if (view.length > 0) opts.onError(new TransportError(TransportErrorCode.INVALID_START_BYTE, `Skipped ${view.length - position} bytes before finding start byte`, position));
|
|
282
|
+
dynBuffer.reset();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (startPos > position) opts.onError(new TransportError(TransportErrorCode.INVALID_START_BYTE, `Skipped ${startPos - position} bytes before start byte`, position));
|
|
286
|
+
state = "IN_MESSAGE";
|
|
287
|
+
messageStartPos = startPos;
|
|
288
|
+
position = startPos + 1;
|
|
289
|
+
}
|
|
290
|
+
if (state === "IN_MESSAGE") {
|
|
291
|
+
const endPos = findEndSequence(view, position);
|
|
292
|
+
if (endPos === -1) {
|
|
293
|
+
const currentSize = view.length - messageStartPos - 1;
|
|
294
|
+
if (opts.maxMessageSize !== void 0 && currentSize > opts.maxMessageSize) {
|
|
295
|
+
opts.onError(new TransportError(TransportErrorCode.MESSAGE_TOO_LARGE, `Message size ${currentSize} exceeds maximum ${opts.maxMessageSize}`, messageStartPos));
|
|
296
|
+
state = "WAITING_START";
|
|
297
|
+
position = messageStartPos + 1;
|
|
298
|
+
dynBuffer.consume(position);
|
|
299
|
+
position = 0;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (messageStartPos > 0) {
|
|
303
|
+
dynBuffer.consume(messageStartPos);
|
|
304
|
+
messageStartPos = 0;
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const messageData = view.slice(messageStartPos + 1, endPos);
|
|
309
|
+
const messageLength = messageData.length;
|
|
310
|
+
if (opts.maxMessageSize !== void 0 && messageLength > opts.maxMessageSize) opts.onError(new TransportError(TransportErrorCode.MESSAGE_TOO_LARGE, `Message size ${messageLength} exceeds maximum ${opts.maxMessageSize}`, messageStartPos));
|
|
311
|
+
else controller.enqueue({
|
|
312
|
+
byteLength: messageLength,
|
|
313
|
+
data: messageData,
|
|
314
|
+
text: textDecoder.decode(messageData)
|
|
315
|
+
});
|
|
316
|
+
position = endPos + 2;
|
|
317
|
+
state = "WAITING_START";
|
|
318
|
+
dynBuffer.consume(position);
|
|
319
|
+
position = 0;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
flush(_controller) {
|
|
325
|
+
if (state === "IN_MESSAGE" && dynBuffer.available > 0) opts.onError(new TransportError(TransportErrorCode.INCOMPLETE_MESSAGE, "Stream ended with incomplete MLLP message", messageStartPos));
|
|
326
|
+
},
|
|
327
|
+
transform(chunk, controller) {
|
|
328
|
+
dynBuffer.append(chunk);
|
|
329
|
+
processBuffer(controller);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Create a `TransformStream` that extracts MLLP messages from a raw
|
|
335
|
+
* byte stream.
|
|
336
|
+
*
|
|
337
|
+
* Pipe a TCP socket's `ReadableStream<Uint8Array>` through this stream
|
|
338
|
+
* to receive one {@link DecodedMessage} per MLLP frame, regardless of
|
|
339
|
+
* how the bytes are chunked on the wire.
|
|
340
|
+
*
|
|
341
|
+
* @example
|
|
342
|
+
* ```typescript
|
|
343
|
+
* import { createDecoderStream } from "@glion/mllp";
|
|
344
|
+
*
|
|
345
|
+
* const decoder = createDecoderStream({
|
|
346
|
+
* maxMessageSize: 1024 * 1024, // 1 MB limit
|
|
347
|
+
* onError: (err) => log.warn(err), // custom error handler
|
|
348
|
+
* });
|
|
349
|
+
*
|
|
350
|
+
* const messages = socket.readable.pipeThrough(decoder).getReader();
|
|
351
|
+
*
|
|
352
|
+
* while (true) {
|
|
353
|
+
* const { done, value } = await messages.read();
|
|
354
|
+
* if (done) break;
|
|
355
|
+
* console.log(value.text); // HL7v2 message string
|
|
356
|
+
* }
|
|
357
|
+
* ```;
|
|
358
|
+
*
|
|
359
|
+
* @param options - Optional decoder settings (max size, encoding, error
|
|
360
|
+
* handler).
|
|
361
|
+
* @returns A `TransformStream<Uint8Array, DecodedMessage>`.
|
|
362
|
+
*/
|
|
363
|
+
function createDecoderStream(options) {
|
|
364
|
+
return new TransformStream(createDecoderTransformer(options));
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Class-based variant of {@link createDecoderStream} for cases where
|
|
368
|
+
* a reusable instance is preferred over the factory function.
|
|
369
|
+
*
|
|
370
|
+
* Functionally identical — both delegate to the same internal
|
|
371
|
+
* transformer.
|
|
372
|
+
*
|
|
373
|
+
* @example
|
|
374
|
+
* ```typescript
|
|
375
|
+
* const decoder = new MLLPDecoderStream({ maxMessageSize: 512_000 });
|
|
376
|
+
* socket.readable.pipeThrough(decoder);
|
|
377
|
+
* ```;
|
|
378
|
+
*/
|
|
379
|
+
var MLLPDecoderStream = class extends TransformStream {
|
|
380
|
+
constructor(options) {
|
|
381
|
+
super(createDecoderTransformer(options));
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
//#endregion
|
|
385
|
+
//#region src/transport/encoder.ts
|
|
386
|
+
/** Module-level singleton — avoids allocation per encode() call. */
|
|
387
|
+
const textEncoder = new TextEncoder();
|
|
388
|
+
/**
|
|
389
|
+
* Encode a single HL7v2 message into an MLLP frame.
|
|
390
|
+
*
|
|
391
|
+
* Wraps the message with MLLP framing:
|
|
392
|
+
* - Start byte: 0x0B (VT)
|
|
393
|
+
* - End sequence: 0x1C 0x0D (FS + CR)
|
|
394
|
+
*/
|
|
395
|
+
function encode(message, _options) {
|
|
396
|
+
const messageBytes = typeof message === "string" ? textEncoder.encode(message) : message;
|
|
397
|
+
const frame = new Uint8Array(messageBytes.length + 3);
|
|
398
|
+
frame[0] = 11;
|
|
399
|
+
frame.set(messageBytes, 1);
|
|
400
|
+
frame[frame.length - 2] = 28;
|
|
401
|
+
frame[frame.length - 1] = 13;
|
|
402
|
+
return frame;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Encode multiple HL7v2 messages into concatenated MLLP frames.
|
|
406
|
+
*/
|
|
407
|
+
function encodeMultiple(messages, options) {
|
|
408
|
+
const frames = messages.map((msg) => encode(msg, options));
|
|
409
|
+
let totalLength = 0;
|
|
410
|
+
for (const frame of frames) totalLength += frame.length;
|
|
411
|
+
const result = new Uint8Array(totalLength);
|
|
412
|
+
let offset = 0;
|
|
413
|
+
for (const frame of frames) {
|
|
414
|
+
result.set(frame, offset);
|
|
415
|
+
offset += frame.length;
|
|
416
|
+
}
|
|
417
|
+
return result;
|
|
418
|
+
}
|
|
419
|
+
//#endregion
|
|
420
|
+
//#region src/server/compose.ts
|
|
421
|
+
/**
|
|
422
|
+
* Compose an array of middleware into a single function that executes
|
|
423
|
+
* them in onion-model order (like Hono/Koa).
|
|
424
|
+
*
|
|
425
|
+
* Each middleware receives (ctx, next). Calling next() proceeds to the
|
|
426
|
+
* next middleware. If a middleware returns a Response without
|
|
427
|
+
* calling next(), it short-circuits the chain.
|
|
428
|
+
*
|
|
429
|
+
* Response handling follows Hono's pattern: return values are written
|
|
430
|
+
* to ctx.res, and middleware can also set ctx.res directly.
|
|
431
|
+
* Last write wins.
|
|
432
|
+
*/
|
|
433
|
+
function compose(middlewares) {
|
|
434
|
+
return async (ctx) => {
|
|
435
|
+
let index = -1;
|
|
436
|
+
async function dispatch(i) {
|
|
437
|
+
if (i <= index) throw new Error("next() called multiple times");
|
|
438
|
+
index = i;
|
|
439
|
+
if (i >= middlewares.length) return;
|
|
440
|
+
const middleware = middlewares[i];
|
|
441
|
+
const result = await middleware(ctx, () => dispatch(i + 1));
|
|
442
|
+
if (result && typeof result === "object" && "raw" in result && typeof result.raw === "string") ctx.res = result;
|
|
443
|
+
}
|
|
444
|
+
await dispatch(0);
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
//#endregion
|
|
448
|
+
//#region src/server/context.ts
|
|
449
|
+
/**
|
|
450
|
+
* Create a new Context for an incoming message.
|
|
451
|
+
*
|
|
452
|
+
* Only the **parse** step runs eagerly (sync, fast). Routing fields are
|
|
453
|
+
* extracted as strings from the parsed tree immediately — these cached
|
|
454
|
+
* strings always reflect the original wire values (see ADR-0013).
|
|
455
|
+
*
|
|
456
|
+
* The expensive pipeline stages are lazy:
|
|
457
|
+
*
|
|
458
|
+
* - `ctx.tree()` — triggers `run()` (transformers: annotations, escape decoding,
|
|
459
|
+
* lint). Cached after first call.
|
|
460
|
+
* - `ctx.result()` — triggers `run()` + `stringify()` (compilation, e.g., JSON
|
|
461
|
+
* via hl7v2Jsonify). Cached after first call.
|
|
462
|
+
*
|
|
463
|
+
* If no handler matches or middleware short-circuits before accessing
|
|
464
|
+
* the tree, the transform and compile steps never execute.
|
|
465
|
+
*/
|
|
466
|
+
function createContext(options) {
|
|
467
|
+
const { raw, bytes, connection, processor } = options;
|
|
468
|
+
const variables = /* @__PURE__ */ new Map();
|
|
469
|
+
let varSnapshot;
|
|
470
|
+
const file = new VFile(raw);
|
|
471
|
+
const parsed = processor.parse(file);
|
|
472
|
+
const controlId = value(parsed, "MSH-10")?.value ?? "";
|
|
473
|
+
const messageType = value(parsed, "MSH-9.1")?.value ?? "";
|
|
474
|
+
const triggerEvent = value(parsed, "MSH-9.2")?.value ?? "";
|
|
475
|
+
const messageStructure = value(parsed, "MSH-9.3")?.value ?? "";
|
|
476
|
+
const version = value(parsed, "MSH-12.1")?.value ?? "";
|
|
477
|
+
let transformedTree;
|
|
478
|
+
let transformPromise;
|
|
479
|
+
function getTree() {
|
|
480
|
+
if (transformedTree) return Promise.resolve(transformedTree);
|
|
481
|
+
if (!transformPromise) transformPromise = (async () => {
|
|
482
|
+
const result = await processor.run(parsed, file);
|
|
483
|
+
transformedTree = result;
|
|
484
|
+
return result;
|
|
485
|
+
})();
|
|
486
|
+
return transformPromise;
|
|
487
|
+
}
|
|
488
|
+
let resultPromise;
|
|
489
|
+
function getResult() {
|
|
490
|
+
if (!resultPromise) resultPromise = (async () => {
|
|
491
|
+
const tree = await getTree();
|
|
492
|
+
return processor.compiler ? processor.stringify(tree, file) : void 0;
|
|
493
|
+
})();
|
|
494
|
+
return resultPromise;
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
ast: parsed,
|
|
498
|
+
connection: Object.freeze(connection),
|
|
499
|
+
controlId,
|
|
500
|
+
file,
|
|
501
|
+
get(key) {
|
|
502
|
+
return variables.get(key);
|
|
503
|
+
},
|
|
504
|
+
messageStructure,
|
|
505
|
+
messageType,
|
|
506
|
+
req: Object.freeze({
|
|
507
|
+
bytes,
|
|
508
|
+
raw
|
|
509
|
+
}),
|
|
510
|
+
res: void 0,
|
|
511
|
+
result: getResult,
|
|
512
|
+
set(key, value) {
|
|
513
|
+
variables.set(key, value);
|
|
514
|
+
varSnapshot = void 0;
|
|
515
|
+
},
|
|
516
|
+
tree: getTree,
|
|
517
|
+
triggerEvent,
|
|
518
|
+
get var() {
|
|
519
|
+
if (!varSnapshot) varSnapshot = Object.freeze(Object.fromEntries(variables));
|
|
520
|
+
return varSnapshot;
|
|
521
|
+
},
|
|
522
|
+
version
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
//#endregion
|
|
526
|
+
//#region src/server/types.ts
|
|
527
|
+
/**
|
|
528
|
+
* Parse a pattern string like "ADT^A01", "ADT^*", "*^A01", or "*"
|
|
529
|
+
* into a structured RoutePattern.
|
|
530
|
+
*/
|
|
531
|
+
function parsePattern(pattern) {
|
|
532
|
+
if (pattern === "*") return {
|
|
533
|
+
messageType: "*",
|
|
534
|
+
source: pattern,
|
|
535
|
+
triggerEvent: "*"
|
|
536
|
+
};
|
|
537
|
+
const caretIndex = pattern.indexOf("^");
|
|
538
|
+
if (caretIndex === -1) return {
|
|
539
|
+
messageType: pattern,
|
|
540
|
+
source: pattern,
|
|
541
|
+
triggerEvent: "*"
|
|
542
|
+
};
|
|
543
|
+
return {
|
|
544
|
+
messageType: pattern.slice(0, caretIndex) || "*",
|
|
545
|
+
source: pattern,
|
|
546
|
+
triggerEvent: pattern.slice(caretIndex + 1) || "*"
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Test whether a route pattern matches a given message type and trigger event.
|
|
551
|
+
*/
|
|
552
|
+
function matchPattern(pattern, messageType, triggerEvent) {
|
|
553
|
+
const typeMatch = pattern.messageType === "*" || pattern.messageType === messageType;
|
|
554
|
+
const eventMatch = pattern.triggerEvent === "*" || pattern.triggerEvent === triggerEvent;
|
|
555
|
+
return typeMatch && eventMatch;
|
|
556
|
+
}
|
|
557
|
+
//#endregion
|
|
558
|
+
//#region src/server/router.ts
|
|
559
|
+
/**
|
|
560
|
+
* Create a matcher function from a string pattern.
|
|
561
|
+
*/
|
|
562
|
+
function patternMatcher(pattern) {
|
|
563
|
+
const parsed = parsePattern(pattern);
|
|
564
|
+
return (ctx) => matchPattern(parsed, ctx.messageType, ctx.triggerEvent);
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Pattern-matching router for HL7v2 message types.
|
|
568
|
+
*
|
|
569
|
+
* Routes are matched in registration order (first match wins).
|
|
570
|
+
* Supports string patterns with wildcards or filter functions.
|
|
571
|
+
*
|
|
572
|
+
* String patterns:
|
|
573
|
+
*
|
|
574
|
+
* - `"ADT^A01"` — exact match
|
|
575
|
+
* - `"ADT^*"` — any ADT message
|
|
576
|
+
* - `"*"` — catch-all
|
|
577
|
+
*
|
|
578
|
+
* Filter functions:
|
|
579
|
+
*
|
|
580
|
+
* - `(ctx) => ctx.messageType === "ADT" && ctx.version === "2.5.1"`
|
|
581
|
+
*/
|
|
582
|
+
var Router = class {
|
|
583
|
+
#routes = [];
|
|
584
|
+
#globalMiddleware = [];
|
|
585
|
+
#scopedMiddleware = [];
|
|
586
|
+
/**
|
|
587
|
+
* Register a route handler with a string pattern or filter function.
|
|
588
|
+
*/
|
|
589
|
+
add(patternOrFilter, handler) {
|
|
590
|
+
const filter = typeof patternOrFilter === "string" ? patternMatcher(patternOrFilter) : patternOrFilter;
|
|
591
|
+
this.#routes.push({
|
|
592
|
+
filter,
|
|
593
|
+
handler
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
addMiddleware(patternOrFilterOrMiddleware, middlewareOrPrepend) {
|
|
597
|
+
if (typeof patternOrFilterOrMiddleware === "function" && (middlewareOrPrepend === void 0 || typeof middlewareOrPrepend === "boolean")) {
|
|
598
|
+
if (middlewareOrPrepend === true) this.#globalMiddleware.unshift(patternOrFilterOrMiddleware);
|
|
599
|
+
else this.#globalMiddleware.push(patternOrFilterOrMiddleware);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (typeof middlewareOrPrepend === "function") {
|
|
603
|
+
const middleware = middlewareOrPrepend;
|
|
604
|
+
const filter = typeof patternOrFilterOrMiddleware === "string" ? patternMatcher(patternOrFilterOrMiddleware) : patternOrFilterOrMiddleware;
|
|
605
|
+
this.#scopedMiddleware.push({
|
|
606
|
+
filter,
|
|
607
|
+
middleware
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Match a context against registered routes and middleware.
|
|
613
|
+
* Returns the first matching handler and all applicable middleware.
|
|
614
|
+
*/
|
|
615
|
+
match(ctx) {
|
|
616
|
+
let handler;
|
|
617
|
+
for (const route of this.#routes) if (route.filter(ctx)) {
|
|
618
|
+
handler = route.handler;
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
const middlewares = [...this.#globalMiddleware];
|
|
622
|
+
for (const scoped of this.#scopedMiddleware) if (scoped.filter(ctx)) middlewares.push(scoped.middleware);
|
|
623
|
+
return {
|
|
624
|
+
handler,
|
|
625
|
+
middlewares
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
//#endregion
|
|
630
|
+
//#region src/server/mllp.ts
|
|
631
|
+
/**
|
|
632
|
+
* Internal store for associating message routing info with errors.
|
|
633
|
+
* Uses a WeakMap so error objects can be garbage-collected normally.
|
|
634
|
+
*/
|
|
635
|
+
const errorMessageInfo = /* @__PURE__ */ new WeakMap();
|
|
636
|
+
/**
|
|
637
|
+
* Retrieve the {@link MessageInfo} associated with an error thrown
|
|
638
|
+
* by `Mllp.handle()`. Returns `undefined` for errors that did not
|
|
639
|
+
* originate from message processing (e.g., lifecycle callback errors).
|
|
640
|
+
*/
|
|
641
|
+
function getMessageInfo(error) {
|
|
642
|
+
return errorMessageInfo.get(error);
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* MLLP application for HL7v2 messaging.
|
|
646
|
+
*
|
|
647
|
+
* A pure routing and middleware engine with no TCP/server concerns.
|
|
648
|
+
* Use `serve()` to bind this to a TCP server.
|
|
649
|
+
*
|
|
650
|
+
* @example
|
|
651
|
+
* ```typescript
|
|
652
|
+
* import { Mllp } from "@glion/mllp";
|
|
653
|
+
* import { parseHL7v2 } from "@glion/hl7v2";
|
|
654
|
+
*
|
|
655
|
+
* const app = new Mllp().parser(parseHL7v2).on("ADT^A01", async (ctx) => {
|
|
656
|
+
* const tree = await ctx.tree();
|
|
657
|
+
* return { raw: "..." };
|
|
658
|
+
* });
|
|
659
|
+
* ```;
|
|
660
|
+
*/
|
|
661
|
+
var Mllp = class {
|
|
662
|
+
#router = new Router();
|
|
663
|
+
#processor;
|
|
664
|
+
#errorHandler;
|
|
665
|
+
/**
|
|
666
|
+
* Register an HL7v2 processor for incoming messages.
|
|
667
|
+
*
|
|
668
|
+
* Accepts a unified `Processor` — either a pre-built one like
|
|
669
|
+
* `parseHL7v2` from `@glion/hl7v2`, or a custom composition
|
|
670
|
+
* via `unified().use(hl7v2Parser).use(...)`.
|
|
671
|
+
*
|
|
672
|
+
* Must be called before `handle()`. Calling multiple times replaces
|
|
673
|
+
* the previous processor (last-write-wins).
|
|
674
|
+
*/
|
|
675
|
+
parser(processor) {
|
|
676
|
+
this.#processor = processor;
|
|
677
|
+
return this;
|
|
678
|
+
}
|
|
679
|
+
use(first, second) {
|
|
680
|
+
if ((typeof first === "string" || typeof first === "function") && typeof second === "function") {
|
|
681
|
+
this.#router.addMiddleware(first, second);
|
|
682
|
+
return this;
|
|
683
|
+
}
|
|
684
|
+
if (typeof first === "function") {
|
|
685
|
+
const prepend = second && typeof second === "object" ? second.prepend : false;
|
|
686
|
+
this.#router.addMiddleware(first, prepend);
|
|
687
|
+
return this;
|
|
688
|
+
}
|
|
689
|
+
return this;
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Register a route handler for a message type pattern or filter function.
|
|
693
|
+
*
|
|
694
|
+
* String patterns:
|
|
695
|
+
*
|
|
696
|
+
* - `"ADT^A01"` — exact match
|
|
697
|
+
* - `"ADT^*"` — any ADT message
|
|
698
|
+
* - `"*^A01"` — any type with A01 trigger
|
|
699
|
+
* - `"*"` — catch-all
|
|
700
|
+
*
|
|
701
|
+
* Filter functions:
|
|
702
|
+
*
|
|
703
|
+
* - `(ctx) => ctx.messageType === "ADT" && ctx.version === "2.5.1"`
|
|
704
|
+
*/
|
|
705
|
+
on(patternOrFilter, handler) {
|
|
706
|
+
this.#router.add(patternOrFilter, handler);
|
|
707
|
+
return this;
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Register a global error handler.
|
|
711
|
+
*
|
|
712
|
+
* Called when middleware or a handler throws. Without an error handler,
|
|
713
|
+
* the error is re-thrown to the caller (e.g. `serve()`).
|
|
714
|
+
*
|
|
715
|
+
* If the error handler returns a response, it is used as the reply.
|
|
716
|
+
* If the error handler itself throws, the new error is re-thrown.
|
|
717
|
+
*/
|
|
718
|
+
onError(handler) {
|
|
719
|
+
this.#errorHandler = handler;
|
|
720
|
+
return this;
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Process a raw HL7v2 message through the middleware chain and router.
|
|
724
|
+
* This is the integration point — analogous to Hono's `fetch()`.
|
|
725
|
+
*
|
|
726
|
+
* The pipeline is lazy (see ADR-0013):
|
|
727
|
+
* 1. Parse (sync, fast) — always runs, extracts routing fields
|
|
728
|
+
* 2. Route match — uses pre-transform routing fields
|
|
729
|
+
* 3. If no match → return undefined (no transform/compile cost)
|
|
730
|
+
* 4. Transform/compile — only when handlers access ctx.tree()/ctx.result()
|
|
731
|
+
*
|
|
732
|
+
* Throws `MllpError` if no processor has been registered via `app.parser()`.
|
|
733
|
+
*/
|
|
734
|
+
async handle(raw, bytes, connection) {
|
|
735
|
+
if (!this.#processor) throw new MllpError("ERR_NO_PARSER", "No parser registered. Call app.parser() before handling messages.");
|
|
736
|
+
const ctx = createContext({
|
|
737
|
+
bytes,
|
|
738
|
+
connection,
|
|
739
|
+
processor: this.#processor,
|
|
740
|
+
raw
|
|
741
|
+
});
|
|
742
|
+
try {
|
|
743
|
+
const match = this.#router.match(ctx);
|
|
744
|
+
const middlewares = [...match.middlewares];
|
|
745
|
+
if (match.handler) {
|
|
746
|
+
const handler = match.handler;
|
|
747
|
+
middlewares.push((handlerCtx) => handler(handlerCtx));
|
|
748
|
+
}
|
|
749
|
+
await compose(middlewares)(ctx);
|
|
750
|
+
return ctx.res;
|
|
751
|
+
} catch (error) {
|
|
752
|
+
return this.#handleError(error instanceof Error ? error : new Error(String(error)), ctx);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Handle an error during message processing.
|
|
757
|
+
*
|
|
758
|
+
* If a custom error handler is registered, delegates to it.
|
|
759
|
+
* If the error handler itself throws, re-throws the error handler's error.
|
|
760
|
+
* If no error handler is registered, re-throws the original error.
|
|
761
|
+
*
|
|
762
|
+
* In both re-throw cases, message routing fields are attached to the
|
|
763
|
+
* error via {@link getMessageInfo} so callers can include message
|
|
764
|
+
* context in error reporting.
|
|
765
|
+
*
|
|
766
|
+
* Callers (e.g. `serve()`) are responsible for catching these errors
|
|
767
|
+
* and deciding how to handle them at the transport level.
|
|
768
|
+
*/
|
|
769
|
+
async #handleError(err, ctx) {
|
|
770
|
+
const info = {
|
|
771
|
+
controlId: ctx.controlId,
|
|
772
|
+
messageStructure: ctx.messageStructure,
|
|
773
|
+
messageType: ctx.messageType,
|
|
774
|
+
triggerEvent: ctx.triggerEvent,
|
|
775
|
+
version: ctx.version
|
|
776
|
+
};
|
|
777
|
+
if (this.#errorHandler) try {
|
|
778
|
+
return await this.#errorHandler(err, ctx);
|
|
779
|
+
} catch (handlerError) {
|
|
780
|
+
const e = handlerError instanceof Error ? handlerError : new Error(String(handlerError));
|
|
781
|
+
errorMessageInfo.set(e, info);
|
|
782
|
+
throw e;
|
|
783
|
+
}
|
|
784
|
+
errorMessageInfo.set(err, info);
|
|
785
|
+
throw err;
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
//#endregion
|
|
789
|
+
export { encode as a, createDecoderStream as c, MllpError as d, MLLP_END_BYTE_1 as f, MLLP_TRAILER as g, MLLP_START_BYTE as h, parsePattern as i, TransportErrorCode as l, MLLP_HEADER as m, getMessageInfo as n, encodeMultiple as o, MLLP_END_BYTE_2 as p, matchPattern as r, MLLPDecoderStream as s, Mllp as t, TransportError as u };
|
|
790
|
+
|
|
791
|
+
//# sourceMappingURL=mllp.js.map
|