jspurefix 5.9.2 → 5.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +35 -0
  2. package/dist/buffer/ascii/ascii-encoder.d.ts +1 -0
  3. package/dist/buffer/ascii/ascii-encoder.js +15 -0
  4. package/dist/buffer/ascii/ascii-encoder.js.map +1 -1
  5. package/dist/buffer/msg-encoder.d.ts +2 -0
  6. package/dist/buffer/msg-encoder.js +1 -0
  7. package/dist/buffer/msg-encoder.js.map +1 -1
  8. package/dist/runtime/session-container.d.ts +3 -0
  9. package/dist/runtime/session-container.js +8 -0
  10. package/dist/runtime/session-container.js.map +1 -1
  11. package/dist/runtime/session-launcher.d.ts +2 -1
  12. package/dist/runtime/session-launcher.js +4 -1
  13. package/dist/runtime/session-launcher.js.map +1 -1
  14. package/dist/transport/ascii/ascii-msg-transmitter.d.ts +1 -0
  15. package/dist/transport/ascii/ascii-msg-transmitter.js +15 -0
  16. package/dist/transport/ascii/ascii-msg-transmitter.js.map +1 -1
  17. package/dist/transport/ascii/ascii-session-msg-factory.js +1 -7
  18. package/dist/transport/ascii/ascii-session-msg-factory.js.map +1 -1
  19. package/dist/transport/fixml/fixml-session-msg-factory.js +1 -6
  20. package/dist/transport/fixml/fixml-session-msg-factory.js.map +1 -1
  21. package/dist/transport/index.d.ts +3 -0
  22. package/dist/transport/index.js +3 -0
  23. package/dist/transport/index.js.map +1 -1
  24. package/dist/transport/msg-payload.d.ts +3 -1
  25. package/dist/transport/msg-payload.js +2 -1
  26. package/dist/transport/msg-payload.js.map +1 -1
  27. package/dist/transport/msg-transmitter.d.ts +4 -1
  28. package/dist/transport/msg-transmitter.js +18 -2
  29. package/dist/transport/msg-transmitter.js.map +1 -1
  30. package/dist/transport/send-callback.d.ts +7 -0
  31. package/dist/transport/send-callback.js +3 -0
  32. package/dist/transport/send-callback.js.map +1 -0
  33. package/dist/transport/session/fix-session.d.ts +2 -1
  34. package/dist/transport/session/fix-session.js +12 -3
  35. package/dist/transport/session/fix-session.js.map +1 -1
  36. package/dist/transport/session/session-description.d.ts +2 -0
  37. package/dist/transport/session/session-description.js.map +1 -1
  38. package/docs/custom-logon.md +225 -0
  39. package/jsfix.test_client.txt +62 -62
  40. package/jsfix.test_server.txt +72 -72
  41. package/package.json +1 -1
  42. package/src/buffer/ascii/ascii-encoder.ts +20 -0
  43. package/src/buffer/msg-encoder.ts +12 -0
  44. package/src/runtime/session-container.ts +20 -0
  45. package/src/runtime/session-launcher.ts +19 -2
  46. package/src/transport/ascii/ascii-msg-transmitter.ts +20 -0
  47. package/src/transport/ascii/ascii-session-msg-factory.ts +6 -1
  48. package/src/transport/fixml/fixml-session-msg-factory.ts +3 -1
  49. package/src/transport/index.ts +5 -0
  50. package/src/transport/msg-payload.ts +5 -1
  51. package/src/transport/msg-transmitter.ts +31 -2
  52. package/src/transport/send-callback.ts +33 -0
  53. package/src/transport/session/fix-session.ts +22 -3
  54. package/src/transport/session/session-description.ts +14 -0
@@ -20,7 +20,23 @@ import { FixmlEncoder, FiXmlParser } from '../buffer/fixml'
20
20
  import { FixEntity } from '../transport/fix-entity'
21
21
  import { IHttpAdapter } from '../transport/http/http-adapter'
22
22
 
23
+ /**
24
+ * Supplies the session message factory for a description. Return null to accept the
25
+ * stock factory for the protocol.
26
+ */
27
+ export type SessionMsgFactoryProvider = (description: ISessionDescription) => ISessionMsgFactory | null
28
+
23
29
  export class SessionContainer {
30
+ /**
31
+ * @param msgFactoryProvider an application that needs its own session messages -
32
+ * typically a Logon carrying tags the standard message does not - can pass one
33
+ * here rather than subclassing to override makeSessionFactory:
34
+ *
35
+ * new SessionContainer(d => new MyFactory(d))
36
+ */
37
+ constructor (public readonly msgFactoryProvider: SessionMsgFactoryProvider | null = null) {
38
+ }
39
+
24
40
  public reset (): void {
25
41
  container.reset()
26
42
  }
@@ -45,6 +61,10 @@ export class SessionContainer {
45
61
  }
46
62
 
47
63
  protected makeSessionFactory (description: ISessionDescription): ISessionMsgFactory {
64
+ const supplied = this.msgFactoryProvider?.(description)
65
+ if (supplied) {
66
+ return supplied
67
+ }
48
68
  const fixml = !this.isAscii(description)
49
69
  return fixml
50
70
  ? new FixmlSessionMsgFactory(description)
@@ -1,6 +1,6 @@
1
1
  import * as path from 'path'
2
2
  import { IJsFixConfig, IJsFixLogger, JsFixLoggerFactory, JsFixWinstonLogFactory, WinstonLogger } from '../config'
3
- import { FixEntity, FixSession, ISessionDescription } from '../transport'
3
+ import { FixEntity, FixSession, ISessionDescription, ISessionMsgFactory } from '../transport'
4
4
  import { DependencyContainer } from 'tsyringe'
5
5
  import { EngineFactory } from './engine-factory'
6
6
  import { SessionContainer } from './session-container'
@@ -24,7 +24,24 @@ export abstract class SessionLauncher {
24
24
  this.acceptorConfig = acceptorConfig ? this.loadConfig(acceptorConfig) : null
25
25
  }
26
26
 
27
- protected sessionContainer: SessionContainer = new SessionContainer()
27
+ // the arrow defers to makeSessionMsgFactory at resolve time, so a subclass need
28
+ // only override that one method - no SessionContainer subclass required
29
+ protected sessionContainer: SessionContainer =
30
+ new SessionContainer((d: ISessionDescription) => this.makeSessionMsgFactory(d))
31
+
32
+ /**
33
+ * Override to supply this application's own session message factory - the place to
34
+ * put a Logon carrying tags the standard message does not, or a header a
35
+ * counterparty stamps differently. Return null (the default) for the stock
36
+ * factory.
37
+ *
38
+ * For a Logon that only needs extra fields, prefer the "Logon" block in the session
39
+ * description - it needs no code at all. Reach for a factory when the values are
40
+ * computed at run time (a signature, a nonce, a token fetched at start up).
41
+ */
42
+ protected makeSessionMsgFactory (_description: ISessionDescription): ISessionMsgFactory | null {
43
+ return null
44
+ }
28
45
 
29
46
  private async empty (): Promise<any> {
30
47
  return await new Promise((resolve, reject) => {
@@ -28,6 +28,26 @@ export class AsciiMsgTransmitter extends MsgTransmitter {
28
28
  const components = config.definitions.component
29
29
  this.header = components.get('StandardHeader')
30
30
  this.trailer = components.get('StandardTrailer')
31
+ this.reportUnknownFields(config)
32
+ }
33
+
34
+ /**
35
+ * A field the dictionary does not declare on the message being encoded has no tag,
36
+ * so it cannot be written and is dropped. Say so once per offending field - the
37
+ * alternative, and what this library did for years, is that a bespoke Logon tag
38
+ * simply never appears on the wire and nothing anywhere explains why.
39
+ */
40
+ private reportUnknownFields (config: IJsFixConfig): void {
41
+ const name = config.description.application?.name ?? 'na'
42
+ const logger = config.logFactory.logger(`${name}:encoder`)
43
+ const reported = new Set<string>()
44
+ this.encoder.onUnknownField = (fieldName: string, setName: string) => {
45
+ const key = `${setName}.${fieldName}`
46
+ if (reported.has(key)) return
47
+ reported.add(key)
48
+ logger.warning(`field '${fieldName}' is not declared on ${setName} in this dictionary - it will not be sent`)
49
+ this.emit('unknown-field', fieldName, setName)
50
+ }
31
51
  }
32
52
 
33
53
  private checksum (): number {
@@ -23,7 +23,12 @@ export class AsciiSessionMsgFactory extends ASessionMsgFactory {
23
23
  Password: description.Password,
24
24
  HeartBtInt: description.HeartBtInt,
25
25
  ResetSeqNumFlag: description.ResetSeqNumFlag,
26
- EncryptMethod: EncryptMethod.None
26
+ EncryptMethod: EncryptMethod.None,
27
+ // a counterparty demanding a tag the standard Logon does not carry needs no
28
+ // code at all - name it under "Logon" in the session description. Spread last
29
+ // so an application can also override what the engine derived, or null out a
30
+ // field it does not want sent. See issue #93.
31
+ ...description.Logon
27
32
  } as ILogon
28
33
  return this.mutate(o, MsgType.Logon)
29
34
  }
@@ -42,7 +42,9 @@ export class FixmlSessionMsgFactory extends ASessionMsgFactory {
42
42
  Username: description.Username,
43
43
  Password: description.Password,
44
44
  UserRequestID: userRequestId,
45
- UserRequestType: UserRequestType.LogOnUser
45
+ UserRequestType: UserRequestType.LogOnUser,
46
+ // same escape hatch as the ascii factory - see ISessionDescription.Logon
47
+ ...description.Logon
46
48
  } as IUserRequest
47
49
  return this.mutate(o, MsgType.Logon)
48
50
  } else {
@@ -4,7 +4,12 @@ export * from './fix-acceptor'
4
4
  export * from './fix-initiator'
5
5
  export * from './msg-transmitter'
6
6
  export * from './msg-payload'
7
+ export * from './send-callback'
7
8
  export * from './tick-action'
8
9
  export * from './msg-application'
9
10
  export * from './ascii/ascii-session'
11
+ // the factory an application extends to customise its Logon or header - it belongs
12
+ // at the root alongside ASessionMsgFactory, not behind a dist/ path
13
+ export * from './ascii/ascii-session-msg-factory'
14
+ export * from './fixml/fixml-session-msg-factory'
10
15
  export * from './session'
@@ -1,7 +1,11 @@
1
1
  import { ILooseObject } from '../collections/collection'
2
+ import { SendCallback } from './send-callback'
2
3
 
3
4
  export class MsgPayload {
4
5
  public encoded: Buffer
5
- constructor (public readonly msgType: string, public readonly obj: ILooseObject) {
6
+ constructor (public readonly msgType: string,
7
+ public readonly obj: ILooseObject,
8
+ /** invoked once this payload has been encoded, or has failed to encode */
9
+ public readonly callback: SendCallback | null = null) {
6
10
  }
7
11
  }
@@ -4,6 +4,7 @@ import { Transform } from 'stream'
4
4
  import { MsgPayload } from './msg-payload'
5
5
  import { ILooseObject } from '../collections/collection'
6
6
  import { ISessionDescription } from './session/session-description'
7
+ import { SendCallback } from './send-callback'
7
8
  import * as events from 'events'
8
9
 
9
10
  export abstract class MsgTransmitter extends events.EventEmitter {
@@ -24,8 +25,23 @@ export abstract class MsgTransmitter extends events.EventEmitter {
24
25
  }
25
26
 
26
27
  // messages at front, byte stream at back
27
- public send (msgType: string, obj: ILooseObject): void {
28
- this.encodeStream.write(new MsgPayload(msgType, obj))
28
+ public send (msgType: string, obj: ILooseObject, callback: SendCallback | null = null): void {
29
+ this.encodeStream.write(new MsgPayload(msgType, obj, callback))
30
+ }
31
+
32
+ /**
33
+ * Report a send outcome to whoever asked for one, without letting their code break
34
+ * the encode stream - a throwing callback would otherwise surface as a transform
35
+ * failure and be indistinguishable from an encoding fault.
36
+ */
37
+ protected settle (payload: MsgPayload, error: Error | null, header: ILooseObject | null): void {
38
+ const callback = payload.callback
39
+ if (!callback) return
40
+ try {
41
+ callback(error, { msgType: payload.msgType, header, encoded: payload.encoded ?? null })
42
+ } catch (e) {
43
+ this.emit('error', e)
44
+ }
29
45
  }
30
46
 
31
47
  public abstract encodeMessage (msgType: string, obj: ILooseObject): (ILooseObject | null)
@@ -46,8 +62,21 @@ export abstract class MsgTransmitter extends events.EventEmitter {
46
62
  this.push(payload.encoded)
47
63
  const encodedTxt = transmitter.buffer.toString()
48
64
  transmitter.emit('encoded', msgType, encodedTxt, state)
65
+ // encodeMessage returns null when it could not build the message - an
66
+ // unknown msgType, or a factory that produced no header. It reports that
67
+ // on the error channel and carries on, so without this the callback would
68
+ // announce success for a message that never formed.
69
+ state
70
+ ? transmitter.settle(payload, null, state)
71
+ : transmitter.settle(payload, new Error(`could not encode ${msgType}`), null)
72
+ // note: the second argument to a Transform callback is *pushed to the
73
+ // readable side*, and this readable side is piped to the socket - so a
74
+ // result cannot be returned that way without corrupting the byte stream.
75
+ // Hence the per-payload callback above.
49
76
  done()
50
77
  } catch (e) {
78
+ transmitter.settle(payload, e as Error, null)
79
+ // still fail the stream, so the session's error handling is unchanged
51
80
  done(e)
52
81
  }
53
82
  }
@@ -0,0 +1,33 @@
1
+ import { ILooseObject } from '../collections/collection'
2
+
3
+ /**
4
+ * What a send produced. `header` is the StandardHeader the engine stamped on the
5
+ * message - the sequence number it went out under, the sending time, the comp ids -
6
+ * which is the piece applications most often need to correlate an outbound message
7
+ * with an acknowledgement, a database row, or a resend.
8
+ */
9
+ export interface SendResult {
10
+ msgType: string
11
+ /** the stamped StandardHeader, or null if the message never got that far */
12
+ header: ILooseObject | null
13
+ /** the bytes written to the transport */
14
+ encoded: Buffer | null
15
+ }
16
+
17
+ /**
18
+ * Called once per `send`, after the message has been encoded and pushed to the
19
+ * transport, or as soon as encoding fails.
20
+ *
21
+ * `send` is otherwise fire and forget: it writes into a stream and returns, so
22
+ * nothing tells the caller which sequence number their message went out under, and a
23
+ * failure surfaces on the session's error channel with no way to tell which call
24
+ * caused it. See https://github.com/TimelordUK/jspurefix/issues/86
25
+ *
26
+ * Note this fires when the message has been *encoded and handed to the transport*,
27
+ * not when the socket has flushed it - the FIX session layer has no acknowledgement
28
+ * of the latter either.
29
+ *
30
+ * A callback that throws is reported on the transmitter's error channel rather than
31
+ * being allowed to tear down the encode stream.
32
+ */
33
+ export type SendCallback = (error: Error | null, result: SendResult) => void
@@ -4,6 +4,7 @@ import { FixSessionState } from './fix-session-state'
4
4
  import { MsgTransport } from '../factory'
5
5
  import { MsgTag } from '../../types'
6
6
  import { ILooseObject } from '../../collections/collection'
7
+ import { SendCallback } from '../send-callback'
7
8
 
8
9
  import * as events from 'events'
9
10
  import { SessionState } from './session-state'
@@ -401,17 +402,35 @@ export abstract class FixSession extends events.EventEmitter {
401
402
  }
402
403
  }
403
404
 
404
- protected send (msgType: string, obj: ILooseObject): void {
405
+ /**
406
+ * @param callback optional, invoked once the message has been encoded and handed to
407
+ * the transport, or as soon as that fails. Without it a send is fire and forget:
408
+ * nothing tells the caller which sequence number the message went out under, and a
409
+ * message dropped because the session is down, or refused by the encoder, goes
410
+ * unanswered. See https://github.com/TimelordUK/jspurefix/issues/86
411
+ */
412
+ protected send (msgType: string, obj: ILooseObject, callback: SendCallback | null = null): void {
405
413
  const state = this.sessionState.state
406
414
  switch (state) {
407
415
  case SessionState.Stopped: {
408
- this.sessionLogger.warning(`can't send in state ${this.stateString()}`)
416
+ const msg = `can't send in state ${this.stateString()}`
417
+ this.sessionLogger.warning(msg)
418
+ // the message is dropped either way - but a caller that asked to be told is
419
+ // now told, instead of watching for a log line
420
+ callback?.(new Error(msg), { msgType, header: null, encoded: null })
409
421
  break
410
422
  }
411
423
 
412
424
  default: {
413
425
  this.sessionState.LastSentAt = new Date()
414
- this.transport?.transmitter.send(msgType, obj)
426
+ const transmitter = this.transport?.transmitter
427
+ if (!transmitter) {
428
+ const msg = `no transport to send ${msgType} on`
429
+ this.sessionLogger.warning(msg)
430
+ callback?.(new Error(msg), { msgType, header: null, encoded: null })
431
+ break
432
+ }
433
+ transmitter.send(msgType, obj, callback)
415
434
  break
416
435
  }
417
436
  }
@@ -1,5 +1,6 @@
1
1
  import { IMsgApplication } from '../msg-application'
2
2
  import { StoreConfig } from '../../store/store-config'
3
+ import { ILooseObject } from '../../collections/collection'
3
4
 
4
5
  export interface IDynamicSessionParams {
5
6
  readonly Name: string
@@ -15,6 +16,19 @@ export interface IDynamicSessionParams {
15
16
 
16
17
  export interface ISessionDescription extends IDynamicSessionParams {
17
18
  readonly application?: IMsgApplication
19
+ /**
20
+ * Extra fields to place on the outbound Logon, merged over the ones the engine
21
+ * derives from this description. Counterparties routinely demand a tag the
22
+ * standard Logon does not carry - an account, a default application version, a
23
+ * free text token - and this is the no-code way to send it:
24
+ *
25
+ * "Logon": { "Account": "TVKD_001", "DefaultApplVerID": "9" }
26
+ *
27
+ * A field named here still has to exist on Logon in the dictionary the session
28
+ * loads, otherwise the encoder has no tag to write it to. Set a field to null to
29
+ * suppress one the engine would otherwise send. See issues #93, #39, #96.
30
+ */
31
+ readonly Logon?: ILooseObject
18
32
  HeartBtInt: number
19
33
  LastSentSeqNum?: number
20
34
  readonly LastReceivedSeqNum?: number