@glion/mllp 0.15.3 → 0.19.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/dist/mllp.js CHANGED
@@ -1,421 +1,48 @@
1
1
  import { value } from "@glion/util-query";
2
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
3
  //#region src/errors.ts
29
4
  /**
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
5
+ * Errors raised by the `@glion/mllp` server itself.
186
6
  *
187
- * TCP delivers data in arbitrarily-sized chunks that rarely align with
188
- * MLLP frame boundaries. A single chunk may contain:
7
+ * Per ADR 0018: one base error class discriminated by a stable `code` string.
8
+ * A NAK the server *builds* in response to a message is an `@glion/ack`
9
+ * `AckException` — a different concern from a server-lifecycle/config failure,
10
+ * which is what `MllpServerError` represents.
189
11
  *
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.
12
+ * @module
224
13
  */
225
- function createDecoderTransformer(options) {
226
- const opts = {
227
- ...defaultDecoderOptions,
228
- ...options
229
- };
230
- const textDecoder = new TextDecoder(opts.encoding);
14
+ const MllpServerErrorCode = {
231
15
  /**
232
- * Accumulation buffer — grows as chunks arrive, shrinks as frames are
233
- * consumed.
16
+ * An inbound message could not be decoded as UTF-8 — the only character set
17
+ * supported for now. The underlying `@glion/util-charset` `CharsetError` is
18
+ * kept on `cause`; callers branch on this code without importing that
19
+ * package.
234
20
  */
235
- const dynBuffer = new DynamicBuffer();
236
- /** Current state machine position. */
237
- let state = "WAITING_START";
21
+ INCOMPATIBLE_CHARSET: "INCOMPATIBLE_CHARSET",
238
22
  /**
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.
23
+ * `app.handle()` was called before a parser was registered via
24
+ * `app.parser()`.
258
25
  */
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
- }
26
+ NO_PARSER: "NO_PARSER",
263
27
  /**
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.
28
+ * The MLLP byte stream itself was violated and the connection closed:
29
+ * the remote system sent bytes outside a message, glued or unterminated
30
+ * messages, an over-cap message — or a handler's response contained a
31
+ * reserved VT/FS byte and could not be framed. The underlying
32
+ * `@glion/mllp-codec` `MllpCodecError` is kept on `cause`; branch on this
33
+ * code without importing that package.
272
34
  */
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
- }
35
+ PROTOCOL_VIOLATION: "PROTOCOL_VIOLATION"
383
36
  };
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;
37
+ /** The one error class the `@glion/mllp` server raises. Branch on `code`. */
38
+ var MllpServerError = class extends Error {
39
+ code;
40
+ constructor(code, message, opts) {
41
+ super(message, opts);
42
+ this.name = "MllpServerError";
43
+ this.code = code;
416
44
  }
417
- return result;
418
- }
45
+ };
419
46
  //#endregion
420
47
  //#region src/server/compose.ts
421
48
  /**
@@ -724,15 +351,17 @@ var Mllp = class {
724
351
  * This is the integration point — analogous to Hono's `fetch()`.
725
352
  *
726
353
  * The pipeline is lazy (see ADR-0013):
354
+ *
727
355
  * 1. Parse (sync, fast) — always runs, extracts routing fields
728
356
  * 2. Route match — uses pre-transform routing fields
729
357
  * 3. If no match → return undefined (no transform/compile cost)
730
358
  * 4. Transform/compile — only when handlers access ctx.tree()/ctx.result()
731
359
  *
732
- * Throws `MllpError` if no processor has been registered via `app.parser()`.
360
+ * Throws `MllpServerError` (`NO_PARSER`) if no processor has been registered
361
+ * via `app.parser()`.
733
362
  */
734
363
  async handle(raw, bytes, connection) {
735
- if (!this.#processor) throw new MllpError("ERR_NO_PARSER", "No parser registered. Call app.parser() before handling messages.");
364
+ if (!this.#processor) throw new MllpServerError(MllpServerErrorCode.NO_PARSER, "No parser registered. Call app.parser() before handling messages.");
736
365
  const ctx = createContext({
737
366
  bytes,
738
367
  connection,
@@ -786,6 +415,6 @@ var Mllp = class {
786
415
  }
787
416
  };
788
417
  //#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 };
418
+ export { MllpServerError as a, parsePattern as i, getMessageInfo as n, MllpServerErrorCode as o, matchPattern as r, Mllp as t };
790
419
 
791
420
  //# sourceMappingURL=mllp.js.map