@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnixal/openclaw-nats-plugin",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
4
4
  "description": "NATS JetStream event-driven plugin for OpenClaw",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 url = new URL(req.url!, `http://${req.headers.host}`);
32
- const subPath = url.pathname.slice(ROUTE_PREFIX.length);
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
- // API proxy: /nats-dashboard/api/* → sidecar
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
- // Use node:http directly — global fetch() may be intercepted by gateway SSRF guards
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
- timeout: 10_000,
77
- }, body);
94
+ body,
95
+ signal: AbortSignal.timeout(10_000),
96
+ });
78
97
 
79
- res.statusCode = upstream.statusCode;
80
- res.setHeader('content-type', upstream.headers['content-type'] || 'application/json');
81
- 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);
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[] = [];
@@ -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) {