@homaiohq/napi-quickfix 0.2.0 → 0.3.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/README.md CHANGED
@@ -34,6 +34,9 @@ included.
34
34
  field (`MsgType`, `Side`, `ExecType`, `OrdStatus`, ...) are
35
35
  generated from the bundled QuickFIX headers with literal types, frozen, and
36
36
  importable without loading the native addon.
37
+ - **TLS for initiators** — `createTlsTunnel` is a built-in, stunnel-style TLS
38
+ tunnel (mTLS, SNI, certificate verification via Node's `tls`), so you don't need
39
+ a separate stunnel process.
37
40
 
38
41
  ## Install
39
42
 
@@ -385,6 +388,110 @@ the engine owning it has been stopped. A constructor that fails on a later
385
388
  (so the ids are not locked out by the failed attempt) and the engine-level log
386
389
  QuickFIX had already opened for it.
387
390
 
391
+ ### TLS (stunnel-style) for initiators
392
+
393
+ QuickFIX's own `SSLSocketInitiator` needs OpenSSL compiled into the engine, and
394
+ this package's prebuilds are built without it. Instead, `createTlsTunnel` does
395
+ what a stunnel client does, inside your process. It listens on a loopback port,
396
+ you point the initiator's `SocketConnectHost`/`SocketConnectPort` at it, and it
397
+ carries each connection to the counterparty over Node's
398
+ [`tls.connect`](https://nodejs.org/api/tls.html#tlsconnectoptions-callback):
399
+
400
+ ```text
401
+ Initiator --plain TCP--> 127.0.0.1:<tunnel.port> --TLS--> fix.broker.com:9443
402
+ ```
403
+
404
+ ```ts
405
+ import { readFileSync } from 'node:fs';
406
+ import { createTlsTunnel, Initiator, SessionSettings } from '@homaiohq/napi-quickfix';
407
+
408
+ const tunnel = await createTlsTunnel({
409
+ host: 'fix.broker.com',
410
+ port: 9443,
411
+ tls: {
412
+ ca: readFileSync('broker-ca.pem'), // omit to trust Node's default CAs
413
+ cert: readFileSync('client.pem'), // mTLS, if the venue requires it
414
+ key: readFileSync('client.key'),
415
+ minVersion: 'TLSv1.2',
416
+ },
417
+ });
418
+ tunnel.on('secureConnect', (info) => console.log('TLS up', info.protocol, info.peerCertificate.subject));
419
+ tunnel.on('tlsError', (err) => console.error('TLS failed:', err.message));
420
+
421
+ const settings = SessionSettings.fromString(`
422
+ [DEFAULT]
423
+ ConnectionType=initiator
424
+ SocketConnectHost=${tunnel.host}
425
+ SocketConnectPort=${tunnel.port}
426
+ ...
427
+ `);
428
+ const initiator = new Initiator({ settings, handlers });
429
+ await initiator.start();
430
+
431
+ // Shutdown: the engine first, so its Logout still crosses the tunnel.
432
+ await initiator.stop();
433
+ await tunnel.close();
434
+ ```
435
+
436
+ - **Options.** `tls` accepts any `tls.ConnectionOptions` except
437
+ `host`/`port`/`socket`/`path`. `servername` (SNI, and the name the server
438
+ certificate is checked against) defaults to `host`, unless `host` is an IP
439
+ address.
440
+ - **Fixed port.** Pass `localPort` to listen on a fixed port, for example to
441
+ keep `SocketConnectPort=9001` in a static `.cfg` file. A port that is already
442
+ in use makes `createTlsTunnel` reject with `EADDRINUSE`.
443
+ - **Failover.** Open one tunnel per `SocketConnectHost<n>`, and set
444
+ `SocketConnectHost<n>=127.0.0.1` with `SocketConnectPort<n>=${tunnelN.port}`.
445
+ - **Failures.** If the TLS connection fails or breaks (DNS, TCP, handshake,
446
+ certificate, or `tls` options that `tls.connect` rejects such as an
447
+ unreadable key), the tunnel emits `'tlsError'` and drops the initiator's
448
+ loopback connection. QuickFIX then reconnects after its `ReconnectInterval`.
449
+ The tunnel never emits `'error'`, so a tunnel with no listeners cannot crash
450
+ the process.
451
+ - **Shutdown of a connection.** When either side closes, the other is ended
452
+ rather than reset, so data still in the tunnel (a Logout, TLS's
453
+ `close_notify`) is delivered. A connection that has not fully closed 5 seconds
454
+ after the first side closed is destroyed.
455
+ - **Lifecycle.** The tunnel's handles are unref'd, so the tunnel alone never
456
+ keeps the process alive (a started engine does). `close()` destroys live
457
+ connections at once. It is idempotent, and
458
+ `await using tunnel = await createTlsTunnel(...)` closes the tunnel at the end
459
+ of the block.
460
+
461
+ **Security notes**
462
+
463
+ - The plain-text hop listens on `127.0.0.1` only. As with stunnel, other
464
+ processes on the same host can still connect to that port, so treat the host
465
+ as trusted.
466
+ - QuickFIX's own logs show the loopback address (`Connecting to 127.0.0.1 on
467
+ port ...`). Use the `'secureConnect'` event to see the real peer and its
468
+ certificate.
469
+ - Tunnel traffic runs on the JS event loop, which the application handlers
470
+ already depend on. A blocked loop stalls both in the same way.
471
+
472
+ **Acceptor side.** There is no in-process equivalent for acceptors. QuickFIX
473
+ 1.16's acceptor always listens on every interface, and it checks
474
+ `AllowedRemoteAddresses` only after processing the Logon. A tunnel in front of
475
+ it could therefore be bypassed by connecting to the plain port directly. To
476
+ accept TLS, terminate it outside the process (stunnel in server mode, a load
477
+ balancer, a service mesh) and firewall the acceptor's port.
478
+
479
+ If you'd rather run stunnel as a sidecar on the initiator side, this config is
480
+ equivalent to the example above:
481
+
482
+ ```ini
483
+ [fix-broker]
484
+ client = yes
485
+ accept = 127.0.0.1:9001
486
+ connect = fix.broker.com:9443
487
+ CAfile = broker-ca.pem
488
+ cert = client.pem
489
+ key = client.key
490
+ verifyChain = yes
491
+ checkHost = fix.broker.com
492
+ sslVersionMin = TLSv1.2
493
+ ```
494
+
388
495
  ### Session
389
496
 
390
497
  A handle on one **live** session, obtained from `engine.getSession(id)` or the
@@ -503,6 +610,8 @@ numSessions(): number;
503
610
 
504
611
  version(): string; // engine / addon version string
505
612
 
613
+ createTlsTunnel(options: TlsTunnelOptions): Promise<TlsTunnel>; // see "TLS (stunnel-style) for initiators"
614
+
506
615
  FIELD // field-name -> tag number, e.g. FIELD.MsgType === 35, FIELD.Password === 554
507
616
  MsgType // MsgType values, e.g. MsgType.Logon === 'A'
508
617
  Side // Side values, e.g. Side.Buy === '1'
package/cpp/addon.cpp CHANGED
@@ -30,7 +30,7 @@ void RegisterSessionStatic(Napi::Env env, Napi::Object exports);
30
30
  namespace {
31
31
 
32
32
  // Keep in sync with package.json "version".
33
- constexpr const char* kAddonVersion = "0.2.0";
33
+ constexpr const char* kAddonVersion = "0.3.0";
34
34
 
35
35
  Napi::Value Version(const Napi::CallbackInfo& info) {
36
36
  return Napi::String::New(info.Env(), kAddonVersion);
@@ -14,6 +14,8 @@ export { Session, lookupSession, doesSessionExist, getSessions, numSessions } fr
14
14
  export { FixReject, fixReject } from './application.js';
15
15
  export type { ApplicationHandlers, FixRejectKind } from './application.js';
16
16
  export type { QuickFixError } from './native.js';
17
+ export { createTlsTunnel } from './tls-tunnel.js';
18
+ export type { TlsTunnel, TlsTunnelOptions, TlsTunnelEvents, TlsTunnelSecureConnectInfo, } from './tls-tunnel.js';
17
19
  export { enums, FIELD, VALUES, MsgType, Side, OrdType, TimeInForce } from './enums.js';
18
20
  export type { Enums, EnumGroup, FieldName, ValueGroups, ValueGroupName } from './enums.js';
19
21
  /**
package/dist/cjs/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.quickfixParseMsgType = exports.version = exports.TimeInForce = exports.OrdType = exports.Side = exports.MsgType = exports.VALUES = exports.FIELD = exports.enums = exports.fixReject = exports.FixReject = exports.numSessions = exports.getSessions = exports.doesSessionExist = exports.lookupSession = exports.Session = exports.Engine = exports.Acceptor = exports.Initiator = exports.DataDictionary = exports.SessionSettings = exports.SessionID = exports.Group = exports.parseMessage = exports.createMessage = exports.Message = void 0;
3
+ exports.quickfixParseMsgType = exports.version = exports.TimeInForce = exports.OrdType = exports.Side = exports.MsgType = exports.VALUES = exports.FIELD = exports.enums = exports.createTlsTunnel = exports.fixReject = exports.FixReject = exports.numSessions = exports.getSessions = exports.doesSessionExist = exports.lookupSession = exports.Session = exports.Engine = exports.Acceptor = exports.Initiator = exports.DataDictionary = exports.SessionSettings = exports.SessionID = exports.Group = exports.parseMessage = exports.createMessage = exports.Message = void 0;
4
4
  exports.sendToTarget = sendToTarget;
5
5
  /**
6
6
  * `@homaiohq/napi-quickfix` — a modern Node-API binding for the QuickFIX C++
@@ -39,6 +39,8 @@ Object.defineProperty(exports, "numSessions", { enumerable: true, get: function
39
39
  var application_js_1 = require("./application.js");
40
40
  Object.defineProperty(exports, "FixReject", { enumerable: true, get: function () { return application_js_1.FixReject; } });
41
41
  Object.defineProperty(exports, "fixReject", { enumerable: true, get: function () { return application_js_1.fixReject; } });
42
+ var tls_tunnel_js_1 = require("./tls-tunnel.js");
43
+ Object.defineProperty(exports, "createTlsTunnel", { enumerable: true, get: function () { return tls_tunnel_js_1.createTlsTunnel; } });
42
44
  // --- Enums / constants ----------------------------------------------------
43
45
  // Every other value group (ExecType, OrdStatus, SecurityType, ...) is reachable
44
46
  // as `enums.<Field>` / `VALUES.<Field>`, or by name from the
@@ -0,0 +1,143 @@
1
+ /**
2
+ * In-process TLS tunnel for initiators: the stunnel client model.
3
+ *
4
+ * QuickFIX is built without its OpenSSL-based `SSLSocketInitiator` (see
5
+ * CMakeLists.txt), so TLS is terminated in Node instead: the tunnel listens on
6
+ * a loopback port, the plain `FIX::SocketInitiator` connects to it (point
7
+ * `SocketConnectHost`/`SocketConnectPort` at {@link TlsTunnel.host} /
8
+ * {@link TlsTunnel.port}), and every accepted connection is carried to the real
9
+ * counterparty over `tls.connect`.
10
+ */
11
+ import { EventEmitter } from 'node:events';
12
+ import { type Server } from 'node:net';
13
+ import { type CipherNameAndProtocol, type ConnectionOptions, type PeerCertificate } from 'node:tls';
14
+ /** Options for {@link createTlsTunnel}. */
15
+ export interface TlsTunnelOptions {
16
+ /** Host name or IP of the counterparty's TLS endpoint. */
17
+ host: string;
18
+ /** Port of the counterparty's TLS endpoint. */
19
+ port: number;
20
+ /**
21
+ * Passed to `tls.connect()` for every connection (CA, client certificate for
22
+ * mTLS, `minVersion`, ciphers, ...). `servername` (SNI, and the name the
23
+ * server certificate is checked against) defaults to `host` unless `host` is
24
+ * an IP address.
25
+ */
26
+ tls?: Omit<ConnectionOptions, 'host' | 'port' | 'socket' | 'path'>;
27
+ /**
28
+ * Loopback port to listen on. Defaults to `0`, an ephemeral port read back
29
+ * from {@link TlsTunnel.port}. Set it to point a static `.cfg` file at the
30
+ * tunnel, like stunnel's `accept = 127.0.0.1:<port>`.
31
+ */
32
+ localPort?: number;
33
+ }
34
+ /** Details of an established TLS connection, emitted as `'secureConnect'`. */
35
+ export interface TlsTunnelSecureConnectInfo {
36
+ /** Whether the server certificate was verified against the trusted CAs. */
37
+ authorized: boolean;
38
+ /**
39
+ * Why verification failed, as the OpenSSL verification code (e.g.
40
+ * `'UNABLE_TO_VERIFY_LEAF_SIGNATURE'`, the `code` a `'tlsError'` carries
41
+ * otherwise). Only reachable with `rejectUnauthorized: false`.
42
+ */
43
+ authorizationError?: string;
44
+ /** Negotiated protocol, e.g. `'TLSv1.3'`. */
45
+ protocol: string | null;
46
+ /** Negotiated cipher. */
47
+ cipher: CipherNameAndProtocol;
48
+ /** The server's certificate (an empty object if it sent none). */
49
+ peerCertificate: PeerCertificate;
50
+ }
51
+ /**
52
+ * Events emitted by a {@link TlsTunnel}. There is deliberately no `'error'`
53
+ * event: connection failures are reported as `'tlsError'`, so a tunnel without
54
+ * listeners never crashes the process.
55
+ */
56
+ export interface TlsTunnelEvents {
57
+ /** A connection to the counterparty completed its TLS handshake. */
58
+ secureConnect: [info: TlsTunnelSecureConnectInfo];
59
+ /**
60
+ * A connection to the counterparty failed (DNS, TCP, handshake, certificate
61
+ * verification, `tls` options `tls.connect` rejects) or broke. The
62
+ * initiator's loopback connection is closed with it, so QuickFIX reconnects
63
+ * after its `ReconnectInterval`. Also emitted when the loopback server fails
64
+ * to accept a connection (e.g. `EMFILE`).
65
+ */
66
+ tlsError: [error: Error];
67
+ }
68
+ /**
69
+ * A running TLS tunnel, created by {@link createTlsTunnel}.
70
+ *
71
+ * Its handles are unref'd: the tunnel alone never keeps the process alive (a
72
+ * started engine does). Close it after the engine has stopped, so a graceful
73
+ * Logout still reaches the counterparty.
74
+ */
75
+ export declare class TlsTunnel extends EventEmitter {
76
+ #private;
77
+ /** Loopback address the tunnel listens on (`SocketConnectHost`). */
78
+ readonly host = "127.0.0.1";
79
+ /** Loopback port the tunnel listens on (`SocketConnectPort`). */
80
+ readonly port: number;
81
+ /** The counterparty's TLS endpoint. */
82
+ readonly remote: {
83
+ readonly host: string;
84
+ readonly port: number;
85
+ };
86
+ /** @internal Use {@link createTlsTunnel}. */
87
+ constructor(server: Server, port: number, options: TlsTunnelOptions);
88
+ /**
89
+ * Stop listening and destroy every live connection. Idempotent: later calls
90
+ * return the same Promise.
91
+ */
92
+ close(): Promise<void>;
93
+ /** Same as {@link TlsTunnel.close}; enables `await using tunnel = ...`. */
94
+ [Symbol.asyncDispose](): Promise<void>;
95
+ }
96
+ /**
97
+ * Open a TLS tunnel to `options.host`:`options.port` and resolve once it
98
+ * listens on a loopback port.
99
+ *
100
+ * @example
101
+ * ```ts
102
+ * const tunnel = await createTlsTunnel({
103
+ * host: 'fix.broker.com',
104
+ * port: 9443,
105
+ * tls: { ca: readFileSync('broker-ca.pem') },
106
+ * });
107
+ * tunnel.on('tlsError', (err) => console.error('TLS:', err.message));
108
+ * const settings = SessionSettings.fromString(`
109
+ * [DEFAULT]
110
+ * ConnectionType=initiator
111
+ * SocketConnectHost=${tunnel.host}
112
+ * SocketConnectPort=${tunnel.port}
113
+ * ...`);
114
+ * const initiator = new Initiator({ settings, handlers });
115
+ * await initiator.start();
116
+ * // ... later: engine first, so the Logout still crosses the tunnel
117
+ * await initiator.stop();
118
+ * await tunnel.close();
119
+ * ```
120
+ *
121
+ * @returns A Promise that rejects with a `TypeError` if `host`, `port` or
122
+ * `localPort` is invalid, and with the listen error (e.g. `EADDRINUSE` for a
123
+ * fixed `localPort` already in use) if the loopback port cannot be bound.
124
+ */
125
+ export declare function createTlsTunnel(options: TlsTunnelOptions): Promise<TlsTunnel>;
126
+ export interface TlsTunnel {
127
+ /** Register a listener for a tunnel event (typed per {@link TlsTunnelEvents}). */
128
+ on<E extends keyof TlsTunnelEvents>(event: E, listener: (...args: TlsTunnelEvents[E]) => void): this;
129
+ /** Register a one-shot listener for a tunnel event. */
130
+ once<E extends keyof TlsTunnelEvents>(event: E, listener: (...args: TlsTunnelEvents[E]) => void): this;
131
+ /** Alias of {@link TlsTunnel.on}. */
132
+ addListener<E extends keyof TlsTunnelEvents>(event: E, listener: (...args: TlsTunnelEvents[E]) => void): this;
133
+ /** Remove a previously registered listener. */
134
+ off<E extends keyof TlsTunnelEvents>(event: E, listener: (...args: TlsTunnelEvents[E]) => void): this;
135
+ /** Alias of {@link TlsTunnel.off}. */
136
+ removeListener<E extends keyof TlsTunnelEvents>(event: E, listener: (...args: TlsTunnelEvents[E]) => void): this;
137
+ /** Remove all listeners (optionally for a single event). */
138
+ removeAllListeners<E extends keyof TlsTunnelEvents>(event?: E): this;
139
+ /** Emit a tunnel event with its typed arguments. */
140
+ emit<E extends keyof TlsTunnelEvents>(event: E, ...args: TlsTunnelEvents[E]): boolean;
141
+ /** List the listeners registered for an event. */
142
+ listeners<E extends keyof TlsTunnelEvents>(event: E): Array<(...args: TlsTunnelEvents[E]) => void>;
143
+ }
@@ -0,0 +1,261 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TlsTunnel = void 0;
4
+ exports.createTlsTunnel = createTlsTunnel;
5
+ /**
6
+ * In-process TLS tunnel for initiators: the stunnel client model.
7
+ *
8
+ * QuickFIX is built without its OpenSSL-based `SSLSocketInitiator` (see
9
+ * CMakeLists.txt), so TLS is terminated in Node instead: the tunnel listens on
10
+ * a loopback port, the plain `FIX::SocketInitiator` connects to it (point
11
+ * `SocketConnectHost`/`SocketConnectPort` at {@link TlsTunnel.host} /
12
+ * {@link TlsTunnel.port}), and every accepted connection is carried to the real
13
+ * counterparty over `tls.connect`.
14
+ */
15
+ const node_events_1 = require("node:events");
16
+ const node_net_1 = require("node:net");
17
+ const node_tls_1 = require("node:tls");
18
+ const LOOPBACK = '127.0.0.1';
19
+ /** How long a connection may take to wind down once either side has ended. */
20
+ const DRAIN_TIMEOUT_MS = 5000;
21
+ /**
22
+ * A running TLS tunnel, created by {@link createTlsTunnel}.
23
+ *
24
+ * Its handles are unref'd: the tunnel alone never keeps the process alive (a
25
+ * started engine does). Close it after the engine has stopped, so a graceful
26
+ * Logout still reaches the counterparty.
27
+ */
28
+ class TlsTunnel extends node_events_1.EventEmitter {
29
+ /** Loopback address the tunnel listens on (`SocketConnectHost`). */
30
+ host = LOOPBACK;
31
+ /** Loopback port the tunnel listens on (`SocketConnectPort`). */
32
+ port;
33
+ /** The counterparty's TLS endpoint. */
34
+ remote;
35
+ #server;
36
+ #tls;
37
+ #pairs = new Set();
38
+ #closing;
39
+ /** @internal Use {@link createTlsTunnel}. */
40
+ constructor(server, port, options) {
41
+ super();
42
+ this.#server = server;
43
+ this.port = port;
44
+ this.remote = Object.freeze({ host: options.host, port: options.port });
45
+ // `socket` and `path` are excluded by the types only: drop them for JS
46
+ // callers too, or every connection would go to them instead of host:port.
47
+ const { socket: _socket, path: _path, ...tls } = (options.tls ?? {});
48
+ const servername = tls.servername ?? ((0, node_net_1.isIP)(options.host) === 0 ? options.host : undefined);
49
+ this.#tls = {
50
+ ...tls,
51
+ ...(servername === undefined ? {} : { servername }),
52
+ host: options.host,
53
+ port: options.port,
54
+ };
55
+ server.on('connection', (local) => this.#onConnection(local));
56
+ // A failed accept() (EMFILE, ENFILE, ...) is emitted on the server: without
57
+ // a listener it would be thrown.
58
+ server.on('error', (err) => {
59
+ if (!this.#closing)
60
+ this.emit('tlsError', err);
61
+ });
62
+ }
63
+ #onConnection(local) {
64
+ if (this.#closing) {
65
+ local.destroy();
66
+ return;
67
+ }
68
+ local.unref();
69
+ // QuickFIX writes its Logon as soon as it connects: hold it until the
70
+ // handshake is done and the pipe below takes over. It is read rather than
71
+ // left in a paused socket, which is not read at all: the engine's EOF or
72
+ // reset would go unseen, and a handshake completing after the engine gave
73
+ // up would still deliver its stale Logon.
74
+ const pending = [];
75
+ const hold = (chunk) => pending.push(chunk);
76
+ local.on('data', hold);
77
+ // tls.connect throws synchronously on options it cannot build a secure
78
+ // context from (bad PEM, unknown minVersion, ...): report it like any other
79
+ // connection failure instead of letting it escape the 'connection' handler.
80
+ let remote;
81
+ try {
82
+ remote = (0, node_tls_1.connect)(this.#tls);
83
+ }
84
+ catch (err) {
85
+ local.on('error', () => { });
86
+ local.destroy();
87
+ this.emit('tlsError', err instanceof Error ? err : new Error(String(err)));
88
+ return;
89
+ }
90
+ remote.unref();
91
+ const pair = { local, remote };
92
+ this.#pairs.add(pair);
93
+ // Once piped, a side that ends or closes is wound down gracefully on the
94
+ // other: it is ended, so the data still queued in the tunnel (a Logout, the
95
+ // TLS close_notify) is delivered rather than reset, and drained of what it
96
+ // still receives so it sees its peer's EOF. Anything still open
97
+ // DRAIN_TIMEOUT_MS after the first EOF is destroyed. Before the handshake
98
+ // completes there is nothing to deliver: an EOF or close drops both sides,
99
+ // and QuickFIX reconnects on its own schedule.
100
+ let piped = false;
101
+ let open = 2;
102
+ let drainTimer;
103
+ const linger = () => {
104
+ drainTimer ??= setTimeout(() => {
105
+ local.destroy();
106
+ remote.destroy();
107
+ }, DRAIN_TIMEOUT_MS).unref();
108
+ };
109
+ const onClose = (closed, other) => () => {
110
+ if (--open === 0) {
111
+ clearTimeout(drainTimer);
112
+ this.#pairs.delete(pair);
113
+ }
114
+ else if (!piped) {
115
+ other.destroy();
116
+ }
117
+ else {
118
+ other.unpipe(closed);
119
+ other.resume();
120
+ other.end();
121
+ linger();
122
+ }
123
+ };
124
+ local.on('error', () => {
125
+ // ECONNRESET and the like from the engine's side: nothing to report.
126
+ });
127
+ remote.on('error', (err) => {
128
+ if (!this.#closing)
129
+ this.emit('tlsError', err);
130
+ });
131
+ local.once('close', onClose(local, remote));
132
+ remote.once('close', onClose(remote, local));
133
+ local.once('end', () => {
134
+ if (piped) {
135
+ linger();
136
+ }
137
+ else {
138
+ local.destroy();
139
+ remote.destroy();
140
+ }
141
+ });
142
+ remote.once('secureConnect', () => {
143
+ local.setNoDelay(true);
144
+ remote.setNoDelay(true);
145
+ // Each side's EOF ends the other. The loopback socket is half-open, so
146
+ // after QuickFIX's EOF the counterparty's last bytes still reach it; the
147
+ // TLS socket ends itself on the counterparty's EOF, so what QuickFIX
148
+ // still sends is discarded (its peer has gone).
149
+ local.off('data', hold);
150
+ for (const chunk of pending)
151
+ remote.write(chunk);
152
+ pending.length = 0;
153
+ local.pipe(remote);
154
+ remote.pipe(local);
155
+ remote.once('end', () => {
156
+ local.unpipe(remote);
157
+ local.resume();
158
+ linger();
159
+ });
160
+ piped = true;
161
+ // Typed as an Error, but a string code at runtime, and null once the
162
+ // certificate verified.
163
+ const authorizationError = remote.authorizationError;
164
+ this.emit('secureConnect', {
165
+ authorized: remote.authorized,
166
+ ...(authorizationError ? { authorizationError } : {}),
167
+ protocol: remote.getProtocol(),
168
+ cipher: remote.getCipher(),
169
+ peerCertificate: remote.getPeerCertificate(),
170
+ });
171
+ });
172
+ }
173
+ /**
174
+ * Stop listening and destroy every live connection. Idempotent: later calls
175
+ * return the same Promise.
176
+ */
177
+ close() {
178
+ this.#closing ??= new Promise((resolve) => {
179
+ for (const { local, remote } of this.#pairs) {
180
+ local.destroy();
181
+ remote.destroy();
182
+ }
183
+ this.#pairs.clear();
184
+ // The only possible error is "not running", which still means closed.
185
+ this.#server.close(() => resolve());
186
+ });
187
+ return this.#closing;
188
+ }
189
+ /** Same as {@link TlsTunnel.close}; enables `await using tunnel = ...`. */
190
+ [Symbol.asyncDispose]() {
191
+ return this.close();
192
+ }
193
+ }
194
+ exports.TlsTunnel = TlsTunnel;
195
+ /**
196
+ * Open a TLS tunnel to `options.host`:`options.port` and resolve once it
197
+ * listens on a loopback port.
198
+ *
199
+ * @example
200
+ * ```ts
201
+ * const tunnel = await createTlsTunnel({
202
+ * host: 'fix.broker.com',
203
+ * port: 9443,
204
+ * tls: { ca: readFileSync('broker-ca.pem') },
205
+ * });
206
+ * tunnel.on('tlsError', (err) => console.error('TLS:', err.message));
207
+ * const settings = SessionSettings.fromString(`
208
+ * [DEFAULT]
209
+ * ConnectionType=initiator
210
+ * SocketConnectHost=${tunnel.host}
211
+ * SocketConnectPort=${tunnel.port}
212
+ * ...`);
213
+ * const initiator = new Initiator({ settings, handlers });
214
+ * await initiator.start();
215
+ * // ... later: engine first, so the Logout still crosses the tunnel
216
+ * await initiator.stop();
217
+ * await tunnel.close();
218
+ * ```
219
+ *
220
+ * @returns A Promise that rejects with a `TypeError` if `host`, `port` or
221
+ * `localPort` is invalid, and with the listen error (e.g. `EADDRINUSE` for a
222
+ * fixed `localPort` already in use) if the loopback port cannot be bound.
223
+ */
224
+ async function createTlsTunnel(options) {
225
+ if (typeof options !== 'object' || options === null) {
226
+ throw new TypeError('createTlsTunnel({ host, port, tls?, localPort? })');
227
+ }
228
+ if (typeof options.host !== 'string' || options.host === '') {
229
+ throw new TypeError('host must be a non-empty string');
230
+ }
231
+ if (!isPort(options.port, 1)) {
232
+ throw new TypeError('port must be an integer from 1 to 65535');
233
+ }
234
+ const localPort = options.localPort ?? 0;
235
+ if (!isPort(localPort, 0)) {
236
+ throw new TypeError('localPort must be an integer from 0 to 65535');
237
+ }
238
+ // Half-open, so QuickFIX's EOF does not end the loopback socket before the
239
+ // counterparty's last bytes have crossed it (see #onConnection).
240
+ const server = (0, node_net_1.createServer)({ allowHalfOpen: true });
241
+ await new Promise((resolve, reject) => {
242
+ const onError = (err) => {
243
+ server.off('listening', onListening);
244
+ reject(err);
245
+ };
246
+ const onListening = () => {
247
+ server.off('error', onError);
248
+ resolve();
249
+ };
250
+ server.once('error', onError);
251
+ server.once('listening', onListening);
252
+ server.listen(localPort, LOOPBACK);
253
+ });
254
+ server.unref();
255
+ const addr = server.address();
256
+ const port = typeof addr === 'object' && addr ? addr.port : localPort;
257
+ return new TlsTunnel(server, port, options);
258
+ }
259
+ function isPort(value, min) {
260
+ return Number.isInteger(value) && value >= min && value <= 65535;
261
+ }
@@ -14,6 +14,8 @@ export { Session, lookupSession, doesSessionExist, getSessions, numSessions } fr
14
14
  export { FixReject, fixReject } from './application.js';
15
15
  export type { ApplicationHandlers, FixRejectKind } from './application.js';
16
16
  export type { QuickFixError } from './native.js';
17
+ export { createTlsTunnel } from './tls-tunnel.js';
18
+ export type { TlsTunnel, TlsTunnelOptions, TlsTunnelEvents, TlsTunnelSecureConnectInfo, } from './tls-tunnel.js';
17
19
  export { enums, FIELD, VALUES, MsgType, Side, OrdType, TimeInForce } from './enums.js';
18
20
  export type { Enums, EnumGroup, FieldName, ValueGroups, ValueGroupName } from './enums.js';
19
21
  /**
package/dist/esm/index.js CHANGED
@@ -18,6 +18,7 @@ export { Acceptor } from './acceptor.js';
18
18
  export { Engine } from './engine.js';
19
19
  export { Session, lookupSession, doesSessionExist, getSessions, numSessions } from './session.js';
20
20
  export { FixReject, fixReject } from './application.js';
21
+ export { createTlsTunnel } from './tls-tunnel.js';
21
22
  // --- Enums / constants ----------------------------------------------------
22
23
  // Every other value group (ExecType, OrdStatus, SecurityType, ...) is reachable
23
24
  // as `enums.<Field>` / `VALUES.<Field>`, or by name from the
@@ -0,0 +1,143 @@
1
+ /**
2
+ * In-process TLS tunnel for initiators: the stunnel client model.
3
+ *
4
+ * QuickFIX is built without its OpenSSL-based `SSLSocketInitiator` (see
5
+ * CMakeLists.txt), so TLS is terminated in Node instead: the tunnel listens on
6
+ * a loopback port, the plain `FIX::SocketInitiator` connects to it (point
7
+ * `SocketConnectHost`/`SocketConnectPort` at {@link TlsTunnel.host} /
8
+ * {@link TlsTunnel.port}), and every accepted connection is carried to the real
9
+ * counterparty over `tls.connect`.
10
+ */
11
+ import { EventEmitter } from 'node:events';
12
+ import { type Server } from 'node:net';
13
+ import { type CipherNameAndProtocol, type ConnectionOptions, type PeerCertificate } from 'node:tls';
14
+ /** Options for {@link createTlsTunnel}. */
15
+ export interface TlsTunnelOptions {
16
+ /** Host name or IP of the counterparty's TLS endpoint. */
17
+ host: string;
18
+ /** Port of the counterparty's TLS endpoint. */
19
+ port: number;
20
+ /**
21
+ * Passed to `tls.connect()` for every connection (CA, client certificate for
22
+ * mTLS, `minVersion`, ciphers, ...). `servername` (SNI, and the name the
23
+ * server certificate is checked against) defaults to `host` unless `host` is
24
+ * an IP address.
25
+ */
26
+ tls?: Omit<ConnectionOptions, 'host' | 'port' | 'socket' | 'path'>;
27
+ /**
28
+ * Loopback port to listen on. Defaults to `0`, an ephemeral port read back
29
+ * from {@link TlsTunnel.port}. Set it to point a static `.cfg` file at the
30
+ * tunnel, like stunnel's `accept = 127.0.0.1:<port>`.
31
+ */
32
+ localPort?: number;
33
+ }
34
+ /** Details of an established TLS connection, emitted as `'secureConnect'`. */
35
+ export interface TlsTunnelSecureConnectInfo {
36
+ /** Whether the server certificate was verified against the trusted CAs. */
37
+ authorized: boolean;
38
+ /**
39
+ * Why verification failed, as the OpenSSL verification code (e.g.
40
+ * `'UNABLE_TO_VERIFY_LEAF_SIGNATURE'`, the `code` a `'tlsError'` carries
41
+ * otherwise). Only reachable with `rejectUnauthorized: false`.
42
+ */
43
+ authorizationError?: string;
44
+ /** Negotiated protocol, e.g. `'TLSv1.3'`. */
45
+ protocol: string | null;
46
+ /** Negotiated cipher. */
47
+ cipher: CipherNameAndProtocol;
48
+ /** The server's certificate (an empty object if it sent none). */
49
+ peerCertificate: PeerCertificate;
50
+ }
51
+ /**
52
+ * Events emitted by a {@link TlsTunnel}. There is deliberately no `'error'`
53
+ * event: connection failures are reported as `'tlsError'`, so a tunnel without
54
+ * listeners never crashes the process.
55
+ */
56
+ export interface TlsTunnelEvents {
57
+ /** A connection to the counterparty completed its TLS handshake. */
58
+ secureConnect: [info: TlsTunnelSecureConnectInfo];
59
+ /**
60
+ * A connection to the counterparty failed (DNS, TCP, handshake, certificate
61
+ * verification, `tls` options `tls.connect` rejects) or broke. The
62
+ * initiator's loopback connection is closed with it, so QuickFIX reconnects
63
+ * after its `ReconnectInterval`. Also emitted when the loopback server fails
64
+ * to accept a connection (e.g. `EMFILE`).
65
+ */
66
+ tlsError: [error: Error];
67
+ }
68
+ /**
69
+ * A running TLS tunnel, created by {@link createTlsTunnel}.
70
+ *
71
+ * Its handles are unref'd: the tunnel alone never keeps the process alive (a
72
+ * started engine does). Close it after the engine has stopped, so a graceful
73
+ * Logout still reaches the counterparty.
74
+ */
75
+ export declare class TlsTunnel extends EventEmitter {
76
+ #private;
77
+ /** Loopback address the tunnel listens on (`SocketConnectHost`). */
78
+ readonly host = "127.0.0.1";
79
+ /** Loopback port the tunnel listens on (`SocketConnectPort`). */
80
+ readonly port: number;
81
+ /** The counterparty's TLS endpoint. */
82
+ readonly remote: {
83
+ readonly host: string;
84
+ readonly port: number;
85
+ };
86
+ /** @internal Use {@link createTlsTunnel}. */
87
+ constructor(server: Server, port: number, options: TlsTunnelOptions);
88
+ /**
89
+ * Stop listening and destroy every live connection. Idempotent: later calls
90
+ * return the same Promise.
91
+ */
92
+ close(): Promise<void>;
93
+ /** Same as {@link TlsTunnel.close}; enables `await using tunnel = ...`. */
94
+ [Symbol.asyncDispose](): Promise<void>;
95
+ }
96
+ /**
97
+ * Open a TLS tunnel to `options.host`:`options.port` and resolve once it
98
+ * listens on a loopback port.
99
+ *
100
+ * @example
101
+ * ```ts
102
+ * const tunnel = await createTlsTunnel({
103
+ * host: 'fix.broker.com',
104
+ * port: 9443,
105
+ * tls: { ca: readFileSync('broker-ca.pem') },
106
+ * });
107
+ * tunnel.on('tlsError', (err) => console.error('TLS:', err.message));
108
+ * const settings = SessionSettings.fromString(`
109
+ * [DEFAULT]
110
+ * ConnectionType=initiator
111
+ * SocketConnectHost=${tunnel.host}
112
+ * SocketConnectPort=${tunnel.port}
113
+ * ...`);
114
+ * const initiator = new Initiator({ settings, handlers });
115
+ * await initiator.start();
116
+ * // ... later: engine first, so the Logout still crosses the tunnel
117
+ * await initiator.stop();
118
+ * await tunnel.close();
119
+ * ```
120
+ *
121
+ * @returns A Promise that rejects with a `TypeError` if `host`, `port` or
122
+ * `localPort` is invalid, and with the listen error (e.g. `EADDRINUSE` for a
123
+ * fixed `localPort` already in use) if the loopback port cannot be bound.
124
+ */
125
+ export declare function createTlsTunnel(options: TlsTunnelOptions): Promise<TlsTunnel>;
126
+ export interface TlsTunnel {
127
+ /** Register a listener for a tunnel event (typed per {@link TlsTunnelEvents}). */
128
+ on<E extends keyof TlsTunnelEvents>(event: E, listener: (...args: TlsTunnelEvents[E]) => void): this;
129
+ /** Register a one-shot listener for a tunnel event. */
130
+ once<E extends keyof TlsTunnelEvents>(event: E, listener: (...args: TlsTunnelEvents[E]) => void): this;
131
+ /** Alias of {@link TlsTunnel.on}. */
132
+ addListener<E extends keyof TlsTunnelEvents>(event: E, listener: (...args: TlsTunnelEvents[E]) => void): this;
133
+ /** Remove a previously registered listener. */
134
+ off<E extends keyof TlsTunnelEvents>(event: E, listener: (...args: TlsTunnelEvents[E]) => void): this;
135
+ /** Alias of {@link TlsTunnel.off}. */
136
+ removeListener<E extends keyof TlsTunnelEvents>(event: E, listener: (...args: TlsTunnelEvents[E]) => void): this;
137
+ /** Remove all listeners (optionally for a single event). */
138
+ removeAllListeners<E extends keyof TlsTunnelEvents>(event?: E): this;
139
+ /** Emit a tunnel event with its typed arguments. */
140
+ emit<E extends keyof TlsTunnelEvents>(event: E, ...args: TlsTunnelEvents[E]): boolean;
141
+ /** List the listeners registered for an event. */
142
+ listeners<E extends keyof TlsTunnelEvents>(event: E): Array<(...args: TlsTunnelEvents[E]) => void>;
143
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * In-process TLS tunnel for initiators: the stunnel client model.
3
+ *
4
+ * QuickFIX is built without its OpenSSL-based `SSLSocketInitiator` (see
5
+ * CMakeLists.txt), so TLS is terminated in Node instead: the tunnel listens on
6
+ * a loopback port, the plain `FIX::SocketInitiator` connects to it (point
7
+ * `SocketConnectHost`/`SocketConnectPort` at {@link TlsTunnel.host} /
8
+ * {@link TlsTunnel.port}), and every accepted connection is carried to the real
9
+ * counterparty over `tls.connect`.
10
+ */
11
+ import { EventEmitter } from 'node:events';
12
+ import { createServer, isIP } from 'node:net';
13
+ import { connect as tlsConnect, } from 'node:tls';
14
+ const LOOPBACK = '127.0.0.1';
15
+ /** How long a connection may take to wind down once either side has ended. */
16
+ const DRAIN_TIMEOUT_MS = 5000;
17
+ /**
18
+ * A running TLS tunnel, created by {@link createTlsTunnel}.
19
+ *
20
+ * Its handles are unref'd: the tunnel alone never keeps the process alive (a
21
+ * started engine does). Close it after the engine has stopped, so a graceful
22
+ * Logout still reaches the counterparty.
23
+ */
24
+ export class TlsTunnel extends EventEmitter {
25
+ /** Loopback address the tunnel listens on (`SocketConnectHost`). */
26
+ host = LOOPBACK;
27
+ /** Loopback port the tunnel listens on (`SocketConnectPort`). */
28
+ port;
29
+ /** The counterparty's TLS endpoint. */
30
+ remote;
31
+ #server;
32
+ #tls;
33
+ #pairs = new Set();
34
+ #closing;
35
+ /** @internal Use {@link createTlsTunnel}. */
36
+ constructor(server, port, options) {
37
+ super();
38
+ this.#server = server;
39
+ this.port = port;
40
+ this.remote = Object.freeze({ host: options.host, port: options.port });
41
+ // `socket` and `path` are excluded by the types only: drop them for JS
42
+ // callers too, or every connection would go to them instead of host:port.
43
+ const { socket: _socket, path: _path, ...tls } = (options.tls ?? {});
44
+ const servername = tls.servername ?? (isIP(options.host) === 0 ? options.host : undefined);
45
+ this.#tls = {
46
+ ...tls,
47
+ ...(servername === undefined ? {} : { servername }),
48
+ host: options.host,
49
+ port: options.port,
50
+ };
51
+ server.on('connection', (local) => this.#onConnection(local));
52
+ // A failed accept() (EMFILE, ENFILE, ...) is emitted on the server: without
53
+ // a listener it would be thrown.
54
+ server.on('error', (err) => {
55
+ if (!this.#closing)
56
+ this.emit('tlsError', err);
57
+ });
58
+ }
59
+ #onConnection(local) {
60
+ if (this.#closing) {
61
+ local.destroy();
62
+ return;
63
+ }
64
+ local.unref();
65
+ // QuickFIX writes its Logon as soon as it connects: hold it until the
66
+ // handshake is done and the pipe below takes over. It is read rather than
67
+ // left in a paused socket, which is not read at all: the engine's EOF or
68
+ // reset would go unseen, and a handshake completing after the engine gave
69
+ // up would still deliver its stale Logon.
70
+ const pending = [];
71
+ const hold = (chunk) => pending.push(chunk);
72
+ local.on('data', hold);
73
+ // tls.connect throws synchronously on options it cannot build a secure
74
+ // context from (bad PEM, unknown minVersion, ...): report it like any other
75
+ // connection failure instead of letting it escape the 'connection' handler.
76
+ let remote;
77
+ try {
78
+ remote = tlsConnect(this.#tls);
79
+ }
80
+ catch (err) {
81
+ local.on('error', () => { });
82
+ local.destroy();
83
+ this.emit('tlsError', err instanceof Error ? err : new Error(String(err)));
84
+ return;
85
+ }
86
+ remote.unref();
87
+ const pair = { local, remote };
88
+ this.#pairs.add(pair);
89
+ // Once piped, a side that ends or closes is wound down gracefully on the
90
+ // other: it is ended, so the data still queued in the tunnel (a Logout, the
91
+ // TLS close_notify) is delivered rather than reset, and drained of what it
92
+ // still receives so it sees its peer's EOF. Anything still open
93
+ // DRAIN_TIMEOUT_MS after the first EOF is destroyed. Before the handshake
94
+ // completes there is nothing to deliver: an EOF or close drops both sides,
95
+ // and QuickFIX reconnects on its own schedule.
96
+ let piped = false;
97
+ let open = 2;
98
+ let drainTimer;
99
+ const linger = () => {
100
+ drainTimer ??= setTimeout(() => {
101
+ local.destroy();
102
+ remote.destroy();
103
+ }, DRAIN_TIMEOUT_MS).unref();
104
+ };
105
+ const onClose = (closed, other) => () => {
106
+ if (--open === 0) {
107
+ clearTimeout(drainTimer);
108
+ this.#pairs.delete(pair);
109
+ }
110
+ else if (!piped) {
111
+ other.destroy();
112
+ }
113
+ else {
114
+ other.unpipe(closed);
115
+ other.resume();
116
+ other.end();
117
+ linger();
118
+ }
119
+ };
120
+ local.on('error', () => {
121
+ // ECONNRESET and the like from the engine's side: nothing to report.
122
+ });
123
+ remote.on('error', (err) => {
124
+ if (!this.#closing)
125
+ this.emit('tlsError', err);
126
+ });
127
+ local.once('close', onClose(local, remote));
128
+ remote.once('close', onClose(remote, local));
129
+ local.once('end', () => {
130
+ if (piped) {
131
+ linger();
132
+ }
133
+ else {
134
+ local.destroy();
135
+ remote.destroy();
136
+ }
137
+ });
138
+ remote.once('secureConnect', () => {
139
+ local.setNoDelay(true);
140
+ remote.setNoDelay(true);
141
+ // Each side's EOF ends the other. The loopback socket is half-open, so
142
+ // after QuickFIX's EOF the counterparty's last bytes still reach it; the
143
+ // TLS socket ends itself on the counterparty's EOF, so what QuickFIX
144
+ // still sends is discarded (its peer has gone).
145
+ local.off('data', hold);
146
+ for (const chunk of pending)
147
+ remote.write(chunk);
148
+ pending.length = 0;
149
+ local.pipe(remote);
150
+ remote.pipe(local);
151
+ remote.once('end', () => {
152
+ local.unpipe(remote);
153
+ local.resume();
154
+ linger();
155
+ });
156
+ piped = true;
157
+ // Typed as an Error, but a string code at runtime, and null once the
158
+ // certificate verified.
159
+ const authorizationError = remote.authorizationError;
160
+ this.emit('secureConnect', {
161
+ authorized: remote.authorized,
162
+ ...(authorizationError ? { authorizationError } : {}),
163
+ protocol: remote.getProtocol(),
164
+ cipher: remote.getCipher(),
165
+ peerCertificate: remote.getPeerCertificate(),
166
+ });
167
+ });
168
+ }
169
+ /**
170
+ * Stop listening and destroy every live connection. Idempotent: later calls
171
+ * return the same Promise.
172
+ */
173
+ close() {
174
+ this.#closing ??= new Promise((resolve) => {
175
+ for (const { local, remote } of this.#pairs) {
176
+ local.destroy();
177
+ remote.destroy();
178
+ }
179
+ this.#pairs.clear();
180
+ // The only possible error is "not running", which still means closed.
181
+ this.#server.close(() => resolve());
182
+ });
183
+ return this.#closing;
184
+ }
185
+ /** Same as {@link TlsTunnel.close}; enables `await using tunnel = ...`. */
186
+ [Symbol.asyncDispose]() {
187
+ return this.close();
188
+ }
189
+ }
190
+ /**
191
+ * Open a TLS tunnel to `options.host`:`options.port` and resolve once it
192
+ * listens on a loopback port.
193
+ *
194
+ * @example
195
+ * ```ts
196
+ * const tunnel = await createTlsTunnel({
197
+ * host: 'fix.broker.com',
198
+ * port: 9443,
199
+ * tls: { ca: readFileSync('broker-ca.pem') },
200
+ * });
201
+ * tunnel.on('tlsError', (err) => console.error('TLS:', err.message));
202
+ * const settings = SessionSettings.fromString(`
203
+ * [DEFAULT]
204
+ * ConnectionType=initiator
205
+ * SocketConnectHost=${tunnel.host}
206
+ * SocketConnectPort=${tunnel.port}
207
+ * ...`);
208
+ * const initiator = new Initiator({ settings, handlers });
209
+ * await initiator.start();
210
+ * // ... later: engine first, so the Logout still crosses the tunnel
211
+ * await initiator.stop();
212
+ * await tunnel.close();
213
+ * ```
214
+ *
215
+ * @returns A Promise that rejects with a `TypeError` if `host`, `port` or
216
+ * `localPort` is invalid, and with the listen error (e.g. `EADDRINUSE` for a
217
+ * fixed `localPort` already in use) if the loopback port cannot be bound.
218
+ */
219
+ export async function createTlsTunnel(options) {
220
+ if (typeof options !== 'object' || options === null) {
221
+ throw new TypeError('createTlsTunnel({ host, port, tls?, localPort? })');
222
+ }
223
+ if (typeof options.host !== 'string' || options.host === '') {
224
+ throw new TypeError('host must be a non-empty string');
225
+ }
226
+ if (!isPort(options.port, 1)) {
227
+ throw new TypeError('port must be an integer from 1 to 65535');
228
+ }
229
+ const localPort = options.localPort ?? 0;
230
+ if (!isPort(localPort, 0)) {
231
+ throw new TypeError('localPort must be an integer from 0 to 65535');
232
+ }
233
+ // Half-open, so QuickFIX's EOF does not end the loopback socket before the
234
+ // counterparty's last bytes have crossed it (see #onConnection).
235
+ const server = createServer({ allowHalfOpen: true });
236
+ await new Promise((resolve, reject) => {
237
+ const onError = (err) => {
238
+ server.off('listening', onListening);
239
+ reject(err);
240
+ };
241
+ const onListening = () => {
242
+ server.off('error', onError);
243
+ resolve();
244
+ };
245
+ server.once('error', onError);
246
+ server.once('listening', onListening);
247
+ server.listen(localPort, LOOPBACK);
248
+ });
249
+ server.unref();
250
+ const addr = server.address();
251
+ const port = typeof addr === 'object' && addr ? addr.port : localPort;
252
+ return new TlsTunnel(server, port, options);
253
+ }
254
+ function isPort(value, min) {
255
+ return Number.isInteger(value) && value >= min && value <= 65535;
256
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homaiohq/napi-quickfix",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Modern Node-API (N-API) C++ binding for the QuickFIX FIX-protocol engine.",
5
5
  "license": "MIT",
6
6
  "repository": {
Binary file
Binary file