diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2c0dcb2..cb6c168 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -13,13 +13,13 @@ jobs: id-token: write strategy: matrix: - node-version: [ 18.x, 20.x, 'lts/*' ] + node-version: [ 20.x, 'lts/*' ] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: persist-credentials: false - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - name: Install Dependencies @@ -27,7 +27,7 @@ jobs: - name: Run Lint Fix run: npm run lint:fix - name: Run Unit Tests - run: npm run test:ci + run: npm run test Release: runs-on: ubuntu-latest needs: [ 'Test' ] @@ -37,11 +37,11 @@ jobs: pull-requests: write id-token: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 'lts/*' - name: NPM Install diff --git a/CHANGELOG.md b/CHANGELOG.md index dc225f6..0971117 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# [1.2.0-beta.1](https://github.com/Bugs5382/node-hl7-client/compare/v1.1.3...v1.2.0-beta.1) (2024-01-22) + + +### Features + +* better error reporting ([#73](https://github.com/Bugs5382/node-hl7-client/issues/73)) ([f5707ae](https://github.com/Bugs5382/node-hl7-client/commit/f5707aed9c2bed71dd783f6a632f791ce4203127)) +* fix error reporting issue ([414276a](https://github.com/Bugs5382/node-hl7-client/commit/414276af1a207b4d781d0a0b277d0808251fc83d)) + ## [1.1.3](https://github.com/Bugs5382/node-hl7-client/compare/v1.1.2...v1.1.3) (2024-01-11) diff --git a/__tests__/__utils__/index.ts b/__tests__/__utils__/index.ts index e9d1ec8..6dda517 100644 --- a/__tests__/__utils__/index.ts +++ b/__tests__/__utils__/index.ts @@ -9,24 +9,3 @@ export const sleep = async (ms: number): Promise => { export const expectEvent = async (emitter: EventEmitter, name: string | symbol): Promise => { return await new Promise((resolve) => { emitter.once(name, resolve) }) } - -/** @internal */ -export interface Deferred { - resolve: (value: T | PromiseLike) => void - reject: (reason?: any) => void - promise: Promise -} - -/** @internal */ -export const createDeferred = (noUncaught?: boolean): Deferred => { - const dfd: any = {} - dfd.promise = new Promise((resolve, reject) => { - dfd.resolve = resolve - dfd.reject = reject - }) - /* istanbul ignore next */ - if (noUncaught === false) { - dfd.promise.catch(() => {}) - } - return dfd -} diff --git a/__tests__/__utils__/server.ts b/__tests__/__utils__/server.ts new file mode 100644 index 0000000..696db9f --- /dev/null +++ b/__tests__/__utils__/server.ts @@ -0,0 +1,24 @@ +import { createHL7Date, Message } from '../../src' + +export function _createAckMessage (type: string, message: Message): Message { + const ackMessage = new Message({ + messageHeader: { + msh_9_1: 'ACK', + msh_9_2: message.get('MSH.9.2').toString(), + msh_10: `ACK${createHL7Date(new Date())}`, + msh_11_1: message.get('MSH.11.1').toString() as 'P' | 'D' | 'T' + } + }) + + ackMessage.set('MSH.3', message.get('MSH.5').toString()) + ackMessage.set('MSH.4', message.get('MSH.6').toString()) + ackMessage.set('MSH.5', message.get('MSH.3').toString()) + ackMessage.set('MSH.6', message.get('MSH.4').toString()) + ackMessage.set('MSH.12', message.get('MSH.12').toString()) + + const segment = ackMessage.addSegment('MSA') + segment.set('1', type) + segment.set('2', message.get('MSH.10').toString()) + + return ackMessage +} diff --git a/__tests__/hl7.build.test.ts b/__tests__/hl7.build.test.ts index a5c7640..401ca2c 100644 --- a/__tests__/hl7.build.test.ts +++ b/__tests__/hl7.build.test.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'crypto' import fs from 'fs' import path from 'path' +import { describe, expect, test, beforeEach, beforeAll } from 'vitest'; import { FileBatch, Batch, Message, createHL7Date, HL7Node, EmptyNode } from '../src' import { HL7_2_1, HL7_2_7, HL7_2_2, HL7_2_3, HL7_2_3_1, HL7_2_4, HL7_2_5, HL7_2_5_1, HL7_2_6, HL7_2_7_1, HL7_2_8 } from '../src/hl7' import {MSH_HEADER} from "./__data__/constants"; diff --git a/__tests__/hl7.client.test.ts b/__tests__/hl7.client.test.ts index 6fcb7ed..110a9ae 100644 --- a/__tests__/hl7.client.test.ts +++ b/__tests__/hl7.client.test.ts @@ -1,3 +1,4 @@ +import { describe, expect, test, beforeEach } from 'vitest'; import { Client } from '../src' describe('node hl7 client', () => { @@ -53,7 +54,7 @@ describe('node hl7 client', () => { test('properties exist', async () => { const client = new Client({ host: 'hl7.server.local' }) - expect(client).toHaveProperty('createOutbound') + expect(client).toHaveProperty('createConnection') }) test('ensure getHost() is what we set in the host', async () => { @@ -73,7 +74,7 @@ describe('node hl7 client', () => { test('error - no port specified', async () => { try { // @ts-expect-error port is not specified - client.createOutbound() + client.createConnection() } catch (err: any) { expect(err.message).toBe('port is not defined.') } @@ -82,7 +83,7 @@ describe('node hl7 client', () => { test('error - port not a number', async () => { try { // @ts-expect-error port is not specified as a number - client.createOutbound({ port: '12345' }, async () => {}) + client.createConnection({ port: '12345' }, async () => {}) } catch (err: any) { expect(err.message).toBe('port is not valid number.') } @@ -90,18 +91,19 @@ describe('node hl7 client', () => { test('error - port less than 0', async () => { try { - client.createOutbound({ port: -1 }, async () => {}) + client.createConnection({ port: -1 }, async () => {}) } catch (err: any) { - expect(err.message).toBe('port must be a number (0, 65353).') + expect(err.message).toBe('port must be a number (1, 65353).') } }) test('error - port greater than 65353', async () => { try { - client.createOutbound({ port: 65354 }, async () => {}) + client.createConnection({ port: 65354 }, async () => {}) } catch (err: any) { - expect(err.message).toBe('port must be a number (0, 65353).') + expect(err.message).toBe('port must be a number (1, 65353).') } }) + }) }) diff --git a/__tests__/hl7.end2end.test.ts b/__tests__/hl7.end2end.test.ts index e586bc1..e3dc52f 100644 --- a/__tests__/hl7.end2end.test.ts +++ b/__tests__/hl7.end2end.test.ts @@ -1,254 +1,40 @@ -import fs from 'fs' -import { HL7Inbound, Server } from 'node-hl7-server' -import { Batch, Client, HL7Outbound, Message } from '../src' -import path from 'node:path' -import portfinder from 'portfinder' -import { createDeferred, Deferred, expectEvent, sleep } from './__utils__' +import fs from "fs"; +import {Server} from 'node-hl7-server' +import path from "node:path"; +import { describe, expect, test } from 'vitest'; +import tcpPortUsed from 'tcp-port-used' +import Client, {Batch, Message} from '../src' +import {createDeferred} from "../src/utils/utils"; +import {expectEvent} from './__utils__' describe('node hl7 end to end - client', () => { - let dfd: Deferred describe('server/client sanity checks', () => { - // please run these tests using the described block. otherwise tests will fail - - let waitAck: number = 0 - - let server: Server - let listener: HL7Inbound - - let client: Client - let outGoing: HL7Outbound - - beforeEach(async () => { - const LISTEN_PORT = await portfinder.getPortPromise({ - port: 3000, - stopPort: 65353 - }) - - server = new Server({ bindAddress: '0.0.0.0' }) - listener = server.createInbound({ port: LISTEN_PORT }, async () => {}) - - client = new Client({ host: '0.0.0.0' }) - outGoing = client.createOutbound({ port: LISTEN_PORT, waitAck: waitAck !== 2 }, async () => {}) - }) - - afterEach(async () => { - await outGoing.close() - await listener.close() - - waitAck = waitAck + 1 - }) test('...simple connect', async () => { - // please run these tests using the described block. otherwise tests will fail - - const LISTEN_PORT = await portfinder.getPortPromise({ - port: 3000, - stopPort: 65353 - }) - - const server = new Server({ bindAddress: '0.0.0.0' }) - const listener = server.createInbound({ port: LISTEN_PORT }, async () => {}) - - const client = new Client({ host: '0.0.0.0' }) - const outGoing = client.createOutbound({ port: LISTEN_PORT }, async () => {}) - - await expectEvent(listener, 'client.connect') - await expectEvent(outGoing, 'client.connect') - - // this test is only done once - expect(client.totalConnections()).toBe(1) - - await outGoing.close() - - // this test is only done once - expect(client.totalConnections()).toBe(0) - - await listener.close() - }) - - test('...send simple message, just to make sure it sends, no data checks', async () => { - // please run these tests using the described block. otherwise tests will fail - const message = new Message({ - messageHeader: { - msh_9_1: 'ADT', - msh_9_2: 'A01', - msh_10: 'CONTROL_ID', - msh_11_1: 'D' - } - }) - - await outGoing.sendMessage(message) - }) - - test('...send simple message twice, fails because no ACK of the first', async () => { - // please run these tests using the described block. otherwise tests will fail - - try { - const message = new Message({ - messageHeader: { - msh_9_1: 'ADT', - msh_9_2: 'A01', - msh_10: 'CONTROL_ID', - msh_11_1: 'D' - } - }) - - await outGoing.sendMessage(message) - await outGoing.sendMessage(message) - } catch (err: any) { - expect(err.message).toBe('Can\'t send message while we are waiting for a response.') - } - }) - - // Note: For this test to pass, you must run it from the describe block! - test('...send simple message twice, no ACK needed', async () => { - // please run these tests using the described block. otherwise tests will fail - - let message = new Message({ - messageHeader: { - msh_9_1: 'ADT', - msh_9_2: 'A01', - msh_10: 'CONTROL_ID', - msh_11_1: 'D' - } - }) - - await outGoing.sendMessage(message) - - message = new Message({ - messageHeader: { - msh_9_1: 'ADT', - msh_9_2: 'A01', - msh_10: 'CONTROL_ID', - msh_11_1: 'D' - } - }) - - await outGoing.sendMessage(message) - }) - }) - - describe('server/client failure checks', () => { - test('...host does not exist, timeout', async () => { - const client = new Client({ host: '192.0.2.1' }) - - // forced to lower connection timeout so unit testing is not slow - const ob = client.createOutbound({ port: 1234, connectionTimeout: 100 }, async () => {}) - - await expectEvent(ob, 'timeout') - }) - - test('...host exist, but not listening on the port, timeout', async () => { - const server = new Server({ bindAddress: '0.0.0.0' }) - const listener = server.createInbound({ port: 3000 }, async () => {}) - - await expectEvent(listener, 'listen') - - const client = new Client({ host: '0.0.0.0' }) - - // forced to lower connection timeout so unit testing is not slow - const ob = client.createOutbound({ port: 1234, connectionTimeout: 10 }, async () => {}) - - // we couldn't connect - await expectEvent(ob, 'error') - // we are attempting to reconnect - await expectEvent(ob, 'connecting') - - // close ob now. were done - await ob.close() - - // close the server connection - await listener.close() - }) - }) + let dfd = createDeferred() - describe('...send message, get proper ACK', () => { - let LISTEN_PORT: number - - beforeEach(async () => { - LISTEN_PORT = await portfinder.getPortPromise({ - port: 3000, - stopPort: 65353 - }) - - dfd = createDeferred() - }) - - test('...no tls', async () => { - const server = new Server({ bindAddress: '0.0.0.0' }) - const IB_ADT = server.createInbound({ port: LISTEN_PORT }, async (req, res) => { + const server = new Server({bindAddress: '0.0.0.0'}) + const listener = server.createInbound({port: 3000}, async (req, res) => { const messageReq = req.getMessage() - const messageType = req.getType() - expect(messageType).toBe('message') expect(messageReq.get('MSH.12').toString()).toBe('2.7') await res.sendResponse('AA') }) - // await expectEvent(IB_ADT, 'listen') + await expectEvent(listener, 'listen') const client = new Client({ host: '0.0.0.0' }) - const OB_ADT = client.createOutbound({ port: LISTEN_PORT }, async (res) => { - const messageRes = res.getMessage() - expect(messageRes.get('MSA.1').toString()).toBe('AA') - dfd.resolve() - }) - - await expectEvent(OB_ADT, 'client.connect') - - const message = new Message({ - messageHeader: { - msh_9_1: 'ADT', - msh_9_2: 'A01', - msh_10: 'CONTROL_ID', - msh_11_1: 'D' - } - }) - - await OB_ADT.sendMessage(message) - - await sleep(10) - - dfd.promise - - expect(OB_ADT.stats.sent).toEqual(1) - - await OB_ADT.close() - await IB_ADT.close() - }) - - test('...tls', async () => { - const server = new Server( - { - bindAddress: '0.0.0.0', - tls: - { - key: fs.readFileSync(path.join('certs/', 'server-key.pem')), - cert: fs.readFileSync(path.join('certs/', 'server-crt.pem')), - rejectUnauthorized: false - } - }) - const IB_ADT = server.createInbound({ port: LISTEN_PORT }, async (req, res) => { - const messageReq = req.getMessage() - const messageType = req.getType() - expect(messageType).toBe('message') - expect(messageReq.get('MSH.12').toString()).toBe('2.7') - await res.sendResponse('AA') - }) - // await expectEvent(IB_ADT, 'listen') - - const client = new Client({ host: '0.0.0.0', tls: { rejectUnauthorized: false } }) - const OB_ADT = client.createOutbound({ port: LISTEN_PORT }, async (res) => { + const outbound = client.createConnection({ port: 3000 }, async (res) => { const messageRes = res.getMessage() expect(messageRes.get('MSA.1').toString()).toBe('AA') dfd.resolve() }) - await expectEvent(OB_ADT, 'client.connect') + await expectEvent(outbound, 'connect') - const message = new Message({ + let message = new Message({ messageHeader: { msh_9_1: 'ADT', msh_9_2: 'A01', @@ -257,113 +43,43 @@ describe('node hl7 end to end - client', () => { } }) - await OB_ADT.sendMessage(message) + await outbound.sendMessage(message) - await sleep(10) + await dfd.promise - dfd.promise + expect(client.totalSent()).toEqual(1) + expect(client.totalAck()).toEqual(1) - expect(OB_ADT.stats.sent).toEqual(1) + await outbound.close() + await listener.close() - await OB_ADT.close() - await IB_ADT.close() - }) - }) + client.closeAll() - describe('...send batch with one message, get proper ACK', () => { - let LISTEN_PORT: number - beforeEach(async () => { - LISTEN_PORT = await portfinder.getPortPromise({ - port: 3000, - stopPort: 65353 - }) - - dfd = createDeferred() }) - test('...no tls', async () => { - const server = new Server({ bindAddress: '0.0.0.0' }) - const IB_ADT = server.createInbound({ port: LISTEN_PORT }, async (req, res) => { - const messageReq = req.getMessage() - const messageType = req.getType() - expect(messageType).toBe('batch') - expect(messageReq.get('MSH.12').toString()).toBe('2.7') - await res.sendResponse('AA') - }) - - // await expectEvent(IB_ADT, 'listen') - - const client = new Client({ host: '0.0.0.0' }) - const OB_ADT = client.createOutbound({ port: LISTEN_PORT }, async (res) => { - const messageRes = res.getMessage() - expect(messageRes.get('MSA.1').toRaw()).toBe('AA') - dfd.resolve() - }) - - await expectEvent(OB_ADT, 'client.connect') - - const batch = new Batch() - batch.start() - - const message = new Message({ - messageHeader: { - msh_9_1: 'ADT', - msh_9_2: 'A01', - msh_10: 'CONTROL_ID1', - msh_11_1: 'D' - } - }) - - batch.add(message) - - batch.end() - - await OB_ADT.sendMessage(batch) - - await sleep(10) - - dfd.promise + test('...send simple message twice, no ACK needed', async () => { - expect(OB_ADT.stats.sent).toEqual(1) + await tcpPortUsed.check(3000, '0.0.0.0') - await OB_ADT.close() - await IB_ADT.close() - }) + let dfd = createDeferred() - test('...tls', async () => { - const server = new Server( - { - bindAddress: '0.0.0.0', - tls: - { - key: fs.readFileSync(path.join('certs/', 'server-key.pem')), - cert: fs.readFileSync(path.join('certs/', 'server-crt.pem')), - rejectUnauthorized: false - } - }) - const IB_ADT = server.createInbound({ port: LISTEN_PORT }, async (req, res) => { + const server = new Server({bindAddress: '0.0.0.0'}) + const listener = server.createInbound({port: 3000}, async (req, res) => { const messageReq = req.getMessage() - const messageType = req.getType() - expect(messageType).toBe('batch') expect(messageReq.get('MSH.12').toString()).toBe('2.7') await res.sendResponse('AA') }) - // await expectEvent(IB_ADT, 'listen') + await expectEvent(listener, 'listen') - const client = new Client({ host: '0.0.0.0', tls: { rejectUnauthorized: false } }) - const OB_ADT = client.createOutbound({ port: LISTEN_PORT }, async (res) => { - const messageRes = res.getMessage() - expect(messageRes.get('MSA.1').toString()).toBe('AA') + const client = new Client({ host: '0.0.0.0' }) + const outbound = client.createConnection({ port: 3000, waitAck: false }, async () => { dfd.resolve() }) - await expectEvent(OB_ADT, 'client.connect') - - const batch = new Batch() - batch.start() + await expectEvent(outbound, 'connect') - const message = new Message({ + let message = new Message({ messageHeader: { msh_9_1: 'ADT', msh_9_2: 'A01', @@ -372,349 +88,148 @@ describe('node hl7 end to end - client', () => { } }) - batch.add(message) + await outbound.sendMessage(message) - batch.end() + await dfd.promise - await OB_ADT.sendMessage(batch) + expect(client.totalSent()).toEqual(1) + expect(client.totalAck()).toEqual(1) - await sleep(10) + await outbound.close() + await listener.close() - dfd.promise + client.closeAll() - await OB_ADT.close() - await IB_ADT.close() }) }) - describe('...send batch with two message, get proper ACK', () => { - let LISTEN_PORT: number - beforeEach(async () => { - LISTEN_PORT = await portfinder.getPortPromise({ - port: 3000, - stopPort: 65353 - }) - - dfd = createDeferred() - }) - - test('...no tls', async () => { - const server = new Server({ bindAddress: '0.0.0.0' }) - const IB_ADT = server.createInbound({ port: LISTEN_PORT }, async (req, res) => { - const messageReq = req.getMessage() - const messageType = req.getType() - expect(messageType).toBe('batch') - expect(messageReq.get('MSH.12').toString()).toBe('2.7') - await res.sendResponse('AA') - }) - - // await expectEvent(IB_ADT, 'listen') + describe('server/client failure checks', () => { + test('...host does not exist, error out', async () => { - let count: number = 0 const client = new Client({ host: '0.0.0.0' }) - const OB_ADT = client.createOutbound({ port: LISTEN_PORT }, async (res) => { - const messageRes = res.getMessage() - expect(messageRes.get('MSA.1').toString()).toBe('AA') - count = count + 1 - if (count === 2) { - dfd.resolve() - } - }) - - await expectEvent(OB_ADT, 'client.connect') + const outbound = client.createConnection({ port: 1234, maxConnectionAttempts: 1 }, async () => {}) - const batch = new Batch() - batch.start() - - const message = new Message({ - messageHeader: { - msh_9_1: 'ADT', - msh_9_2: 'A01', - msh_10: 'CONTROL_ID1', - msh_11_1: 'D' - } - }) + const error = await expectEvent(outbound, 'client.error') + expect(error.code).toBe('ECONNREFUSED') + }) - batch.add(message) - batch.add(message) + }) - batch.end() + describe('...no tls', () => { - await OB_ADT.sendMessage(batch) + describe('...no file', () => { - await sleep(10) + test('...send batch with two message, get proper ACK', async () => { - dfd.promise + let dfd = createDeferred() - await OB_ADT.close() - await IB_ADT.close() - }) - - test('...tls', async () => { - const server = new Server( - { - bindAddress: '0.0.0.0', - tls: - { - key: fs.readFileSync(path.join('certs/', 'server-key.pem')), - cert: fs.readFileSync(path.join('certs/', 'server-crt.pem')), - rejectUnauthorized: false - } + const server = new Server({ bindAddress: '0.0.0.0' }) + const inbound = server.createInbound({ port: 3000 }, async (req, res) => { + const messageReq = req.getMessage() + expect(messageReq.get('MSH.12').toString()).toBe('2.7') + await res.sendResponse('AA') }) - const IB_ADT = server.createInbound({ port: LISTEN_PORT }, async (req, res) => { - const messageReq = req.getMessage() - const messageType = req.getType() - expect(messageType).toBe('batch') - expect(messageReq.get('MSH.12').toString()).toBe('2.7') - await res.sendResponse('AA') - }) - // await expectEvent(IB_ADT, 'listen') + await expectEvent(inbound, 'listen') - let count: number = 0 - const client = new Client({ host: '0.0.0.0', tls: { rejectUnauthorized: false } }) - const OB_ADT = client.createOutbound({ port: LISTEN_PORT }, async (res) => { - const messageRes = res.getMessage() - expect(messageRes.get('MSA.1').toString()).toBe('AA') - count = count + 1 - if (count === 2) { + const client = new Client({ host: '0.0.0.0' }) + const outbound = client.createConnection({ port: 3000 }, async (res) => { + const messageRes = res.getMessage() + expect(messageRes.get('MSA.1').toString()).toBe('AA') dfd.resolve() - } - }) - - await expectEvent(OB_ADT, 'client.connect') - - const batch = new Batch() - batch.start() - - const message = new Message({ - messageHeader: { - msh_9_1: 'ADT', - msh_9_2: 'A01', - msh_10: 'CONTROL_ID', - msh_11_1: 'D' - } - }) - - batch.add(message) - batch.add(message) - - batch.end() - - await OB_ADT.sendMessage(batch) - - await sleep(10) - - dfd.promise - - await OB_ADT.close() - await IB_ADT.close() - }) - }) - - describe('...send file with one message, get proper ACK', () => { - let LISTEN_PORT: number - - const hl7_string: string = 'MSH|^~\\&|||||20081231||ADT^A01^ADT_A01|12345|D|2.7\rEVN||20081231' - - beforeAll(async () => { - fs.readdir('temp/', (err, files) => { - if (err != null) return - for (const file of files) { - fs.unlink(path.join('temp/', file), (err) => { - if (err != null) throw err - }) - } - }) + }) - await sleep(2) + const batch = new Batch() + batch.start() - const message = new Message({ text: hl7_string, date: '8' }) - message.toFile('readFileTestMSH', true, 'temp/') + const message = new Message({ + messageHeader: { + msh_9_1: 'ADT', + msh_9_2: 'A01', + msh_10: 'CONTROL_ID', + msh_11_1: 'D' + } + }) - fs.access('temp/hl7.readFileTestMSH.20081231.hl7', fs.constants.F_OK, (err) => { - if (err == null) { - // Do something - } - }) + batch.add(message) + batch.add(message) - await (async () => { - try { - await fs.promises.access('temp/hl7.readFileTestMSH.20081231.hl7', fs.constants.F_OK) - // Do something - } catch (err) { - // Handle error - } - })() - }) + batch.end() - beforeEach(async () => { - LISTEN_PORT = await portfinder.getPortPromise({ - port: 3000, - stopPort: 65353 - }) + await outbound.sendMessage(batch) - dfd = createDeferred() - }) + await dfd.promise - test('...no tls', async () => { - const server = new Server({ bindAddress: '0.0.0.0' }) - const IB_ADT = server.createInbound({ port: LISTEN_PORT }, async (req, res) => { - const messageReq = req.getMessage() - expect(messageReq.get('MSH.12').toString()).toBe('2.7') - await res.sendResponse('AA') - }) + expect(client.totalSent()).toEqual(1) + expect(client.totalAck()).toEqual(1) - // await expectEvent(IB_ADT, 'listen') + await outbound.close() + await inbound.close() - const client = new Client({ host: '0.0.0.0' }) - const OB_ADT = client.createOutbound({ port: LISTEN_PORT }, async (res) => { - const messageRes = res.getMessage() - expect(messageRes.get('MSA.1').toString()).toBe('AA') - dfd.resolve() + client.closeAll() }) - await expectEvent(OB_ADT, 'client.connect') - - const fileBatch = await OB_ADT.readFile('temp/hl7.readFileTestMSH.20081231.hl7') - - await OB_ADT.sendMessage(fileBatch) - - await dfd.promise - - await OB_ADT.close() - await IB_ADT.close() }) - test('...tls', async () => { - const server = new Server( - { - bindAddress: '0.0.0.0', - tls: - { - key: fs.readFileSync(path.join('certs/', 'server-key.pem')), - cert: fs.readFileSync(path.join('certs/', 'server-crt.pem')), - rejectUnauthorized: false - } - }) - const IB_ADT = server.createInbound({ port: LISTEN_PORT }, async (req, res) => { - const messageReq = req.getMessage() - expect(messageReq.get('MSH.12').toString()).toBe('2.7') - await res.sendResponse('AA') - }) - - // await expectEvent(IB_ADT, 'listen') - - const client = new Client({ host: '0.0.0.0', tls: { rejectUnauthorized: false } }) - const OB_ADT = client.createOutbound({ port: LISTEN_PORT }, async (res) => { - const messageRes = res.getMessage() - expect(messageRes.get('MSA.1').toString()).toBe('AA') - dfd.resolve() - }) + }) - await expectEvent(OB_ADT, 'client.connect') + describe('...tls', () => { - const fileBatch = await OB_ADT.readFile('temp/hl7.readFileTestMSH.20081231.hl7') + describe('...no file', () => { - await OB_ADT.sendMessage(fileBatch) + test('...simple', async () => { - await dfd.promise + let dfd = createDeferred() - await OB_ADT.close() - await IB_ADT.close() - }) - }) - - describe('...send file with two message, get proper ACK', () => { - let LISTEN_PORT: number - let fileName: string - - const hl7_string: string[] = [ - "MSH|^~\\&|||||20081231||ADT^A01^ADT_A01|12345|D|2.7\rEVN||20081231", - "MSH|^~\\&|||||20081231||ADT^A01^ADT_A01|12345|D|2.7\rEVN||20081231" - ] - - beforeAll(async () => { - fs.readdir('temp/', (err, files) => { - if (err != null) return - for (const file of files) { - fs.unlink(path.join('temp/', file), (err) => { - if (err != null) throw err + const server = new Server( + { + bindAddress: '0.0.0.0', + tls: + { + key: fs.readFileSync(path.join('certs/', 'server-key.pem')), + cert: fs.readFileSync(path.join('certs/', 'server-crt.pem')), + rejectUnauthorized: false + } }) - } - }) + const inbound = server.createInbound({ port: 3000 }, async (req, res) => { + const messageReq = req.getMessage() + expect(messageReq.get('MSH.12').toString()).toBe('2.7') + await res.sendResponse('AA') + }) - await sleep(2) + await expectEvent(inbound, 'listen') - const batch = new Batch() + const client = new Client({ host: '0.0.0.0', tls: { rejectUnauthorized: false } }) + const outbound = client.createConnection({ port: 3000 }, async (res) => { + const messageRes = res.getMessage() + expect(messageRes.get('MSA.1').toString()).toBe('AA') + dfd.resolve() + }) - batch.start() + await expectEvent(outbound, 'connect') - for (let i = 0; i < hl7_string.length; i++) { - const message = new Message({text: hl7_string[i] }) - batch.add(message) - } + const message = new Message({ + messageHeader: { + msh_9_1: 'ADT', + msh_9_2: 'A01', + msh_10: 'CONTROL_ID', + msh_11_1: 'D' + } + }) - batch.end() + await outbound.sendMessage(message) - fileName = batch.toFile('readFileTestMSH', true, 'temp/') + dfd.promise - fs.access(`temp/${fileName as string}`, fs.constants.F_OK, (err) => { - if (err == null) { - // Do something - } - }) + await outbound.close() + await inbound.close() - await (async () => { - try { - await fs.promises.access(`temp/${fileName as string}`, fs.constants.F_OK) - // Do something - } catch (err) { - // Handle error - } - })() - }) + client.closeAll() - beforeEach(async () => { - LISTEN_PORT = await portfinder.getPortPromise({ - port: 3000, - stopPort: 65353 }) - dfd = createDeferred() }) - test('...no tls', async () => { - const server = new Server({ bindAddress: '0.0.0.0' }) - const IB_ADT = server.createInbound({ port: LISTEN_PORT }, async (req, res) => { - const messageReq = req.getMessage() - expect(messageReq.get('MSH.12').toString()).toBe('2.7') - await res.sendResponse('AA') - }) - - // await expectEvent(IB_ADT, 'listen') - - const client = new Client({ host: '0.0.0.0' }) - let count: number = 0 - const OB_ADT = client.createOutbound({ port: LISTEN_PORT }, async (res) => { - const messageRes = res.getMessage() - expect(messageRes.get('MSA.1').toString()).toBe('AA') - count += 1 - if (count === 2) { - dfd.resolve() - } - }) - - await expectEvent(OB_ADT, 'client.connect') - - const fileBatch = await OB_ADT.readFile(`temp/${fileName as string}`) - - await OB_ADT.sendMessage(fileBatch) - - await dfd.promise - - await OB_ADT.close() - await IB_ADT.close() - }) }) + }) diff --git a/__tests__/hl7.sanity.test.ts b/__tests__/hl7.sanity.test.ts index 2498d7b..dc5f82b 100644 --- a/__tests__/hl7.sanity.test.ts +++ b/__tests__/hl7.sanity.test.ts @@ -1,3 +1,4 @@ +import { describe, expect, test } from 'vitest'; import {Batch, FileBatch, isBatch, isFile, Message} from "../src"; import {MSH_HEADER} from "./__data__/constants"; diff --git a/__tests__/hl7.server.parser.test.ts b/__tests__/hl7.server.parser.test.ts new file mode 100644 index 0000000..bb5ed35 --- /dev/null +++ b/__tests__/hl7.server.parser.test.ts @@ -0,0 +1,35 @@ +// These are functions that exist within the node-hl7-server NPM package +// that are tested here for diag purposes. + +import { describe, expect, test } from 'vitest'; +import {Message} from "../src"; +import {_createAckMessage} from "./__utils__/server"; + +describe('hl7 module tests', () => { + + describe('sendResponse', () => { + + test('adt siu', async () => { + + const messageString = `MSH|^~\\&|||||20220304102435|ESBCKGRND|SIU^S12|521 +SCH||60014711||||Sch|||5|MIN|^^5^20220218153000^20220218153500|ESEOD^CADENCE^EOD^PROCESSING||||ESEOD^CADENCE^EOD^PROCESSING||||ESEOD^CADENCE^EOD^PROCESSING|||||Sch +PID|1||3002505^^^MRN^MRN||CHILD^AMB^^^^^D||20150122|F|||123 STREET^^BROOKLYN^^11233^^L||(718)250-0000^P^H^^^718^2500000~^NET^Internet^cool@gmail.com|||SINGLE||60014711|111-52-5454||One^Mother^^|||||||||N +ZPD|Cent Amer In|MYCH|||||||||||||||||||||N|F +PD1||||9454^KOTHARI^VIPUL^^^^^^PROVID^^^^PROVID +PV1||OUTPATIENT|GGEVAC^^^^^^^^^^EDEP||||||||||||||||60014711|||||||||||||||||||||||||20220218||||||60014711 +RGS|1||10008938^MAIN CAMPUS COVID +AIS|1|||||||||Sch +AIG|1||^COVID-19 VACCINE|2^RESOURCE||||20220218153000|0|MIN|5|MIN` + + const message = new Message({ text: messageString}) + + const ackMessage = _createAckMessage("AA", message) + expect(ackMessage.get('MSH.9.1').toString()).toEqual('ACK') + expect(ackMessage.get('MSA.9.2').toString()).not.toBeUndefined() + expect(ackMessage.get('MSA.1').toString()).toEqual('AA') + + }) + + }) + +}) \ No newline at end of file diff --git a/jest.config.ts b/jest.config.ts deleted file mode 100644 index 6b2206d..0000000 --- a/jest.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { JestConfigWithTsJest } from 'ts-jest' - -const jestConfig: JestConfigWithTsJest = { - preset: 'ts-jest', - testEnvironment: 'node', - coveragePathIgnorePatterns: ['/__tests__/'], - modulePathIgnorePatterns: ['docs'], - testPathIgnorePatterns: ['/__fixtures__/', '/__utils__/', '/__data__/'], - resolver: 'jest-ts-webcompat-resolver' -} - -export default jestConfig diff --git a/package.json b/package.json index f1af31d..2197961 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "node-hl7-client", - "version": "1.1.3", + "version": "1.2.0-beta.1", "description": "A pure Node.js HL7 Client that allows for communication to a HL7 Broker/Server that can send properly formatted HL7 messages with ease.It can also parse and then you can extract message segments out.", "module": "./lib/esm/index.js", "main": "./lib/cjs/index.js", @@ -17,41 +17,40 @@ "lib/" ], "engines": { - "node": "^18 || ^20" + "node": ">=20.0.0" }, "scripts": { "clean": "rm -rf coverage docs lib temp", "build": "tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json && tsc -p tsconfig.types.json && ./bin/build-types.sh", "build:watch": "tsc -p tsconfig.esm.json -w", + "build:watch:cjs": "tsc -p tsconfig.cjs.json -w", "npm:lint": "npmPkgJsonLint .", "lint": "npm run npm:lint && ts-standard | snazzy", "lint:fix": "npm run npm:lint . && ts-standard --fix | snazzy", "pack": "npm pack", "prepublishOnly": "npm run clean && npm run build && npm run pack", - "test": "jest", - "test:verbose": "jest --verbose", - "test:open": "jest --detectOpenHandles", - "test:watch": "jest --watch", - "test:ci": "jest --ci", - "test:coverage": "jest --coverage", + "test": "vitest run", + "test:verbose": "vitest run --reporter verbose", + "test:watch": "vitest watch", + "test:coverage": "vitest --coverage", "typedoc": "typedoc", "typedoc:watch": "typedoc -watch", "semantic-release": "semantic-release", "semantic-release:dry-run": "semantic-release --dry-run", - "update": "npx npm-check-updates -u && npm run update:post-update", - "update:post-update": "npm install && npm run test:ci" + "update": "npx npm-check-updates -u --enginesNode && npm run update:post-update", + "update:post-update": "npm install && npm run test" }, "repository": { "type": "git", "url": "git+https://github.com/Bugs5382/node-hl7-client.git" }, "keywords": [ - "HL7", - "HL7 Parser", - "HL7 Client", - "HL7 Builder", - "HL7 Speffications", - "HL7 Validation" + "hl7", + "hl7-parser", + "hl7-client", + "hl7-builder", + "hl7-speffications", + "hl7-validation" ], "author": "Shane Froebel", "license": "MIT", @@ -64,27 +63,26 @@ "@semantic-release/commit-analyzer": "^11.1.0", "@semantic-release/git": "^10.0.1", "@semantic-release/release-notes-generator": "^12.1.0", - "@the-rabbit-hole/semantic-release-config": "^1.4.0", - "@types/jest": "^29.5.11", - "@types/node": "^20.10.8", + "@the-rabbit-hole/semantic-release-config": "^1.5.0", + "@types/node": "^20.11.16", "@types/tcp-port-used": "^1.0.4", - "@typescript-eslint/parser": "^6.18.1", - "jest": "^29.7.0", - "jest-ts-webcompat-resolver": "^1.0.0", - "node-hl7-server": "^1.2.1", - "npm-check-updates": "^16.14.12", + "@typescript-eslint/parser": "^6.20.0", + "@vitest/coverage-v8": "^1.2.2", + "@vitest/ui": "^1.2.2", + "node-hl7-server": "^1.2.3", + "npm-check-updates": "^16.14.14", "npm-package-json-lint": "^7.1.0", "portfinder": "^1.0.32", "pre-commit": "^1.2.2", - "semantic-release": "^22.0.12", + "semantic-release": "^23.0.0", "snazzy": "^9.0.0", "tcp-port-used": "^1.0.2", - "ts-jest": "^29.1.1", "ts-node": "^10.9.2", "ts-standard": "^12.0.2", - "tsd": "^0.30.3", + "tsd": "^0.30.4", "typedoc": "^0.25.7", - "typescript": "5.3.3" + "typescript": "5.3.3", + "vitest": "^1.2.2" }, "precommit": [ "lint:fix", diff --git a/pages/client/index.md b/pages/client/index.md index f155c8a..7cdf6ab 100644 --- a/pages/client/index.md +++ b/pages/client/index.md @@ -50,7 +50,7 @@ Since HL7 message are sent to ports, and to establish a connection you have to start an outbound connection ("OB") and for this example, port `5678` ```ts -const OB_ADT = client.createOutbound({ port: 5678 }, async (res) => { +const OB_ADT = client.createConnection({ port: 5678 }, async (res) => { const messageRes = res.getMessage() const check = messageRes.get('MSA.1').toString() // MSA is a Message Acknoedlgement Segment if (check === "AA") { diff --git a/src/builder/batch.ts b/src/builder/batch.ts index ac1f6dc..b2dc012 100644 --- a/src/builder/batch.ts +++ b/src/builder/batch.ts @@ -139,7 +139,7 @@ export class Batch extends RootBase { } return message } - throw new HL7ParserError(500, 'No messages inside batch.') + throw new HL7FatalError('No messages inside batch.') } /** @@ -229,21 +229,21 @@ export class Batch extends RootBase { } } else { if (typeof segmentName === 'undefined') { - throw new HL7FatalError(500, 'segment name is not defined.') + throw new HL7FatalError('Segment name is not defined.') } const segment = this._getFirstSegment(segmentName) if (typeof segment !== 'undefined') { return segment.read(path) } } - throw new HL7FatalError(500, 'Unable to process the read function correctly.') + throw new HL7FatalError('Unable to process the read function correctly.') } /** @internal */ protected writeCore (path: string[], value: string): HL7Node { const segmentName = path.shift() as string if (typeof segmentName === 'undefined') { - throw new HL7FatalError(500, 'segment name is not defined.') + throw new HL7ParserError('Segment name is not defined.') } return this.writeAtIndex(path, value, 0, segmentName) } @@ -251,12 +251,12 @@ export class Batch extends RootBase { /** @internal **/ private _addSegment (path: string): Segment { if (typeof path === 'undefined') { - throw new HL7FatalError(404, 'Missing segment path.') + throw new HL7ParserError('Missing segment path.') } const preparedPath = this.preparePath(path) if (preparedPath.length !== 1) { - throw new HL7FatalError(500, `"Invalid segment ${path}."`) + throw new HL7ParserError(`Invalid segment ${path}.`) } return this.addChild(preparedPath[0]) as Segment @@ -271,7 +271,7 @@ export class Batch extends RootBase { return segment } } - throw new HL7FatalError(500, 'Unable to process _getFirstSegment.') + throw new HL7FatalError('Unable to process _getFirstSegment.') } } diff --git a/src/builder/fileBatch.ts b/src/builder/fileBatch.ts index 031e46b..61b3c59 100644 --- a/src/builder/fileBatch.ts +++ b/src/builder/fileBatch.ts @@ -82,7 +82,7 @@ export class FileBatch extends RootBase { } else { // if there are already messages added before a batch if (this._messagesCount >= 1) { - throw new HL7ParserError(500, 'Unable to add a batch segment, since there is already messages added individually.') + throw new HL7ParserError('Unable to add a batch segment, since there is already messages added individually.') } this._batchCount = this._batchCount + 1 this.children.push(message) @@ -98,11 +98,11 @@ export class FileBatch extends RootBase { const getFSHDate = this.get('FHS.7').toString() if (typeof name === 'undefined') { - throw new HL7FatalError(404, 'Missing file name.') + throw new HL7FatalError('Missing file name.') } if (NAME_FORMAT.test(name)) { - throw new HL7FatalError(500, 'name must not contain certain characters: `!@#$%^&*()+\\-=\\[\\]{};\':"\\\\|,.<>\\/?~.') + throw new HL7FatalError('name must not contain certain characters: `!@#$%^&*()+\\-=\\[\\]{};\':"\\\\|,.<>\\/?~.') } if (typeof this._opt.location !== 'undefined') { @@ -188,7 +188,7 @@ export class FileBatch extends RootBase { } return message } - throw new HL7ParserError(500, 'No messages inside file segment.') + throw new HL7FatalError('No messages inside file segment.') } /** @@ -236,21 +236,21 @@ export class FileBatch extends RootBase { } } else { if (typeof segmentName === 'undefined') { - throw new HL7FatalError(500, 'segment name is not defined.') + throw new HL7ParserError('Segment name is not defined.') } const segment = this._getFirstSegment(segmentName) if (typeof segment !== 'undefined') { return segment.read(path) } } - throw new HL7FatalError(500, 'Unable to process the read function correctly.') + throw new HL7FatalError('Unable to process the read function correctly.') } /** @internal */ protected writeCore (path: string[], value: string): HL7Node { const segmentName = path.shift() as string if (typeof segmentName === 'undefined') { - throw new HL7FatalError(500, 'segment name is not defined.') + throw new HL7ParserError('Segment name is not defined.') } return this.writeAtIndex(path, value, 0, segmentName) } @@ -258,12 +258,12 @@ export class FileBatch extends RootBase { /** @internal **/ private _addSegment (path: string): Segment { if (typeof path === 'undefined') { - throw new HL7FatalError(404, 'Missing segment path.') + throw new HL7ParserError('Missing segment path.') } const preparedPath = this.preparePath(path) if (preparedPath.length !== 1) { - throw new HL7FatalError(500, `"Invalid segment ${path}."`) + throw new HL7ParserError(`"Invalid segment ${path}."`) } return this.addChild(preparedPath[0]) as Segment @@ -277,7 +277,7 @@ export class FileBatch extends RootBase { return children[i] as Batch } } - throw new HL7FatalError(500, 'Unable to process _getFirstBatch.') + throw new HL7FatalError('Unable to process _getFirstBatch.') } /** @internal */ @@ -289,7 +289,7 @@ export class FileBatch extends RootBase { return segment } } - throw new HL7FatalError(500, 'Unable to process _getFirstSegment.') + throw new HL7ParserError('Unable to process _getFirstSegment.') } } diff --git a/src/builder/message.ts b/src/builder/message.ts index d82150e..59087b2 100644 --- a/src/builder/message.ts +++ b/src/builder/message.ts @@ -44,7 +44,7 @@ export class Message extends RootBase { if (typeof opt.text !== 'undefined' && opt.parsing === true && opt.text !== '') { const totalMsh = split(opt.text).filter(line => line.startsWith('MSH')) if (totalMsh.length !== 0 && totalMsh.length !== 1) { - throw new HL7ParserError(500, 'Multiple MSH segments found. Use Batch.') + throw new HL7FatalError('Multiple MSH segments found. Use Batch.') } } @@ -76,12 +76,12 @@ export class Message extends RootBase { */ addSegment (path: string): Segment { if (typeof path === 'undefined') { - throw new HL7FatalError(404, 'Missing segment path.') + throw new HL7ParserError('Missing segment path.') } const preparedPath = this.preparePath(path) if (preparedPath.length !== 1) { - throw new HL7FatalError(500, `"Invalid segment ${path}."`) + throw new HL7ParserError(`Invalid segment ${path}.`) } return this.addChild(preparedPath[0]) as Segment @@ -122,7 +122,7 @@ export class Message extends RootBase { } } else { if (typeof segmentName === 'undefined') { - throw new HL7FatalError(500, 'segment name is not defined.') + throw new HL7ParserError('Segment name is not defined.') } const segment = this._getFirstSegment(segmentName) if (typeof segment !== 'undefined') { @@ -168,11 +168,28 @@ export class Message extends RootBase { return this } - throw new HL7FatalError(500, 'Path must be a string or number.') + throw new HL7ParserError('Path must be a string or number.') } - toFile (name: string, newLine?: boolean, location?: string): void { - const fileBatch = new FileBatch({ location, newLine: newLine === true ? '\n' : '' }) + /** + * Create File from a Message + * @description Will procure a file of the saved MSH in the proper format + * that includes a FHS and FTS segments. + * @since 1.0.0 + * @param name File Name + * @param newLine Provide a New Line + * @param location Where to save the exported file + * @param extension Custom extension of the file. + * Default: hl7 + * @example + * ```ts + * const message = new Message({text: hl7_batch_string}) + * message.toFile('readTestMSH', true, 'temp/') + * ``` + * You can set an `extension` parameter on Batch to set a custom extension if you don't want to be HL7. + */ + toFile (name: string, newLine?: boolean, location?: string, extension: string = 'hl7'): string { + const fileBatch = new FileBatch({ location, newLine: newLine === true ? '\n' : '', extension }) fileBatch.start() fileBatch.set('FHS.3', this.get('MSH.3').toString()) @@ -188,6 +205,8 @@ export class Message extends RootBase { fileBatch.end() fileBatch.createFile(name) + + return fileBatch.fileName() } /** @@ -200,7 +219,7 @@ export class Message extends RootBase { protected writeCore (path: string[], value: string): HL7Node { const segmentName = path.shift() as string if (typeof segmentName === 'undefined') { - throw new HL7FatalError(500, 'segment name is not defined.') + throw new HL7ParserError('Segment name is not defined.') } let index = this._getFirstSegmentIndex(segmentName) if (index === undefined) { diff --git a/src/builder/modules/fieldRepetition.ts b/src/builder/modules/fieldRepetition.ts index ff83cd7..dc96e7a 100644 --- a/src/builder/modules/fieldRepetition.ts +++ b/src/builder/modules/fieldRepetition.ts @@ -26,7 +26,7 @@ export class FieldRepetition extends ValueNode { /** @internal */ protected pathCore (): string[] { if (this.parent == null) { - throw new HL7FatalError(500, 'this.parent must not be null.') + throw new HL7FatalError('this.parent must not be null.') } return this.parent.path } diff --git a/src/builder/modules/nodeBase.ts b/src/builder/modules/nodeBase.ts index 35bc13f..499cd8b 100644 --- a/src/builder/modules/nodeBase.ts +++ b/src/builder/modules/nodeBase.ts @@ -1,3 +1,4 @@ +import EventEmitter from 'events' import { isHL7Number, isHL7String, padHL7Date } from '../../utils/utils.js' import { Batch } from '../batch.js' import { EmptyNode } from './emptyNode.js' @@ -11,7 +12,7 @@ import { Message } from '../message.js' * @since 1.0.0 * @extends HL7Node */ -export class NodeBase implements HL7Node { +export class NodeBase extends EventEmitter implements HL7Node { protected parent: NodeBase | null _name: string @@ -24,6 +25,7 @@ export class NodeBase implements HL7Node { private _dirty: boolean constructor (parent: NodeBase | null, text: string = '', delimiter: Delimiters | undefined = undefined) { + super() this.parent = parent this._children = [] @@ -82,7 +84,7 @@ export class NodeBase implements HL7Node { return this } - throw new HL7FatalError(500, 'Path must be a string or number.') + throw new HL7FatalError('Path must be a string or number.') } get name (): string { @@ -164,7 +166,7 @@ export class NodeBase implements HL7Node { } else if (isHL7String(path)) { return this.write(this.preparePath(path), '') } - throw new HL7FatalError(500, 'There seems to be a problem.') + throw new HL7FatalError('There seems to be a problem.') } /** @internal */ @@ -176,7 +178,7 @@ export class NodeBase implements HL7Node { } if (!this._isSubPath(parts)) { - throw new HL7FatalError(500, "'" + parts.toString() + "' is not a sub-path of '" + this.path.toString() + "'") + throw new HL7FatalError("'" + parts.toString() + "' is not a sub-path of '" + this.path.toString() + "'") } return this._remainderOf(parts) @@ -269,11 +271,11 @@ export class NodeBase implements HL7Node { /** @internal */ protected get delimiter (): string { if (typeof this.message === 'undefined') { - throw new HL7FatalError(500, 'this.message is not defined.') + throw new HL7FatalError('this.message is not defined.') } if (typeof this._delimiter === 'undefined') { - throw new HL7FatalError(500, 'this.message is not defined.') + throw new HL7FatalError('this.message is not defined.') } this._delimiterText = this.message.delimiters[this._delimiter] diff --git a/src/builder/modules/rootBase.ts b/src/builder/modules/rootBase.ts index aab7930..614c9e4 100644 --- a/src/builder/modules/rootBase.ts +++ b/src/builder/modules/rootBase.ts @@ -67,7 +67,7 @@ export class RootBase extends NodeBase { /** @internal */ escape (text: string): string { if (text === null) { - throw new HL7FatalError(500, 'text must be passed in escape function.') + throw new HL7FatalError('Text must be passed in escape function.') } return text.replace(this._matchEscape, (match: string) => { @@ -96,14 +96,14 @@ export class RootBase extends NodeBase { return `${escape}${ch}${escape}` } - throw new HL7FatalError(500, `Escape sequence for ${match} is not known.`) + throw new HL7FatalError(`Escape sequence for ${match} is not known.`) }) } /** @internal */ unescape (text: string): string { if (text === null) { - throw new HL7FatalError(500, 'text must be passed in unescape function.') + throw new HL7FatalError('Text must be passed in unescape function.') } // Slightly faster for a normal case of no escape sequences in text diff --git a/src/builder/modules/segment.ts b/src/builder/modules/segment.ts index 5b98da6..a636dd9 100644 --- a/src/builder/modules/segment.ts +++ b/src/builder/modules/segment.ts @@ -19,7 +19,7 @@ export class Segment extends NodeBase { constructor (parent: NodeBase, text: string) { super(parent, text, Delimiters.Field) if (!isHL7String(text) || text.length === 0) { - throw new HL7FatalError(500, 'Segment must have a name.') + throw new HL7FatalError('Segment must have a name.') } this._segmentName = text.slice(0, 3) this._name = this._segmentName @@ -29,7 +29,7 @@ export class Segment extends NodeBase { read (path: string[]): HL7Node { let index = parseInt(path.shift() as string) if (index < 1) { - throw new HL7FatalError(500, 'index must be 1 or greater.') + throw new HL7FatalError('Index must be 1 or greater.') } if ((this._name === 'MSH') || (this._name === 'BHS') || (this._name === 'FHS')) { if (typeof this.message !== 'undefined' && index === 1) { @@ -40,7 +40,7 @@ export class Segment extends NodeBase { } const field = this.children[index] - return path.length > 0 ? field.read(path) : field + return typeof field !== 'undefined' && path.length > 0 ? field.read(path) : field } /** @internal */ @@ -74,18 +74,18 @@ export class Segment extends NodeBase { return this } - throw new HL7FatalError(500, 'Path must be a string or number.') + throw new HL7FatalError('Path must be a string or number.') } /** @internal */ protected writeCore (path: string[], value: string): HL7Node { let index = parseInt(path.shift() as string) if (index < 1 || isNaN(index)) { - throw new HL7FatalError(500, "Can't have an index < 1 or not be a valid number.") + throw new HL7FatalError("Can't have an index < 1 or not be a valid number.") } if ((this._name === 'MSH') || (this._name === 'BHS') || (this._name === 'FHS')) { if (index === 1 || index === 2) { - throw new HL7FatalError(500, 'You cannot assign the field separator or encoding characters') + throw new HL7FatalError('You cannot assign the field separator or encoding characters') } else { index = index - 1 } diff --git a/src/builder/modules/subComponent.ts b/src/builder/modules/subComponent.ts index b03f252..079df30 100644 --- a/src/builder/modules/subComponent.ts +++ b/src/builder/modules/subComponent.ts @@ -9,7 +9,7 @@ export class SubComponent extends ValueNode { if (typeof this.message !== 'undefined') { return this.message.unescape(this.toRaw()) } - throw new HL7FatalError(500, 'this.message is undefined. Unable to continue.') + throw new HL7FatalError('this.message is undefined. Unable to continue.') } /** @internal */ diff --git a/src/builder/modules/valueNode.ts b/src/builder/modules/valueNode.ts index c6f0211..cfe31ac 100644 --- a/src/builder/modules/valueNode.ts +++ b/src/builder/modules/valueNode.ts @@ -32,7 +32,7 @@ export class ValueNode extends NodeBase { /** YYYYMMDDHHMMSS **/ return new Date(parseInt(text.slice(0, 4)), parseInt(text.slice(4, 6)) - 1, parseInt(text.slice(6, 8)), parseInt(text.slice(8, 10)), parseInt(text.slice(10, 12)), parseInt(text.slice(12, 14))) } - throw new HL7FatalError(500, 'Invalid Date Format') + throw new HL7FatalError('Invalid Date Format') } /** @internal */ @@ -53,13 +53,13 @@ export class ValueNode extends NodeBase { case 'N': return false } - throw new HL7FatalError(500, 'Not a valid value for boolean value.') + throw new HL7FatalError('Not a valid value for boolean value.') } /** @internal */ protected pathCore (): string[] { if (this.parent === null) { - throw new HL7FatalError(404, 'Somehow, this.parent is null.') + throw new HL7FatalError('Somehow, this.parent is null.') } return this.parent.path.concat([this.key]) } diff --git a/src/client/client.ts b/src/client/client.ts index d96d596..ef6621b 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -1,11 +1,11 @@ import EventEmitter from 'events' import { - normalizeClientOptions, ClientListenerOptions, ClientOptions, + normalizeClientOptions, OutboundHandler } from '../utils/normalizedClient.js' -import { HL7Outbound } from './hl7Outbound.js' +import { Connection } from './connection.js' /** * Client Class @@ -15,7 +15,19 @@ export class Client extends EventEmitter { /** @internal */ _opt: ReturnType /** @internal */ - private _totalConnections: number + _connections: Connection[] + /** @internal */ + readonly stats = { + /** Total outbound connections able to connect to at this moment. + * @since 1.1.0 */ + _totalConnections: 0, + /** Overall total sent messages + * @since 2.0.0 */ + _totalSent: 0, + /** Overall Ack * + * @since 2.0.0 */ + _totalAck: 0 + } /** * @since 1.0.0 @@ -28,7 +40,20 @@ export class Client extends EventEmitter { constructor (props?: ClientOptions) { super() this._opt = normalizeClientOptions(props) - this._totalConnections = 0 + this._connections = [] + } + + /** + * Close all connections + * @since 2.0.0 + */ + closeAll (): void { + // loop through! + this._connections.map(async (connection) => { + void connection.close() + }) + // reset! + this._connections = [] } /** Connect to a listener to a specified port. @@ -43,19 +68,27 @@ export class Client extends EventEmitter { * ``` * Review the {@link InboundResponse} on the properties returned. */ - createOutbound (props: ClientListenerOptions, cb: OutboundHandler): HL7Outbound { - const outbound = new HL7Outbound(this, props, cb) - outbound.on('client.connect', () => { - this._totalConnections++ + createConnection (props: ClientListenerOptions, cb: OutboundHandler): Connection { + const outbound = new Connection(this, props, cb) + + outbound.on('client.acknowledged', (total) => { + this.stats._totalAck = this.stats._totalAck + total }) - outbound.on('client.close', () => { - this._totalConnections-- + + outbound.on('client.sent', (total) => { + this.stats._totalSent = this.stats._totalSent + total }) + + // add this connection + this._connections.push(outbound) + + // send back current outbound return outbound } /** - * Get the host that we are currently connecting to. + * Get the host that we will connect to. + * The port might be different from each different "connection" * @since 1.1.0 */ getHost (): string { @@ -63,10 +96,18 @@ export class Client extends EventEmitter { } /** - * Total connections ready to accept messages. - * @since 1.1.0 + * Total ack in this object lifetime. + * @since 2.0.0 + */ + totalAck (): number { + return this.stats._totalAck + } + + /** + * Total sent messages in this object lifetime. + * @since 2.0.0 */ - totalConnections (): number { - return this._totalConnections + totalSent (): number { + return this.stats._totalSent } } diff --git a/src/client/connection.ts b/src/client/connection.ts new file mode 100644 index 0000000..2b0d720 --- /dev/null +++ b/src/client/connection.ts @@ -0,0 +1,403 @@ +import EventEmitter from 'node:events' +import net, { Socket } from 'node:net' +import { clearTimeout } from 'node:timers' +import tls from 'node:tls' +import Batch from '../builder/batch.js' +import FileBatch from '../builder/fileBatch.js' +import Message from '../builder/message.js' +import { PROTOCOL_MLLP_FOOTER, PROTOCOL_MLLP_HEADER } from '../utils/constants.js' +import { ReadyState } from '../utils/enum.js' +import { HL7FatalError } from '../utils/exception.js' +import { ClientListenerOptions, normalizeClientListenerOptions, OutboundHandler } from '../utils/normalizedClient.js' +import { createDeferred, Deferred, expBackoff } from '../utils/utils.js' +import { Client } from './client.js' +import { InboundResponse } from './module/inboundResponse.js' + +/* eslint-disable */ +export interface Connection extends EventEmitter { + /** The connection has been closed manually. You have to start the connection again. */ + on(name: 'close', cb: () => void): this; + /** The connection is made. */ + on(name: 'connect', cb: () => void): this; + /** The connection is being (re)established or attempting to re-connect. */ + on(name: 'connection', cb: () => void): this; + /** The handle is open to do a manual start to connect. */ + on(name: 'open', cb: () => void): this; + /** The total acknowledged for this connection. */ + on(name: 'client.acknowledged', cb: (number: number) => void): this; + /** The connection has an error. */ + on(name: 'client.error', cb: (err: any) => void): this; + /** The total sent for this connection. */ + on(name: 'client.sent', cb: (number: number) => void): this; +} +/* eslint-enable */ + +/** Connection Class + * @description Create a connection customer that will listen to result send to the particular port. + * @since 1.0.0 */ +export class Connection extends EventEmitter implements Connection { + /** @internal */ + _handler: OutboundHandler + /** @internal */ + private readonly _main: Client + /** @internal */ + private readonly _opt: ReturnType + /** @internal */ + private _retryCount: number + /** @internal */ + _retryTimer: NodeJS.Timeout | undefined + /** @internal */ + private _socket: Socket | undefined + /** @internal */ + protected _readyState: ReadyState + /** @internal */ + _pendingSetup: Promise | boolean + /** @internal */ + _onConnect: Deferred + /** @internal */ + private _awaitingResponse: boolean + /** @internal */ + readonly stats = { + /** Total acknowledged messages back from server. + * @since 1.1.0 */ + acknowledged: 0, + /** Total message sent to server. + * @since 1.1.0 */ + sent: 0 + } + + /** + * @since 1.0.0 + * @param client The client parent that we are connecting too. + * @param props The individual port connection options. + * Some values will be defaulted by the parent server connection. + * @param handler The function that will send the returned information back to the client after we got a response from the server. + * @example + * ```ts + * const OB = client.createConnection({ port: 3000 }, async (res) => {}) + * ``` + */ + constructor (client: Client, props: ClientListenerOptions, handler: OutboundHandler) { + super() + + this._handler = handler + this._main = client + this._awaitingResponse = false + + this._opt = normalizeClientListenerOptions(client._opt, props) + + this._connect = this._connect.bind(this) + + this._pendingSetup = true + this._retryCount = 0 + this._retryTimer = undefined + this._onConnect = createDeferred(true) + + if (this._opt.autoConnect) { + this._readyState = ReadyState.CONNECTING + this.emit('connecting') + this._socket = this._connect() + } else { + this._readyState = ReadyState.OPEN + this.emit('open') + this._socket = undefined + } + } + + /** Close Client Listener Instance. + * @description Force close a connection. + * It Will stop any re-connection timers. + * If you want to restart, your app has to restart the connection. + * @since 1.0.0 + * @example + * ```ts + * OB.close() + * ``` + */ + async close (): Promise { + if (this._readyState === ReadyState.CLOSED) { + return // We are already closed. Nothing to do. + } + + if (this._readyState === ReadyState.CLOSING) { + return await new Promise(resolve => this._socket?.once('close', resolve)) + } + + if (this._readyState === ReadyState.CONNECTING) { + // clear retry timer + if (typeof this._retryTimer !== 'undefined') { + clearTimeout(this._retryTimer) + } + // let's clear out the try timer forcefully + this._retryTimer = undefined + } + + // normal closing + this._readyState = ReadyState.CLOSING + + // remove socket + this._socket?.destroy() + this._socket?.end() + + this.emit('close') + + this._readyState = ReadyState.CLOSED + } + + /** + * Get Port + * @description Get the port that this connection will connect to. + * @since 2.0.0 + */ + getPort (): number { + return this._opt.port + } + + /** + * Start the connection if not auto started. + * @since 2.0.0 + */ + async start (): Promise { + if (this._readyState === ReadyState.CONNECTING) { + return + } + + if (this._readyState === ReadyState.CONNECTED) { + return + } + + if (this._readyState === ReadyState.OPEN) { + return + } + + if (this._readyState === ReadyState.CLOSING) { + return await new Promise(resolve => this._socket?.once('close', resolve)) + } + + this.emit('connecting') + + this._socket = this._connect() + } + + /** Send a HL7 Message to the Listener + * @description This function sends a message/batch/file batch to the remote side. + * It has the ability, if set to auto-retry (defaulted to 1 re-connect before connection closes) + * @since 1.0.0 + * @param message The message we need to send to the port. + * @example + * ```ts + * + * // the OB was set from the orginial 'createConnection' method. + * + * let message = new Message({ + * messageHeader: { + * msh_9_1: "ADT", + * msh_9_2: "A01", + * msh_11_1: "P" // marked for production here in the example + * }async sendMessage (message: Message | Batch | FileBatch): void { + * }) + * + * await OB.sendMessage(message) + * + * ``` + */ + async sendMessage (message: Message | Batch | FileBatch): Promise { + let attempts = 0 + const maxAttempts = this._opt.maxAttempts + const emitter = new EventEmitter() + + const checkConnection = async (): Promise => { + return this._readyState === ReadyState.CONNECTED + } + + const checkAcknowledgement = async (): Promise => { + return this._awaitingResponse + } + + const checkSend = async (_message: string): Promise => { + while (true) { // noinspection InfiniteLoopJS + try { + if ((this._readyState === ReadyState.CLOSED) || (this._readyState === ReadyState.CLOSING)) { + // noinspection ExceptionCaughtLocallyJS + throw new HL7FatalError('In an invalid state to be able to send message.') + } + if (this._readyState !== ReadyState.CONNECTED) { + // if we are not connected, + // check to see if we are now connected. + if (this._pendingSetup === false) { + this._pendingSetup = checkConnection().finally(() => { + this._pendingSetup = false + }) + } + } else if (this._readyState === ReadyState.CONNECTED && this._opt.waitAck && this._awaitingResponse) { + // Ok, we ar now confirmed connected. + // However, since we are checking + // to make sure we wait for an ACKNOWLEDGEMENT from the server, + // that the message was gotten correctly from the last one we sent. + // We are still waiting, we need to recheck again + // if we are not connected, + // check to see if we are now connected. + if (this._pendingSetup === false) { + this._pendingSetup = checkAcknowledgement().finally(() => { + this._pendingSetup = false + }) + } + } + return await this._pendingSetup + } catch (err: any) { + Error.captureStackTrace(err) + if (++attempts >= maxAttempts) { + throw err + } else { + emitter.emit('retry', err) + } + } + } + } + + // get the message + const theMessage = message.toString() + + // check to see if we should be sending + await checkSend(theMessage) + + // ok, if our options are to wait for an acknowledgement, set the var to "true" + if (this._opt.waitAck) { + this._awaitingResponse = true + } + + // add MLLP settings to the message + const messageToSend = Buffer.from(`${PROTOCOL_MLLP_HEADER}${theMessage}${PROTOCOL_MLLP_FOOTER}`) + + // send the message + this._socket?.write(messageToSend, this._opt.encoding, () => { + // we sent a message + ++this.stats.sent + // emit + this.emit('client.sent', this.stats.sent) + }) + } + + /** @internal */ + private _connect (): Socket { + let socket: Socket + const host = this._main._opt.host + const port = this._opt.port + + this._retryTimer = undefined + + if (typeof this._main._opt.tls !== 'undefined') { + socket = tls.connect({ + host, + port, + ...this._main._opt.socket, + ...this._main._opt.tls + }) + } else { + socket = net.connect({ + host, + port + }) + } + + this._socket = socket + + // set no delay + socket.setNoDelay(true) + + let connectionError: Error | boolean | undefined + + socket.on('error', err => { + connectionError = (connectionError != null) ? connectionError : err + }) + + socket.on('close', () => { + if (this._readyState === ReadyState.CLOSING) { + this._readyState = ReadyState.CLOSED + } else { + connectionError = (connectionError != null) ? connectionError : new HL7FatalError('Socket closed unexpectedly by server.') + if (this._readyState === ReadyState.OPEN) { + this._onConnect = createDeferred(true) + } + this._readyState = ReadyState.CONNECTING + const retryCount = this._retryCount++ + const delay = expBackoff(this._opt.retryLow, this._opt.retryHigh, retryCount) + if (retryCount < this._opt.maxConnectionAttempts) { + this._retryTimer = setTimeout(this._connect, delay) + this.emit('client.error', connectionError) + } else if (retryCount > this._opt.maxConnectionAttempts) { + // stop this from going again + void this.close() + } + } + }) + + socket.on('connect', () => { + // accepting connections + this._readyState = ReadyState.CONNECTED + // reset retryCount count + this._retryCount = 1 + // emit + this.emit('connect') + }) + + socket.on('data', (buffer) => { + // we got some sort of response, bad, good, or error, + // so lets tell the system we got "something" + this._awaitingResponse = false + + socket.cork() + + const indexOfVT = buffer.toString().indexOf(PROTOCOL_MLLP_HEADER) + const indexOfFSCR = buffer.toString().indexOf(PROTOCOL_MLLP_FOOTER) + + let loadedMessage = buffer.toString().substring(indexOfVT, indexOfFSCR + 2) + loadedMessage = loadedMessage.replace(PROTOCOL_MLLP_HEADER, '') + + if (loadedMessage.includes(PROTOCOL_MLLP_FOOTER)) { + // strip them out + loadedMessage = loadedMessage.replace(PROTOCOL_MLLP_FOOTER, '') + if (typeof this._handler !== 'undefined') { + // response + const response = new InboundResponse(loadedMessage) + // got an ACK, failure or not + ++this.stats.acknowledged + // update ack total + this.emit('client.acknowledged', this.stats.acknowledged) + // send it back + void this._handler(response) + } + } + + socket.uncork() + }) + + const readerLoop = async (): Promise => { + try { + await this._negotiate() + } catch (err: any) { + if (err.code !== 'READ_END') { + socket.destroy(err) + } + } + } + + void readerLoop().then(_r => { /* noop */ }) + + return socket + } + + /** @internal */ + private async _negotiate (): Promise { + if (this._socket?.writable === true) { + // we are open, not yet ready, but we can + this._readyState = ReadyState.OPEN + // on connect resolve + this._onConnect.resolve() + // emit + this.emit('connection') + } + } +} + +export default Connection diff --git a/src/client/hl7Outbound.ts b/src/client/hl7Outbound.ts deleted file mode 100644 index a62d2f7..0000000 --- a/src/client/hl7Outbound.ts +++ /dev/null @@ -1,406 +0,0 @@ -import EventEmitter from 'node:events' -import fs from 'node:fs' -import net, { Socket } from 'node:net' -import tls from 'node:tls' -import { Batch } from '../builder/batch.js' -import { FileBatch } from '../builder/fileBatch.js' -import { Message } from '../builder/message.js' -import { PROTOCOL_MLLP_FOOTER, PROTOCOL_MLLP_HEADER } from '../utils/constants.js' -import { ReadyState } from '../utils/enum.js' -import { HL7FatalError } from '../utils/exception.js' -import { ClientListenerOptions, normalizeClientListenerOptions, OutboundHandler } from '../utils/normalizedClient.js' -import { expBackoff, randomString } from '../utils/utils.js' -import { Client } from './client.js' -import { InboundResponse } from './module/inboundResponse.js' - -/** HL7 Outbound Class - * @description Create a connection to a server on a particular port. - * @since 1.0.0 */ -export class HL7Outbound extends EventEmitter { - /** @internal */ - private _awaitingResponse: boolean - /** @internal */ - _connectionTimer: NodeJS.Timeout | undefined - /** @internal */ - private readonly _handler: (res: InboundResponse) => void - /** @internal */ - private readonly _main: Client - /** @internal */ - private readonly _nodeId: string - /** @internal */ - private readonly _opt: ReturnType - /** @internal */ - private _retryCount: number - /** @internal */ - _retryTimer: NodeJS.Timeout | undefined - /** @internal */ - private readonly _socket: Socket - /** @internal */ - private readonly _sockets: Map - /** @internal */ - protected _readyState: ReadyState - /** @internal */ - _pendingSetup: Promise | boolean - /** @internal */ - private _responseBuffer: string - /** @internal */ - private _initialConnection: boolean - /** @internal */ - readonly stats = { - /** Total acknowledged messages back from server. - * @since 1.1.0 */ - acknowledged: 0, - /** Total message sent to server. - * @since 1.1.0 */ - sent: 0 - } - - /** - * @since 1.0.0 - * @param client The client parent that we are connecting too. - * @param props The individual port connection options. - * Some values will be defaulted by the parent server connection. - * @param handler The function that will send the returned information back to the client after we got a response from the server. - * @example - * ```ts - * const OB = client.createOutbound({ port: 3000 }, async (res) => {}) - * ``` - */ - constructor (client: Client, props: ClientListenerOptions, handler: OutboundHandler) { - super() - this._awaitingResponse = false - this._initialConnection = false - this._connectionTimer = undefined - this._handler = handler // eslint-disable-line @typescript-eslint/no-misused-promises - this._main = client - this._nodeId = randomString(5) - - this._opt = normalizeClientListenerOptions(props) - - this._pendingSetup = true - this._sockets = new Map() - this._retryCount = 1 - this._retryTimer = undefined - this._readyState = ReadyState.CONNECTING - this._responseBuffer = '' - - this._connect = this._connect.bind(this) - this._socket = this._connect() - } - - /** Close Client Listener Instance. - * @description Force close a connection. - * It Will stop any re-connection timers. - * If you want to restart, your app has to restart the connection. - * @since 1.0.0 - * @example - * ```ts - * OB.close() - * ``` - */ - async close (): Promise { - // mark that we set our internal that we are closing, so we do not try to re-connect - this._readyState = ReadyState.CLOSING - this._sockets.forEach((socket) => { - if (typeof socket.destroyed !== 'undefined') { - socket.end() - socket.destroy() - } - }) - this._sockets.clear() - - this.emit('client.close') - - return true - } - - /** - * Read a file. - * @description We need to read a file. - * We are not doing anything else other than getting the {@link Buffer} of the file, - * so we can pass it onto the File Batch class to send it to the {@link sendMessage} method as a separate step - * @since 1.0.0 - * @param fullFilePath The full file path of the file we need to read. - */ - async readFile (fullFilePath: string): Promise { - try { - const regex = /\n/mg - const subst = '\\r' - const fileBuffer = fs.readFileSync(fullFilePath) - const text = fileBuffer.toString().replace(regex, subst) - return new FileBatch({ text }) - } catch (e: any) { - throw new HL7FatalError(500, `Unable to read file: ${fullFilePath}`) - } - } - - /** Send a HL7 Message to the Listener - * @description This function sends a message/batch/file batch to the remote side. - * It has the ability, if set to auto-retry (defaulted to 1 re-connect before connection closes) - * @since 1.0.0 - * @param message The message we need to send to the port. - * @example - * ```ts - * - * // the OB was set from the orginial 'createOutbound' method. - * - * let message = new Message({ - * messageHeader: { - * msh_9_1: "ADT", - * msh_9_2: "A01", - * msh_11_1: "P" // marked for production here in the example - * } - * }) - * - * await OB.sendMessage(message) - * - * ``` - */ - async sendMessage (message: Message | Batch | FileBatch): Promise { - let attempts = 0 - const maxAttempts = typeof this._opt.maxAttempts === 'undefined' ? this._main._opt.maxAttempts : this._opt.maxAttempts - - const checkConnection = async (): Promise => { - return this._readyState === ReadyState.CONNECTED - } - - const checkAck = async (): Promise => { - return this._awaitingResponse - } - - const checkSend = async (_message: string): Promise => { - // noinspection InfiniteLoopJS - while (true) { - try { - // first, if we are closed, sorry, no more sending messages - if ((this._readyState === ReadyState.CLOSED) || (this._readyState === ReadyState.CLOSING)) { - // noinspection ExceptionCaughtLocallyJS - throw new HL7FatalError(500, 'In an invalid state to be able to send message.') - } - if (this._readyState !== ReadyState.CONNECTED) { - // if we are not connected, - // check to see if we are now connected. - if (this._pendingSetup === false) { - this._pendingSetup = checkConnection().finally(() => { this._pendingSetup = false }) - } - } else if (this._readyState === ReadyState.CONNECTED && this._opt.waitAck && this._awaitingResponse) { - // Ok, we ar now confirmed connected. - // However, since we are checking - // to make sure we wait for an ACKNOWLEDGEMENT from the server, - // that the message was gotten correctly from the last one we sent. - // We are still waiting, we need to recheck again - // if we are not connected, - // check to see if we are now connected. - if (this._pendingSetup === false) { - this._pendingSetup = checkAck().finally(() => { this._pendingSetup = false }) - } - } - return await this._pendingSetup - } catch (err: any) { - Error.captureStackTrace(err) - if (++attempts >= maxAttempts) { - throw err - } else { - emitter.emit('retry', err) - } - } - } - } - - const emitter = new EventEmitter() - - // get the message - const theMessage = message.toString() - - // check to see if we should be sending - await checkSend(theMessage) - - // ok, if our options are to wait for an acknowledgement, set the var to "true" - if (this._opt.waitAck) { - this._awaitingResponse = true - } - - // add MLLP settings to the message - const messageToSend = Buffer.from(`${PROTOCOL_MLLP_HEADER}${theMessage}${PROTOCOL_MLLP_FOOTER}`) - - return this._socket.write(messageToSend, this._opt.encoding, () => { - // we sent a message - ++this.stats.sent - }) - } - - /** @internal */ - private _addSocket (nodeId: string, socket: any, b: boolean): void { - const s = this._sockets.get(nodeId) - if (!b && typeof s !== 'undefined' && typeof s.destroyed !== 'undefined') { - return - } - this._sockets.set(nodeId, socket) - } - - /** @internal */ - private _connect (): Socket { - this._retryTimer = undefined - - let socket: Socket - const host = this._main._opt.host - const port = this._opt.port - - if (typeof this._main._opt.tls !== 'undefined') { - socket = tls.connect({ host, port, timeout: this._opt.connectionTimeout, ...this._main._opt.socket, ...this._main._opt.tls }, () => this._listener(socket)) - } else { - socket = net.connect({ host, port, timeout: this._opt.connectionTimeout }, () => this._listener(socket)) - } - - socket.setNoDelay(true) - - // send this to tell we are pending connecting to the host/port - this.emit('connecting') - - let connectionError: Error | undefined - - if (this._opt.connectionTimeout > 0) { - this._connectionTimer = setTimeout(() => { - socket.destroy(new HL7FatalError(500, 'Connection timed out.')) - this._removeSocket(this._nodeId) - }, this._opt.connectionTimeout) - } - - socket.on('timeout', () => { - this._readyState = ReadyState.CLOSING - this._removeSocket(this._nodeId) - this.emit('timeout') - }) - - socket.on('ready', () => { - this.emit('ready') - // fired right after successful connection, - // tell the user ready to be able to send messages to server/broker - }) - - socket.on('error', err => { - connectionError = typeof connectionError !== 'undefined' ? err : undefined - }) - - socket.on('close', () => { - if (this._readyState === ReadyState.CLOSING) { - this._readyState = ReadyState.CLOSED - this._reset() - } else { - connectionError = typeof connectionError !== 'undefined' ? new HL7FatalError(500, 'Socket closed unexpectedly by server.') : undefined - const retryHigh = typeof this._opt.retryHigh === 'undefined' ? this._main._opt.retryHigh : this._opt.retryLow - const retryLow = typeof this._opt.retryLow === 'undefined' ? this._main._opt.retryLow : this._opt.retryLow - const retryCount = this._retryCount++ - const delay = expBackoff(retryLow, retryHigh, retryCount) - this._readyState = ReadyState.OPEN - this._reset() - this._retryTimer = setTimeout(this._connect, delay) - if ((retryCount <= 1) && (retryCount < this._opt.maxConnectionAttempts)) { - this.emit('error', connectionError) - } else if ((retryCount > this._opt.maxConnectionAttempts) && !this._initialConnection) { - // this._removeSocket(this._nodeId) - this.emit('timeout') - } - } - }) - - socket.on('connect', () => { - // we have connected. we should now follow trying to reconnect until total failure - this._initialConnection = true - this._readyState = ReadyState.CONNECTED - this.emit('client.connect', true, this._socket) - }) - - socket.on('data', (buffer: Buffer) => { - this._awaitingResponse = false - this._responseBuffer += buffer.toString() - - while (this._responseBuffer !== '') { - const indexOfVT = this._responseBuffer.indexOf(PROTOCOL_MLLP_HEADER) - const indexOfFSCR = this._responseBuffer.indexOf(PROTOCOL_MLLP_FOOTER) - - let loadedMessage = this._responseBuffer.substring(indexOfVT, indexOfFSCR + 2) - this._responseBuffer = this._responseBuffer.slice(indexOfFSCR + 2, this._responseBuffer.length) - - loadedMessage = loadedMessage.replace(PROTOCOL_MLLP_HEADER, '') - // is there is F5 and CR in this message? - if (loadedMessage.includes(PROTOCOL_MLLP_FOOTER)) { - // strip them out - loadedMessage = loadedMessage.replace(PROTOCOL_MLLP_FOOTER, '') - - // response - const response = new InboundResponse(loadedMessage) - - // got an ACK, failure or not - ++this.stats.acknowledged - - // send it back - this._handler(response) - } - } - }) - - socket.on('end', () => { - this._removeSocket(this._nodeId) - this.emit('end') - }) - - socket.unref() - - this._addSocket(this._nodeId, socket, true) - - return socket - } - - /** @internal */ - private _listener (socket: Socket): void { - // set no delay - socket.setNoDelay(true) - - // add socket - this._addSocket(this._nodeId, socket, true) - - // check to make sure we do not max out on connections, we shouldn't... - if (this._sockets.size > this._opt.maxConnections) { - this._manageConnections() - } - - this._readyState = ReadyState.CONNECTED - } - - /** @internal */ - private _manageConnections (): void { - let count = this._sockets.size - this._opt.maxConnections - if (count <= 0) { - return - } - - const list: Array<{ nodeID: any, lastUsed: any }> = [] - this._sockets.forEach((socket, nodeID) => list.push({ nodeID, lastUsed: socket.lastUsed })) - list.sort((a, b) => a.lastUsed - b.lastUsed) - - count = Math.min(count, list.length - 1) - const removable = list.slice(0, count) - - removable.forEach(({ nodeID }) => this._removeSocket(nodeID)) - } - - /** @internal */ - private _removeSocket (nodeId: string): void { - const socket = this._sockets.get(nodeId) - if (typeof socket !== 'undefined' && typeof socket.destroyed !== 'undefined') { - socket.destroy() - } - this._sockets.delete(nodeId) - } - - /** @internal */ - private _reset (): void { - if (typeof this._connectionTimer !== 'undefined') { - clearTimeout(this._connectionTimer) - } - this._connectionTimer = undefined - } -} - -export default HL7Outbound diff --git a/src/client/module/inboundResponse.ts b/src/client/module/inboundResponse.ts index 9475977..8365123 100644 --- a/src/client/module/inboundResponse.ts +++ b/src/client/module/inboundResponse.ts @@ -15,7 +15,7 @@ export class InboundResponse { * @param data */ constructor (data: string) { - this._message = new Message({ text: data.toString() }) + this._message = new Message({ text: data.toString().trimEnd() }) } /** ' diff --git a/src/index.ts b/src/index.ts index bd1f682..b0d936d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import { Client } from './client/client.js' import { Message } from './builder/message.js' import { Batch } from './builder/batch.js' import { FileBatch } from './builder/fileBatch.js' -import { HL7Outbound } from './client/hl7Outbound.js' +import { Connection } from './client/connection.js' import { Delimiters, ReadyState } from './utils/enum.js' import { OutboundHandler } from './utils/normalizedClient.js' import { InboundResponse } from './client/module/inboundResponse.js' @@ -19,4 +19,4 @@ export type { ClientBuilderFileOptions, ClientBuilderMessageOptions, ClientBuild export type { HL7Error, HL7FatalError, HL7ParserError } from './utils/exception.js' export default Client -export { Client, HL7Outbound, OutboundHandler, InboundResponse, FileBatch, Batch, Message, ReadyState, NodeBase, EmptyNode, Segment, Delimiters, HL7Node } +export { Client, Connection, OutboundHandler, InboundResponse, FileBatch, Batch, Message, ReadyState, NodeBase, EmptyNode, Segment, Delimiters, HL7Node } diff --git a/src/utils/exception.ts b/src/utils/exception.ts index 2c62e68..c5e088a 100644 --- a/src/utils/exception.ts +++ b/src/utils/exception.ts @@ -15,11 +15,14 @@ class HL7Error extends Error { class HL7FatalError extends HL7Error { /** @internal */ name = 'HL7FatalError' + constructor (message: string) { + super(500, message) + } } /** Used to indicate a fatal failure of a connection. * @since 1.0.0 */ -class HL7ParserError extends HL7Error { +class HL7ParserError extends Error { /** @internal */ name = 'HL7ParserError' } diff --git a/src/utils/normalizedBuilder.ts b/src/utils/normalizedBuilder.ts index 16e0836..242da70 100644 --- a/src/utils/normalizedBuilder.ts +++ b/src/utils/normalizedBuilder.ts @@ -113,13 +113,13 @@ export function normalizedClientMessageBuilderOptions (raw?: ClientBuilderMessag const props: ClientBuilderMessageOptions = { ...DEFAULT_CLIENT_BUILDER_OPTS, ...raw } if (typeof props.messageHeader === 'undefined' && props.text === '') { - throw new HL7FatalError(500, 'mshHeader must be set if no HL7 message is being passed.') + throw new HL7FatalError('mshHeader must be set if no HL7 message is being passed.') } else if (typeof props.messageHeader === 'undefined' && typeof props.text !== 'undefined' && props.text.slice(0, 3) !== 'MSH') { throw new Error('text must begin with the MSH segment.') } if ((typeof props.newLine !== 'undefined' && props.newLine === '\\r') || props.newLine === '\\n') { - throw new HL7FatalError(500, ('newLine must be \r or \n')) + throw new HL7FatalError('newLine must be \r or \n') } if (props.date !== '8' && props.date !== '12' && props.date !== '14') { @@ -149,15 +149,15 @@ export function normalizedClientBatchBuilderOptions (raw?: ClientBuilderOptions) const props: ClientBuilderOptions = { ...DEFAULT_CLIENT_BUILDER_OPTS, ...raw } if (typeof props.text !== 'undefined' && props.text !== '' && props.text.slice(0, 3) !== 'BHS' && props.text.slice(0, 3) !== 'MSH') { - throw new HL7FatalError(500, ('text must begin with the BHS or MSH segment.')) + throw new HL7FatalError('text must begin with the BHS or MSH segment.') } if (typeof props.text !== 'undefined' && props.text !== '' && props.text.slice(0, 3) === 'MSH' && !isBatch(props.text)) { - throw new HL7FatalError(500, ('Unable to process a single MSH as a batch. Use Message.')) + throw new HL7FatalError('Unable to process a single MSH as a batch. Use Message.') } if ((typeof props.newLine !== 'undefined' && props.newLine === '\\r') || props.newLine === '\\n') { - throw new HL7FatalError(500, ('newLine must be \r or \n')) + throw new HL7FatalError('newLine must be \r or \n') } if (props.date !== '8' && props.date !== '12' && props.date !== '14') { @@ -185,19 +185,19 @@ export function normalizedClientFileBuilderOptions (raw?: ClientBuilderFileOptio const props: ClientBuilderFileOptions = { ...DEFAULT_CLIENT_FILE_OPTS, ...DEFAULT_CLIENT_BUILDER_OPTS, ...raw } if (typeof props.text !== 'undefined' && props.text !== '' && props.text.slice(0, 3) !== 'FHS') { - throw new HL7FatalError(500, ('text must begin with the FHS segment.')) + throw new HL7FatalError('text must begin with the FHS segment.') } if ((typeof props.newLine !== 'undefined' && props.newLine === '\\r') || props.newLine === '\\n') { - throw new HL7FatalError(500, ('newLine must be \r or \n')) + throw new HL7FatalError('newLine must be \r or \n') } if (typeof props.extension !== 'undefined' && props.extension.length !== 3) { - throw new HL7FatalError(500, ('The extension for file save must be 3 characters long.')) + throw new HL7FatalError('The extension for file save must be 3 characters long.') } if (typeof props.fullFilePath !== 'undefined' && typeof props.fileBuffer !== 'undefined') { - throw new HL7FatalError(500, ('You can not have specified a file path and a buffer. Please choose one or the other.')) + throw new HL7FatalError('You can not have specified a file path and a buffer. Please choose one or the other.') } if (props.date !== '8' && props.date !== '12' && props.date !== '14') { diff --git a/src/utils/normalizedClient.ts b/src/utils/normalizedClient.ts index 5b2f216..6af7087 100644 --- a/src/utils/normalizedClient.ts +++ b/src/utils/normalizedClient.ts @@ -10,31 +10,22 @@ import { assertNumber, validIPv4, validIPv6 } from './utils.js' * @since 1.0.0 * @param res */ -export type OutboundHandler = (res: InboundResponse) => Promise +export type OutboundHandler = (res: InboundResponse) => Promise | void const DEFAULT_CLIENT_OPTS = { - connectionTimeout: 10000, encoding: 'utf-8', - maxConnections: 10, retryHigh: 30000, retryLow: 1000 } const DEFAULT_LISTEN_CLIENT_OPTS = { - connectionTimeout: 10000, - encoding: 'utf-8', + autoConnect: true, maxAttempts: 10, - maxConnectionAttempts: 30, - maxConnections: 10, - retryHigh: 30000, - retryLow: 1000, + maxConnectionAttempts: 10, waitAck: true } export interface ClientOptions { - /** Max wait time, in milliseconds, for a connection attempt - * @default 10_000 */ - connectionTimeout?: number /** Host - You can do a FQDN or the IPv(4|6) address. */ host?: string /** IPv4 - If this is set to true, only IPv4 address will be used and also validated upon installation from the hostname property. @@ -43,9 +34,6 @@ export interface ClientOptions { /** IPv6 - If this is set to true, only IPv6 address will be used and also validated upon installation from the hostname property. * @default false */ ipv6?: boolean - /** Keep the connection alive after sending data and getting a response. - * @default true */ - keepAlive?: boolean /** Max attempts * to send the message before an error is thrown if we are in the process of re-attempting to connect to the server. * Has to be greater than 1. You cannot exceed 50. @@ -72,6 +60,11 @@ export interface ClientOptions { } export interface ClientListenerOptions extends ClientOptions { + /** If set to false, you have to tell the system to start trying to connect + * by sending 'start' method. + * @default true + */ + autoConnect?: boolean /** Encoding of the messages we expect from the HL7 message. * @default "utf-8" */ @@ -80,28 +73,28 @@ export interface ClientListenerOptions extends ClientOptions { * Has to be greater than 1. * @default 10 */ maxConnections?: number - /** The port we should connect on the server. */ + /** The port we should connect to on the server. */ port: number - /** Wait for ACK **/ + /** Wait for ACK before sending a new message. + * If this is set to false, you can send as many messages as you want but since you are not expecting any ACK from a + * previous message sent before sending another one. + * This does not stop the "total acknowledgement" counter on the + * client object to stop increasing. + * @default true **/ waitAck?: boolean } type ValidatedClientKeys = - | 'connectionTimeout' | 'host' - | 'maxAttempts' type ValidatedClientListenerKeys = - | 'connectionTimeout' + | 'autoConnect' | 'port' | 'maxAttempts' | 'maxConnectionAttempts' - | 'maxConnections' interface ValidatedClientOptions extends Pick, ValidatedClientKeys> { - connectionTimeout: number host: string - maxAttempts: number retryHigh: number retryLow: number socket?: TcpSocketConnectOpts @@ -109,12 +102,11 @@ interface ValidatedClientOptions extends Pick, Validated } interface ValidatedClientListenerOptions extends Pick, ValidatedClientListenerKeys> { - connectionTimeout: number + autoConnect: boolean encoding: BufferEncoding port: number maxAttempts: number maxConnectionAttempts: number - maxConnections: number retryHigh: number retryLow: number waitAck: boolean @@ -125,28 +117,25 @@ export function normalizeClientOptions (raw?: ClientOptions): ValidatedClientOpt const props: any = { ...DEFAULT_CLIENT_OPTS, ...raw } if (typeof props.host === 'undefined' || props.host.length <= 0) { - throw new HL7FatalError(500, 'host is not defined or the length is less than 0.') + throw new HL7FatalError('host is not defined or the length is less than 0.') } if (props.ipv4 === true && props.ipv6 === true) { - throw new HL7FatalError(500, 'ipv4 and ipv6 both can\'t be set to be both used exclusively.') + throw new HL7FatalError('ipv4 and ipv6 both can\'t be set to be both used exclusively.') } if (typeof props.host !== 'string' && props.ipv4 === false && props.ipv6 === false) { - throw new HL7FatalError(500, 'host is not valid string.') + throw new HL7FatalError('host is not valid string.') } else if (typeof props.host === 'string' && props.ipv4 === true && props.ipv6 === false) { if (!validIPv4(props.host)) { - throw new HL7FatalError(500, 'host is not a valid IPv4 address.') + throw new HL7FatalError('host is not a valid IPv4 address.') } } else if (typeof props.host === 'string' && props.ipv4 === false && props.ipv6 === true) { if (!validIPv6(props.host)) { - throw new HL7FatalError(500, 'host is not a valid IPv6 address.') + throw new HL7FatalError('host is not a valid IPv6 address.') } } - assertNumber(props, 'connectionTimeout', 0) - assertNumber(props, 'maxConnections', 1, 50) - if (props.tls === true) { props.tls = {} } @@ -155,22 +144,28 @@ export function normalizeClientOptions (raw?: ClientOptions): ValidatedClientOpt } /** @internal */ -export function normalizeClientListenerOptions (raw?: ClientListenerOptions): ValidatedClientListenerOptions { +export function normalizeClientListenerOptions (client: ClientOptions, raw?: ClientListenerOptions): ValidatedClientListenerOptions { const props: any = { ...DEFAULT_LISTEN_CLIENT_OPTS, ...raw } if (typeof props.port === 'undefined') { - throw new HL7FatalError(500, 'port is not defined.') + throw new HL7FatalError('port is not defined.') } if (typeof props.port !== 'number') { - throw new HL7FatalError(500, 'port is not valid number.') + throw new HL7FatalError('port is not valid number.') + } + + if (typeof props.retryHigh === 'undefined') { + props.retryHigh = client.retryHigh + } + + if (typeof props.retryLow === 'undefined') { + props.retryLow = client.retryLow } - assertNumber(props, 'connectionTimeout', 0) assertNumber(props, 'maxAttempts', 1, 50) assertNumber(props, 'maxConnectionAttempts', 1, 50) - assertNumber(props, 'maxConnections', 1, 50) - assertNumber(props, 'port', 0, 65353) + assertNumber(props, 'port', 1, 65353) return props } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index b0c5f8a..9ebe87d 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -228,3 +228,30 @@ const getSegIndexes = (names: string[], data: string, list: string[] = []): stri } return list } + +/** + * @since 2.0.0 + */ +export interface Deferred { + resolve: (value: T | PromiseLike) => void + reject: (reason?: any) => void + promise: Promise +} + +/** + * Create Deferred + * @since 2.0.0 + * @param noUncaught + */ +export const createDeferred = (noUncaught?: boolean): Deferred => { + const dfd: any = {} + dfd.promise = new Promise((resolve, reject) => { + dfd.resolve = resolve + dfd.reject = reject + }) + /* istanbul ignore next */ + if (noUncaught === false) { + dfd.promise.catch(() => {}) + } + return dfd +} diff --git a/tsconfig.build.json b/tsconfig.build.json index f0c77f3..15fbb51 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -4,7 +4,7 @@ "./src/**/*.ts" ], "exclude": [ - "./jest.config.ts", + "./vitest.config.ts", "./release.config.{cjs|js}" ] } \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..359b6ad --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + coverage: { + exclude: [ + '__tests__/__utils__/**', + '__tests__/__data__/**', + 'bin', + 'certs', + 'docs', + 'lib', + 'src/api.ts', + 'release.config.cjs' + ] + } + } +})