@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.
Files changed (145) hide show
  1. package/CMakeLists.txt +5 -1
  2. package/README.md +344 -18
  3. package/cpp/acceptor_wrap.cpp +253 -19
  4. package/cpp/acceptor_wrap.h +60 -1
  5. package/cpp/addon.cpp +10 -6
  6. package/cpp/application_bridge.cpp +114 -30
  7. package/cpp/application_bridge.h +57 -4
  8. package/cpp/data_dictionary_wrap.cpp +89 -3
  9. package/cpp/data_dictionary_wrap.h +27 -5
  10. package/cpp/engine_common.h +108 -0
  11. package/cpp/engine_workers.h +99 -18
  12. package/cpp/field_map_ops.h +492 -0
  13. package/cpp/group_wrap.cpp +221 -0
  14. package/cpp/group_wrap.h +76 -0
  15. package/cpp/initiator_wrap.cpp +262 -25
  16. package/cpp/initiator_wrap.h +37 -2
  17. package/cpp/message_wrap.cpp +171 -78
  18. package/cpp/message_wrap.h +47 -5
  19. package/cpp/session_op_gate.h +201 -0
  20. package/cpp/session_static.cpp +139 -12
  21. package/cpp/session_wrap.cpp +438 -0
  22. package/cpp/session_wrap.h +144 -0
  23. package/dist/cjs/acceptor.d.ts +0 -1
  24. package/dist/cjs/acceptor.js +0 -1
  25. package/dist/cjs/application.d.ts +0 -1
  26. package/dist/cjs/application.js +0 -1
  27. package/dist/cjs/data-dictionary.d.ts +21 -3
  28. package/dist/cjs/data-dictionary.js +32 -4
  29. package/dist/cjs/engine.d.ts +42 -4
  30. package/dist/cjs/engine.js +50 -5
  31. package/dist/cjs/enums.d.ts +48 -14
  32. package/dist/cjs/enums.js +20 -25
  33. package/dist/cjs/field-value.d.ts +12 -0
  34. package/dist/cjs/field-value.js +40 -0
  35. package/dist/cjs/generated/fields.d.ts +0 -1
  36. package/dist/cjs/generated/fields.js +0 -1
  37. package/dist/cjs/generated/values.d.ts +8548 -0
  38. package/dist/cjs/generated/values.js +8580 -0
  39. package/dist/cjs/group.d.ts +126 -0
  40. package/dist/cjs/group.js +185 -0
  41. package/dist/cjs/index.d.ts +28 -5
  42. package/dist/cjs/index.js +30 -28
  43. package/dist/cjs/initiator.d.ts +0 -1
  44. package/dist/cjs/initiator.js +0 -1
  45. package/dist/cjs/message.d.ts +129 -5
  46. package/dist/cjs/message.js +146 -9
  47. package/dist/cjs/native.d.ts +179 -17
  48. package/dist/cjs/native.js +0 -1
  49. package/dist/cjs/session-id.d.ts +12 -1
  50. package/dist/cjs/session-id.js +21 -1
  51. package/dist/cjs/session-settings.d.ts +0 -1
  52. package/dist/cjs/session-settings.js +0 -1
  53. package/dist/cjs/session.d.ts +213 -0
  54. package/dist/cjs/session.js +337 -0
  55. package/dist/cjs/tls-tunnel.d.ts +143 -0
  56. package/dist/cjs/tls-tunnel.js +261 -0
  57. package/dist/esm/acceptor.d.ts +0 -1
  58. package/dist/esm/acceptor.js +0 -1
  59. package/dist/esm/application.d.ts +0 -1
  60. package/dist/esm/application.js +0 -1
  61. package/dist/esm/data-dictionary.d.ts +21 -3
  62. package/dist/esm/data-dictionary.js +32 -4
  63. package/dist/esm/engine.d.ts +42 -4
  64. package/dist/esm/engine.js +51 -6
  65. package/dist/esm/enums.d.ts +48 -14
  66. package/dist/esm/enums.js +14 -24
  67. package/dist/esm/field-value.d.ts +12 -0
  68. package/dist/esm/field-value.js +37 -0
  69. package/dist/esm/generated/fields.d.ts +0 -1
  70. package/dist/esm/generated/fields.js +0 -1
  71. package/dist/esm/generated/values.d.ts +8548 -0
  72. package/dist/esm/generated/values.js +8564 -0
  73. package/dist/esm/group.d.ts +126 -0
  74. package/dist/esm/group.js +181 -0
  75. package/dist/esm/index.d.ts +28 -5
  76. package/dist/esm/index.js +18 -26
  77. package/dist/esm/initiator.d.ts +0 -1
  78. package/dist/esm/initiator.js +0 -1
  79. package/dist/esm/message.d.ts +129 -5
  80. package/dist/esm/message.js +146 -9
  81. package/dist/esm/native.d.ts +179 -17
  82. package/dist/esm/native.js +0 -1
  83. package/dist/esm/session-id.d.ts +12 -1
  84. package/dist/esm/session-id.js +20 -1
  85. package/dist/esm/session-settings.d.ts +0 -1
  86. package/dist/esm/session-settings.js +0 -1
  87. package/dist/esm/session.d.ts +213 -0
  88. package/dist/esm/session.js +329 -0
  89. package/dist/esm/tls-tunnel.d.ts +143 -0
  90. package/dist/esm/tls-tunnel.js +256 -0
  91. package/package.json +13 -1
  92. package/prebuilds/darwin-arm64/node.napi.node +0 -0
  93. package/prebuilds/darwin-x64/node.napi.node +0 -0
  94. package/prebuilds/linux-x64/node.napi.glibc.node +0 -0
  95. package/prebuilds/linux-x64/node.napi.musl.node +0 -0
  96. package/prebuilds/win32-x64/node.napi.node +0 -0
  97. package/cpp/enums.cpp +0 -66
  98. package/dist/cjs/acceptor.d.ts.map +0 -1
  99. package/dist/cjs/acceptor.js.map +0 -1
  100. package/dist/cjs/application.d.ts.map +0 -1
  101. package/dist/cjs/application.js.map +0 -1
  102. package/dist/cjs/data-dictionary.d.ts.map +0 -1
  103. package/dist/cjs/data-dictionary.js.map +0 -1
  104. package/dist/cjs/engine.d.ts.map +0 -1
  105. package/dist/cjs/engine.js.map +0 -1
  106. package/dist/cjs/enums.d.ts.map +0 -1
  107. package/dist/cjs/enums.js.map +0 -1
  108. package/dist/cjs/generated/fields.d.ts.map +0 -1
  109. package/dist/cjs/generated/fields.js.map +0 -1
  110. package/dist/cjs/index.d.ts.map +0 -1
  111. package/dist/cjs/index.js.map +0 -1
  112. package/dist/cjs/initiator.d.ts.map +0 -1
  113. package/dist/cjs/initiator.js.map +0 -1
  114. package/dist/cjs/message.d.ts.map +0 -1
  115. package/dist/cjs/message.js.map +0 -1
  116. package/dist/cjs/native.d.ts.map +0 -1
  117. package/dist/cjs/native.js.map +0 -1
  118. package/dist/cjs/session-id.d.ts.map +0 -1
  119. package/dist/cjs/session-id.js.map +0 -1
  120. package/dist/cjs/session-settings.d.ts.map +0 -1
  121. package/dist/cjs/session-settings.js.map +0 -1
  122. package/dist/esm/acceptor.d.ts.map +0 -1
  123. package/dist/esm/acceptor.js.map +0 -1
  124. package/dist/esm/application.d.ts.map +0 -1
  125. package/dist/esm/application.js.map +0 -1
  126. package/dist/esm/data-dictionary.d.ts.map +0 -1
  127. package/dist/esm/data-dictionary.js.map +0 -1
  128. package/dist/esm/engine.d.ts.map +0 -1
  129. package/dist/esm/engine.js.map +0 -1
  130. package/dist/esm/enums.d.ts.map +0 -1
  131. package/dist/esm/enums.js.map +0 -1
  132. package/dist/esm/generated/fields.d.ts.map +0 -1
  133. package/dist/esm/generated/fields.js.map +0 -1
  134. package/dist/esm/index.d.ts.map +0 -1
  135. package/dist/esm/index.js.map +0 -1
  136. package/dist/esm/initiator.d.ts.map +0 -1
  137. package/dist/esm/initiator.js.map +0 -1
  138. package/dist/esm/message.d.ts.map +0 -1
  139. package/dist/esm/message.js.map +0 -1
  140. package/dist/esm/native.d.ts.map +0 -1
  141. package/dist/esm/native.js.map +0 -1
  142. package/dist/esm/session-id.d.ts.map +0 -1
  143. package/dist/esm/session-id.js.map +0 -1
  144. package/dist/esm/session-settings.d.ts.map +0 -1
  145. 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
- file(GLOB ADDON_SOURCES "${CMAKE_SOURCE_DIR}/cpp/*.cpp")
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) *and* the pure layer (`Message`,
27
- `SessionSettings`, `DataDictionary`, `SessionID`).
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?: { validate?: boolean });
210
- Message.parse(raw: string, opts?: { validate?: boolean }): Message;
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?: { validate?: boolean }): Message;
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; // graceful drain by default; force to abort
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
- FIELD // field-name -> tag number, e.g. FIELD.MsgType === 35, FIELD.Password === 554
311
- MsgType // MsgType values, e.g. MsgType.Logon === 'A'
312
- Side // Side values, e.g. Side.Buy === '1'
313
- enums // the full frozen tree of FIX constants
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 table is also exposed as a subpath export that does **not** load the native addon,
322
- for tooling that only needs tag numbers (log parsers, test helpers, bundles for platforms
323
- without a prebuild):
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