@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.
- package/PLUGIN.md +94 -0
- package/bin/cli.ts +75 -0
- package/cli/bun-setup.ts +133 -0
- package/cli/detect-runtime.ts +40 -0
- package/cli/docker-setup.ts +54 -0
- package/cli/download-nats.ts +110 -0
- package/cli/env-writer.ts +58 -0
- package/cli/lifecycle.ts +109 -0
- package/cli/nats-config.ts +32 -0
- package/cli/paths.ts +20 -0
- package/cli/service-units.ts +168 -0
- package/cli/setup.ts +23 -0
- package/dashboard/dist/assets/index-CafgidIc.css +2 -0
- package/dashboard/dist/assets/index-OUWnIZmb.js +15 -0
- package/dashboard/dist/index.html +13 -0
- package/docker/docker-compose.yml +48 -0
- package/hooks/command-publisher/HOOK.md +13 -0
- package/hooks/command-publisher/handler.ts +23 -0
- package/hooks/gateway-startup/HOOK.md +13 -0
- package/hooks/gateway-startup/handler.ts +31 -0
- package/hooks/lifecycle-publisher/HOOK.md +12 -0
- package/hooks/lifecycle-publisher/handler.ts +20 -0
- package/hooks/shared/sidecar-client.ts +23 -0
- package/index.ts +3 -0
- package/openclaw.plugin.json +8 -0
- package/package.json +48 -0
- package/plugins/nats-context-engine/PLUGIN.md +14 -0
- package/plugins/nats-context-engine/http-handler.ts +131 -0
- package/plugins/nats-context-engine/index.ts +89 -0
- package/sidecar/Dockerfile +11 -0
- package/sidecar/bun.lock +212 -0
- package/sidecar/drizzle.config.ts +10 -0
- package/sidecar/package.json +28 -0
- package/sidecar/src/app.module.ts +33 -0
- package/sidecar/src/auth/api-key.middleware.ts +39 -0
- package/sidecar/src/config.ts +40 -0
- package/sidecar/src/consumer/consumer.module.ts +12 -0
- package/sidecar/src/consumer/consumer.service.ts +113 -0
- package/sidecar/src/db/migrations/0000_complete_mulholland_black.sql +5 -0
- package/sidecar/src/db/migrations/0001_high_psylocke.sql +9 -0
- package/sidecar/src/db/migrations/0002_common_stellaris.sql +1 -0
- package/sidecar/src/db/migrations/meta/0000_snapshot.json +49 -0
- package/sidecar/src/db/migrations/meta/0001_snapshot.json +109 -0
- package/sidecar/src/db/migrations/meta/0002_snapshot.json +117 -0
- package/sidecar/src/db/migrations/meta/_journal.json +27 -0
- package/sidecar/src/db/schema.ts +22 -0
- package/sidecar/src/dedup/dedup.module.ts +9 -0
- package/sidecar/src/dedup/dedup.repository.ts +29 -0
- package/sidecar/src/dedup/dedup.service.ts +38 -0
- package/sidecar/src/gateway/gateway-client.module.ts +8 -0
- package/sidecar/src/gateway/gateway-client.service.ts +131 -0
- package/sidecar/src/health/health.controller.ts +15 -0
- package/sidecar/src/health/health.module.ts +13 -0
- package/sidecar/src/health/health.service.ts +51 -0
- package/sidecar/src/index.ts +21 -0
- package/sidecar/src/nats-streams/nats-adapter.service.ts +133 -0
- package/sidecar/src/nats-streams/nats-streams.module.ts +8 -0
- package/sidecar/src/pending/pending.controller.ts +24 -0
- package/sidecar/src/pending/pending.module.ts +11 -0
- package/sidecar/src/pending/pending.repository.ts +62 -0
- package/sidecar/src/pending/pending.service.ts +38 -0
- package/sidecar/src/pre-handlers/dedup.handler.ts +22 -0
- package/sidecar/src/pre-handlers/enrich.handler.ts +14 -0
- package/sidecar/src/pre-handlers/filter.handler.ts +39 -0
- package/sidecar/src/pre-handlers/pipeline.service.ts +38 -0
- package/sidecar/src/pre-handlers/pre-handler.interface.ts +10 -0
- package/sidecar/src/pre-handlers/pre-handlers.module.ts +14 -0
- package/sidecar/src/pre-handlers/priority.handler.ts +14 -0
- package/sidecar/src/publisher/envelope.ts +36 -0
- package/sidecar/src/publisher/publisher.controller.ts +21 -0
- package/sidecar/src/publisher/publisher.module.ts +12 -0
- package/sidecar/src/publisher/publisher.service.ts +20 -0
- package/sidecar/src/validation/schemas.ts +19 -0
- 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 @@
|
|
|
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,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
|
+
}
|