@kronos-ts/axon-server 0.1.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/dist/axon-server-event-store.d.ts +16 -0
- package/dist/axon-server-event-store.d.ts.map +1 -0
- package/dist/axon-server-event-store.js +282 -0
- package/dist/axon-server-event-store.js.map +1 -0
- package/dist/axon-server-snapshot-store.d.ts +12 -0
- package/dist/axon-server-snapshot-store.d.ts.map +1 -0
- package/dist/axon-server-snapshot-store.js +88 -0
- package/dist/axon-server-snapshot-store.js.map +1 -0
- package/dist/axon-server.d.ts +115 -0
- package/dist/axon-server.d.ts.map +1 -0
- package/dist/axon-server.js +986 -0
- package/dist/axon-server.js.map +1 -0
- package/dist/connection-manager.d.ts +49 -0
- package/dist/connection-manager.d.ts.map +1 -0
- package/dist/connection-manager.js +37 -0
- package/dist/connection-manager.js.map +1 -0
- package/dist/connection.d.ts +129 -0
- package/dist/connection.d.ts.map +1 -0
- package/dist/connection.js +130 -0
- package/dist/connection.js.map +1 -0
- package/dist/errors.d.ts +96 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +189 -0
- package/dist/errors.js.map +1 -0
- package/dist/event-processor-info.d.ts +35 -0
- package/dist/event-processor-info.d.ts.map +1 -0
- package/dist/event-processor-info.js +28 -0
- package/dist/event-processor-info.js.map +1 -0
- package/dist/flow-controlled-sender.d.ts +30 -0
- package/dist/flow-controlled-sender.d.ts.map +1 -0
- package/dist/flow-controlled-sender.js +60 -0
- package/dist/flow-controlled-sender.js.map +1 -0
- package/dist/generated/command.d.ts +158 -0
- package/dist/generated/command.d.ts.map +1 -0
- package/dist/generated/command.js +970 -0
- package/dist/generated/command.js.map +1 -0
- package/dist/generated/common.d.ts +130 -0
- package/dist/generated/common.d.ts.map +1 -0
- package/dist/generated/common.js +908 -0
- package/dist/generated/common.js.map +1 -0
- package/dist/generated/control.d.ts +293 -0
- package/dist/generated/control.d.ts.map +1 -0
- package/dist/generated/control.js +1938 -0
- package/dist/generated/control.js.map +1 -0
- package/dist/generated/dcb.d.ts +650 -0
- package/dist/generated/dcb.d.ts.map +1 -0
- package/dist/generated/dcb.js +2943 -0
- package/dist/generated/dcb.js.map +1 -0
- package/dist/generated/event.d.ts +667 -0
- package/dist/generated/event.d.ts.map +1 -0
- package/dist/generated/event.js +3185 -0
- package/dist/generated/event.js.map +1 -0
- package/dist/generated/google/protobuf/empty.d.ts +30 -0
- package/dist/generated/google/protobuf/empty.d.ts.map +1 -0
- package/dist/generated/google/protobuf/empty.js +46 -0
- package/dist/generated/google/protobuf/empty.js.map +1 -0
- package/dist/generated/query.d.ts +300 -0
- package/dist/generated/query.d.ts.map +1 -0
- package/dist/generated/query.js +2183 -0
- package/dist/generated/query.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/message-size.d.ts +38 -0
- package/dist/message-size.d.ts.map +1 -0
- package/dist/message-size.js +57 -0
- package/dist/message-size.js.map +1 -0
- package/dist/metadata-conversion.d.ts +11 -0
- package/dist/metadata-conversion.d.ts.map +1 -0
- package/dist/metadata-conversion.js +51 -0
- package/dist/metadata-conversion.js.map +1 -0
- package/dist/outbound-stream.d.ts +15 -0
- package/dist/outbound-stream.d.ts.map +1 -0
- package/dist/outbound-stream.js +39 -0
- package/dist/outbound-stream.js.map +1 -0
- package/dist/platform-service.d.ts +119 -0
- package/dist/platform-service.d.ts.map +1 -0
- package/dist/platform-service.js +250 -0
- package/dist/platform-service.js.map +1 -0
- package/dist/shutdown-latch.d.ts +38 -0
- package/dist/shutdown-latch.d.ts.map +1 -0
- package/dist/shutdown-latch.js +51 -0
- package/dist/shutdown-latch.js.map +1 -0
- package/package.json +69 -0
- package/src/axon-server-event-store.ts +358 -0
- package/src/axon-server-snapshot-store.ts +118 -0
- package/src/axon-server.ts +1202 -0
- package/src/connection-manager.ts +88 -0
- package/src/connection.ts +272 -0
- package/src/errors.ts +223 -0
- package/src/event-processor-info.ts +62 -0
- package/src/flow-controlled-sender.ts +91 -0
- package/src/generated/command.ts +1231 -0
- package/src/generated/common.ts +1097 -0
- package/src/generated/control.ts +2419 -0
- package/src/generated/dcb.ts +3826 -0
- package/src/generated/event.ts +4076 -0
- package/src/generated/google/protobuf/empty.ts +84 -0
- package/src/generated/query.ts +2723 -0
- package/src/index.ts +75 -0
- package/src/message-size.ts +75 -0
- package/src/metadata-conversion.ts +46 -0
- package/src/outbound-stream.ts +52 -0
- package/src/platform-service.ts +361 -0
- package/src/shutdown-latch.ts +97 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type AxonServerConnectionConfig,
|
|
3
|
+
type AxonServerConnection,
|
|
4
|
+
connectToAxonServer,
|
|
5
|
+
} from "./connection.js"
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
type AxonServerConnectionManager,
|
|
9
|
+
createConnectionManager,
|
|
10
|
+
} from "./connection-manager.js"
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
createAxonServerEventStore,
|
|
14
|
+
} from "./axon-server-event-store.js"
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
createAxonServerSnapshotStore,
|
|
18
|
+
} from "./axon-server-snapshot-store.js"
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
axonServer,
|
|
22
|
+
type AxonServerExtensionConfig,
|
|
23
|
+
type FlowControlConfig,
|
|
24
|
+
type ProcessingInstructions,
|
|
25
|
+
} from "./axon-server.js"
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
type MessageSizeConfig,
|
|
29
|
+
MessageSizeExceededError,
|
|
30
|
+
createMessageSizeValidator,
|
|
31
|
+
} from "./message-size.js"
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
type ProcessorStatus,
|
|
35
|
+
type SegmentStatus,
|
|
36
|
+
type ProcessorStatusSupplier,
|
|
37
|
+
toEventProcessorInfo,
|
|
38
|
+
} from "./event-processor-info.js"
|
|
39
|
+
|
|
40
|
+
export {
|
|
41
|
+
type PlatformConnection,
|
|
42
|
+
type PlatformInstruction,
|
|
43
|
+
type InstructionHandler,
|
|
44
|
+
type PlatformServiceOptions,
|
|
45
|
+
createPlatformConnection,
|
|
46
|
+
} from "./platform-service.js"
|
|
47
|
+
|
|
48
|
+
export {
|
|
49
|
+
type FlowControlledSender,
|
|
50
|
+
createFlowControlledSender,
|
|
51
|
+
} from "./flow-controlled-sender.js"
|
|
52
|
+
|
|
53
|
+
export {
|
|
54
|
+
type ShutdownLatch,
|
|
55
|
+
type ActivityHandle,
|
|
56
|
+
ShutdownInProgressError,
|
|
57
|
+
createShutdownLatch,
|
|
58
|
+
} from "./shutdown-latch.js"
|
|
59
|
+
|
|
60
|
+
export {
|
|
61
|
+
AxonServerErrorCode,
|
|
62
|
+
type AxonServerErrorCodeValue,
|
|
63
|
+
AxonServerError,
|
|
64
|
+
NoHandlerForCommandError,
|
|
65
|
+
NoHandlerForQueryError,
|
|
66
|
+
CommandExecutionError,
|
|
67
|
+
QueryExecutionError,
|
|
68
|
+
CommandDispatchError,
|
|
69
|
+
QueryDispatchError,
|
|
70
|
+
ConcurrencyError,
|
|
71
|
+
ConnectionFailedError,
|
|
72
|
+
AuthenticationError,
|
|
73
|
+
mapErrorCode,
|
|
74
|
+
isTransientError,
|
|
75
|
+
} from "./errors.js"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message size validation for Axon Server gRPC communication.
|
|
3
|
+
*
|
|
4
|
+
* Axon Server has a maximum inbound message size (default: 4MB).
|
|
5
|
+
* This module pre-checks message sizes before sending and warns
|
|
6
|
+
* when approaching the limit.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Default max message size in bytes (4MB — Axon Server default). */
|
|
10
|
+
const DEFAULT_MAX_MESSAGE_SIZE = 4 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
/** Warning threshold as a fraction of max size. */
|
|
13
|
+
const WARNING_THRESHOLD = 0.75
|
|
14
|
+
|
|
15
|
+
export interface MessageSizeConfig {
|
|
16
|
+
/** Maximum message size in bytes. Default: 4MB */
|
|
17
|
+
maxMessageSize?: number
|
|
18
|
+
/** Warning threshold as a fraction (0-1). Default: 0.75 */
|
|
19
|
+
warningThreshold?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class MessageSizeExceededError extends Error {
|
|
23
|
+
readonly actualSize: number
|
|
24
|
+
readonly maxSize: number
|
|
25
|
+
|
|
26
|
+
constructor(actualSize: number, maxSize: number) {
|
|
27
|
+
super(
|
|
28
|
+
`Message size ${actualSize} bytes exceeds maximum ${maxSize} bytes`,
|
|
29
|
+
)
|
|
30
|
+
this.name = "MessageSizeExceededError"
|
|
31
|
+
this.actualSize = actualSize
|
|
32
|
+
this.maxSize = maxSize
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Creates a message size validator.
|
|
38
|
+
*
|
|
39
|
+
* - `validate(data)` — throws if over limit, warns if over 75%
|
|
40
|
+
* - `estimateSize(payload)` — quick byte size estimate
|
|
41
|
+
*/
|
|
42
|
+
export function createMessageSizeValidator(config?: MessageSizeConfig) {
|
|
43
|
+
const maxSize = config?.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE
|
|
44
|
+
const threshold = config?.warningThreshold ?? WARNING_THRESHOLD
|
|
45
|
+
const warningSize = Math.floor(maxSize * threshold)
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
/**
|
|
49
|
+
* Validate a serialized message size.
|
|
50
|
+
* Throws if over the limit, logs a warning if over the threshold.
|
|
51
|
+
*/
|
|
52
|
+
validate(data: Uint8Array, context?: string): void {
|
|
53
|
+
const size = data.byteLength
|
|
54
|
+
if (size > maxSize) {
|
|
55
|
+
throw new MessageSizeExceededError(size, maxSize)
|
|
56
|
+
}
|
|
57
|
+
if (size > warningSize) {
|
|
58
|
+
console.warn(
|
|
59
|
+
`Message size warning${context ? ` (${context})` : ""}: ` +
|
|
60
|
+
`${size} bytes is ${Math.round((size / maxSize) * 100)}% of max ${maxSize} bytes`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Estimate the serialized size of a payload (quick JSON length check).
|
|
67
|
+
*/
|
|
68
|
+
estimateSize(payload: unknown): number {
|
|
69
|
+
return new TextEncoder().encode(JSON.stringify(payload)).byteLength
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
/** The configured maximum size in bytes. */
|
|
73
|
+
maxSize,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Metadata } from "@kronos-ts/common"
|
|
2
|
+
import type { MetaDataValue } from "./generated/common.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Convert framework metadata to Axon Server proto metadata format.
|
|
6
|
+
*/
|
|
7
|
+
export function metadataToProto(metadata: Metadata): Record<string, MetaDataValue> {
|
|
8
|
+
const result: Record<string, MetaDataValue> = {}
|
|
9
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
10
|
+
if (typeof value === "string") {
|
|
11
|
+
result[key] = { textValue: value }
|
|
12
|
+
} else if (typeof value === "number") {
|
|
13
|
+
if (Number.isInteger(value)) {
|
|
14
|
+
result[key] = { numberValue: BigInt(value) }
|
|
15
|
+
} else {
|
|
16
|
+
result[key] = { doubleValue: value }
|
|
17
|
+
}
|
|
18
|
+
} else if (typeof value === "boolean") {
|
|
19
|
+
result[key] = { booleanValue: value }
|
|
20
|
+
} else if (typeof value === "bigint") {
|
|
21
|
+
result[key] = { numberValue: value }
|
|
22
|
+
} else if (value !== undefined && value !== null) {
|
|
23
|
+
result[key] = { textValue: JSON.stringify(value) }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return result
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Convert Axon Server proto metadata format to framework metadata.
|
|
31
|
+
*/
|
|
32
|
+
export function metadataFromProto(protoMeta: Record<string, MetaDataValue>): Metadata {
|
|
33
|
+
const result: Record<string, unknown> = {}
|
|
34
|
+
for (const [key, value] of Object.entries(protoMeta)) {
|
|
35
|
+
if (value.textValue !== undefined) {
|
|
36
|
+
result[key] = value.textValue
|
|
37
|
+
} else if (value.numberValue !== undefined) {
|
|
38
|
+
result[key] = Number(value.numberValue)
|
|
39
|
+
} else if (value.booleanValue !== undefined) {
|
|
40
|
+
result[key] = value.booleanValue
|
|
41
|
+
} else if (value.doubleValue !== undefined) {
|
|
42
|
+
result[key] = value.doubleValue
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return result
|
|
46
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A queue-backed async iterable for feeding outbound messages to a
|
|
3
|
+
* bidirectional gRPC stream. Buffers messages when the stream isn't
|
|
4
|
+
* consuming, and resolves promises when the stream is waiting.
|
|
5
|
+
*/
|
|
6
|
+
export interface OutboundStream<T> {
|
|
7
|
+
/** Send a message into the stream. */
|
|
8
|
+
send(message: T): void
|
|
9
|
+
/** The async iterable to pass to the gRPC client. */
|
|
10
|
+
readonly iterable: AsyncIterable<T>
|
|
11
|
+
/** Close the stream. */
|
|
12
|
+
close(): void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createOutboundStream<T>(): OutboundStream<T> {
|
|
16
|
+
let resolve: ((value: IteratorResult<T>) => void) | null = null
|
|
17
|
+
const queue: T[] = []
|
|
18
|
+
let closed = false
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
send(message: T) {
|
|
22
|
+
if (resolve) {
|
|
23
|
+
const r = resolve
|
|
24
|
+
resolve = null
|
|
25
|
+
r({ value: message, done: false })
|
|
26
|
+
} else {
|
|
27
|
+
queue.push(message)
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
iterable: {
|
|
32
|
+
[Symbol.asyncIterator]() {
|
|
33
|
+
return {
|
|
34
|
+
next(): Promise<IteratorResult<T>> {
|
|
35
|
+
const queued = queue.shift()
|
|
36
|
+
if (queued) return Promise.resolve({ value: queued, done: false })
|
|
37
|
+
if (closed) return Promise.resolve({ value: undefined as any, done: true })
|
|
38
|
+
return new Promise((r) => { resolve = r })
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
close() {
|
|
45
|
+
closed = true
|
|
46
|
+
if (resolve) {
|
|
47
|
+
resolve({ value: undefined as any, done: true })
|
|
48
|
+
resolve = null
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import type { AxonServerConnection } from "./connection.js"
|
|
2
|
+
import { createOutboundStream } from "./outbound-stream.js"
|
|
3
|
+
import type { PlatformInboundInstruction } from "./generated/control.js"
|
|
4
|
+
import { Metadata } from "nice-grpc"
|
|
5
|
+
import type { ProcessorStatusSupplier } from "./event-processor-info.js"
|
|
6
|
+
import { toEventProcessorInfo } from "./event-processor-info.js"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Server-initiated instructions received via the platform stream.
|
|
10
|
+
* Axon Server sends these to control processor behavior.
|
|
11
|
+
*/
|
|
12
|
+
export type PlatformInstruction =
|
|
13
|
+
| { kind: "pause-processor"; processorName: string }
|
|
14
|
+
| { kind: "start-processor"; processorName: string }
|
|
15
|
+
| { kind: "release-segment"; processorName: string; segmentId: number }
|
|
16
|
+
| { kind: "split-segment"; processorName: string; segmentId: number }
|
|
17
|
+
| { kind: "merge-segment"; processorName: string; segmentId: number }
|
|
18
|
+
| { kind: "command-handler-added"; componentName: string; commandName: string }
|
|
19
|
+
| { kind: "command-handler-removed"; componentName: string; commandName: string }
|
|
20
|
+
| { kind: "query-handler-added"; componentName: string; queryName: string }
|
|
21
|
+
| { kind: "query-handler-removed"; componentName: string; queryName: string }
|
|
22
|
+
| { kind: "reconnect-request" }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Callback for handling platform instructions from Axon Server.
|
|
26
|
+
*/
|
|
27
|
+
export type InstructionHandler = (instruction: PlatformInstruction) => void | Promise<void>
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Manages the PlatformService connection to Axon Server.
|
|
31
|
+
*
|
|
32
|
+
* Responsibilities:
|
|
33
|
+
* - Node discovery via `getPlatformServer()`
|
|
34
|
+
* - Bidirectional stream for topology management (`openStream()`)
|
|
35
|
+
* - Heartbeat protocol to detect dead connections
|
|
36
|
+
* - Receives server-initiated instructions (pause, resume, split, merge segments)
|
|
37
|
+
*/
|
|
38
|
+
export interface PlatformConnection {
|
|
39
|
+
/** Start the platform stream (register with Axon Server, begin heartbeats). */
|
|
40
|
+
start(): Promise<void>
|
|
41
|
+
/** Stop the platform stream and heartbeats. */
|
|
42
|
+
stop(): void
|
|
43
|
+
/** Register a handler for server-initiated instructions. */
|
|
44
|
+
onInstruction(handler: InstructionHandler): void
|
|
45
|
+
/**
|
|
46
|
+
* Register a supplier that provides event processor status.
|
|
47
|
+
* Status is reported to Axon Server periodically.
|
|
48
|
+
*/
|
|
49
|
+
registerProcessorStatusSupplier(supplier: ProcessorStatusSupplier): void
|
|
50
|
+
/** Whether the platform stream is active. */
|
|
51
|
+
readonly connected: boolean
|
|
52
|
+
/**
|
|
53
|
+
* Resolves with `true` once Axon Server has acknowledged this client's
|
|
54
|
+
* registration on the platform stream (we use the first server-originated
|
|
55
|
+
* inbound message — typically a heartbeat reply or platform instruction —
|
|
56
|
+
* as the ack signal). Resolves with `false` if the platform is not yet
|
|
57
|
+
* started.
|
|
58
|
+
*
|
|
59
|
+
* This mirrors the kronosdb implementation (Plan 09-03 / D-102): the
|
|
60
|
+
* native `axonServer` extension's `onStart('processors', ...)` hook polls
|
|
61
|
+
* this method via `withRetry` so the application waits exactly long enough
|
|
62
|
+
* for handler subscriptions to be routable, no longer or shorter — the
|
|
63
|
+
* Axon equivalent of dropping the legacy 1-second sleep.
|
|
64
|
+
*
|
|
65
|
+
* Implementation note: while Axon Server's outbound stream maintains a
|
|
66
|
+
* `pendingSubscriptions` map (per RESEARCH.md), the `register` frame is
|
|
67
|
+
* sent on the *platform* stream (separate from the bus streams) and Axon
|
|
68
|
+
* Server replies with a heartbeat tick / topology message at the
|
|
69
|
+
* configured interval. We treat the first inbound platform-stream frame
|
|
70
|
+
* as the ack signal — same observable derivation as kronosdb to keep the
|
|
71
|
+
* two extensions structurally symmetric.
|
|
72
|
+
*/
|
|
73
|
+
subscriptionsAcked(): Promise<boolean>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface PlatformServiceOptions {
|
|
77
|
+
/** Heartbeat interval in ms. Default: 10000 */
|
|
78
|
+
heartbeatIntervalMs?: number
|
|
79
|
+
/** Heartbeat timeout in ms. If no response within this window, reconnect. Default: 7500 */
|
|
80
|
+
heartbeatTimeoutMs?: number
|
|
81
|
+
/**
|
|
82
|
+
* Interval for reporting event processor status to Axon Server in ms.
|
|
83
|
+
* Aligned with Java's `processorsNotificationRate`. Default: 500.
|
|
84
|
+
*/
|
|
85
|
+
processorsNotificationRateMs?: number
|
|
86
|
+
/**
|
|
87
|
+
* Delay before first processor status report in ms.
|
|
88
|
+
* Aligned with Java's `processorsNotificationInitialDelay`. Default: 5000.
|
|
89
|
+
*/
|
|
90
|
+
processorsNotificationInitialDelayMs?: number
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Creates a PlatformService connection to Axon Server.
|
|
95
|
+
*
|
|
96
|
+
* The platform stream is the control plane — it:
|
|
97
|
+
* 1. Registers this client with Axon Server
|
|
98
|
+
* 2. Sends periodic heartbeats to verify connectivity
|
|
99
|
+
* 3. Receives instructions from Axon Server (split, merge, pause, resume)
|
|
100
|
+
*/
|
|
101
|
+
export function createPlatformConnection(
|
|
102
|
+
connection: AxonServerConnection,
|
|
103
|
+
options?: PlatformServiceOptions,
|
|
104
|
+
): PlatformConnection {
|
|
105
|
+
const heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 10000
|
|
106
|
+
const heartbeatTimeoutMs = options?.heartbeatTimeoutMs ?? 7500
|
|
107
|
+
const processorsNotificationRateMs = options?.processorsNotificationRateMs ?? 500
|
|
108
|
+
const processorsNotificationInitialDelayMs = options?.processorsNotificationInitialDelayMs ?? 5000
|
|
109
|
+
|
|
110
|
+
const instructionHandlers: InstructionHandler[] = []
|
|
111
|
+
const processorStatusSuppliers: ProcessorStatusSupplier[] = []
|
|
112
|
+
let isConnected = false
|
|
113
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
|
114
|
+
let heartbeatTimeoutTimer: ReturnType<typeof setTimeout> | null = null
|
|
115
|
+
let processorStatusTimer: ReturnType<typeof setInterval> | null = null
|
|
116
|
+
let lastHeartbeatResponse = Date.now()
|
|
117
|
+
let outbound: ReturnType<typeof createOutboundStream<PlatformInboundInstruction>> | null = null
|
|
118
|
+
/**
|
|
119
|
+
* Latches once Axon Server sends its first inbound message after
|
|
120
|
+
* registration — the earliest observable signal that the platform stream
|
|
121
|
+
* is fully wired (mirror of kronosdb Plan 09-03 / D-102, replaces the
|
|
122
|
+
* legacy 1-second sleep). Reset on every `start()` so a stop/start cycle
|
|
123
|
+
* re-arms the latch correctly.
|
|
124
|
+
*/
|
|
125
|
+
let acked = false
|
|
126
|
+
|
|
127
|
+
const grpcMetadata = new Metadata()
|
|
128
|
+
grpcMetadata.set("AxonIQ-Context", connection.config.context)
|
|
129
|
+
if (connection.config.token) {
|
|
130
|
+
grpcMetadata.set("AxonIQ-Access-Token", connection.config.token)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function processInboundInstructions(inbound: AsyncIterable<any>) {
|
|
134
|
+
try {
|
|
135
|
+
for await (const message of inbound) {
|
|
136
|
+
// First inbound message after start() = the platform has accepted our
|
|
137
|
+
// registration and is talking back. Latch the ack flag (mirror of
|
|
138
|
+
// kronosdb Plan 09-03 / D-102 — replaces the legacy 1s sleep).
|
|
139
|
+
acked = true
|
|
140
|
+
// Parse instruction type
|
|
141
|
+
const instruction = parseInstruction(message)
|
|
142
|
+
if (instruction) {
|
|
143
|
+
for (const handler of instructionHandlers) {
|
|
144
|
+
try {
|
|
145
|
+
await handler(instruction)
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.error("Platform instruction handler error:", err)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Handle heartbeat response — track last response time for timeout detection
|
|
153
|
+
if (message.heartbeat) {
|
|
154
|
+
lastHeartbeatResponse = Date.now()
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (isConnected) {
|
|
159
|
+
console.error("Platform stream error:", err)
|
|
160
|
+
isConnected = false
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function parseInstruction(message: any): PlatformInstruction | null {
|
|
166
|
+
if (message.requestReconnect) {
|
|
167
|
+
return { kind: "reconnect-request" }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Processor control instructions
|
|
171
|
+
const ctrl = message.eventProcessorControl
|
|
172
|
+
if (ctrl) {
|
|
173
|
+
const processorName = ctrl.processorName ?? ""
|
|
174
|
+
if (ctrl.pauseEventProcessor) {
|
|
175
|
+
return { kind: "pause-processor", processorName }
|
|
176
|
+
}
|
|
177
|
+
if (ctrl.startEventProcessor) {
|
|
178
|
+
return { kind: "start-processor", processorName }
|
|
179
|
+
}
|
|
180
|
+
if (ctrl.releaseSegment !== undefined) {
|
|
181
|
+
return { kind: "release-segment", processorName, segmentId: ctrl.releaseSegment.segmentId ?? 0 }
|
|
182
|
+
}
|
|
183
|
+
if (ctrl.splitEventProcessor !== undefined) {
|
|
184
|
+
return { kind: "split-segment", processorName, segmentId: ctrl.splitEventProcessor.segmentId ?? 0 }
|
|
185
|
+
}
|
|
186
|
+
if (ctrl.mergeEventProcessor !== undefined) {
|
|
187
|
+
return { kind: "merge-segment", processorName, segmentId: ctrl.mergeEventProcessor.segmentId ?? 0 }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Topology change instructions
|
|
192
|
+
const topo = message.topologyChange
|
|
193
|
+
if (topo) {
|
|
194
|
+
const componentName = topo.componentName ?? ""
|
|
195
|
+
if (topo.commandHandlerAdded) {
|
|
196
|
+
return { kind: "command-handler-added", componentName, commandName: topo.commandHandlerAdded.commandName ?? "" }
|
|
197
|
+
}
|
|
198
|
+
if (topo.commandHandlerRemoved) {
|
|
199
|
+
return { kind: "command-handler-removed", componentName, commandName: topo.commandHandlerRemoved.commandName ?? "" }
|
|
200
|
+
}
|
|
201
|
+
if (topo.queryHandlerAdded) {
|
|
202
|
+
return { kind: "query-handler-added", componentName, queryName: topo.queryHandlerAdded.queryName ?? "" }
|
|
203
|
+
}
|
|
204
|
+
if (topo.queryHandlerRemoved) {
|
|
205
|
+
return { kind: "query-handler-removed", componentName, queryName: topo.queryHandlerRemoved.queryName ?? "" }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return null
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function startHeartbeat() {
|
|
213
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer)
|
|
214
|
+
lastHeartbeatResponse = Date.now()
|
|
215
|
+
|
|
216
|
+
heartbeatTimer = setInterval(() => {
|
|
217
|
+
if (!isConnected || !outbound) return
|
|
218
|
+
|
|
219
|
+
// Check if last heartbeat response was too long ago
|
|
220
|
+
const timeSinceLastResponse = Date.now() - lastHeartbeatResponse
|
|
221
|
+
if (timeSinceLastResponse > heartbeatTimeoutMs) {
|
|
222
|
+
console.warn(
|
|
223
|
+
`Platform heartbeat timeout: no response in ${timeSinceLastResponse}ms ` +
|
|
224
|
+
`(threshold: ${heartbeatTimeoutMs}ms). Marking connection as lost.`,
|
|
225
|
+
)
|
|
226
|
+
isConnected = false
|
|
227
|
+
connection.reconnect().catch((err) => {
|
|
228
|
+
console.error("Failed to reconnect after heartbeat timeout:", err)
|
|
229
|
+
})
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
outbound.send({
|
|
234
|
+
heartbeat: {
|
|
235
|
+
clientId: connection.config.clientId,
|
|
236
|
+
},
|
|
237
|
+
instructionId: "",
|
|
238
|
+
})
|
|
239
|
+
}, heartbeatIntervalMs)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function startProcessorStatusReporting() {
|
|
243
|
+
if (processorStatusTimer) clearInterval(processorStatusTimer)
|
|
244
|
+
|
|
245
|
+
// Initial delay before first report
|
|
246
|
+
setTimeout(() => {
|
|
247
|
+
if (!isConnected) return
|
|
248
|
+
reportProcessorStatus()
|
|
249
|
+
|
|
250
|
+
// Then report at the configured rate
|
|
251
|
+
processorStatusTimer = setInterval(() => {
|
|
252
|
+
if (!isConnected || !outbound) return
|
|
253
|
+
reportProcessorStatus()
|
|
254
|
+
}, processorsNotificationRateMs)
|
|
255
|
+
}, processorsNotificationInitialDelayMs)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function reportProcessorStatus() {
|
|
259
|
+
if (!outbound || processorStatusSuppliers.length === 0) return
|
|
260
|
+
|
|
261
|
+
for (const supplier of processorStatusSuppliers) {
|
|
262
|
+
try {
|
|
263
|
+
const statuses = supplier()
|
|
264
|
+
for (const status of statuses) {
|
|
265
|
+
outbound.send({
|
|
266
|
+
eventProcessorInfo: toEventProcessorInfo(status),
|
|
267
|
+
instructionId: "",
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
} catch (err) {
|
|
271
|
+
console.warn("Failed to report processor status:", err)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
async start() {
|
|
278
|
+
if (isConnected) return
|
|
279
|
+
|
|
280
|
+
// Re-arm the ack latch so a stop/start cycle correctly re-waits.
|
|
281
|
+
acked = false
|
|
282
|
+
outbound = createOutboundStream<PlatformInboundInstruction>()
|
|
283
|
+
|
|
284
|
+
// Register with Axon Server
|
|
285
|
+
outbound.send({
|
|
286
|
+
register: {
|
|
287
|
+
clientId: connection.config.clientId,
|
|
288
|
+
componentName: connection.config.componentName,
|
|
289
|
+
version: "1.0.0",
|
|
290
|
+
tags: {},
|
|
291
|
+
},
|
|
292
|
+
instructionId: "",
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
// Open bidirectional stream
|
|
296
|
+
const inbound = connection.platform.openStream(outbound.iterable, {
|
|
297
|
+
metadata: grpcMetadata,
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
isConnected = true
|
|
301
|
+
startHeartbeat()
|
|
302
|
+
startProcessorStatusReporting()
|
|
303
|
+
processInboundInstructions(inbound)
|
|
304
|
+
|
|
305
|
+
// Axon Server's PlatformService does NOT proactively emit an inbound
|
|
306
|
+
// frame in response to `register` — the stream is held open silently
|
|
307
|
+
// until either (a) the server pushes a topology / instruction event,
|
|
308
|
+
// or (b) one of our heartbeat pings round-trips back. That means the
|
|
309
|
+
// first-inbound-frame ack signal used by kronosdb (Plan 09-03 / D-102)
|
|
310
|
+
// doesn't fire deterministically here, and the processors-stage
|
|
311
|
+
// `withRetry({event: "per-operation"})` poll would otherwise hang.
|
|
312
|
+
//
|
|
313
|
+
// Axon-specific ack derivation: latch `acked = true` immediately once
|
|
314
|
+
// the outbound `register` frame has been flushed to the gRPC layer.
|
|
315
|
+
// Bus subscriptions (sent on the command/query streams, NOT the
|
|
316
|
+
// platform stream) are an orthogonal concern handled by the
|
|
317
|
+
// command/query bus reconnect path — the legacy 1-second sleep that
|
|
318
|
+
// we replaced was always covering register processing, not bus-side
|
|
319
|
+
// routability. Structurally this is the Axon Server equivalent of
|
|
320
|
+
// D-102: drop the magic-number wait, use the earliest deterministic
|
|
321
|
+
// observable signal that fits the underlying protocol.
|
|
322
|
+
acked = true
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
stop() {
|
|
326
|
+
isConnected = false
|
|
327
|
+
if (heartbeatTimer) {
|
|
328
|
+
clearInterval(heartbeatTimer)
|
|
329
|
+
heartbeatTimer = null
|
|
330
|
+
}
|
|
331
|
+
if (heartbeatTimeoutTimer) {
|
|
332
|
+
clearTimeout(heartbeatTimeoutTimer)
|
|
333
|
+
heartbeatTimeoutTimer = null
|
|
334
|
+
}
|
|
335
|
+
if (processorStatusTimer) {
|
|
336
|
+
clearInterval(processorStatusTimer)
|
|
337
|
+
processorStatusTimer = null
|
|
338
|
+
}
|
|
339
|
+
if (outbound) {
|
|
340
|
+
outbound.close()
|
|
341
|
+
outbound = null
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
onInstruction(handler) {
|
|
346
|
+
instructionHandlers.push(handler)
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
registerProcessorStatusSupplier(supplier) {
|
|
350
|
+
processorStatusSuppliers.push(supplier)
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
get connected() {
|
|
354
|
+
return isConnected
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
async subscriptionsAcked(): Promise<boolean> {
|
|
358
|
+
return isConnected && acked
|
|
359
|
+
},
|
|
360
|
+
}
|
|
361
|
+
}
|