@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
package/CMakeLists.txt
CHANGED
|
@@ -85,7 +85,11 @@ string(REPLACE "\n" "" NODE_ADDON_API_DIR ${NODE_ADDON_API_DIR})
|
|
|
85
85
|
# ---------------------------------------------------------------------------
|
|
86
86
|
# The native addon.
|
|
87
87
|
# ---------------------------------------------------------------------------
|
|
88
|
-
|
|
88
|
+
# CONFIGURE_DEPENDS re-globs on every build, so adding or deleting a cpp/*.cpp
|
|
89
|
+
# file is picked up by an already-configured tree. cmake-js only re-runs the
|
|
90
|
+
# configure step when CMakeCache.txt is missing, so without it a deleted source
|
|
91
|
+
# stays in the build graph until `yarn clean`.
|
|
92
|
+
file(GLOB ADDON_SOURCES CONFIGURE_DEPENDS "${CMAKE_SOURCE_DIR}/cpp/*.cpp")
|
|
89
93
|
|
|
90
94
|
add_library(napi_quickfix SHARED ${ADDON_SOURCES} ${CMAKE_JS_SRC})
|
|
91
95
|
set_target_properties(napi_quickfix PROPERTIES PREFIX "" SUFFIX ".node")
|
package/README.md
CHANGED
|
@@ -23,11 +23,20 @@ included.
|
|
|
23
23
|
points.
|
|
24
24
|
- **TypeScript types included** — hand-written `.d.ts` for the full public API.
|
|
25
25
|
- **Full FIX surface** — the session engine (`Initiator`/`Acceptor` +
|
|
26
|
-
synchronous application handlers)
|
|
27
|
-
|
|
26
|
+
synchronous application handlers), per-session control (`Session`: sequence
|
|
27
|
+
numbers, logon/logout, runtime options) *and* the pure layer (`Message` with
|
|
28
|
+
repeating `Group`s and dictionary-aware parsing, `SessionSettings`,
|
|
29
|
+
`DataDictionary`, `SessionID`).
|
|
28
30
|
- **Ergonomic, safe API** — chainable message building with automatic
|
|
29
31
|
header/trailer field routing, typed rejections, and C++ exceptions surfaced as
|
|
30
32
|
JavaScript `Error`s.
|
|
33
|
+
- **Every FIX constant, typed** — `FIELD` (tag numbers) and one value group per
|
|
34
|
+
field (`MsgType`, `Side`, `ExecType`, `OrdStatus`, ...) are
|
|
35
|
+
generated from the bundled QuickFIX headers with literal types, frozen, and
|
|
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.
|
|
31
40
|
|
|
32
41
|
## Install
|
|
33
42
|
|
|
@@ -206,20 +215,41 @@ come from `@homaiohq/napi-quickfix`.
|
|
|
206
215
|
### Message
|
|
207
216
|
|
|
208
217
|
```ts
|
|
209
|
-
new Message(raw?: string, opts?:
|
|
210
|
-
Message.parse(raw: string, opts?:
|
|
218
|
+
new Message(raw?: string, opts?: MessageParseOptions);
|
|
219
|
+
Message.parse(raw: string, opts?: MessageParseOptions): Message;
|
|
220
|
+
// MessageParseOptions: { validate?: boolean; dictionary?: DataDictionary;
|
|
221
|
+
// sessionDictionary?: DataDictionary; applicationDictionary?: DataDictionary }
|
|
211
222
|
|
|
212
223
|
message.setField(tag: number, value: string | number): this; // chainable, auto-routes header/trailer fields
|
|
213
|
-
message.getField(tag: number): string;
|
|
224
|
+
message.getField(tag: number): string; // throws QuickFixError (FieldNotFound) if absent
|
|
225
|
+
// Standard header/trailer tags route to their section. Any other tag is a body field, unless the
|
|
226
|
+
// header or trailer already holds it (a field a dictionary declares there): then it is read and
|
|
227
|
+
// edited in place. A new dictionary-declared header field goes in with setHeaderField.
|
|
228
|
+
message.getFieldIfSet(tag: number): string | undefined;
|
|
229
|
+
message.isSetField(tag: number): boolean;
|
|
230
|
+
message.removeField(tag: number): this;
|
|
214
231
|
message.setHeaderField(tag, value): this; message.getHeaderField(tag): string;
|
|
215
232
|
message.setTrailerField(tag, value): this; message.getTrailerField(tag): string;
|
|
233
|
+
message.fields(): [tag: number, value: string][]; // body fields in wire order (also iterable)
|
|
234
|
+
message.headerFields(); message.trailerFields(); // same shape
|
|
235
|
+
message.isEmpty(): boolean; message.totalFields(): number; message.clear(): this;
|
|
236
|
+
|
|
237
|
+
// Tags must be integers (positive for setters) and group indices integers: anything else throws a TypeError.
|
|
238
|
+
// Repeating groups (indices are 1-based, as in QuickFIX)
|
|
239
|
+
message.addGroup(group: Group): this; // appends a copy, maintains the count field; TypeError if group.delim differs from the stored entries'
|
|
240
|
+
message.getGroup(index: number, tag: number): Group; // snapshot copy; throws FieldNotFound if absent
|
|
241
|
+
message.replaceGroup(index: number, group: Group): this; // throws FieldNotFound if absent, TypeError if group.delim differs
|
|
242
|
+
message.removeGroup(tag: number): this; message.removeGroup(index: number, tag: number): this; // (index, tag) throws FieldNotFound if absent
|
|
243
|
+
message.hasGroup(tag: number): boolean; message.hasGroup(index: number, tag: number): boolean;
|
|
244
|
+
message.groupCount(tag: number): number;
|
|
245
|
+
|
|
216
246
|
message.getMsgType(): string;
|
|
217
247
|
message.toString(): string; // raw SOH-delimited wire string
|
|
218
248
|
message.toPretty(): string; // human-readable
|
|
219
249
|
message.toJSON(): { msgType?: string; raw: string };
|
|
220
250
|
|
|
221
251
|
createMessage(fields?: Record<number, string | number>): Message;
|
|
222
|
-
parseMessage(raw: string, opts?:
|
|
252
|
+
parseMessage(raw: string, opts?: MessageParseOptions): Message;
|
|
223
253
|
```
|
|
224
254
|
|
|
225
255
|
Field values are accepted as `string | number` (numbers are stringified) and
|
|
@@ -228,6 +258,62 @@ always returned as `string`. Header/trailer fields such as `MsgType`,
|
|
|
228
258
|
section by `setField`, so you rarely need `setHeaderField`/`setTrailerField`
|
|
229
259
|
directly.
|
|
230
260
|
|
|
261
|
+
**Parsing and repeating groups.** A parse without a dictionary is *flat*: the
|
|
262
|
+
fields of a repeating group are kept as plain repeated body fields and
|
|
263
|
+
`getGroup` throws. Pass a `dictionary` (or a `sessionDictionary` /
|
|
264
|
+
`applicationDictionary` pair for FIXT/FIX 5) to parse groups structurally.
|
|
265
|
+
With `UseDataDictionary=Y` (the default), messages delivered to engine handlers
|
|
266
|
+
are already parsed with the session's dictionary, so `getGroup` works on them
|
|
267
|
+
directly; with `UseDataDictionary=N` they arrive flat.
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
import { DataDictionary, Group, Message, FIELD, MsgType } from '@homaiohq/napi-quickfix';
|
|
271
|
+
|
|
272
|
+
const dd = DataDictionary.fromFile('./spec/FIX44.xml');
|
|
273
|
+
|
|
274
|
+
const order = new Message()
|
|
275
|
+
.setField(FIELD.MsgType, MsgType.NewOrderSingle)
|
|
276
|
+
.setField(FIELD.ClOrdID, 'order-1')
|
|
277
|
+
.addGroup(new Group(FIELD.NoPartyIDs, FIELD.PartyID).setField(FIELD.PartyID, 'TRADER-1').setField(FIELD.PartyRole, 11))
|
|
278
|
+
.addGroup(new Group(FIELD.NoPartyIDs, FIELD.PartyID).setField(FIELD.PartyID, 'FIRM-7').setField(FIELD.PartyRole, 1));
|
|
279
|
+
// 453=2|448=TRADER-1|452=11|448=FIRM-7|452=1 on the wire
|
|
280
|
+
|
|
281
|
+
const parsed = Message.parse(order.toString(), { dictionary: dd });
|
|
282
|
+
parsed.groupCount(FIELD.NoPartyIDs); // 2
|
|
283
|
+
parsed.getGroup(2, FIELD.NoPartyIDs).getField(FIELD.PartyID); // 'FIRM-7'
|
|
284
|
+
|
|
285
|
+
// getGroup returns a snapshot: write changes back with replaceGroup.
|
|
286
|
+
const first = parsed.getGroup(1, FIELD.NoPartyIDs).setField(FIELD.PartyRole, 12);
|
|
287
|
+
parsed.replaceGroup(1, first);
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### Group
|
|
291
|
+
|
|
292
|
+
One entry of a repeating group, identified by its **count tag** (`field`, e.g.
|
|
293
|
+
`453 NoPartyIDs`) and its **delimiter** (`delim`, the first tag of every entry,
|
|
294
|
+
e.g. `448 PartyID`). Groups nest through the same `addGroup`/`getGroup` API.
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
new Group(field: number, delim: number, order?: number[]); // order: full entry field order, delimiter first, no tag twice
|
|
298
|
+
// delim and order tags are capped at 2^20 (RangeError beyond that)
|
|
299
|
+
group.field; group.delim; // read-only
|
|
300
|
+
|
|
301
|
+
group.setField(tag, value: string | number): this; group.getField(tag): string;
|
|
302
|
+
group.getFieldIfSet(tag): string | undefined; group.isSetField(tag): boolean;
|
|
303
|
+
group.removeField(tag): this; group.isEmpty(); group.totalFields(); group.clear(): this;
|
|
304
|
+
group.fields(): [tag, value][]; // in wire order (also iterable)
|
|
305
|
+
group.toString(): string; // the entry's fields, SOH-delimited, in wire order
|
|
306
|
+
|
|
307
|
+
// Nested groups — same contract as on Message
|
|
308
|
+
group.addGroup(g: Group): this; group.getGroup(index, tag): Group; group.replaceGroup(index, g): this;
|
|
309
|
+
group.removeGroup(tag): this; group.removeGroup(index, tag): this;
|
|
310
|
+
group.hasGroup(tag): boolean; group.hasGroup(index, tag): boolean; group.groupCount(tag): number;
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Without an explicit `order`, an entry's fields are emitted delimiter-first and
|
|
314
|
+
then by ascending tag number, which matches the standard dictionaries for most
|
|
315
|
+
groups; pass `order` (e.g. `[448, 447, 452]`) when a group's spec order differs.
|
|
316
|
+
|
|
231
317
|
### SessionID
|
|
232
318
|
|
|
233
319
|
```ts
|
|
@@ -250,9 +336,18 @@ settings.getSessions(): SessionID[];
|
|
|
250
336
|
```ts
|
|
251
337
|
DataDictionary.fromString(xml: string): DataDictionary;
|
|
252
338
|
DataDictionary.fromFile(xmlPath: string): DataDictionary;
|
|
253
|
-
dictionary.validate(msg: Message): void; // throws QuickFixError if invalid
|
|
339
|
+
dictionary.validate(msg: Message, bodyOnly?: boolean): void; // throws QuickFixError if invalid
|
|
340
|
+
dictionary.getVersion(): string; // e.g. 'FIX.4.4'
|
|
341
|
+
dictionary.getFieldName(tag: number): string | undefined;
|
|
342
|
+
dictionary.getFieldTag(name: string): number | undefined;
|
|
343
|
+
dictionary.isField(tag: number): boolean;
|
|
344
|
+
dictionary.isMsgType(msgType: string): boolean;
|
|
254
345
|
```
|
|
255
346
|
|
|
347
|
+
`validate(msg, true)` validates as the *application* dictionary only: the
|
|
348
|
+
BeginString version check and header/trailer validation are skipped. A
|
|
349
|
+
dictionary also drives structural parsing — see `Message.parse` above.
|
|
350
|
+
|
|
256
351
|
### Initiator / Acceptor (the session engine)
|
|
257
352
|
|
|
258
353
|
Both extend Node's `EventEmitter` and share the same options and lifecycle:
|
|
@@ -261,13 +356,214 @@ Both extend Node's `EventEmitter` and share the same options and lifecycle:
|
|
|
261
356
|
new Initiator({ settings, handlers?, store?: 'file' | 'memory', log?: 'screen' | 'file' | 'none' });
|
|
262
357
|
new Acceptor({ settings, handlers?, store?, log? });
|
|
263
358
|
|
|
264
|
-
engine.start(): void
|
|
265
|
-
engine.stop(force?: boolean): void
|
|
266
|
-
engine.isLoggedOn(): boolean;
|
|
359
|
+
engine.start(): Promise<void>;
|
|
360
|
+
engine.stop(force?: boolean): Promise<void>; // graceful drain by default; force to abort
|
|
361
|
+
engine.isLoggedOn(sessionID?: SessionID): boolean; // any session, or just that one
|
|
362
|
+
engine.getSessions(): SessionID[]; // the [SESSION]s it was configured with
|
|
363
|
+
engine.getSession(sessionID: SessionID): Session | undefined;
|
|
267
364
|
engine.ref(): void; // keep the event loop alive while running (default)
|
|
268
365
|
engine.unref(): void; // let a short script / test exit
|
|
269
366
|
```
|
|
270
367
|
|
|
368
|
+
`start`/`stop` resolve once the engine has finished starting up / shutting down;
|
|
369
|
+
they run off the main thread so your handlers can fire meanwhile. Once `stop()`
|
|
370
|
+
settles the engine's sessions are destroyed: `getSession` returns `undefined` and
|
|
371
|
+
any `Session` handle you still hold throws `SessionNotFound`. Until then they are
|
|
372
|
+
still live: a graceful `stop()` settles only after the `'logout'` events it
|
|
373
|
+
triggered have been delivered, so a `'logout'` listener can still read the
|
|
374
|
+
session, and a `sendToTarget()` that has already reached its session completes
|
|
375
|
+
before the sessions go away. One that has not reached it by then is refused
|
|
376
|
+
with `SessionNotFound` rather than run against the engine that is gone; a send
|
|
377
|
+
is bound to the engine that owns the session when it is called, never to one
|
|
378
|
+
created later with the same id.
|
|
379
|
+
`stop(true)` discards the events still queued and destroys the sessions right
|
|
380
|
+
away. A `stop()` called while another is still in flight (from that `'logout'`
|
|
381
|
+
listener, say) returns the same Promise, so it too settles only once the
|
|
382
|
+
sessions are gone; the first call's `force` applies to both.
|
|
383
|
+
|
|
384
|
+
Two live engines cannot share a `SessionID`: constructing one whose session
|
|
385
|
+
already exists in the process throws a `ConfigError` ("Duplicate Session") until
|
|
386
|
+
the engine owning it has been stopped. A constructor that fails on a later
|
|
387
|
+
`[SESSION]` (any `ConfigError`) cleans up the sessions it had already created
|
|
388
|
+
(so the ids are not locked out by the failed attempt) and the engine-level log
|
|
389
|
+
QuickFIX had already opened for it.
|
|
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
|
+
|
|
495
|
+
### Session
|
|
496
|
+
|
|
497
|
+
A handle on one **live** session, obtained from `engine.getSession(id)` or the
|
|
498
|
+
module-level `lookupSession(id)`. It stores only the `SessionID` and the identity
|
|
499
|
+
of the engine it came from, and re-resolves the engine-owned `FIX::Session` on
|
|
500
|
+
every call, so it never dangles — once the session is gone (its engine was
|
|
501
|
+
stopped) every method throws a `QuickFixError` whose `fixErrorName` is
|
|
502
|
+
`'SessionNotFound'`, even after a new engine has been created with the same id.
|
|
503
|
+
A handle never crosses over to another engine's session; get a new one from the
|
|
504
|
+
new engine.
|
|
505
|
+
|
|
506
|
+
```ts
|
|
507
|
+
session.sessionID: SessionID;
|
|
508
|
+
|
|
509
|
+
// State (sync)
|
|
510
|
+
session.isLoggedOn(): boolean; session.isEnabled(): boolean;
|
|
511
|
+
session.sentLogon(): boolean; session.sentLogout(): boolean; session.receivedLogon(): boolean;
|
|
512
|
+
session.isInitiator(): boolean; session.isAcceptor(): boolean;
|
|
513
|
+
session.isSessionTime(now?: Date): boolean; session.isLogonTime(now?: Date): boolean;
|
|
514
|
+
|
|
515
|
+
// Sequence numbers (sync; setters write to the message store -- on a quiet session, see below)
|
|
516
|
+
session.getExpectedSenderNum(): number; session.getExpectedTargetNum(): number;
|
|
517
|
+
session.setNextSenderMsgSeqNum(n: number): void; session.setNextTargetMsgSeqNum(n: number): void;
|
|
518
|
+
|
|
519
|
+
// Runtime options (sync; change the live session, not its SessionSettings)
|
|
520
|
+
session.getResetOnLogon() / setResetOnLogon(v: boolean) // likewise: ResetOnLogout,
|
|
521
|
+
session.getLogonTimeout() / setLogonTimeout(seconds: number) // ResetOnDisconnect, RefreshOnLogon,
|
|
522
|
+
session.getTimestampPrecision() / setTimestampPrecision(0..9) // CheckCompId, CheckLatency, MaxLatency,
|
|
523
|
+
// LogoutTimeout, PersistMessages,
|
|
524
|
+
// SendRedundantResendRequests,
|
|
525
|
+
// ValidateLengthAndChecksum,
|
|
526
|
+
// SendNextExpectedMsgSeqNum, IsNonStopSession
|
|
527
|
+
|
|
528
|
+
// Control
|
|
529
|
+
session.logon(): void; // enable; an initiator reconnects + logs on
|
|
530
|
+
session.logout(reason?: string): void; // disable + graceful Logout; stays down until logon()
|
|
531
|
+
session.refresh(): void; // re-read state from the message store
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Everything is synchronous: none of these members takes the mutex QuickFIX holds
|
|
535
|
+
while it runs your handlers, so they cannot deadlock the main thread — and
|
|
536
|
+
because they run inline, `session.logout('x'); session.logon();` takes effect in
|
|
537
|
+
that order. (`logon()`/`logout()` flip a plain flag the engine's network thread
|
|
538
|
+
reads without a lock, exactly as QuickFIX's own `Initiator::stop()` does from
|
|
539
|
+
the stopping thread; the engine picks the change up on its next pass.) The
|
|
540
|
+
sequence-number setters are likewise not serialised against a message the
|
|
541
|
+
engine is sending or receiving on that session at the same moment: QuickFIX
|
|
542
|
+
reads the number, runs your handler, then increments from the store under a
|
|
543
|
+
lock the setters do not take, so a value set in that window — from a handler,
|
|
544
|
+
or while a `sendToTarget()` or a heartbeat is in flight — is overwritten and the
|
|
545
|
+
session ends up one off from its counterparty. Renumber a **quiet** session:
|
|
546
|
+
before `start()`, from a `'logout'` event, or when you know nothing is in
|
|
547
|
+
flight. QuickFIX's `Session::disconnect()` and `reset()` are deliberately **not**
|
|
548
|
+
exposed: they drop the transport through the engine's socket monitor, which is
|
|
549
|
+
only safe from the engine's own network thread (QuickFIX itself never calls them
|
|
550
|
+
from anywhere else). Bring a session down with `logout()` and renumber it with
|
|
551
|
+
the sequence-number setters. Observe the outcome through the engine's
|
|
552
|
+
`'logon'` / `'logout'` events:
|
|
553
|
+
|
|
554
|
+
```ts
|
|
555
|
+
initiator.on('logon', (id) => {
|
|
556
|
+
console.log('next outbound seq', initiator.getSession(id)!.getExpectedSenderNum());
|
|
557
|
+
});
|
|
558
|
+
initiator.on('logout', (id) => {
|
|
559
|
+
// The session is quiet now: renumber it, e.g. after the counterparty reset.
|
|
560
|
+
initiator.getSession(id)?.setNextTargetMsgSeqNum(1);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
lookupSession(id)?.logout('maintenance'); // -> 'logout' event, isLoggedOn() === false
|
|
564
|
+
lookupSession(id)?.logon(); // -> reconnects, 'logon' event
|
|
565
|
+
```
|
|
566
|
+
|
|
271
567
|
### Handlers vs. events
|
|
272
568
|
|
|
273
569
|
This is the key distinction to understand:
|
|
@@ -304,13 +600,25 @@ Recognized kinds: `'DoNotSend'`, `'RejectLogon'`, `'UnsupportedMessageType'`,
|
|
|
304
600
|
### Module functions and constants
|
|
305
601
|
|
|
306
602
|
```ts
|
|
307
|
-
sendToTarget(message: Message, sessionID: SessionID): boolean
|
|
603
|
+
sendToTarget(message: Message, sessionID: SessionID): Promise<boolean>;
|
|
604
|
+
sendToTarget(message: Message, qualifier?: string): Promise<boolean>; // session from the message header (8/49/56)
|
|
605
|
+
|
|
606
|
+
lookupSession(sessionID: SessionID): Session | undefined; // any engine in this process
|
|
607
|
+
doesSessionExist(sessionID: SessionID): boolean;
|
|
608
|
+
getSessions(): SessionID[]; // every session in this process, across all engines
|
|
609
|
+
numSessions(): number;
|
|
610
|
+
|
|
308
611
|
version(): string; // engine / addon version string
|
|
309
612
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
613
|
+
createTlsTunnel(options: TlsTunnelOptions): Promise<TlsTunnel>; // see "TLS (stunnel-style) for initiators"
|
|
614
|
+
|
|
615
|
+
FIELD // field-name -> tag number, e.g. FIELD.MsgType === 35, FIELD.Password === 554
|
|
616
|
+
MsgType // MsgType values, e.g. MsgType.Logon === 'A'
|
|
617
|
+
Side // Side values, e.g. Side.Buy === '1'
|
|
618
|
+
OrdType // e.g. OrdType.Limit === '2'
|
|
619
|
+
TimeInForce // e.g. TimeInForce.Day === '0'
|
|
620
|
+
VALUES // every value group keyed by field name, e.g. VALUES.ExecType.Fill === '2'
|
|
621
|
+
enums // the full frozen tree: every value group plus FIELD
|
|
314
622
|
```
|
|
315
623
|
|
|
316
624
|
`FIELD` is generated from the bundled QuickFIX's `FixFieldNumbers.h`, so it has every
|
|
@@ -318,12 +626,30 @@ enums // the full frozen tree of FIX constants
|
|
|
318
626
|
is typed `554`, and a misspelt field name is a compile error. `FieldName` is the union of
|
|
319
627
|
all field names.
|
|
320
628
|
|
|
321
|
-
The
|
|
322
|
-
|
|
323
|
-
|
|
629
|
+
The value groups are generated the same way from `FixValues.h`: one frozen object per
|
|
630
|
+
field (690 groups, 5700+ values), each value a string because FIX is string-on-the-wire
|
|
631
|
+
(`EncryptMethod.None === '0'`, not `0`). Names derive from QuickFIX's
|
|
632
|
+
`FIX::<Field>_<VALUE>` constants with the SCREAMING_SNAKE suffix turned into PascalCase —
|
|
633
|
+
`Side_SELL_SHORT` → `Side.SellShort`, `ExecType_DONE_FOR_DAY` → `ExecType.DoneForDay` —
|
|
634
|
+
while `MsgType`, whose upstream names already mirror message names, is kept verbatim
|
|
635
|
+
(`MsgType.NewOrderSingle`, `MsgType.IOI`). Every word is lower-cased, acronyms included
|
|
636
|
+
(`SecurityIDSource_ISIN_NUMBER` → `SecurityIDSource.IsinNumber`, `MDEntryType_VWAP` →
|
|
637
|
+
`MDEntryType.Vwap`): the upstream names carry no acronym information, so there is no
|
|
638
|
+
allowlist to maintain and the keys are stable. The exact rule is documented in
|
|
639
|
+
`scripts/gen-values.mjs`. Values are literal types too (`Side.Buy` is typed `'1'`), and
|
|
640
|
+
`ValueGroupName` is the union of all group names.
|
|
641
|
+
|
|
642
|
+
The root entry exports `MsgType`, `Side`, `OrdType` and `TimeInForce` by name and every
|
|
643
|
+
group through `VALUES` / `enums`. Both tables are also exposed as subpath exports that
|
|
644
|
+
do **not** load the native addon, for tooling that only needs the constants (log
|
|
645
|
+
parsers, test helpers, bundles for platforms without a prebuild); each group is a named
|
|
646
|
+
export there, so bundlers resolving the ESM build keep only the groups you import (the
|
|
647
|
+
CJS build is not tree-shakeable: tsc drops the `@__PURE__` annotations when it emits
|
|
648
|
+
CommonJS):
|
|
324
649
|
|
|
325
650
|
```ts
|
|
326
651
|
import { FIELD } from '@homaiohq/napi-quickfix/fields';
|
|
652
|
+
import { ExecType, OrdStatus, SecurityType } from '@homaiohq/napi-quickfix/values';
|
|
327
653
|
```
|
|
328
654
|
|
|
329
655
|
### Errors
|