@homaiohq/napi-quickfix 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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'
@@ -185,6 +185,14 @@ void AcceptorWrap::DestroyStoppedEngine() {
185
185
  SessionOpGate::Unregister(sessionIDs_, gate_.get());
186
186
  acceptor_.reset();
187
187
  gate_->Unfreeze();
188
+ // The engine is gone, so nothing will call the handlers again: drop the
189
+ // bridge's reference to them. It is what kept this wrap reachable from the
190
+ // user's Engine object (the handlers close over it) and so made the wrap
191
+ // -- and the Engine, its listeners and the parsed dictionaries -- immortal.
192
+ // On the graceful path this runs from the barrier queued behind every
193
+ // callback stop() fired, so none of them is lost; on the force path the
194
+ // TSFN was aborted first, discarding the queue.
195
+ if (bridge_) bridge_->DropHandlers();
188
196
  }
189
197
 
190
198
  Napi::Value AcceptorWrap::Start(const Napi::CallbackInfo& info) {
@@ -248,9 +256,13 @@ Napi::Value AcceptorWrap::Stop(const Napi::CallbackInfo& info) {
248
256
  return deferred.Promise();
249
257
  }
250
258
  if (!acceptor_) {
251
- // Already torn down: just release the TSFN and resolve.
259
+ // Already torn down: just release the TSFN, drop the handlers (nothing
260
+ // can call them without an engine) and resolve.
252
261
  stopped_ = true;
253
- if (bridge_) bridge_->Release();
262
+ if (bridge_) {
263
+ bridge_->Release();
264
+ bridge_->DropHandlers();
265
+ }
254
266
  deferred.Resolve(env.Undefined());
255
267
  return deferred.Promise();
256
268
  }
@@ -323,8 +335,15 @@ Napi::Value AcceptorWrap::Stop(const Napi::CallbackInfo& info) {
323
335
  self->gate_->Unfreeze();
324
336
  if (bridge) {
325
337
  if (force) {
338
+ // The abort discarded the queue: no callback can reach JS
339
+ // any more, so the handlers can go now.
326
340
  bridge->Abort();
341
+ bridge->DropHandlers();
327
342
  } else {
343
+ // Callbacks already queued (a trailing 'logout') are still
344
+ // dispatched after the release, and read the handlers: keep
345
+ // them. The retried stop() drops them in
346
+ // DestroyStoppedEngine().
328
347
  bridge->Release();
329
348
  }
330
349
  }
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.1";
34
34
 
35
35
  Napi::Value Version(const Napi::CallbackInfo& info) {
36
36
  return Napi::String::New(info.Env(), kAddonVersion);
@@ -167,6 +167,18 @@ void ApplicationBridge::WakeAllPending() {
167
167
  }
168
168
  }
169
169
 
170
+ void ApplicationBridge::DropHandlers() {
171
+ if (!handlers_.IsEmpty()) {
172
+ handlers_.Reset();
173
+ }
174
+ // The dictionaries pin a parsed FIX spec per session (a copy of the
175
+ // session's DataDictionaryProvider, which shares the dictionary's
176
+ // ownership); the sessions that could still want them are gone. Locked
177
+ // for form: the network threads that fill the map are joined by now.
178
+ std::lock_guard<std::mutex> lock(dictionariesMtx_);
179
+ dictionaries_.clear();
180
+ }
181
+
170
182
  void ApplicationBridge::Ref(Napi::Env env) {
171
183
  if (!released_) {
172
184
  tsfn_.Ref(env);
@@ -125,6 +125,13 @@ class ApplicationBridge : public FIX::Application {
125
125
  void Ref(Napi::Env env);
126
126
  void Unref(Napi::Env env);
127
127
 
128
+ // Drop what the bridge roots on the engine's behalf: the handlers object
129
+ // and the per-session dictionaries (see handlers_ below). JS thread only;
130
+ // call it once the engine is gone, or once no queued callback can reach JS
131
+ // any more (after Abort()). A callback dispatched afterwards passes
132
+ // through as if no handler were installed.
133
+ void DropHandlers();
134
+
128
135
  // Queue `fn` to run on the JS thread AFTER every callback already queued
129
136
  // (the TSFN queue is FIFO), and keep the event loop alive until it has run.
130
137
  // JS thread only; call it before Release()/Abort(). Returns false, without
@@ -167,6 +174,14 @@ class ApplicationBridge : public FIX::Application {
167
174
  std::map<FIX::SessionID, std::shared_ptr<const FIX::DataDictionary>>
168
175
  dictionaries_;
169
176
 
177
+ // The user's handlers object, held strongly for the engine's whole life so
178
+ // a started engine stays reachable through them (the wrapper's handlers
179
+ // close over the Engine), like a net.Server does through its listeners.
180
+ // That makes a reference cycle through native memory the GC cannot see:
181
+ // wrap -> bridge -> handlers -> Engine -> wrap. The wraps break it with
182
+ // DropHandlers() as soon as the engine is destroyed (stop() settled, or
183
+ // the force/failed-stop paths that abort the TSFN); the destructor's Reset
184
+ // is only the fallback for a wrap collected without stop().
170
185
  Napi::ObjectReference handlers_;
171
186
  TSFN tsfn_;
172
187
  bool released_ = false;
@@ -207,6 +207,14 @@ void InitiatorWrap::DestroyStoppedEngine() {
207
207
  SessionOpGate::Unregister(sessionIDs_, gate_.get());
208
208
  initiator_.reset();
209
209
  gate_->Unfreeze();
210
+ // The engine is gone, so nothing will call the handlers again: drop the
211
+ // bridge's reference to them. It is what kept this wrap reachable from the
212
+ // user's Engine object (the handlers close over it) and so made the wrap
213
+ // -- and the Engine, its listeners and the parsed dictionaries -- immortal.
214
+ // On the graceful path this runs from the barrier queued behind every
215
+ // callback stop() fired, so none of them is lost; on the force path the
216
+ // TSFN was aborted first, discarding the queue.
217
+ if (bridge_) bridge_->DropHandlers();
210
218
  }
211
219
 
212
220
  Napi::Value InitiatorWrap::Start(const Napi::CallbackInfo& info) {
@@ -272,9 +280,13 @@ Napi::Value InitiatorWrap::Stop(const Napi::CallbackInfo& info) {
272
280
  return deferred.Promise();
273
281
  }
274
282
  if (!initiator_) {
275
- // Already torn down: just release the TSFN and resolve.
283
+ // Already torn down: just release the TSFN, drop the handlers (nothing
284
+ // can call them without an engine) and resolve.
276
285
  stopped_ = true;
277
- if (bridge_) bridge_->Release();
286
+ if (bridge_) {
287
+ bridge_->Release();
288
+ bridge_->DropHandlers();
289
+ }
278
290
  deferred.Resolve(env.Undefined());
279
291
  return deferred.Promise();
280
292
  }
@@ -347,8 +359,15 @@ Napi::Value InitiatorWrap::Stop(const Napi::CallbackInfo& info) {
347
359
  self->gate_->Unfreeze();
348
360
  if (bridge) {
349
361
  if (force) {
362
+ // The abort discarded the queue: no callback can reach JS
363
+ // any more, so the handlers can go now.
350
364
  bridge->Abort();
365
+ bridge->DropHandlers();
351
366
  } else {
367
+ // Callbacks already queued (a trailing 'logout') are still
368
+ // dispatched after the release, and read the handlers: keep
369
+ // them. The retried stop() drops them in
370
+ // DestroyStoppedEngine().
352
371
  bridge->Release();
353
372
  }
354
373
  }
@@ -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.1",
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