@omnixal/openclaw-nats-plugin 0.2.15 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnixal/openclaw-nats-plugin",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "description": "NATS JetStream event-driven plugin for OpenClaw",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,21 +2,12 @@ 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
10
 
12
- let sidecarParsed: URL;
13
- try {
14
- sidecarParsed = new URL(SIDECAR_URL);
15
- } catch (e) {
16
- console.error(`[nats-dashboard] Invalid NATS_SIDECAR_URL: ${SIDECAR_URL}`, e);
17
- sidecarParsed = new URL('http://127.0.0.1:3104');
18
- }
19
-
20
11
  // Stable location (copied during setup) takes priority over in-package dist
21
12
  const STABLE_DIST = path.join(homedir(), '.openclaw', 'nats-plugin', 'dashboard');
22
13
  const PACKAGE_DIST = path.resolve(__dirname, '../../dashboard/dist');
@@ -46,7 +37,7 @@ export function createDashboardHandler() {
46
37
  subPath = url.pathname;
47
38
  }
48
39
 
49
- // Debug endpoint: /nats-dashboard/api/_debug (or /api/_debug if prefix stripped)
40
+ // Debug endpoint
50
41
  if (subPath === '/api/_debug') {
51
42
  res.statusCode = 200;
52
43
  res.setHeader('content-type', 'application/json');
@@ -55,15 +46,8 @@ export function createDashboardHandler() {
55
46
  pathname: url.pathname,
56
47
  subPath,
57
48
  sidecarUrl: SIDECAR_URL,
58
- sidecarHost: sidecarParsed.hostname,
59
- sidecarPort: sidecarParsed.port,
60
- apiKey: API_KEY ? `${API_KEY.slice(0, 4)}...` : '(not set)',
61
49
  distDir: DIST_DIR,
62
50
  distExists: existsSync(path.join(DIST_DIR, 'index.html')),
63
- env: {
64
- NATS_SIDECAR_URL: process.env.NATS_SIDECAR_URL || '(default)',
65
- NATS_PLUGIN_API_KEY: process.env.NATS_PLUGIN_API_KEY ? 'set' : '(default)',
66
- },
67
51
  }, null, 2));
68
52
  return true;
69
53
  }
@@ -91,6 +75,7 @@ async function proxyToSidecar(
91
75
  res: ServerResponse,
92
76
  ): Promise<boolean> {
93
77
  try {
78
+ const targetUrl = `${SIDECAR_URL}${subPath}${search}`;
94
79
  const headers: Record<string, string> = {
95
80
  'Authorization': `Bearer ${API_KEY}`,
96
81
  };
@@ -103,69 +88,29 @@ async function proxyToSidecar(
103
88
  body = await readBody(req);
104
89
  }
105
90
 
106
- // Use node:http directly — global fetch() may be intercepted by gateway SSRF guards
107
- const upstream = await httpRequest({
108
- hostname: sidecarParsed.hostname,
109
- port: Number(sidecarParsed.port),
110
- path: `${subPath}${search}`,
91
+ const upstream = await fetch(targetUrl, {
111
92
  method: req.method || 'GET',
112
93
  headers,
113
- timeout: 10_000,
114
- }, body);
94
+ body,
95
+ signal: AbortSignal.timeout(10_000),
96
+ });
115
97
 
116
- res.statusCode = upstream.statusCode;
117
- res.setHeader('content-type', upstream.headers['content-type'] || 'application/json');
118
- res.end(upstream.body);
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);
119
102
  } catch (err) {
120
103
  const message = err instanceof Error ? err.message : String(err);
121
- const stack = err instanceof Error ? err.stack : undefined;
122
- console.error(`[nats-dashboard] Sidecar proxy error: ${message} (target=${sidecarParsed.hostname}:${sidecarParsed.port}${subPath})`);
123
- if (stack) console.error(stack);
104
+ console.error(`[nats-dashboard] Sidecar proxy error: ${message} (url=${SIDECAR_URL}${subPath})`);
124
105
  res.statusCode = 502;
125
106
  res.setHeader('content-type', 'application/json');
126
- res.end(JSON.stringify({
127
- error: 'Sidecar unreachable',
128
- detail: message,
129
- target: `${sidecarParsed.hostname}:${sidecarParsed.port}${subPath}`,
130
- sidecarUrl: SIDECAR_URL,
131
- hint: 'Open /nats-dashboard/api/_debug for full diagnostics',
132
- }));
107
+ res.end(JSON.stringify({ error: 'Sidecar unreachable', detail: message }));
133
108
  }
134
109
  return true;
135
110
  }
136
111
 
137
112
  const MAX_BODY_BYTES = 1_048_576; // 1MB
138
113
 
139
- interface HttpResponse {
140
- statusCode: number;
141
- headers: Record<string, string | string[] | undefined>;
142
- body: string;
143
- }
144
-
145
- function httpRequest(
146
- opts: http.RequestOptions,
147
- body?: string,
148
- ): Promise<HttpResponse> {
149
- return new Promise((resolve, reject) => {
150
- const req = http.request(opts, (res) => {
151
- const chunks: Buffer[] = [];
152
- res.on('data', (chunk: Buffer) => chunks.push(chunk));
153
- res.on('end', () => {
154
- resolve({
155
- statusCode: res.statusCode || 500,
156
- headers: res.headers,
157
- body: Buffer.concat(chunks).toString(),
158
- });
159
- });
160
- res.on('error', reject);
161
- });
162
- req.on('error', reject);
163
- req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
164
- if (body) req.write(body);
165
- req.end();
166
- });
167
- }
168
-
169
114
  function readBody(req: IncomingMessage): Promise<string> {
170
115
  return new Promise((resolve, reject) => {
171
116
  const chunks: Buffer[] = [];
@@ -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
- return this.db
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) {