@canmingir/link-express 1.7.0 → 1.7.2

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.
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+
3
+ require("ts-node").register({
4
+ transpileOnly: true,
5
+ compilerOptions: {
6
+ module: "commonjs",
7
+ esModuleInterop: true,
8
+ },
9
+ });
10
+
11
+ const args = process.argv.slice(2);
12
+ const command = args[0];
13
+
14
+ function parseArgs(args: string[]) {
15
+ const options: Record<string, string> = {};
16
+ for (let i = 1; i < args.length; i++) {
17
+ if (args[i].startsWith("-p") || args[i].startsWith("--port")) {
18
+ options.port = args[i + 1];
19
+ i++;
20
+ }
21
+ }
22
+ return options;
23
+ }
24
+
25
+ if (command === "start") {
26
+ const options = parseArgs(args);
27
+ if (options.port) {
28
+ process.env.PORT = options.port;
29
+ }
30
+ require("../src/event/server/server.ts");
31
+ } else {
32
+ console.log("Usage: event-listener start [-p <port>]");
33
+ console.log("\nCommands:");
34
+ console.log(" start Start the event server");
35
+ console.log("\nOptions:");
36
+ console.log(" -p, --port Port to listen on (default: 8080)");
37
+ process.exit(1);
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canmingir/link-express",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "description": "",
5
5
  "main": "index.ts",
6
6
  "types": "index.ts",
@@ -23,10 +23,16 @@
23
23
  ],
24
24
  "sequelize": [
25
25
  "src/sequelize.ts"
26
+ ],
27
+ "event": [
28
+ "src/event/index.ts"
26
29
  ]
27
30
  }
28
31
  },
29
32
  "author": "NucTeam",
33
+ "bin": {
34
+ "event-listener": "./bin/event-listener.ts"
35
+ },
30
36
  "exports": {
31
37
  ".": "./index.ts",
32
38
  "./models": "./src/models/index.ts",
@@ -34,7 +40,10 @@
34
40
  "./authorization": "./src/authorization.ts",
35
41
  "./error": "./src/error.ts",
36
42
  "./types": "./src/types/index.ts",
37
- "./sequelize": "./src/sequelize.ts"
43
+ "./sequelize": "./src/sequelize.ts",
44
+ "./event": "./src/event/serc/Event.ts",
45
+ "./event/client": "./src/event/client/index.ts",
46
+ "./event/server": "./src/event/server/server.ts"
38
47
  },
39
48
  "scripts": {
40
49
  "prepare": "tsx prepare.ts",
@@ -57,15 +66,20 @@
57
66
  "joi": "^17.13.3",
58
67
  "joi-to-swagger": "^6.2.0",
59
68
  "jsonwebtoken": "^9.0.2",
69
+ "kafkajs": "^2.2.4",
60
70
  "lodash": "^4.17.21",
61
71
  "morgan": "^1.10.0",
72
+ "oracledb": "^6.10.0",
62
73
  "pg": "^8.12.0",
63
74
  "pino": "^10.1.0",
64
75
  "pino-elasticsearch": "^8.1.0",
65
76
  "prom-client": "^15.1.3",
66
77
  "sequelize": "^6.37.7",
67
78
  "sequelize-typescript": "^2.1.6",
79
+ "socket.io": "^4.8.1",
80
+ "socket.io-client": "^4.8.1",
68
81
  "swagger-jsdoc": "^6.2.8",
82
+ "ts-node": "^10.9.2",
69
83
  "uuid": "^10.0.0"
70
84
  },
71
85
  "devDependencies": {
@@ -0,0 +1,140 @@
1
+ import { Consumer, Kafka, Producer } from "kafkajs";
2
+
3
+ import { EventAdapter } from "../types/types";
4
+
5
+ export class KafkaAdapter implements EventAdapter {
6
+ private kafka: Kafka;
7
+ private consumer: Consumer | null = null;
8
+ private producer: Producer | null = null;
9
+ private messageHandler?: (type: string, payload: object) => void;
10
+
11
+ constructor(
12
+ private readonly options: {
13
+ clientId: string;
14
+ brokers: string[];
15
+ groupId: string;
16
+ topics: string[];
17
+ }
18
+ ) {
19
+ this.kafka = new Kafka({
20
+ clientId: options.clientId,
21
+ brokers: options.brokers,
22
+ });
23
+ }
24
+
25
+ async connect(): Promise<void> {
26
+ this.producer = this.kafka.producer();
27
+ await this.producer.connect();
28
+ this.consumer = this.kafka.consumer({ groupId: this.options.groupId });
29
+
30
+ await this.consumer.connect();
31
+ await this.consumer.subscribe({
32
+ topics: [/^(?!__).*$/],
33
+ fromBeginning: false,
34
+ });
35
+ await this.consumer.run({
36
+ partitionsConsumedConcurrently: 160,
37
+ eachMessage: async ({ topic, message }) => {
38
+ if (topic.startsWith("__")) {
39
+ return;
40
+ }
41
+
42
+ if (this.messageHandler) {
43
+ try {
44
+ const payload = JSON.parse(message.value?.toString() || "{}");
45
+ this.messageHandler(topic, payload);
46
+ } catch (error) {
47
+ console.error(
48
+ `Error processing message for topic ${topic}:`,
49
+ error
50
+ );
51
+ }
52
+ }
53
+ },
54
+ });
55
+ console.log(`Kafka consumer connected`);
56
+ }
57
+
58
+ async disconnect(): Promise<void> {
59
+ if (this.consumer) {
60
+ await this.consumer.stop();
61
+ await this.consumer.disconnect();
62
+ this.consumer = null;
63
+ }
64
+ if (this.producer) {
65
+ await this.producer.disconnect();
66
+ this.producer = null;
67
+ }
68
+ }
69
+
70
+ async publish<T = object>(type: string, payload: T): Promise<void> {
71
+ if (!this.producer) {
72
+ throw new Error("Producer not connected");
73
+ }
74
+ this.producer.send({
75
+ topic: type,
76
+ messages: [{ value: JSON.stringify(payload) }],
77
+ }).then(() => {
78
+ console.log(`Message published to topic ${type}`);
79
+ }).catch((error) => {
80
+ console.error(`Error publishing message to topic ${type}:`, error);
81
+ return Promise.reject(error);
82
+ });
83
+ }
84
+
85
+ async subscribe(type: string): Promise<void> {
86
+ // No-op: EventManager handles callback registration in memory
87
+ }
88
+
89
+ async unsubscribe(type: string): Promise<void> {
90
+ // No-op: EventManager handles callback removal in memory
91
+ }
92
+
93
+ onMessage(handler: (type: string, payload: object) => void): void {
94
+ this.messageHandler = handler;
95
+ }
96
+
97
+ async getBacklog(topics: string[]): Promise<Map<string, number>> {
98
+ const backlogMap = new Map<string, number>();
99
+
100
+ if (topics.length === 0) {
101
+ return backlogMap;
102
+ }
103
+
104
+ const admin = this.kafka.admin();
105
+ await admin.connect();
106
+
107
+ try {
108
+ for (const topic of topics) {
109
+ const offsetsResponse = await admin.fetchOffsets({
110
+ groupId: this.options.groupId,
111
+ topics: [topic],
112
+ });
113
+
114
+ const topicOffsets = await admin.fetchTopicOffsets(topic);
115
+ let totalLag = 0;
116
+
117
+ const topicResponse = offsetsResponse.find((r) => r.topic === topic);
118
+ if (topicResponse) {
119
+ topicResponse.partitions.forEach((partitionOffset) => {
120
+ const latestOffset = topicOffsets.find(
121
+ (to) => to.partition === partitionOffset.partition
122
+ );
123
+
124
+ if (latestOffset) {
125
+ const consumerOffset = parseInt(partitionOffset.offset);
126
+ const latestOffsetValue = parseInt(latestOffset.offset);
127
+ totalLag += Math.max(0, latestOffsetValue - consumerOffset);
128
+ }
129
+ });
130
+ }
131
+
132
+ backlogMap.set(topic, totalLag);
133
+ }
134
+ } finally {
135
+ await admin.disconnect();
136
+ }
137
+
138
+ return backlogMap;
139
+ }
140
+ }
@@ -0,0 +1,59 @@
1
+ import { Socket, io } from "socket.io-client";
2
+
3
+ import { EventAdapter } from "../types/types";
4
+
5
+ export class SocketAdapter implements EventAdapter {
6
+ private socket: Socket | null = null;
7
+ private messageHandler?: (type: string, payload: object) => void;
8
+
9
+ constructor(private readonly options: {
10
+ host: string;
11
+ port?: number;
12
+ protocol: string;
13
+ }) {}
14
+
15
+ async connect(): Promise<void> {
16
+ const { host, port, protocol } = this.options;
17
+ const socketPath = port ? `${protocol}://${host}:${port}` : `${protocol}://${host}`;
18
+
19
+ this.socket = io(socketPath);
20
+
21
+ this.socket.on("event", ({ type, payload }: { type: string; payload: object }) => {
22
+ if (this.messageHandler) {
23
+ this.messageHandler(type, payload);
24
+ }
25
+ });
26
+ }
27
+
28
+ async disconnect(): Promise<void> {
29
+ if (this.socket) {
30
+ this.socket.disconnect();
31
+ this.socket = null;
32
+ }
33
+ }
34
+
35
+ async publish(type: string, payload: object): Promise<void> {
36
+ if (!this.socket) {
37
+ throw new Error("Socket not connected");
38
+ }
39
+ this.socket.emit("publish", { type, payload });
40
+ }
41
+
42
+ async subscribe(type: string): Promise<void> {
43
+ if (!this.socket) {
44
+ throw new Error("Socket not connected");
45
+ }
46
+ this.socket.emit("subscribe", type);
47
+ }
48
+
49
+ async unsubscribe(type: string): Promise<void> {
50
+ if (!this.socket) {
51
+ throw new Error("Socket not connected");
52
+ }
53
+ this.socket.emit("unsubscribe", type);
54
+ }
55
+
56
+ onMessage(handler: (type: string, payload: object) => void): void {
57
+ this.messageHandler = handler;
58
+ }
59
+ }
@@ -0,0 +1,232 @@
1
+ import * as oracledb from "oracledb";
2
+
3
+ import { EventAdapter } from "../types/types";
4
+
5
+ export class TxEventQAdapter implements EventAdapter {
6
+ private connection: oracledb.Connection | null = null;
7
+ private queue: oracledb.AdvancedQueue<any> | null = null;
8
+ private queueCache: Map<string, oracledb.AdvancedQueue<any>> = new Map();
9
+ private messageHandler?: (type: string, payload: object) => void;
10
+ private isRunning: boolean = false;
11
+
12
+ constructor(
13
+ private readonly options: {
14
+ connectString: string;
15
+ user: string;
16
+ password: string;
17
+ instantClientPath?: string;
18
+ walletPath?: string;
19
+ consumerName?: string;
20
+ batchSize?: number;
21
+ waitTime?: number;
22
+ autoCommit?: boolean;
23
+ }
24
+ ) {}
25
+
26
+ async connect(): Promise<void> {
27
+ try {
28
+ if (this.options.instantClientPath && oracledb.thin) {
29
+ try {
30
+ oracledb.initOracleClient({
31
+ libDir: this.options.instantClientPath,
32
+ configDir: this.options.walletPath,
33
+ walletPath: this.options.walletPath,
34
+ });
35
+ console.log("Oracle Thick client initialized");
36
+ } catch (initError: any) {
37
+ if (initError.code !== "NJS-509") {
38
+ throw initError;
39
+ }
40
+ console.log("Oracle Thick client already initialized");
41
+ }
42
+ }
43
+
44
+ this.connection = await oracledb.getConnection({
45
+ connectString: this.options.connectString,
46
+ user: this.options.user,
47
+ password: this.options.password,
48
+ configDir: this.options.walletPath,
49
+ walletPath: this.options.walletPath,
50
+ });
51
+
52
+ this.isRunning = true;
53
+
54
+ console.log("TxEventQ adapter connected successfully");
55
+ } catch (error: any) {
56
+ console.error("Failed to connect to TxEventQ:", error.message);
57
+ throw error;
58
+ }
59
+ }
60
+
61
+ async disconnect(): Promise<void> {
62
+ this.isRunning = false;
63
+
64
+ if (this.connection) {
65
+ try {
66
+ this.queueCache.clear();
67
+
68
+ await this.connection.close();
69
+ console.log("TxEventQ connection closed");
70
+ } catch (error) {
71
+ console.error("Error closing TxEventQ connection:", error);
72
+ }
73
+ this.connection = null;
74
+ this.queue = null;
75
+ }
76
+ }
77
+
78
+ private async getOrCreateQueue(
79
+ queueName: string,
80
+ options: any
81
+ ): Promise<oracledb.AdvancedQueue<any>> {
82
+ if (!this.connection) {
83
+ throw new Error("TxEventQAdapter not connected");
84
+ }
85
+
86
+ if (this.queueCache.has(queueName)) {
87
+ return this.queueCache.get(queueName)!;
88
+ }
89
+
90
+ const queue = await this.connection.getQueue(queueName, options);
91
+ this.queueCache.set(queueName, queue);
92
+
93
+ console.log(`Queue ${queueName} cached`);
94
+
95
+ return queue;
96
+ }
97
+
98
+ async publish<T = object>(type: string, payload: T): Promise<void> {
99
+ if (!this.connection) {
100
+ throw new Error("TxEventQAdapter not connected");
101
+ }
102
+
103
+ const queueName = type;
104
+
105
+ this.queue = await this.getOrCreateQueue(queueName, {
106
+ payloadType: oracledb.DB_TYPE_JSON,
107
+ } as any);
108
+
109
+ const message = {
110
+ topic: type,
111
+ payload: payload,
112
+ };
113
+
114
+ this.queue
115
+ .enqOne({
116
+ payload: message,
117
+ correlation: type,
118
+ priority: 0,
119
+ delay: 0,
120
+ expiration: -1,
121
+ exceptionQueue: "",
122
+ } as any)
123
+ .then(() => {
124
+ this.connection.commit();
125
+ });
126
+ }
127
+
128
+ async subscribe(type: string): Promise<void> {
129
+ if (!this.connection) {
130
+ throw new Error("Subscriber not initialized");
131
+ }
132
+ this.isRunning = true;
133
+
134
+ const queueName = `TXEVENTQ_USER.${type}`;
135
+
136
+ this.queue = await this.getOrCreateQueue(queueName, {
137
+ payloadType: oracledb.DB_TYPE_JSON,
138
+ });
139
+
140
+ this.queue.deqOptions.wait = 5000;
141
+ this.queue.deqOptions.consumerName =
142
+ this.options.consumerName || `${type.toLowerCase()}_subscriber`;
143
+ try {
144
+ while (this.isRunning) {
145
+ let messages: oracledb.AdvancedQueueMessage[] = [];
146
+
147
+ const message = await this.queue.deqOne();
148
+ if (message) {
149
+ messages = [message];
150
+ }
151
+ if (messages && messages.length > 0) {
152
+ if (this.messageHandler) {
153
+ try {
154
+ const payload = message.payload.payload || {};
155
+ this.messageHandler(type, payload);
156
+ } catch (error) {
157
+ console.error(
158
+ `Error processing message for topic ${type}:`,
159
+ error
160
+ );
161
+ }
162
+ }
163
+ if (this.options.autoCommit) {
164
+ await this.connection.commit();
165
+ console.log(
166
+ `Transaction committed for ${messages.length} message(s)`
167
+ );
168
+ }
169
+ }
170
+ }
171
+ } catch (error) {
172
+ console.error("Fatal error during consumption:", error);
173
+ throw error;
174
+ }
175
+ }
176
+
177
+ async unsubscribe(type: string): Promise<void> {
178
+ if (!this.connection) {
179
+ throw new Error("Subscriber not initialized");
180
+ }
181
+ this.isRunning = false;
182
+ this.queue = null;
183
+ }
184
+
185
+ onMessage(handler: (type: string, payload: object) => void): void {
186
+ this.messageHandler = handler;
187
+ }
188
+
189
+ async getBacklog(topics: string[]): Promise<Map<string, number>> {
190
+ const backlogMap = new Map<string, number>();
191
+ if (!this.connection || !topics?.length) return backlogMap;
192
+
193
+ const sql = `
194
+ SELECT NVL(SUM(s.ENQUEUED_MSGS - s.DEQUEUED_MSGS), 0) AS BACKLOG
195
+ FROM GV$AQ_SHARDED_SUBSCRIBER_STAT s
196
+ JOIN USER_QUEUES q
197
+ ON q.QID = s.QUEUE_ID
198
+ JOIN USER_QUEUE_SUBSCRIBERS sub
199
+ ON sub.SUBSCRIBER_ID = s.SUBSCRIBER_ID
200
+ AND sub.QUEUE_NAME = q.NAME
201
+ WHERE q.NAME IN (:queueName1, :queueName2)
202
+ AND (:consumerName IS NULL OR sub.CONSUMER_NAME = :consumerName)
203
+ `;
204
+
205
+ const consumerName =
206
+ typeof this.options.consumerName === "string"
207
+ ? this.options.consumerName
208
+ : null;
209
+
210
+ for (const topic of topics) {
211
+ const queueName1 = `TXEVENTQ_USER.${topic}`;
212
+ const queueName2 = topic;
213
+
214
+ try {
215
+ const result = await this.connection.execute(
216
+ sql,
217
+ { queueName1, queueName2, consumerName },
218
+ { outFormat: oracledb.OUT_FORMAT_OBJECT }
219
+ );
220
+
221
+ const rows = (result.rows || []) as Array<{ BACKLOG: number }>;
222
+ const val = Number(rows?.[0]?.BACKLOG ?? 0);
223
+ backlogMap.set(topic, isNaN(val) ? 0 : val);
224
+ } catch (err) {
225
+ console.error(`Backlog query failed for topic ${topic}:`, err);
226
+ backlogMap.set(topic, 0);
227
+ }
228
+ }
229
+
230
+ return backlogMap;
231
+ }
232
+ }
@@ -0,0 +1,244 @@
1
+ import { Callback, EventAdapter, InitOptions } from "./types/types";
2
+ import { EventMetrics, PushgatewayConfig } from "./metrics";
3
+
4
+ import { KafkaAdapter } from "./adapters/KafkaAdapter";
5
+ import { SocketAdapter } from "./adapters/SocketAdapter";
6
+ import { TxEventQAdapter } from "./adapters/TxEventQAdapter";
7
+
8
+ const TOPICS = [
9
+ "KNOWLEDGE_CREATED",
10
+ "MESSAGE_USER_MESSAGED",
11
+ "SESSION_USER_MESSAGED",
12
+ "TASK_CREATED",
13
+ "STEP_ADDED",
14
+ "STEP_COMPLETED",
15
+ "MESSAGE_USER_MESSAGED",
16
+ "MESSAGE_ASSISTANT_MESSAGED",
17
+ "RESPONSIBILITY_CREATED",
18
+ "RESPONSIBILITY_DESCRIPTION_GENERATED",
19
+ "SESSION_INITIATED",
20
+ "SESSION_USER_MESSAGED",
21
+ "SESSION_AI_MESSAGED",
22
+ "SUPERVISING_RAISED",
23
+ "SUPERVISING_ANSWERED",
24
+ "TASK_COMPLETED",
25
+ "KNOWLEDGES_LOADED",
26
+ "MESSAGES_LOADED",
27
+ ];
28
+ export class EventManager {
29
+ private adapter: EventAdapter | null = null;
30
+ private callbacks: Map<string, Set<Callback>> = new Map();
31
+ private metrics = new EventMetrics();
32
+ private backlogInterval: NodeJS.Timeout | null = null;
33
+
34
+ async init(options: InitOptions): Promise<void> {
35
+ if (this.adapter) {
36
+ await this.disconnect();
37
+ }
38
+ switch (options.type) {
39
+ case "inMemory":
40
+ this.adapter = new SocketAdapter({
41
+ host: options.host,
42
+ port: options.port,
43
+ protocol: options.protocol,
44
+ });
45
+ break;
46
+ case "kafka":
47
+ this.adapter = new KafkaAdapter({
48
+ clientId: options.clientId,
49
+ brokers: options.brokers,
50
+ groupId: options.groupId,
51
+ topics: TOPICS,
52
+ });
53
+ this.startBacklogMonitoring();
54
+ break;
55
+
56
+ case "txeventq":
57
+ this.adapter = new TxEventQAdapter({
58
+ connectString: options.connectString,
59
+ user: options.user,
60
+ password: options.password,
61
+ instantClientPath: options.instantClientPath,
62
+ walletPath: options.walletPath,
63
+ consumerName: options.consumerName,
64
+ batchSize: options.batchSize,
65
+ waitTime: options.waitTime,
66
+ });
67
+ this.startBacklogMonitoring();
68
+ break;
69
+
70
+ default:
71
+ throw new Error(`Unknown adapter type`);
72
+ }
73
+ await this.adapter.connect();
74
+
75
+ this.adapter.onMessage((type, payload) => {
76
+ this.handleIncomingMessage(type, payload);
77
+ });
78
+ }
79
+
80
+ async publish<T extends object = object>(
81
+ ...args: [...string[], T]
82
+ ): Promise<void> {
83
+ if (args.length < 1) {
84
+ throw new Error("publish requires at least one event type and a payload");
85
+ }
86
+ if (!this.adapter) {
87
+ throw new Error("Event system not initialized");
88
+ }
89
+ const payload = args[args.length - 1] as T;
90
+ const type = args.slice(0, -1) as string[];
91
+ const mergedType = type.join("_");
92
+ this.validateEventType(mergedType);
93
+ const payloadSize = JSON.stringify(payload).length;
94
+ const endTimer = this.metrics.recordPublish(mergedType, payloadSize);
95
+ try {
96
+ await this.adapter.publish(mergedType, payload);
97
+ this.executeCallbacks(mergedType, payload);
98
+ endTimer();
99
+ } catch (error) {
100
+ this.metrics.recordPublishError(mergedType, "publish_error");
101
+ endTimer();
102
+ throw error;
103
+ }
104
+ }
105
+
106
+ async subscribe<T extends object = object>(
107
+ type: string,
108
+ callback: Callback<T>
109
+ ): Promise<() => void> {
110
+ if (!this.callbacks.has(type)) {
111
+ this.callbacks.set(type, new Set());
112
+ }
113
+
114
+ const callbackSet = this.callbacks.get(type)!;
115
+ callbackSet.add(callback as Callback);
116
+
117
+ this.metrics.updateSubscriptions(type, callbackSet.size);
118
+
119
+ if (this.adapter && callbackSet.size === 1) {
120
+ await this.adapter.subscribe(type);
121
+ }
122
+
123
+ return async () => {
124
+ callbackSet.delete(callback as Callback);
125
+
126
+ if (callbackSet.size === 0) {
127
+ this.callbacks.delete(type);
128
+ if (this.adapter) {
129
+ await this.adapter.unsubscribe(type);
130
+ }
131
+ }
132
+
133
+ this.metrics.updateSubscriptions(type, callbackSet.size);
134
+ };
135
+ }
136
+
137
+ async disconnect(): Promise<void> {
138
+ this.stopBacklogMonitoring();
139
+
140
+ if (this.adapter) {
141
+ await this.adapter.disconnect();
142
+ this.adapter = null;
143
+ }
144
+
145
+ this.callbacks.clear();
146
+ }
147
+
148
+ private handleIncomingMessage(type: string, payload: object): void {
149
+ this.executeCallbacks(type, payload);
150
+ }
151
+
152
+ private executeCallbacks(type: string, payload: object): void {
153
+ const callbackSet = this.callbacks.get(type);
154
+ if (!callbackSet) return; // No callbacks for this topic - message ignored
155
+
156
+ callbackSet.forEach((callback) => {
157
+ setTimeout(() => {
158
+ const endTimer = this.metrics.recordCallback(type);
159
+ try {
160
+ callback(payload);
161
+ } catch (error) {
162
+ console.error(`Error in callback for ${type}:`, error);
163
+ }
164
+ endTimer();
165
+ }, 0);
166
+ });
167
+ }
168
+
169
+ private validateEventType(type: string): void {
170
+ if (
171
+ type === "__proto__" ||
172
+ type === "constructor" ||
173
+ type === "prototype"
174
+ ) {
175
+ throw new Error("Invalid event type");
176
+ }
177
+ }
178
+
179
+ private startBacklogMonitoring(intervalMs: number = 60000): void {
180
+ if (!this.adapter) return;
181
+
182
+ // Only monitor for adapters that implement meaningful backlog
183
+ const supportsBacklog =
184
+ this.adapter instanceof KafkaAdapter ||
185
+ this.adapter instanceof TxEventQAdapter;
186
+
187
+ if (!supportsBacklog) return;
188
+
189
+ this.updateBacklogMetrics();
190
+
191
+ this.backlogInterval = setInterval(() => {
192
+ this.updateBacklogMetrics();
193
+ }, intervalMs);
194
+ }
195
+
196
+ private stopBacklogMonitoring(): void {
197
+ if (this.backlogInterval) {
198
+ clearInterval(this.backlogInterval);
199
+ this.backlogInterval = null;
200
+ }
201
+ }
202
+
203
+ private async updateBacklogMetrics(): Promise<void> {
204
+ if (!this.adapter) return;
205
+
206
+ const supportsBacklog =
207
+ this.adapter instanceof KafkaAdapter ||
208
+ this.adapter instanceof TxEventQAdapter;
209
+
210
+ if (!supportsBacklog) return;
211
+
212
+ try {
213
+ const backlog = await (
214
+ this.adapter as KafkaAdapter | TxEventQAdapter
215
+ ).getBacklog(TOPICS);
216
+ backlog.forEach((size, topic) => {
217
+ this.metrics.updateEventBacklog(topic, size);
218
+ console.log(`Backlog for topic ${topic}: ${size} messages`);
219
+ });
220
+ } catch (error) {
221
+ console.error("Error updating backlog metrics:", error);
222
+ }
223
+ }
224
+
225
+ async checkBacklog(): Promise<void> {
226
+ await this.updateBacklogMetrics();
227
+ }
228
+
229
+ startPushgateway(config?: PushgatewayConfig): void {
230
+ this.metrics.startPushgateway(config);
231
+ }
232
+
233
+ stopPushgateway(): void {
234
+ this.metrics.stopPushgateway();
235
+ }
236
+
237
+ async pushMetricsToGateway(): Promise<void> {
238
+ await this.metrics.pushMetricsToGateway();
239
+ }
240
+
241
+ getPushgatewayConfig(): PushgatewayConfig | undefined {
242
+ return this.metrics.getPushgatewayConfig();
243
+ }
244
+ }
@@ -0,0 +1,50 @@
1
+ import * as client from "prom-client";
2
+
3
+ import { Callback, InitOptions } from "./types/types";
4
+ import { EventMetrics, PushgatewayConfig } from "./metrics";
5
+
6
+ import { EventManager } from "./eventManager";
7
+ import { KafkaAdapter } from "./adapters/KafkaAdapter";
8
+ import { SocketAdapter } from "./adapters/SocketAdapter";
9
+ import { TxEventQAdapter } from "./adapters/TxEventQAdapter";
10
+
11
+ const manager = new EventManager();
12
+
13
+ export const event = {
14
+ init: (options: InitOptions) => manager.init(options),
15
+ publish: <T extends object = object>(...args: [...string[], T]) =>
16
+ manager.publish(...args),
17
+ subscribe: <T extends object = object>(type: string, callback: Callback<T>) =>
18
+ manager.subscribe(type, callback),
19
+ disconnect: () => manager.disconnect(),
20
+ checkBacklog: () => manager.checkBacklog(),
21
+
22
+ startBacklogMonitoring: () => {
23
+ console.log("Backlog monitoring starts automatically with Kafka adapter");
24
+ },
25
+ stopBacklogMonitoring: () => {
26
+ console.log("Backlog monitoring stops automatically on disconnect");
27
+ },
28
+ restartKafkaConsumer: async () => {
29
+ console.log("Consumer restart is handled automatically");
30
+ },
31
+
32
+ startPushgateway: (config?: PushgatewayConfig) =>
33
+ manager.startPushgateway(config),
34
+ stopPushgateway: () => manager.stopPushgateway(),
35
+ pushMetricsToGateway: () => manager.pushMetricsToGateway(),
36
+ getPushgatewayConfig: () => manager.getPushgatewayConfig(),
37
+ };
38
+
39
+ export { client };
40
+
41
+ export {
42
+ EventManager,
43
+ EventMetrics,
44
+ SocketAdapter,
45
+ KafkaAdapter,
46
+ TxEventQAdapter,
47
+ };
48
+ export type { PushgatewayConfig };
49
+ export * from "./types/types";
50
+
@@ -0,0 +1,171 @@
1
+ import * as client from "prom-client";
2
+
3
+ export interface PushgatewayConfig {
4
+ url?: string;
5
+ jobName?: string;
6
+ instance?: string;
7
+ interval?: number;
8
+ }
9
+
10
+ export class EventMetrics {
11
+ private readonly registry: client.Registry;
12
+ private pushgatewayInterval?: NodeJS.Timeout;
13
+ private pushgatewayConfig?: PushgatewayConfig;
14
+
15
+ private readonly publishCounter: client.Counter<string>;
16
+ private readonly subscriptionGauge: client.Gauge<string>;
17
+ private readonly publishDuration: client.Histogram<string>;
18
+ private readonly payloadSize: client.Histogram<string>;
19
+ private readonly publishErrors: client.Counter<string>;
20
+ private readonly callbackDuration: client.Histogram<string>;
21
+ private readonly throughput: client.Counter<string>;
22
+ private readonly eventBacklog: client.Gauge<string>;
23
+
24
+ constructor() {
25
+ this.registry = new client.Registry();
26
+
27
+ this.publishCounter = new client.Counter({
28
+ name: "events_published_total",
29
+ help: "Total number of events published",
30
+ labelNames: ["event_type"],
31
+ registers: [this.registry],
32
+ });
33
+
34
+ this.subscriptionGauge = new client.Gauge({
35
+ name: "active_event_subscriptions",
36
+ help: "Number of active event subscriptions",
37
+ labelNames: ["event_type"],
38
+ registers: [this.registry],
39
+ });
40
+
41
+ this.publishDuration = new client.Histogram({
42
+ name: "event_publish_duration_seconds",
43
+ help: "Time taken to publish events",
44
+ labelNames: ["event_type"],
45
+ buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5],
46
+ registers: [this.registry],
47
+ });
48
+
49
+ this.payloadSize = new client.Histogram({
50
+ name: "event_payload_size_bytes",
51
+ help: "Size of event payloads in bytes",
52
+ labelNames: ["event_type"],
53
+ buckets: [10, 100, 1000, 10000, 100000, 1000000],
54
+ registers: [this.registry],
55
+ });
56
+
57
+ this.publishErrors = new client.Counter({
58
+ name: "event_publish_errors_total",
59
+ help: "Total number of event publish errors",
60
+ labelNames: ["event_type", "error_type"],
61
+ registers: [this.registry],
62
+ });
63
+
64
+ this.callbackDuration = new client.Histogram({
65
+ name: "event_callback_duration_seconds",
66
+ help: "Time taken to process event callbacks",
67
+ labelNames: ["event_type"],
68
+ buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5],
69
+ registers: [this.registry],
70
+ });
71
+
72
+ this.throughput = new client.Counter({
73
+ name: "event_callbacks_processed_total",
74
+ help: "Total number of event callbacks processed successfully",
75
+ labelNames: ["event_type"],
76
+ registers: [this.registry],
77
+ });
78
+
79
+ this.eventBacklog = new client.Gauge({
80
+ name: "backlog_events_total",
81
+ help: "Total number of events waiting to be processed",
82
+ labelNames: ["topic"],
83
+ registers: [this.registry],
84
+ });
85
+ }
86
+
87
+ recordPublish(type: string, payloadSizeBytes: number): () => void {
88
+ this.publishCounter.labels(type).inc();
89
+ this.payloadSize.labels(type).observe(payloadSizeBytes);
90
+ return this.publishDuration.labels(type).startTimer();
91
+ }
92
+
93
+ recordPublishError(type: string, errorType: string): void {
94
+ this.publishErrors.labels(type, errorType).inc();
95
+ }
96
+
97
+ recordCallback(type: string): () => void {
98
+ this.throughput.labels(type).inc();
99
+ return this.callbackDuration.labels(type).startTimer();
100
+ }
101
+
102
+ updateSubscriptions(type: string, count: number): void {
103
+ this.subscriptionGauge.labels(type).set(count);
104
+ }
105
+
106
+ updateEventBacklog(topic: string, size: number): void {
107
+ this.eventBacklog.labels(topic).set(size);
108
+ }
109
+
110
+ startPushgateway(config: PushgatewayConfig = {}): void {
111
+ this.pushgatewayConfig = {
112
+ url: config.url || "http://localhost:9091",
113
+ jobName: config.jobName || "node_events",
114
+ instance: config.instance || "default_instance",
115
+ interval: config.interval || 15000,
116
+ };
117
+
118
+ this.stopPushgateway();
119
+
120
+ this.pushgatewayInterval = setInterval(() => {
121
+ this.pushMetricsToGateway();
122
+ }, this.pushgatewayConfig.interval);
123
+
124
+ console.log(
125
+ `Started pushing metrics to Pushgateway every ${this.pushgatewayConfig.interval}ms`
126
+ );
127
+ }
128
+
129
+ stopPushgateway(): void {
130
+ if (this.pushgatewayInterval) {
131
+ clearInterval(this.pushgatewayInterval);
132
+ this.pushgatewayInterval = undefined;
133
+ console.log("Stopped pushing metrics to Pushgateway");
134
+ }
135
+ }
136
+
137
+ async pushMetricsToGateway(): Promise<void> {
138
+ if (!this.pushgatewayConfig) {
139
+ throw new Error(
140
+ "Pushgateway not configured. Call startPushgateway() first."
141
+ );
142
+ }
143
+
144
+ try {
145
+ const body = await this.registry.metrics();
146
+ let url = `${this.pushgatewayConfig.url}/metrics/job/${this.pushgatewayConfig.jobName}`;
147
+
148
+ if (this.pushgatewayConfig.instance) {
149
+ url += `/instance/${this.pushgatewayConfig.instance}`;
150
+ }
151
+
152
+ const response = await fetch(url, {
153
+ method: "POST",
154
+ headers: { "Content-Type": "text/plain" },
155
+ body,
156
+ });
157
+
158
+ if (!response.ok) {
159
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
160
+ }
161
+
162
+ console.log("Metrics pushed to Pushgateway successfully");
163
+ } catch (err) {
164
+ console.error("Failed to push metrics to Pushgateway:", err);
165
+ }
166
+ }
167
+
168
+ getPushgatewayConfig(): PushgatewayConfig | undefined {
169
+ return this.pushgatewayConfig;
170
+ }
171
+ }
@@ -0,0 +1,43 @@
1
+ export type Callback<T = object> = (payload: T) => void;
2
+
3
+ export interface EventAdapter {
4
+ connect(): Promise<void>;
5
+ disconnect(): Promise<void>;
6
+ publish(type: string, payload: object): Promise<void>;
7
+ subscribe(type: string): Promise<void>;
8
+ unsubscribe(type: string): Promise<void>;
9
+ onMessage(handler: (type: string, payload: object) => void): void;
10
+ }
11
+
12
+ export interface BaseInitOptions {
13
+ type: "inMemory" | "kafka" | "txeventq";
14
+ }
15
+
16
+ export interface InMemoryOptions extends BaseInitOptions {
17
+ type: "inMemory";
18
+ host: string;
19
+ port?: number;
20
+ protocol: string;
21
+ }
22
+
23
+ export interface KafkaOptions extends BaseInitOptions {
24
+ type: "kafka";
25
+ clientId: string;
26
+ brokers: string[];
27
+ groupId: string;
28
+ }
29
+
30
+ export interface TxEventQOptions extends BaseInitOptions {
31
+ type: "txeventq";
32
+ connectString: string;
33
+ user: string;
34
+ password: string;
35
+ instantClientPath?: string;
36
+ walletPath?: string;
37
+ consumerName?: string;
38
+ batchSize?: number;
39
+ waitTime?: number;
40
+ topics?: string[];
41
+ }
42
+
43
+ export type InitOptions = InMemoryOptions | KafkaOptions | TxEventQOptions;
@@ -0,0 +1,3 @@
1
+ import { subscribe, publish } from "./src/Event";
2
+
3
+ export { subscribe, publish };
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Server, Socket } from "socket.io";
4
+ import http = require("http");
5
+
6
+ const server = http.createServer();
7
+ const io = new Server(server, {
8
+ cors: { origin: "*" },
9
+ });
10
+
11
+ type Subscriptions = Record<string, Set<string>>;
12
+ const subscriptions: Subscriptions = {};
13
+
14
+ io.on("connection", (socket: Socket) => {
15
+ console.log("Client connected:", socket.id);
16
+
17
+ socket.on("subscribe", (type: string) => {
18
+ if (!subscriptions[type]) subscriptions[type] = new Set();
19
+ subscriptions[type].add(socket.id);
20
+ console.log(`Socket ${socket.id} subscribed to ${type}`);
21
+ });
22
+
23
+ socket.on("unsubscribe", (type: string) => {
24
+ if (subscriptions[type]) {
25
+ subscriptions[type].delete(socket.id);
26
+ if (subscriptions[type].size === 0) delete subscriptions[type];
27
+ console.log(`Socket ${socket.id} unsubscribed from ${type}`);
28
+ }
29
+ });
30
+
31
+ socket.on(
32
+ "publish",
33
+ ({ type, payload }: { type: string; payload: object }) => {
34
+ console.log(`Publish: ${type}`, payload);
35
+ if (subscriptions[type]) {
36
+ subscriptions[type].forEach((sid) => {
37
+ if (sid !== socket.id) {
38
+ io.to(sid).emit("event", { type, payload });
39
+ }
40
+ });
41
+ }
42
+ }
43
+ );
44
+
45
+ socket.on("disconnect", () => {
46
+ Object.keys(subscriptions).forEach((type) => {
47
+ subscriptions[type].delete(socket.id);
48
+ if (subscriptions[type].size === 0) delete subscriptions[type];
49
+ });
50
+ console.log("Client disconnected:", socket.id);
51
+ });
52
+ });
53
+
54
+ const PORT = process.env.PORT || 8080;
55
+ server.listen(PORT, () => {
56
+ console.log(`Event server listening on port ${PORT}`);
57
+ });
@@ -0,0 +1,201 @@
1
+ import client from "prom-client";
2
+ import { v4 as uuid } from "uuid";
3
+
4
+ const subscriptions = {};
5
+ const messages = new Map();
6
+
7
+ const eventPublishCounter = new client.Counter({
8
+ name: "events_published_total",
9
+ help: "Total number of events published",
10
+ labelNames: ["event_type"],
11
+ });
12
+
13
+ const eventSubscriptionGauge = new client.Gauge({
14
+ name: "active_event_subscriptions",
15
+ help: "Number of active event subscriptions",
16
+ labelNames: ["event_type"],
17
+ });
18
+
19
+ const eventPublishDuration = new client.Histogram({
20
+ name: "event_publish_duration_seconds",
21
+ help: "Time taken to publish events",
22
+ labelNames: ["event_type"],
23
+ buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5],
24
+ });
25
+
26
+ // Track payload size for analysis
27
+ const eventPayloadSize = new client.Histogram({
28
+ name: "event_payload_size_bytes",
29
+ help: "Size of event payloads in bytes",
30
+ labelNames: ["event_type"],
31
+ buckets: [10, 100, 1000, 10000, 100000, 1000000],
32
+ });
33
+
34
+ // Track error rates
35
+ const eventPublishErrors = new client.Counter({
36
+ name: "event_publish_errors_total",
37
+ help: "Total number of event publish errors",
38
+ labelNames: ["event_type", "error_type"],
39
+ });
40
+
41
+ // Track callback processing duration
42
+ const callbackProcessingDuration = new client.Histogram({
43
+ name: "event_callback_duration_seconds",
44
+ help: "Time taken to process event callbacks",
45
+ labelNames: ["event_type"],
46
+ buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5],
47
+ });
48
+
49
+ // Track subscription rates
50
+ const subscriptionRate = new client.Counter({
51
+ name: "event_subscriptions_total",
52
+ help: "Total number of event subscriptions created",
53
+ labelNames: ["event_type"],
54
+ });
55
+
56
+ // Track unsubscription rates
57
+ const unsubscriptionRate = new client.Counter({
58
+ name: "event_unsubscriptions_total",
59
+ help: "Total number of event unsubscriptions",
60
+ labelNames: ["event_type"],
61
+ });
62
+
63
+ // Track throughput (events processed per second)
64
+ const eventThroughput = new client.Counter({
65
+ name: "event_callbacks_processed_total",
66
+ help: "Total number of event callbacks processed successfully",
67
+ labelNames: ["event_type"],
68
+ });
69
+
70
+ const colors = [
71
+ "red",
72
+ "green",
73
+ "yellow",
74
+ "blue",
75
+ "magenta",
76
+ "cyan",
77
+ "white",
78
+ "gray",
79
+ ];
80
+
81
+ function typeColor(type) {
82
+ const hash = (str) => {
83
+ let hash = 0;
84
+ for (let i = 0; i < str.length; i++) {
85
+ const char = str.charCodeAt(i);
86
+ hash = (hash << 5) - hash + char;
87
+ hash = hash & hash;
88
+ }
89
+ return Math.abs(hash);
90
+ };
91
+
92
+ const colorIndex = hash(type) % colors.length;
93
+ return colors[colorIndex];
94
+ }
95
+
96
+ const subscribe = (...args) => {
97
+ if (args.length < 2) {
98
+ throw new Error("subscribe requires at least 2 arguments");
99
+ }
100
+
101
+ const callback = args.pop();
102
+ const type = args.join(".");
103
+ const id = uuid();
104
+
105
+ console.debug("node-event", "subscribe", type, id);
106
+
107
+ if (type === "__proto__" || type === "constructor" || type === "prototype") {
108
+ throw new Error("Invalid subscription type");
109
+ }
110
+ if (!subscriptions[type]) {
111
+ subscriptions[type] = {};
112
+ }
113
+
114
+ const registry = {
115
+ id,
116
+ type,
117
+ callback,
118
+ unsubscribe: () => {
119
+ console.debug("node-event", "unsubscribe", type, id);
120
+ delete subscriptions[type][id];
121
+
122
+ // Track unsubscription
123
+ unsubscriptionRate.labels(type).inc();
124
+
125
+ if (Object.keys(subscriptions[type]).length === 0) {
126
+ delete subscriptions[type];
127
+ eventSubscriptionGauge.labels(type).set(0);
128
+ } else {
129
+ eventSubscriptionGauge
130
+ .labels(type)
131
+ .set(Object.keys(subscriptions[type]).length);
132
+ }
133
+ },
134
+ };
135
+
136
+ subscriptions[type][id] = registry;
137
+
138
+ // Update subscription metrics
139
+ subscriptionRate.labels(type).inc();
140
+ eventSubscriptionGauge
141
+ .labels(type)
142
+ .set(Object.keys(subscriptions[type]).length);
143
+
144
+ return registry;
145
+ };
146
+
147
+ const publish = (...args) => {
148
+ if (args.length < 2) {
149
+ throw new Error("publish requires at least 2 arguments");
150
+ }
151
+
152
+ const payload = args.pop();
153
+ const type = args.join(".");
154
+
155
+ console.log("node-event", "publish", type, payload);
156
+ messages.set(type, payload);
157
+
158
+ if (type === "__proto__" || type === "constructor" || type === "prototype") {
159
+ throw new Error("Invalid publish type");
160
+ }
161
+
162
+ // Track metrics for event publishing
163
+ const endTimer = eventPublishDuration.labels(type).startTimer();
164
+ eventPublishCounter.labels(type).inc();
165
+
166
+ // Track payload size
167
+ const payloadSize = JSON.stringify(payload).length;
168
+ eventPayloadSize.labels(type).observe(payloadSize);
169
+
170
+ Object.keys(subscriptions[type] || {}).forEach((key) => {
171
+ const registry = subscriptions[type][key];
172
+
173
+ setTimeout(() => {
174
+ const callbackTimer = callbackProcessingDuration
175
+ .labels(type)
176
+ .startTimer();
177
+ try {
178
+ registry.callback(payload, registry);
179
+ eventThroughput.labels(type).inc();
180
+ } catch (err) {
181
+ console.error("node-event", "error", type, err);
182
+ const errorName = err instanceof Error ? err.name : "UnknownError";
183
+ eventPublishErrors.labels(type, errorName).inc();
184
+ } finally {
185
+ callbackTimer();
186
+ }
187
+ }, 0);
188
+ });
189
+
190
+ endTimer();
191
+ };
192
+
193
+ function last(type, init) {
194
+ if (messages.has(type)) {
195
+ return messages.get(type);
196
+ } else {
197
+ return init;
198
+ }
199
+ }
200
+
201
+ export { subscribe, publish, messages, last, client };