@omnixal/openclaw-nats-plugin 0.2.14 → 0.2.16
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/package.json +1 -1
- package/plugins/nats-context-engine/http-handler.ts +35 -45
- package/sidecar/src/config.ts +4 -0
- package/sidecar/src/pending/pending-flush.service.ts +70 -0
- package/sidecar/src/pending/pending.module.ts +5 -1
- package/sidecar/src/pending/pending.repository.ts +6 -2
- package/sidecar/src/pending/pending.service.ts +2 -2
- package/sidecar/src/scheduler/scheduler.controller.ts +2 -2
package/package.json
CHANGED
|
@@ -2,13 +2,11 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
-
import http from 'node:http';
|
|
6
5
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
7
6
|
|
|
8
7
|
const ROUTE_PREFIX = '/nats-dashboard';
|
|
9
8
|
const SIDECAR_URL = process.env.NATS_SIDECAR_URL || 'http://127.0.0.1:3104';
|
|
10
9
|
const API_KEY = process.env.NATS_PLUGIN_API_KEY || 'dev-nats-plugin-key';
|
|
11
|
-
const sidecarParsed = new URL(SIDECAR_URL);
|
|
12
10
|
|
|
13
11
|
// Stable location (copied during setup) takes priority over in-package dist
|
|
14
12
|
const STABLE_DIST = path.join(homedir(), '.openclaw', 'nats-plugin', 'dashboard');
|
|
@@ -28,10 +26,33 @@ const MIME_TYPES: Record<string, string> = {
|
|
|
28
26
|
|
|
29
27
|
export function createDashboardHandler() {
|
|
30
28
|
return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
|
|
31
|
-
const
|
|
32
|
-
const
|
|
29
|
+
const rawUrl = req.url || '/';
|
|
30
|
+
const url = new URL(rawUrl, `http://${req.headers.host || 'localhost'}`);
|
|
31
|
+
|
|
32
|
+
// Support both prefixed and stripped paths (OpenClaw may strip the prefix)
|
|
33
|
+
let subPath: string;
|
|
34
|
+
if (url.pathname.startsWith(ROUTE_PREFIX)) {
|
|
35
|
+
subPath = url.pathname.slice(ROUTE_PREFIX.length);
|
|
36
|
+
} else {
|
|
37
|
+
subPath = url.pathname;
|
|
38
|
+
}
|
|
33
39
|
|
|
34
|
-
//
|
|
40
|
+
// Debug endpoint
|
|
41
|
+
if (subPath === '/api/_debug') {
|
|
42
|
+
res.statusCode = 200;
|
|
43
|
+
res.setHeader('content-type', 'application/json');
|
|
44
|
+
res.end(JSON.stringify({
|
|
45
|
+
rawUrl,
|
|
46
|
+
pathname: url.pathname,
|
|
47
|
+
subPath,
|
|
48
|
+
sidecarUrl: SIDECAR_URL,
|
|
49
|
+
distDir: DIST_DIR,
|
|
50
|
+
distExists: existsSync(path.join(DIST_DIR, 'index.html')),
|
|
51
|
+
}, null, 2));
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// API proxy: /api/* → sidecar
|
|
35
56
|
if (subPath.startsWith('/api/')) {
|
|
36
57
|
return proxyToSidecar(subPath, url.search, req, res);
|
|
37
58
|
}
|
|
@@ -54,6 +75,7 @@ async function proxyToSidecar(
|
|
|
54
75
|
res: ServerResponse,
|
|
55
76
|
): Promise<boolean> {
|
|
56
77
|
try {
|
|
78
|
+
const targetUrl = `${SIDECAR_URL}${subPath}${search}`;
|
|
57
79
|
const headers: Record<string, string> = {
|
|
58
80
|
'Authorization': `Bearer ${API_KEY}`,
|
|
59
81
|
};
|
|
@@ -66,19 +88,17 @@ async function proxyToSidecar(
|
|
|
66
88
|
body = await readBody(req);
|
|
67
89
|
}
|
|
68
90
|
|
|
69
|
-
|
|
70
|
-
const upstream = await httpRequest({
|
|
71
|
-
hostname: sidecarParsed.hostname,
|
|
72
|
-
port: Number(sidecarParsed.port),
|
|
73
|
-
path: `${subPath}${search}`,
|
|
91
|
+
const upstream = await fetch(targetUrl, {
|
|
74
92
|
method: req.method || 'GET',
|
|
75
93
|
headers,
|
|
76
|
-
|
|
77
|
-
|
|
94
|
+
body,
|
|
95
|
+
signal: AbortSignal.timeout(10_000),
|
|
96
|
+
});
|
|
78
97
|
|
|
79
|
-
res.statusCode = upstream.
|
|
80
|
-
res.setHeader('content-type', upstream.headers
|
|
81
|
-
|
|
98
|
+
res.statusCode = upstream.status;
|
|
99
|
+
res.setHeader('content-type', upstream.headers.get('content-type') || 'application/json');
|
|
100
|
+
const responseBody = await upstream.text();
|
|
101
|
+
res.end(responseBody);
|
|
82
102
|
} catch (err) {
|
|
83
103
|
const message = err instanceof Error ? err.message : String(err);
|
|
84
104
|
console.error(`[nats-dashboard] Sidecar proxy error: ${message} (url=${SIDECAR_URL}${subPath})`);
|
|
@@ -91,36 +111,6 @@ async function proxyToSidecar(
|
|
|
91
111
|
|
|
92
112
|
const MAX_BODY_BYTES = 1_048_576; // 1MB
|
|
93
113
|
|
|
94
|
-
interface HttpResponse {
|
|
95
|
-
statusCode: number;
|
|
96
|
-
headers: Record<string, string | string[] | undefined>;
|
|
97
|
-
body: string;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function httpRequest(
|
|
101
|
-
opts: http.RequestOptions,
|
|
102
|
-
body?: string,
|
|
103
|
-
): Promise<HttpResponse> {
|
|
104
|
-
return new Promise((resolve, reject) => {
|
|
105
|
-
const req = http.request(opts, (res) => {
|
|
106
|
-
const chunks: Buffer[] = [];
|
|
107
|
-
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
108
|
-
res.on('end', () => {
|
|
109
|
-
resolve({
|
|
110
|
-
statusCode: res.statusCode || 500,
|
|
111
|
-
headers: res.headers,
|
|
112
|
-
body: Buffer.concat(chunks).toString(),
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
res.on('error', reject);
|
|
116
|
-
});
|
|
117
|
-
req.on('error', reject);
|
|
118
|
-
req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
|
|
119
|
-
if (body) req.write(body);
|
|
120
|
-
req.end();
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
|
|
124
114
|
function readBody(req: IncomingMessage): Promise<string> {
|
|
125
115
|
return new Promise((resolve, reject) => {
|
|
126
116
|
const chunks: Buffer[] = [];
|
package/sidecar/src/config.ts
CHANGED
|
@@ -28,6 +28,10 @@ export const envSchema = {
|
|
|
28
28
|
ttlSeconds: Env.number({ default: 60, env: 'NATS_DEDUP_TTL_SECONDS' }),
|
|
29
29
|
cleanupIntervalMs: Env.number({ default: 300000, env: 'NATS_DEDUP_CLEANUP_INTERVAL_MS' }),
|
|
30
30
|
},
|
|
31
|
+
pending: {
|
|
32
|
+
flushIntervalMs: Env.number({ default: 30000, env: 'NATS_PENDING_FLUSH_INTERVAL_MS' }),
|
|
33
|
+
flushBatchSize: Env.number({ default: 10, env: 'NATS_PENDING_FLUSH_BATCH_SIZE' }),
|
|
34
|
+
},
|
|
31
35
|
auth: {
|
|
32
36
|
pluginApiKey: Env.string({ default: 'dev-nats-plugin-key', env: 'NATS_PLUGIN_API_KEY' }),
|
|
33
37
|
},
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Service, BaseService, type OnModuleInit, type OnModuleDestroy } from '@onebun/core';
|
|
2
|
+
import { PendingService } from './pending.service';
|
|
3
|
+
import { GatewayClientService, GatewayRpcError } from '../gateway/gateway-client.service';
|
|
4
|
+
import { MetricsService } from '../metrics/metrics.service';
|
|
5
|
+
import { LogService } from '../logs/log.service';
|
|
6
|
+
|
|
7
|
+
@Service()
|
|
8
|
+
export class PendingFlushService extends BaseService implements OnModuleInit, OnModuleDestroy {
|
|
9
|
+
private flushTimer?: ReturnType<typeof setInterval>;
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
private pendingService: PendingService,
|
|
13
|
+
private gatewayClient: GatewayClientService,
|
|
14
|
+
private metrics: MetricsService,
|
|
15
|
+
private logService: LogService,
|
|
16
|
+
) {
|
|
17
|
+
super();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async onModuleInit(): Promise<void> {
|
|
21
|
+
const intervalMs = this.config.get('pending.flushIntervalMs');
|
|
22
|
+
this.flush().catch((e) => {
|
|
23
|
+
this.logger.warn('Initial pending flush failed', { error: String(e) });
|
|
24
|
+
});
|
|
25
|
+
this.flushTimer = setInterval(() => {
|
|
26
|
+
this.flush().catch((e) => {
|
|
27
|
+
this.logger.warn('Pending flush failed', { error: String(e) });
|
|
28
|
+
});
|
|
29
|
+
}, intervalMs);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async onModuleDestroy(): Promise<void> {
|
|
33
|
+
if (this.flushTimer) {
|
|
34
|
+
clearInterval(this.flushTimer);
|
|
35
|
+
this.flushTimer = undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async flush(): Promise<void> {
|
|
40
|
+
if (!this.gatewayClient.isAlive()) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const batchSize = this.config.get('pending.flushBatchSize');
|
|
45
|
+
const pending = await this.pendingService.fetchPending('default', batchSize);
|
|
46
|
+
if (pending.length === 0) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.logger.info(`Flushing ${pending.length} pending event(s)`);
|
|
51
|
+
|
|
52
|
+
for (const event of pending) {
|
|
53
|
+
try {
|
|
54
|
+
const message = `[NATS:${event.subject}] ${JSON.stringify(event.payload)}`;
|
|
55
|
+
await this.gatewayClient.inject({ message, eventId: event.id });
|
|
56
|
+
await this.pendingService.markDelivered([event.id]);
|
|
57
|
+
this.metrics.recordConsume(event.subject);
|
|
58
|
+
await this.logService.logDelivery('pending-flush', event.subject, JSON.stringify({ eventId: event.id }));
|
|
59
|
+
} catch (err) {
|
|
60
|
+
await this.logService.logError('route', 'pending-flush', event.subject, err);
|
|
61
|
+
if (err instanceof GatewayRpcError) {
|
|
62
|
+
this.logger.error(`Pending flush: gateway rejected event ${event.id}: ${err.errorCode}`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
this.logger.error(`Pending flush: network error on event ${event.id}, stopping batch`, err);
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -2,10 +2,14 @@ import { Module } from '@onebun/core';
|
|
|
2
2
|
import { PendingController } from './pending.controller';
|
|
3
3
|
import { PendingService } from './pending.service';
|
|
4
4
|
import { PendingRepository } from './pending.repository';
|
|
5
|
+
import { PendingFlushService } from './pending-flush.service';
|
|
6
|
+
import { MetricsModule } from '../metrics/metrics.module';
|
|
7
|
+
import { LogModule } from '../logs/log.module';
|
|
5
8
|
|
|
6
9
|
@Module({
|
|
10
|
+
imports: [MetricsModule, LogModule],
|
|
7
11
|
controllers: [PendingController],
|
|
8
|
-
providers: [PendingService, PendingRepository],
|
|
12
|
+
providers: [PendingService, PendingRepository, PendingFlushService],
|
|
9
13
|
exports: [PendingService],
|
|
10
14
|
})
|
|
11
15
|
export class PendingModule {}
|
|
@@ -28,12 +28,16 @@ export class PendingRepository extends BaseService {
|
|
|
28
28
|
.onConflictDoNothing();
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
async fetchPending(sessionKey: string): Promise<DbPendingEvent[]> {
|
|
32
|
-
|
|
31
|
+
async fetchPending(sessionKey: string, limit?: number): Promise<DbPendingEvent[]> {
|
|
32
|
+
const query = this.db
|
|
33
33
|
.select()
|
|
34
34
|
.from(pendingEvents)
|
|
35
35
|
.where(and(eq(pendingEvents.sessionKey, sessionKey), isNull(pendingEvents.deliveredAt)))
|
|
36
36
|
.orderBy(desc(pendingEvents.priority));
|
|
37
|
+
if (limit !== undefined) {
|
|
38
|
+
return query.limit(limit);
|
|
39
|
+
}
|
|
40
|
+
return query;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
async markDelivered(ids: string[]): Promise<void> {
|
|
@@ -19,8 +19,8 @@ export class PendingService extends BaseService {
|
|
|
19
19
|
});
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
async fetchPending(sessionKey: string): Promise<DbPendingEvent[]> {
|
|
23
|
-
return this.repo.fetchPending(sessionKey);
|
|
22
|
+
async fetchPending(sessionKey: string, limit?: number): Promise<DbPendingEvent[]> {
|
|
23
|
+
return this.repo.fetchPending(sessionKey, limit);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
async markDelivered(ids: string[]): Promise<void> {
|
|
@@ -124,7 +124,7 @@ export class SchedulerController extends BaseController {
|
|
|
124
124
|
|
|
125
125
|
// ── Queue Handlers ────────────────────────────────────────────────
|
|
126
126
|
|
|
127
|
-
@Subscribe('scheduler.fire.*', { group: 'scheduler-handler' })
|
|
127
|
+
@Subscribe('scheduler.fire.*', { group: 'scheduler-cron-handler' })
|
|
128
128
|
async handleFire(message: Message<unknown>): Promise<void> {
|
|
129
129
|
const jobName = message.pattern?.split('.').pop();
|
|
130
130
|
if (jobName) {
|
|
@@ -132,7 +132,7 @@ export class SchedulerController extends BaseController {
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
@Subscribe('scheduler.timer.*', { group: 'scheduler-handler' })
|
|
135
|
+
@Subscribe('scheduler.timer.*', { group: 'scheduler-timer-handler' })
|
|
136
136
|
async handleTimerFire(message: Message<unknown>): Promise<void> {
|
|
137
137
|
const timerName = message.pattern?.split('.').pop();
|
|
138
138
|
if (timerName) {
|