@homaiohq/napi-quickfix 0.1.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/CMakeLists.txt +5 -1
- package/README.md +344 -18
- package/cpp/acceptor_wrap.cpp +253 -19
- package/cpp/acceptor_wrap.h +60 -1
- package/cpp/addon.cpp +10 -6
- package/cpp/application_bridge.cpp +114 -30
- package/cpp/application_bridge.h +57 -4
- package/cpp/data_dictionary_wrap.cpp +89 -3
- package/cpp/data_dictionary_wrap.h +27 -5
- package/cpp/engine_common.h +108 -0
- package/cpp/engine_workers.h +99 -18
- package/cpp/field_map_ops.h +492 -0
- package/cpp/group_wrap.cpp +221 -0
- package/cpp/group_wrap.h +76 -0
- package/cpp/initiator_wrap.cpp +262 -25
- package/cpp/initiator_wrap.h +37 -2
- package/cpp/message_wrap.cpp +171 -78
- package/cpp/message_wrap.h +47 -5
- package/cpp/session_op_gate.h +201 -0
- package/cpp/session_static.cpp +139 -12
- package/cpp/session_wrap.cpp +438 -0
- package/cpp/session_wrap.h +144 -0
- package/dist/cjs/acceptor.d.ts +0 -1
- package/dist/cjs/acceptor.js +0 -1
- package/dist/cjs/application.d.ts +0 -1
- package/dist/cjs/application.js +0 -1
- package/dist/cjs/data-dictionary.d.ts +21 -3
- package/dist/cjs/data-dictionary.js +32 -4
- package/dist/cjs/engine.d.ts +42 -4
- package/dist/cjs/engine.js +50 -5
- package/dist/cjs/enums.d.ts +48 -14
- package/dist/cjs/enums.js +20 -25
- package/dist/cjs/field-value.d.ts +12 -0
- package/dist/cjs/field-value.js +40 -0
- package/dist/cjs/generated/fields.d.ts +0 -1
- package/dist/cjs/generated/fields.js +0 -1
- package/dist/cjs/generated/values.d.ts +8548 -0
- package/dist/cjs/generated/values.js +8580 -0
- package/dist/cjs/group.d.ts +126 -0
- package/dist/cjs/group.js +185 -0
- package/dist/cjs/index.d.ts +28 -5
- package/dist/cjs/index.js +30 -28
- package/dist/cjs/initiator.d.ts +0 -1
- package/dist/cjs/initiator.js +0 -1
- package/dist/cjs/message.d.ts +129 -5
- package/dist/cjs/message.js +146 -9
- package/dist/cjs/native.d.ts +179 -17
- package/dist/cjs/native.js +0 -1
- package/dist/cjs/session-id.d.ts +12 -1
- package/dist/cjs/session-id.js +21 -1
- package/dist/cjs/session-settings.d.ts +0 -1
- package/dist/cjs/session-settings.js +0 -1
- package/dist/cjs/session.d.ts +213 -0
- package/dist/cjs/session.js +337 -0
- package/dist/cjs/tls-tunnel.d.ts +143 -0
- package/dist/cjs/tls-tunnel.js +261 -0
- package/dist/esm/acceptor.d.ts +0 -1
- package/dist/esm/acceptor.js +0 -1
- package/dist/esm/application.d.ts +0 -1
- package/dist/esm/application.js +0 -1
- package/dist/esm/data-dictionary.d.ts +21 -3
- package/dist/esm/data-dictionary.js +32 -4
- package/dist/esm/engine.d.ts +42 -4
- package/dist/esm/engine.js +51 -6
- package/dist/esm/enums.d.ts +48 -14
- package/dist/esm/enums.js +14 -24
- package/dist/esm/field-value.d.ts +12 -0
- package/dist/esm/field-value.js +37 -0
- package/dist/esm/generated/fields.d.ts +0 -1
- package/dist/esm/generated/fields.js +0 -1
- package/dist/esm/generated/values.d.ts +8548 -0
- package/dist/esm/generated/values.js +8564 -0
- package/dist/esm/group.d.ts +126 -0
- package/dist/esm/group.js +181 -0
- package/dist/esm/index.d.ts +28 -5
- package/dist/esm/index.js +18 -26
- package/dist/esm/initiator.d.ts +0 -1
- package/dist/esm/initiator.js +0 -1
- package/dist/esm/message.d.ts +129 -5
- package/dist/esm/message.js +146 -9
- package/dist/esm/native.d.ts +179 -17
- package/dist/esm/native.js +0 -1
- package/dist/esm/session-id.d.ts +12 -1
- package/dist/esm/session-id.js +20 -1
- package/dist/esm/session-settings.d.ts +0 -1
- package/dist/esm/session-settings.js +0 -1
- package/dist/esm/session.d.ts +213 -0
- package/dist/esm/session.js +329 -0
- package/dist/esm/tls-tunnel.d.ts +143 -0
- package/dist/esm/tls-tunnel.js +256 -0
- package/package.json +13 -1
- package/prebuilds/darwin-arm64/node.napi.node +0 -0
- package/prebuilds/darwin-x64/node.napi.node +0 -0
- package/prebuilds/linux-x64/node.napi.glibc.node +0 -0
- package/prebuilds/linux-x64/node.napi.musl.node +0 -0
- package/prebuilds/win32-x64/node.napi.node +0 -0
- package/cpp/enums.cpp +0 -66
- package/dist/cjs/acceptor.d.ts.map +0 -1
- package/dist/cjs/acceptor.js.map +0 -1
- package/dist/cjs/application.d.ts.map +0 -1
- package/dist/cjs/application.js.map +0 -1
- package/dist/cjs/data-dictionary.d.ts.map +0 -1
- package/dist/cjs/data-dictionary.js.map +0 -1
- package/dist/cjs/engine.d.ts.map +0 -1
- package/dist/cjs/engine.js.map +0 -1
- package/dist/cjs/enums.d.ts.map +0 -1
- package/dist/cjs/enums.js.map +0 -1
- package/dist/cjs/generated/fields.d.ts.map +0 -1
- package/dist/cjs/generated/fields.js.map +0 -1
- package/dist/cjs/index.d.ts.map +0 -1
- package/dist/cjs/index.js.map +0 -1
- package/dist/cjs/initiator.d.ts.map +0 -1
- package/dist/cjs/initiator.js.map +0 -1
- package/dist/cjs/message.d.ts.map +0 -1
- package/dist/cjs/message.js.map +0 -1
- package/dist/cjs/native.d.ts.map +0 -1
- package/dist/cjs/native.js.map +0 -1
- package/dist/cjs/session-id.d.ts.map +0 -1
- package/dist/cjs/session-id.js.map +0 -1
- package/dist/cjs/session-settings.d.ts.map +0 -1
- package/dist/cjs/session-settings.js.map +0 -1
- package/dist/esm/acceptor.d.ts.map +0 -1
- package/dist/esm/acceptor.js.map +0 -1
- package/dist/esm/application.d.ts.map +0 -1
- package/dist/esm/application.js.map +0 -1
- package/dist/esm/data-dictionary.d.ts.map +0 -1
- package/dist/esm/data-dictionary.js.map +0 -1
- package/dist/esm/engine.d.ts.map +0 -1
- package/dist/esm/engine.js.map +0 -1
- package/dist/esm/enums.d.ts.map +0 -1
- package/dist/esm/enums.js.map +0 -1
- package/dist/esm/generated/fields.d.ts.map +0 -1
- package/dist/esm/generated/fields.js.map +0 -1
- package/dist/esm/index.d.ts.map +0 -1
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/initiator.d.ts.map +0 -1
- package/dist/esm/initiator.js.map +0 -1
- package/dist/esm/message.d.ts.map +0 -1
- package/dist/esm/message.js.map +0 -1
- package/dist/esm/native.d.ts.map +0 -1
- package/dist/esm/native.js.map +0 -1
- package/dist/esm/session-id.d.ts.map +0 -1
- package/dist/esm/session-id.js.map +0 -1
- package/dist/esm/session-settings.d.ts.map +0 -1
- package/dist/esm/session-settings.js.map +0 -1
|
@@ -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
|
+
}
|
package/dist/esm/acceptor.d.ts
CHANGED
package/dist/esm/acceptor.js
CHANGED
package/dist/esm/application.js
CHANGED
|
@@ -13,6 +13,8 @@ import type { Message } from './message.js';
|
|
|
13
13
|
* ```ts
|
|
14
14
|
* const dd = DataDictionary.fromFile('./spec/FIX44.xml');
|
|
15
15
|
* dd.validate(msg); // throws QuickFixError if invalid
|
|
16
|
+
* dd.getFieldName(35); // 'MsgType'
|
|
17
|
+
* Message.parse(raw, { dictionary: dd }); // structural parse (repeating groups)
|
|
16
18
|
* ```
|
|
17
19
|
*/
|
|
18
20
|
export declare class DataDictionary {
|
|
@@ -41,9 +43,25 @@ export declare class DataDictionary {
|
|
|
41
43
|
* Validate a message against this dictionary.
|
|
42
44
|
*
|
|
43
45
|
* @param msg The message to validate.
|
|
46
|
+
* @param bodyOnly When `true`, validate only as the *application* dictionary
|
|
47
|
+
* (QuickFIX's `validate(msg, true)`): the BeginString version check and the
|
|
48
|
+
* tag/value validation of the header and trailer fields are skipped. It is
|
|
49
|
+
* not a check of a draft body: `BeginString` and `MsgType` must be set, and
|
|
50
|
+
* every field the dictionary requires, header and trailer included (e.g.
|
|
51
|
+
* `BodyLength`, `CheckSum`), must be present. Defaults to `false`.
|
|
44
52
|
* @throws A `QuickFixError` on any validation failure (e.g.
|
|
45
|
-
* `
|
|
53
|
+
* `fixErrorName: 'InvalidMessage'` / `'RequiredTagMissing'` /
|
|
54
|
+
* `'IncorrectTagValue'` / `'UnsupportedVersion'`).
|
|
46
55
|
*/
|
|
47
|
-
validate(msg: Message): void;
|
|
56
|
+
validate(msg: Message, bodyOnly?: boolean): void;
|
|
57
|
+
/** The BeginString the spec declares (e.g. `'FIX.4.4'`), or `''` if it has none. */
|
|
58
|
+
getVersion(): string;
|
|
59
|
+
/** The name of a field tag (e.g. `35` → `'MsgType'`), or `undefined` if the spec does not define it. */
|
|
60
|
+
getFieldName(tag: number): string | undefined;
|
|
61
|
+
/** The tag of a field name (e.g. `'MsgType'` → `35`), or `undefined` if the spec does not define it. */
|
|
62
|
+
getFieldTag(name: string): number | undefined;
|
|
63
|
+
/** Whether the spec defines a field with this tag. */
|
|
64
|
+
isField(tag: number): boolean;
|
|
65
|
+
/** Whether the spec defines a message with this MsgType (e.g. `'D'`). */
|
|
66
|
+
isMsgType(msgType: string): boolean;
|
|
48
67
|
}
|
|
49
|
-
//# sourceMappingURL=data-dictionary.d.ts.map
|
|
@@ -12,6 +12,8 @@ import { native } from './native.js';
|
|
|
12
12
|
* ```ts
|
|
13
13
|
* const dd = DataDictionary.fromFile('./spec/FIX44.xml');
|
|
14
14
|
* dd.validate(msg); // throws QuickFixError if invalid
|
|
15
|
+
* dd.getFieldName(35); // 'MsgType'
|
|
16
|
+
* Message.parse(raw, { dictionary: dd }); // structural parse (repeating groups)
|
|
15
17
|
* ```
|
|
16
18
|
*/
|
|
17
19
|
export class DataDictionary {
|
|
@@ -63,11 +65,37 @@ export class DataDictionary {
|
|
|
63
65
|
* Validate a message against this dictionary.
|
|
64
66
|
*
|
|
65
67
|
* @param msg The message to validate.
|
|
68
|
+
* @param bodyOnly When `true`, validate only as the *application* dictionary
|
|
69
|
+
* (QuickFIX's `validate(msg, true)`): the BeginString version check and the
|
|
70
|
+
* tag/value validation of the header and trailer fields are skipped. It is
|
|
71
|
+
* not a check of a draft body: `BeginString` and `MsgType` must be set, and
|
|
72
|
+
* every field the dictionary requires, header and trailer included (e.g.
|
|
73
|
+
* `BodyLength`, `CheckSum`), must be present. Defaults to `false`.
|
|
66
74
|
* @throws A `QuickFixError` on any validation failure (e.g.
|
|
67
|
-
* `
|
|
75
|
+
* `fixErrorName: 'InvalidMessage'` / `'RequiredTagMissing'` /
|
|
76
|
+
* `'IncorrectTagValue'` / `'UnsupportedVersion'`).
|
|
68
77
|
*/
|
|
69
|
-
validate(msg) {
|
|
70
|
-
this.#native.validate(msg.nativeHandle);
|
|
78
|
+
validate(msg, bodyOnly = false) {
|
|
79
|
+
this.#native.validate(msg.nativeHandle, bodyOnly);
|
|
80
|
+
}
|
|
81
|
+
/** The BeginString the spec declares (e.g. `'FIX.4.4'`), or `''` if it has none. */
|
|
82
|
+
getVersion() {
|
|
83
|
+
return this.#native.getVersion();
|
|
84
|
+
}
|
|
85
|
+
/** The name of a field tag (e.g. `35` → `'MsgType'`), or `undefined` if the spec does not define it. */
|
|
86
|
+
getFieldName(tag) {
|
|
87
|
+
return this.#native.getFieldName(tag);
|
|
88
|
+
}
|
|
89
|
+
/** The tag of a field name (e.g. `'MsgType'` → `35`), or `undefined` if the spec does not define it. */
|
|
90
|
+
getFieldTag(name) {
|
|
91
|
+
return this.#native.getFieldTag(name);
|
|
92
|
+
}
|
|
93
|
+
/** Whether the spec defines a field with this tag. */
|
|
94
|
+
isField(tag) {
|
|
95
|
+
return this.#native.isField(tag);
|
|
96
|
+
}
|
|
97
|
+
/** Whether the spec defines a message with this MsgType (e.g. `'D'`). */
|
|
98
|
+
isMsgType(msgType) {
|
|
99
|
+
return this.#native.isMsgType(msgType);
|
|
71
100
|
}
|
|
72
101
|
}
|
|
73
|
-
//# sourceMappingURL=data-dictionary.js.map
|
package/dist/esm/engine.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { EventEmitter } from 'node:events';
|
|
|
8
8
|
import { type NativeEngineConstructor } from './native.js';
|
|
9
9
|
import { Message } from './message.js';
|
|
10
10
|
import { SessionID } from './session-id.js';
|
|
11
|
+
import { Session } from './session.js';
|
|
11
12
|
import type { ApplicationHandlers } from './application.js';
|
|
12
13
|
import type { SessionSettings } from './session-settings.js';
|
|
13
14
|
/** Options for constructing an {@link Initiator} or {@link Acceptor}. */
|
|
@@ -73,7 +74,11 @@ export declare abstract class Engine extends EventEmitter {
|
|
|
73
74
|
* Stop the engine.
|
|
74
75
|
*
|
|
75
76
|
* Runs off the main thread and resolves once shutdown completes, keeping the
|
|
76
|
-
* event loop free for any in-flight callbacks.
|
|
77
|
+
* event loop free for any in-flight callbacks. Once it settles the engine's
|
|
78
|
+
* sessions are destroyed. A `stop()` called while another is still in flight
|
|
79
|
+
* (even from a `'logout'` listener that stop fired) returns the same Promise,
|
|
80
|
+
* so it too settles only once the sessions are gone; the first call's
|
|
81
|
+
* `force` applies to both.
|
|
77
82
|
*
|
|
78
83
|
* @param force When `true`, aborts immediately instead of draining gracefully.
|
|
79
84
|
*
|
|
@@ -83,8 +88,42 @@ export declare abstract class Engine extends EventEmitter {
|
|
|
83
88
|
* ```
|
|
84
89
|
*/
|
|
85
90
|
stop(force?: boolean): Promise<void>;
|
|
86
|
-
/**
|
|
87
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Whether a session is logged on.
|
|
93
|
+
*
|
|
94
|
+
* With no argument: `true` if **any** of this engine's sessions is logged on.
|
|
95
|
+
* With a {@link SessionID}: `true` only if that session belongs to this engine
|
|
96
|
+
* and is logged on (`false`, not an error, for a foreign id). Always `false`
|
|
97
|
+
* before {@link Engine.start} and once {@link Engine.stop} has settled; while
|
|
98
|
+
* a graceful stop is still in progress it reports the live state.
|
|
99
|
+
*
|
|
100
|
+
* @throws {TypeError} if `sessionID` is given but is not a {@link SessionID}
|
|
101
|
+
* (it does not fall back to the engine-wide form).
|
|
102
|
+
*/
|
|
103
|
+
isLoggedOn(sessionID?: SessionID): boolean;
|
|
104
|
+
/**
|
|
105
|
+
* The ids of the sessions this engine was configured with (every `[SESSION]`
|
|
106
|
+
* in its settings). Stable for the engine's lifetime, including after
|
|
107
|
+
* {@link Engine.stop}.
|
|
108
|
+
*/
|
|
109
|
+
getSessions(): SessionID[];
|
|
110
|
+
/**
|
|
111
|
+
* A handle on one of this engine's sessions.
|
|
112
|
+
*
|
|
113
|
+
* @returns The {@link Session}, or `undefined` if `sessionID` is not one of
|
|
114
|
+
* this engine's sessions or {@link Engine.stop} has settled (that destroys
|
|
115
|
+
* its sessions). While a stop is still in progress the sessions are alive,
|
|
116
|
+
* so a `'logout'` listener can still read e.g. the final sequence numbers.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```ts
|
|
120
|
+
* initiator.on('logon', (id) => {
|
|
121
|
+
* const session = initiator.getSession(id)!;
|
|
122
|
+
* console.log('next seq', session.getExpectedSenderNum());
|
|
123
|
+
* });
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
getSession(sessionID: SessionID): Session | undefined;
|
|
88
127
|
/**
|
|
89
128
|
* Keep the Node.js event loop alive while the engine is running (default once
|
|
90
129
|
* started). Pair with {@link Engine.unref} to let a short script exit.
|
|
@@ -111,4 +150,3 @@ export interface Engine {
|
|
|
111
150
|
/** List the listeners registered for an event. */
|
|
112
151
|
listeners<E extends keyof EngineEvents>(event: E): Array<(...args: EngineEvents[E]) => void>;
|
|
113
152
|
}
|
|
114
|
-
//# sourceMappingURL=engine.d.ts.map
|
package/dist/esm/engine.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { EventEmitter } from 'node:events';
|
|
8
8
|
import { Message } from './message.js';
|
|
9
|
-
import { SessionID } from './session-id.js';
|
|
9
|
+
import { SessionID, toNativeSessionID } from './session-id.js';
|
|
10
|
+
import { Session } from './session.js';
|
|
10
11
|
/**
|
|
11
12
|
* Base class for the FIX session engines.
|
|
12
13
|
*
|
|
@@ -103,7 +104,11 @@ export class Engine extends EventEmitter {
|
|
|
103
104
|
* Stop the engine.
|
|
104
105
|
*
|
|
105
106
|
* Runs off the main thread and resolves once shutdown completes, keeping the
|
|
106
|
-
* event loop free for any in-flight callbacks.
|
|
107
|
+
* event loop free for any in-flight callbacks. Once it settles the engine's
|
|
108
|
+
* sessions are destroyed. A `stop()` called while another is still in flight
|
|
109
|
+
* (even from a `'logout'` listener that stop fired) returns the same Promise,
|
|
110
|
+
* so it too settles only once the sessions are gone; the first call's
|
|
111
|
+
* `force` applies to both.
|
|
107
112
|
*
|
|
108
113
|
* @param force When `true`, aborts immediately instead of draining gracefully.
|
|
109
114
|
*
|
|
@@ -115,9 +120,50 @@ export class Engine extends EventEmitter {
|
|
|
115
120
|
stop(force) {
|
|
116
121
|
return this.#native.stop(force);
|
|
117
122
|
}
|
|
118
|
-
/**
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Whether a session is logged on.
|
|
125
|
+
*
|
|
126
|
+
* With no argument: `true` if **any** of this engine's sessions is logged on.
|
|
127
|
+
* With a {@link SessionID}: `true` only if that session belongs to this engine
|
|
128
|
+
* and is logged on (`false`, not an error, for a foreign id). Always `false`
|
|
129
|
+
* before {@link Engine.start} and once {@link Engine.stop} has settled; while
|
|
130
|
+
* a graceful stop is still in progress it reports the live state.
|
|
131
|
+
*
|
|
132
|
+
* @throws {TypeError} if `sessionID` is given but is not a {@link SessionID}
|
|
133
|
+
* (it does not fall back to the engine-wide form).
|
|
134
|
+
*/
|
|
135
|
+
isLoggedOn(sessionID) {
|
|
136
|
+
return sessionID === undefined
|
|
137
|
+
? this.#native.isLoggedOn()
|
|
138
|
+
: this.#native.isLoggedOn(toNativeSessionID(sessionID, 'sessionID'));
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The ids of the sessions this engine was configured with (every `[SESSION]`
|
|
142
|
+
* in its settings). Stable for the engine's lifetime, including after
|
|
143
|
+
* {@link Engine.stop}.
|
|
144
|
+
*/
|
|
145
|
+
getSessions() {
|
|
146
|
+
return this.#native.getSessions().map((h) => SessionID.fromNative(h));
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* A handle on one of this engine's sessions.
|
|
150
|
+
*
|
|
151
|
+
* @returns The {@link Session}, or `undefined` if `sessionID` is not one of
|
|
152
|
+
* this engine's sessions or {@link Engine.stop} has settled (that destroys
|
|
153
|
+
* its sessions). While a stop is still in progress the sessions are alive,
|
|
154
|
+
* so a `'logout'` listener can still read e.g. the final sequence numbers.
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* ```ts
|
|
158
|
+
* initiator.on('logon', (id) => {
|
|
159
|
+
* const session = initiator.getSession(id)!;
|
|
160
|
+
* console.log('next seq', session.getExpectedSenderNum());
|
|
161
|
+
* });
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
getSession(sessionID) {
|
|
165
|
+
const handle = this.#native.getSession(toNativeSessionID(sessionID, 'sessionID'));
|
|
166
|
+
return handle ? Session.fromNative(handle) : undefined;
|
|
121
167
|
}
|
|
122
168
|
/**
|
|
123
169
|
* Keep the Node.js event loop alive while the engine is running (default once
|
|
@@ -131,4 +177,3 @@ export class Engine extends EventEmitter {
|
|
|
131
177
|
this.#native.unref();
|
|
132
178
|
}
|
|
133
179
|
}
|
|
134
|
-
//# sourceMappingURL=engine.js.map
|