@omnixal/openclaw-nats-plugin 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.
Files changed (74) hide show
  1. package/PLUGIN.md +94 -0
  2. package/bin/cli.ts +75 -0
  3. package/cli/bun-setup.ts +133 -0
  4. package/cli/detect-runtime.ts +40 -0
  5. package/cli/docker-setup.ts +54 -0
  6. package/cli/download-nats.ts +110 -0
  7. package/cli/env-writer.ts +58 -0
  8. package/cli/lifecycle.ts +109 -0
  9. package/cli/nats-config.ts +32 -0
  10. package/cli/paths.ts +20 -0
  11. package/cli/service-units.ts +168 -0
  12. package/cli/setup.ts +23 -0
  13. package/dashboard/dist/assets/index-CafgidIc.css +2 -0
  14. package/dashboard/dist/assets/index-OUWnIZmb.js +15 -0
  15. package/dashboard/dist/index.html +13 -0
  16. package/docker/docker-compose.yml +48 -0
  17. package/hooks/command-publisher/HOOK.md +13 -0
  18. package/hooks/command-publisher/handler.ts +23 -0
  19. package/hooks/gateway-startup/HOOK.md +13 -0
  20. package/hooks/gateway-startup/handler.ts +31 -0
  21. package/hooks/lifecycle-publisher/HOOK.md +12 -0
  22. package/hooks/lifecycle-publisher/handler.ts +20 -0
  23. package/hooks/shared/sidecar-client.ts +23 -0
  24. package/index.ts +3 -0
  25. package/openclaw.plugin.json +8 -0
  26. package/package.json +48 -0
  27. package/plugins/nats-context-engine/PLUGIN.md +14 -0
  28. package/plugins/nats-context-engine/http-handler.ts +131 -0
  29. package/plugins/nats-context-engine/index.ts +89 -0
  30. package/sidecar/Dockerfile +11 -0
  31. package/sidecar/bun.lock +212 -0
  32. package/sidecar/drizzle.config.ts +10 -0
  33. package/sidecar/package.json +28 -0
  34. package/sidecar/src/app.module.ts +33 -0
  35. package/sidecar/src/auth/api-key.middleware.ts +39 -0
  36. package/sidecar/src/config.ts +40 -0
  37. package/sidecar/src/consumer/consumer.module.ts +12 -0
  38. package/sidecar/src/consumer/consumer.service.ts +113 -0
  39. package/sidecar/src/db/migrations/0000_complete_mulholland_black.sql +5 -0
  40. package/sidecar/src/db/migrations/0001_high_psylocke.sql +9 -0
  41. package/sidecar/src/db/migrations/0002_common_stellaris.sql +1 -0
  42. package/sidecar/src/db/migrations/meta/0000_snapshot.json +49 -0
  43. package/sidecar/src/db/migrations/meta/0001_snapshot.json +109 -0
  44. package/sidecar/src/db/migrations/meta/0002_snapshot.json +117 -0
  45. package/sidecar/src/db/migrations/meta/_journal.json +27 -0
  46. package/sidecar/src/db/schema.ts +22 -0
  47. package/sidecar/src/dedup/dedup.module.ts +9 -0
  48. package/sidecar/src/dedup/dedup.repository.ts +29 -0
  49. package/sidecar/src/dedup/dedup.service.ts +38 -0
  50. package/sidecar/src/gateway/gateway-client.module.ts +8 -0
  51. package/sidecar/src/gateway/gateway-client.service.ts +131 -0
  52. package/sidecar/src/health/health.controller.ts +15 -0
  53. package/sidecar/src/health/health.module.ts +13 -0
  54. package/sidecar/src/health/health.service.ts +51 -0
  55. package/sidecar/src/index.ts +21 -0
  56. package/sidecar/src/nats-streams/nats-adapter.service.ts +133 -0
  57. package/sidecar/src/nats-streams/nats-streams.module.ts +8 -0
  58. package/sidecar/src/pending/pending.controller.ts +24 -0
  59. package/sidecar/src/pending/pending.module.ts +11 -0
  60. package/sidecar/src/pending/pending.repository.ts +62 -0
  61. package/sidecar/src/pending/pending.service.ts +38 -0
  62. package/sidecar/src/pre-handlers/dedup.handler.ts +22 -0
  63. package/sidecar/src/pre-handlers/enrich.handler.ts +14 -0
  64. package/sidecar/src/pre-handlers/filter.handler.ts +39 -0
  65. package/sidecar/src/pre-handlers/pipeline.service.ts +38 -0
  66. package/sidecar/src/pre-handlers/pre-handler.interface.ts +10 -0
  67. package/sidecar/src/pre-handlers/pre-handlers.module.ts +14 -0
  68. package/sidecar/src/pre-handlers/priority.handler.ts +14 -0
  69. package/sidecar/src/publisher/envelope.ts +36 -0
  70. package/sidecar/src/publisher/publisher.controller.ts +21 -0
  71. package/sidecar/src/publisher/publisher.module.ts +12 -0
  72. package/sidecar/src/publisher/publisher.service.ts +20 -0
  73. package/sidecar/src/validation/schemas.ts +19 -0
  74. package/sidecar/tsconfig.json +16 -0
@@ -0,0 +1,113 @@
1
+ import { Service, BaseService, type OnModuleInit, type OnModuleDestroy } from '@onebun/core';
2
+ import type { Message, Subscription } from '@onebun/core';
3
+ import { NatsAdapterService } from '../nats-streams/nats-adapter.service';
4
+ import { PipelineService } from '../pre-handlers/pipeline.service';
5
+ import { GatewayClientService } from '../gateway/gateway-client.service';
6
+ import { PendingService } from '../pending/pending.service';
7
+ import type { NatsEventEnvelope } from '../publisher/envelope';
8
+
9
+ @Service()
10
+ export class ConsumerService extends BaseService implements OnModuleInit, OnModuleDestroy {
11
+ private subscription: Subscription | null = null;
12
+
13
+ constructor(
14
+ private natsAdapter: NatsAdapterService,
15
+ private pipeline: PipelineService,
16
+ private gatewayClient: GatewayClientService,
17
+ private pendingService: PendingService,
18
+ ) {
19
+ super();
20
+ }
21
+
22
+ async onModuleInit(): Promise<void> {
23
+ if (!this.natsAdapter.isConnected()) {
24
+ this.logger.warn('NATS not connected, consumer will not start');
25
+ return;
26
+ }
27
+
28
+ try {
29
+ const consumerName = this.config.get('consumer.name');
30
+ this.subscription = await this.natsAdapter.subscribe(
31
+ 'agent.inbound.>',
32
+ (message: Message<unknown>) => this.handleInbound(message),
33
+ { ackMode: 'manual', group: consumerName },
34
+ );
35
+ this.logger.info(`Consuming from agent_inbound as ${consumerName}`);
36
+ } catch (err: any) {
37
+ this.logger.warn(`Failed to start consumer: ${err?.message}`);
38
+ }
39
+ }
40
+
41
+ private async handleInbound(message: Message<unknown>): Promise<void> {
42
+ try {
43
+ const envelope = this.extractEnvelope(message);
44
+
45
+ const { result, ctx } = await this.pipeline.process(envelope);
46
+
47
+ if (result === 'drop') {
48
+ await message.ack();
49
+ return;
50
+ }
51
+
52
+ // Deliver to Gateway
53
+ if (this.gatewayClient.isAlive()) {
54
+ await this.gatewayClient.inject({
55
+ target: envelope.agentTarget ?? 'main',
56
+ message: this.formatMessage(envelope),
57
+ metadata: {
58
+ source: 'nats',
59
+ eventId: envelope.id,
60
+ subject: envelope.subject,
61
+ priority: (ctx.enrichments['priority'] as number) ?? envelope.meta?.priority ?? 5,
62
+ },
63
+ });
64
+ await message.ack();
65
+ } else {
66
+ // Gateway not available — store as pending for ContextEngine pickup
67
+ await this.pendingService.addPending(envelope);
68
+ await message.ack(); // ack because we stored it locally
69
+ this.logger.warn(`Gateway unavailable, stored pending event ${envelope.id}`);
70
+ }
71
+ } catch (err) {
72
+ this.logger.error('Failed to process message', err);
73
+ await message.nack(true);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Extract the NatsEventEnvelope from the adapter message.
79
+ *
80
+ * The JetStreamQueueAdapter wraps messages in its own envelope:
81
+ * { id, pattern, data, timestamp, metadata }
82
+ *
83
+ * Our NatsEventEnvelope is inside `data` when published via PublisherService,
84
+ * or the raw data itself when published externally.
85
+ */
86
+ private extractEnvelope(message: Message<unknown>): NatsEventEnvelope {
87
+ const data = message.data as any;
88
+
89
+ // If the data already looks like a NatsEventEnvelope (has id, subject, payload),
90
+ // use it directly.
91
+ if (data && typeof data === 'object' && 'subject' in data && 'payload' in data) {
92
+ return data as NatsEventEnvelope;
93
+ }
94
+
95
+ // Otherwise, treat it as a raw payload string that needs parsing
96
+ if (typeof data === 'string') {
97
+ return JSON.parse(data) as NatsEventEnvelope;
98
+ }
99
+
100
+ throw new Error('Unable to extract envelope from message');
101
+ }
102
+
103
+ private formatMessage(envelope: NatsEventEnvelope): string {
104
+ return `[NATS:${envelope.subject}] ${JSON.stringify(envelope.payload)}`;
105
+ }
106
+
107
+ async onModuleDestroy(): Promise<void> {
108
+ if (this.subscription) {
109
+ await this.subscription.unsubscribe();
110
+ this.subscription = null;
111
+ }
112
+ }
113
+ }
@@ -0,0 +1,5 @@
1
+ CREATE TABLE `dedup_events` (
2
+ `event_id` text PRIMARY KEY NOT NULL,
3
+ `subject` text NOT NULL,
4
+ `seen_at` integer NOT NULL
5
+ );
@@ -0,0 +1,9 @@
1
+ CREATE TABLE `pending_events` (
2
+ `id` text PRIMARY KEY NOT NULL,
3
+ `session_key` text NOT NULL,
4
+ `subject` text NOT NULL,
5
+ `payload` text,
6
+ `priority` integer DEFAULT 5 NOT NULL,
7
+ `created_at` integer NOT NULL,
8
+ `delivered_at` integer
9
+ );
@@ -0,0 +1 @@
1
+ CREATE INDEX `dedup_events_seen_at_idx` ON `dedup_events` (`seen_at`);
@@ -0,0 +1,49 @@
1
+ {
2
+ "version": "6",
3
+ "dialect": "sqlite",
4
+ "id": "2749e002-09ea-408a-ace2-896e42924606",
5
+ "prevId": "00000000-0000-0000-0000-000000000000",
6
+ "tables": {
7
+ "dedup_events": {
8
+ "name": "dedup_events",
9
+ "columns": {
10
+ "event_id": {
11
+ "name": "event_id",
12
+ "type": "text",
13
+ "primaryKey": true,
14
+ "notNull": true,
15
+ "autoincrement": false
16
+ },
17
+ "subject": {
18
+ "name": "subject",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true,
22
+ "autoincrement": false
23
+ },
24
+ "seen_at": {
25
+ "name": "seen_at",
26
+ "type": "integer",
27
+ "primaryKey": false,
28
+ "notNull": true,
29
+ "autoincrement": false
30
+ }
31
+ },
32
+ "indexes": {},
33
+ "foreignKeys": {},
34
+ "compositePrimaryKeys": {},
35
+ "uniqueConstraints": {},
36
+ "checkConstraints": {}
37
+ }
38
+ },
39
+ "views": {},
40
+ "enums": {},
41
+ "_meta": {
42
+ "schemas": {},
43
+ "tables": {},
44
+ "columns": {}
45
+ },
46
+ "internal": {
47
+ "indexes": {}
48
+ }
49
+ }
@@ -0,0 +1,109 @@
1
+ {
2
+ "version": "6",
3
+ "dialect": "sqlite",
4
+ "id": "6903e64d-62a4-48c6-a1e7-beacac0755ae",
5
+ "prevId": "2749e002-09ea-408a-ace2-896e42924606",
6
+ "tables": {
7
+ "dedup_events": {
8
+ "name": "dedup_events",
9
+ "columns": {
10
+ "event_id": {
11
+ "name": "event_id",
12
+ "type": "text",
13
+ "primaryKey": true,
14
+ "notNull": true,
15
+ "autoincrement": false
16
+ },
17
+ "subject": {
18
+ "name": "subject",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true,
22
+ "autoincrement": false
23
+ },
24
+ "seen_at": {
25
+ "name": "seen_at",
26
+ "type": "integer",
27
+ "primaryKey": false,
28
+ "notNull": true,
29
+ "autoincrement": false
30
+ }
31
+ },
32
+ "indexes": {},
33
+ "foreignKeys": {},
34
+ "compositePrimaryKeys": {},
35
+ "uniqueConstraints": {},
36
+ "checkConstraints": {}
37
+ },
38
+ "pending_events": {
39
+ "name": "pending_events",
40
+ "columns": {
41
+ "id": {
42
+ "name": "id",
43
+ "type": "text",
44
+ "primaryKey": true,
45
+ "notNull": true,
46
+ "autoincrement": false
47
+ },
48
+ "session_key": {
49
+ "name": "session_key",
50
+ "type": "text",
51
+ "primaryKey": false,
52
+ "notNull": true,
53
+ "autoincrement": false
54
+ },
55
+ "subject": {
56
+ "name": "subject",
57
+ "type": "text",
58
+ "primaryKey": false,
59
+ "notNull": true,
60
+ "autoincrement": false
61
+ },
62
+ "payload": {
63
+ "name": "payload",
64
+ "type": "text",
65
+ "primaryKey": false,
66
+ "notNull": false,
67
+ "autoincrement": false
68
+ },
69
+ "priority": {
70
+ "name": "priority",
71
+ "type": "integer",
72
+ "primaryKey": false,
73
+ "notNull": true,
74
+ "autoincrement": false,
75
+ "default": 5
76
+ },
77
+ "created_at": {
78
+ "name": "created_at",
79
+ "type": "integer",
80
+ "primaryKey": false,
81
+ "notNull": true,
82
+ "autoincrement": false
83
+ },
84
+ "delivered_at": {
85
+ "name": "delivered_at",
86
+ "type": "integer",
87
+ "primaryKey": false,
88
+ "notNull": false,
89
+ "autoincrement": false
90
+ }
91
+ },
92
+ "indexes": {},
93
+ "foreignKeys": {},
94
+ "compositePrimaryKeys": {},
95
+ "uniqueConstraints": {},
96
+ "checkConstraints": {}
97
+ }
98
+ },
99
+ "views": {},
100
+ "enums": {},
101
+ "_meta": {
102
+ "schemas": {},
103
+ "tables": {},
104
+ "columns": {}
105
+ },
106
+ "internal": {
107
+ "indexes": {}
108
+ }
109
+ }
@@ -0,0 +1,117 @@
1
+ {
2
+ "version": "6",
3
+ "dialect": "sqlite",
4
+ "id": "3836f3c7-1186-453d-bb01-c2f5e58a1f4f",
5
+ "prevId": "6903e64d-62a4-48c6-a1e7-beacac0755ae",
6
+ "tables": {
7
+ "dedup_events": {
8
+ "name": "dedup_events",
9
+ "columns": {
10
+ "event_id": {
11
+ "name": "event_id",
12
+ "type": "text",
13
+ "primaryKey": true,
14
+ "notNull": true,
15
+ "autoincrement": false
16
+ },
17
+ "subject": {
18
+ "name": "subject",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true,
22
+ "autoincrement": false
23
+ },
24
+ "seen_at": {
25
+ "name": "seen_at",
26
+ "type": "integer",
27
+ "primaryKey": false,
28
+ "notNull": true,
29
+ "autoincrement": false
30
+ }
31
+ },
32
+ "indexes": {
33
+ "dedup_events_seen_at_idx": {
34
+ "name": "dedup_events_seen_at_idx",
35
+ "columns": [
36
+ "seen_at"
37
+ ],
38
+ "isUnique": false
39
+ }
40
+ },
41
+ "foreignKeys": {},
42
+ "compositePrimaryKeys": {},
43
+ "uniqueConstraints": {},
44
+ "checkConstraints": {}
45
+ },
46
+ "pending_events": {
47
+ "name": "pending_events",
48
+ "columns": {
49
+ "id": {
50
+ "name": "id",
51
+ "type": "text",
52
+ "primaryKey": true,
53
+ "notNull": true,
54
+ "autoincrement": false
55
+ },
56
+ "session_key": {
57
+ "name": "session_key",
58
+ "type": "text",
59
+ "primaryKey": false,
60
+ "notNull": true,
61
+ "autoincrement": false
62
+ },
63
+ "subject": {
64
+ "name": "subject",
65
+ "type": "text",
66
+ "primaryKey": false,
67
+ "notNull": true,
68
+ "autoincrement": false
69
+ },
70
+ "payload": {
71
+ "name": "payload",
72
+ "type": "text",
73
+ "primaryKey": false,
74
+ "notNull": false,
75
+ "autoincrement": false
76
+ },
77
+ "priority": {
78
+ "name": "priority",
79
+ "type": "integer",
80
+ "primaryKey": false,
81
+ "notNull": true,
82
+ "autoincrement": false,
83
+ "default": 5
84
+ },
85
+ "created_at": {
86
+ "name": "created_at",
87
+ "type": "integer",
88
+ "primaryKey": false,
89
+ "notNull": true,
90
+ "autoincrement": false
91
+ },
92
+ "delivered_at": {
93
+ "name": "delivered_at",
94
+ "type": "integer",
95
+ "primaryKey": false,
96
+ "notNull": false,
97
+ "autoincrement": false
98
+ }
99
+ },
100
+ "indexes": {},
101
+ "foreignKeys": {},
102
+ "compositePrimaryKeys": {},
103
+ "uniqueConstraints": {},
104
+ "checkConstraints": {}
105
+ }
106
+ },
107
+ "views": {},
108
+ "enums": {},
109
+ "_meta": {
110
+ "schemas": {},
111
+ "tables": {},
112
+ "columns": {}
113
+ },
114
+ "internal": {
115
+ "indexes": {}
116
+ }
117
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "sqlite",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "6",
8
+ "when": 1773321063387,
9
+ "tag": "0000_complete_mulholland_black",
10
+ "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "6",
15
+ "when": 1773329422050,
16
+ "tag": "0001_high_psylocke",
17
+ "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "6",
22
+ "when": 1773334582658,
23
+ "tag": "0002_common_stellaris",
24
+ "breakpoints": true
25
+ }
26
+ ]
27
+ }
@@ -0,0 +1,22 @@
1
+ import { sqliteTable, text, integer, index } from '@onebun/drizzle/sqlite';
2
+
3
+ export const dedupEvents = sqliteTable('dedup_events', {
4
+ eventId: text('event_id').primaryKey(),
5
+ subject: text('subject').notNull(),
6
+ seenAt: integer('seen_at', { mode: 'timestamp_ms' }).notNull(),
7
+ }, (table) => [
8
+ index('dedup_events_seen_at_idx').on(table.seenAt),
9
+ ]);
10
+
11
+ export const pendingEvents = sqliteTable('pending_events', {
12
+ id: text('id').primaryKey(),
13
+ sessionKey: text('session_key').notNull(),
14
+ subject: text('subject').notNull(),
15
+ payload: text('payload', { mode: 'json' }).$type<unknown>(),
16
+ priority: integer('priority').notNull().default(5),
17
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
18
+ deliveredAt: integer('delivered_at', { mode: 'timestamp_ms' }),
19
+ });
20
+
21
+ export type DbPendingEvent = typeof pendingEvents.$inferSelect;
22
+ export type NewPendingEvent = typeof pendingEvents.$inferInsert;
@@ -0,0 +1,9 @@
1
+ import { Module } from '@onebun/core';
2
+ import { DedupService } from './dedup.service';
3
+ import { DedupRepository } from './dedup.repository';
4
+
5
+ @Module({
6
+ providers: [DedupService, DedupRepository],
7
+ exports: [DedupService],
8
+ })
9
+ export class DedupModule {}
@@ -0,0 +1,29 @@
1
+ import { Service, BaseService } from '@onebun/core';
2
+ import { DrizzleService, eq, lt } from '@onebun/drizzle';
3
+ import { dedupEvents } from '../db/schema';
4
+
5
+ @Service()
6
+ export class DedupRepository extends BaseService {
7
+ constructor(private db: DrizzleService) {
8
+ super();
9
+ }
10
+
11
+ async isDuplicate(eventId: string): Promise<boolean> {
12
+ const rows = await this.db.select().from(dedupEvents).where(eq(dedupEvents.eventId, eventId)).limit(1);
13
+ return rows.length > 0;
14
+ }
15
+
16
+ async markSeen(eventId: string, subject: string): Promise<void> {
17
+ await this.db.insert(dedupEvents).values({
18
+ eventId,
19
+ subject,
20
+ seenAt: new Date(),
21
+ }).onConflictDoNothing();
22
+ }
23
+
24
+ async cleanup(ttlSeconds: number): Promise<number> {
25
+ const cutoff = new Date(Date.now() - ttlSeconds * 1000);
26
+ const result = await this.db.delete(dedupEvents).where(lt(dedupEvents.seenAt, cutoff)) as unknown as { changes: number };
27
+ return result.changes;
28
+ }
29
+ }
@@ -0,0 +1,38 @@
1
+ import { Service, BaseService, type OnModuleInit, type OnModuleDestroy } from '@onebun/core';
2
+ import { DedupRepository } from './dedup.repository';
3
+
4
+ @Service()
5
+ export class DedupService extends BaseService implements OnModuleInit, OnModuleDestroy {
6
+ private ttlSeconds!: number;
7
+ private cleanupTimer?: ReturnType<typeof setInterval>;
8
+
9
+ constructor(private repo: DedupRepository) {
10
+ super();
11
+ }
12
+
13
+ async onModuleInit(): Promise<void> {
14
+ this.ttlSeconds = this.config.get('dedup.ttlSeconds');
15
+ const cleanupIntervalMs = this.config.get('dedup.cleanupIntervalMs');
16
+
17
+ await this.cleanup();
18
+ this.cleanupTimer = setInterval(() => this.cleanup(), cleanupIntervalMs);
19
+ }
20
+
21
+ async onModuleDestroy(): Promise<void> {
22
+ if (this.cleanupTimer) {
23
+ clearInterval(this.cleanupTimer);
24
+ this.cleanupTimer = undefined;
25
+ }
26
+ }
27
+
28
+ async isDuplicate(eventId: string, subject: string): Promise<boolean> {
29
+ const duplicate = await this.repo.isDuplicate(eventId);
30
+ if (duplicate) return true;
31
+ await this.repo.markSeen(eventId, subject);
32
+ return false;
33
+ }
34
+
35
+ async cleanup(): Promise<number> {
36
+ return this.repo.cleanup(this.ttlSeconds);
37
+ }
38
+ }
@@ -0,0 +1,8 @@
1
+ import { Module } from '@onebun/core';
2
+ import { GatewayClientService } from './gateway-client.service';
3
+
4
+ @Module({
5
+ providers: [GatewayClientService],
6
+ exports: [GatewayClientService],
7
+ })
8
+ export class GatewayClientModule {}
@@ -0,0 +1,131 @@
1
+ import { Service, BaseService, type OnModuleInit, type OnModuleDestroy } from '@onebun/core';
2
+
3
+ export interface GatewayInjectPayload {
4
+ target: string;
5
+ message: string;
6
+ metadata?: {
7
+ source: 'nats';
8
+ eventId: string;
9
+ subject: string;
10
+ priority: number;
11
+ };
12
+ }
13
+
14
+ @Service()
15
+ export class GatewayClientService extends BaseService implements OnModuleInit, OnModuleDestroy {
16
+ private ws: WebSocket | null = null;
17
+ private connected = false;
18
+ private reconnectAttempt = 0;
19
+ private reconnectTimer: Timer | null = null;
20
+ private requestId = 0;
21
+ private wsUrl!: string;
22
+ private token!: string;
23
+
24
+ async onModuleInit(): Promise<void> {
25
+ this.wsUrl = this.config.get('gateway.wsUrl');
26
+ this.token = this.config.get('gateway.token');
27
+ if (this.wsUrl) {
28
+ this.connect();
29
+ }
30
+ }
31
+
32
+ private connect(): void {
33
+ try {
34
+ const url = this.token ? `${this.wsUrl}?token=${this.token}` : this.wsUrl;
35
+ this.ws = new WebSocket(url);
36
+
37
+ this.ws.onopen = () => {
38
+ this.logger.info('Gateway WebSocket connected');
39
+ this.reconnectAttempt = 0;
40
+ this.sendConnect();
41
+ };
42
+
43
+ this.ws.onmessage = (event) => {
44
+ this.handleMessage(event);
45
+ };
46
+
47
+ this.ws.onclose = () => {
48
+ this.connected = false;
49
+ this.scheduleReconnect();
50
+ };
51
+
52
+ this.ws.onerror = () => {
53
+ this.logger.warn('Gateway WebSocket error');
54
+ this.connected = false;
55
+ };
56
+ } catch {
57
+ this.logger.warn('Failed to connect to Gateway WebSocket');
58
+ this.scheduleReconnect();
59
+ }
60
+ }
61
+
62
+ private handleMessage(event: { data: unknown }): void {
63
+ try {
64
+ const frame = JSON.parse(String(event.data));
65
+ if (frame.type === 'res' && frame.ok) {
66
+ this.connected = true;
67
+ }
68
+ } catch {
69
+ // ignore parse errors
70
+ }
71
+ }
72
+
73
+ private sendConnect(): void {
74
+ this.send({
75
+ type: 'req',
76
+ id: ++this.requestId,
77
+ method: 'connect',
78
+ params: {},
79
+ });
80
+ }
81
+
82
+ private send(frame: unknown): void {
83
+ if (this.ws?.readyState === WebSocket.OPEN) {
84
+ this.ws.send(JSON.stringify(frame));
85
+ }
86
+ }
87
+
88
+ private scheduleReconnect(): void {
89
+ if (this.reconnectTimer) return;
90
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempt), 30000);
91
+ this.reconnectAttempt++;
92
+ this.logger.debug(`Reconnecting to Gateway in ${delay}ms (attempt ${this.reconnectAttempt})`);
93
+ this.reconnectTimer = setTimeout(() => {
94
+ this.reconnectTimer = null;
95
+ this.connect();
96
+ }, delay);
97
+ }
98
+
99
+ async inject(payload: GatewayInjectPayload): Promise<void> {
100
+ if (!this.isAlive()) {
101
+ throw new Error('Gateway WebSocket not connected');
102
+ }
103
+ this.send({
104
+ type: 'req',
105
+ id: ++this.requestId,
106
+ method: 'send',
107
+ params: {
108
+ target: payload.target,
109
+ message: payload.message,
110
+ metadata: payload.metadata,
111
+ idempotencyKey: payload.metadata?.eventId ?? String(this.requestId),
112
+ },
113
+ });
114
+ }
115
+
116
+ isAlive(): boolean {
117
+ return this.connected && this.ws?.readyState === WebSocket.OPEN;
118
+ }
119
+
120
+ async onModuleDestroy(): Promise<void> {
121
+ if (this.reconnectTimer) {
122
+ clearTimeout(this.reconnectTimer);
123
+ this.reconnectTimer = null;
124
+ }
125
+ if (this.ws) {
126
+ this.ws.close();
127
+ this.ws = null;
128
+ }
129
+ this.connected = false;
130
+ }
131
+ }