@omnixal/openclaw-nats-plugin 0.2.15 → 0.2.17

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 (27) hide show
  1. package/dashboard/src/lib/LogsPanel.svelte +50 -1
  2. package/dashboard/src/lib/RoutesPanel.svelte +120 -11
  3. package/dashboard/src/lib/api.ts +20 -0
  4. package/package.json +1 -1
  5. package/plugins/nats-context-engine/http-handler.ts +12 -67
  6. package/plugins/nats-context-engine/index.ts +17 -3
  7. package/sidecar/src/config.ts +4 -0
  8. package/sidecar/src/consumer/consumer.controller.ts +41 -4
  9. package/sidecar/src/consumer/consumer.module.ts +2 -1
  10. package/sidecar/src/db/migrations/0008_fluffy_pestilence.sql +1 -0
  11. package/sidecar/src/db/migrations/0009_sour_romulus.sql +6 -0
  12. package/sidecar/src/db/migrations/meta/0008_snapshot.json +492 -0
  13. package/sidecar/src/db/migrations/meta/0009_snapshot.json +514 -0
  14. package/sidecar/src/db/migrations/meta/_journal.json +14 -0
  15. package/sidecar/src/db/schema.ts +8 -2
  16. package/sidecar/src/pending/pending-flush.service.ts +70 -0
  17. package/sidecar/src/pending/pending.module.ts +5 -1
  18. package/sidecar/src/pending/pending.repository.ts +6 -2
  19. package/sidecar/src/pending/pending.service.ts +2 -2
  20. package/sidecar/src/route-filter/filter-expression.ts +10 -0
  21. package/sidecar/src/route-filter/route-filter.module.ts +8 -0
  22. package/sidecar/src/route-filter/route-filter.service.ts +61 -0
  23. package/sidecar/src/router/router.controller.ts +14 -1
  24. package/sidecar/src/router/router.repository.ts +15 -4
  25. package/sidecar/src/router/router.service.ts +14 -3
  26. package/sidecar/src/scheduler/scheduler.controller.ts +2 -2
  27. package/sidecar/src/validation/schemas.ts +16 -0
@@ -2,6 +2,7 @@
2
2
  import * as Table from '$lib/components/ui/table';
3
3
  import { Badge } from '$lib/components/ui/badge';
4
4
  import { Button } from '$lib/components/ui/button';
5
+ import { Modal } from '$lib/components/ui/modal';
5
6
  import { type ExecutionLog, type LogFilters, getLogs } from '$lib/api';
6
7
  import { relativeAge } from '$lib/utils';
7
8
 
@@ -87,6 +88,8 @@
87
88
  }
88
89
  }
89
90
 
91
+ let selectedLog: ExecutionLog | null = $state(null);
92
+
90
93
  function parseDetail(detail: string | null): string {
91
94
  if (!detail) return '';
92
95
  try {
@@ -97,6 +100,15 @@
97
100
  }
98
101
  }
99
102
 
103
+ function formatDetailFull(detail: string | null): string {
104
+ if (!detail) return '(no detail)';
105
+ try {
106
+ return JSON.stringify(JSON.parse(detail), null, 2);
107
+ } catch {
108
+ return detail;
109
+ }
110
+ }
111
+
100
112
  $effect(() => {
101
113
  entityType; entityId;
102
114
  loadPage();
@@ -106,6 +118,43 @@
106
118
  let hasFilters = $derived(filterStatus !== '' || filterAction !== '' || filterSubject.trim() !== '');
107
119
  </script>
108
120
 
121
+ {#if selectedLog}
122
+ <Modal
123
+ open={true}
124
+ title="Log Detail"
125
+ onClose={() => (selectedLog = null)}
126
+ >
127
+ {#snippet children()}
128
+ <div class="space-y-3">
129
+ <div class="grid grid-cols-2 gap-2 text-xs">
130
+ <div>
131
+ <span class="text-muted-foreground">Time:</span>
132
+ <span class="font-medium ml-1">{new Date(selectedLog.createdAt).toLocaleString()}</span>
133
+ </div>
134
+ <div>
135
+ <span class="text-muted-foreground">Action:</span>
136
+ <Badge variant="outline" class="ml-1">{selectedLog.action}</Badge>
137
+ </div>
138
+ <div class="col-span-2">
139
+ <span class="text-muted-foreground">Subject:</span>
140
+ <span class="font-mono font-medium ml-1">{selectedLog.subject}</span>
141
+ </div>
142
+ <div>
143
+ <span class="text-muted-foreground">Status:</span>
144
+ <Badge variant={selectedLog.success ? 'default' : 'destructive'} class="ml-1">
145
+ {selectedLog.success ? 'ok' : 'error'}
146
+ </Badge>
147
+ </div>
148
+ </div>
149
+ <div class="space-y-1">
150
+ <span class="text-xs text-muted-foreground">Payload / Detail:</span>
151
+ <pre class="rounded-md bg-muted p-3 text-xs font-mono overflow-auto max-h-80 whitespace-pre-wrap break-all">{formatDetailFull(selectedLog.detail)}</pre>
152
+ </div>
153
+ </div>
154
+ {/snippet}
155
+ </Modal>
156
+ {/if}
157
+
109
158
  <div class="space-y-3">
110
159
  <!-- Filters -->
111
160
  <div class="flex flex-wrap items-end gap-2">
@@ -171,7 +220,7 @@
171
220
  </Table.Header>
172
221
  <Table.Body>
173
222
  {#each items as log}
174
- <Table.Row>
223
+ <Table.Row class="cursor-pointer hover:bg-muted/50" onclick={() => (selectedLog = log)}>
175
224
  <Table.Cell class="text-xs text-muted-foreground whitespace-nowrap">
176
225
  {relativeAge(log.createdAt)}
177
226
  </Table.Cell>
@@ -17,9 +17,12 @@
17
17
 
18
18
  // Create form state
19
19
  let showForm: boolean = $state(false);
20
+ let formName: string = $state('');
20
21
  let formPattern: string = $state('agent.events.');
21
22
  let formTarget: string = $state('main');
22
23
  let formPriority: number = $state(5);
24
+ let formPayload: string = $state('');
25
+ let formFilter: string = $state('');
23
26
  let formError: string | null = $state(null);
24
27
  let actionError: string | null = $state(null);
25
28
  let loading: boolean = $state(false);
@@ -30,6 +33,8 @@
30
33
  let editTarget: string = $state('');
31
34
  let editPriority: number = $state(5);
32
35
  let editEnabled: boolean = $state(true);
36
+ let editPayload: string = $state('');
37
+ let editFilter: string = $state('');
33
38
  let editError: string | null = $state(null);
34
39
  let showDeleteConfirm: boolean = $state(false);
35
40
 
@@ -40,25 +45,54 @@
40
45
  }
41
46
 
42
47
  function resetForm() {
48
+ formName = '';
43
49
  formPattern = 'agent.events.';
44
50
  formTarget = 'main';
45
51
  formPriority = 5;
52
+ formPayload = '';
53
+ formFilter = '';
46
54
  formError = null;
47
55
  showForm = false;
48
56
  }
49
57
 
58
+ function parseJsonPayload(text: string): unknown | undefined {
59
+ const trimmed = text.trim();
60
+ if (!trimmed) return undefined;
61
+ try {
62
+ return JSON.parse(trimmed);
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ }
67
+
68
+ function formatPayload(payload: unknown): string {
69
+ if (payload === null || payload === undefined) return '';
70
+ return JSON.stringify(payload, null, 2);
71
+ }
72
+
50
73
  async function handleCreate() {
51
74
  formError = null;
52
75
  if (!isValidAgentSubject(formPattern)) {
53
76
  formError = 'Pattern must start with "agent.events." followed by at least one token and must not end with "."';
54
77
  return;
55
78
  }
79
+ if (formPayload.trim() && parseJsonPayload(formPayload) === undefined) {
80
+ formError = 'Payload must be valid JSON';
81
+ return;
82
+ }
83
+ if (formFilter.trim() && parseJsonPayload(formFilter) === undefined) {
84
+ formError = 'Filter must be valid JSON';
85
+ return;
86
+ }
56
87
  try {
57
88
  loading = true;
58
89
  await createRoute({
59
90
  pattern: formPattern,
91
+ name: formName.trim() || undefined,
60
92
  target: formTarget || undefined,
61
93
  priority: formPriority,
94
+ payload: parseJsonPayload(formPayload),
95
+ filter: formFilter.trim() ? (parseJsonPayload(formFilter) as any) : undefined,
62
96
  });
63
97
  resetForm();
64
98
  onRefresh();
@@ -75,6 +109,8 @@
75
109
  editTarget = route.target;
76
110
  editPriority = route.priority;
77
111
  editEnabled = route.enabled;
112
+ editPayload = formatPayload(route.customPayload);
113
+ editFilter = route.filter ? JSON.stringify(route.filter, null, 2) : '';
78
114
  editError = null;
79
115
  showDeleteConfirm = false;
80
116
  }
@@ -87,6 +123,14 @@
87
123
 
88
124
  async function handleSave() {
89
125
  if (!selectedRoute) return;
126
+ if (editPayload.trim() && parseJsonPayload(editPayload) === undefined) {
127
+ editError = 'Payload must be valid JSON';
128
+ return;
129
+ }
130
+ if (editFilter.trim() && parseJsonPayload(editFilter) === undefined) {
131
+ editError = 'Filter must be valid JSON';
132
+ return;
133
+ }
90
134
  try {
91
135
  editError = null;
92
136
  loading = true;
@@ -94,6 +138,8 @@
94
138
  target: editTarget,
95
139
  priority: editPriority,
96
140
  enabled: editEnabled,
141
+ payload: editPayload.trim() ? parseJsonPayload(editPayload) : null,
142
+ filter: editFilter.trim() ? (parseJsonPayload(editFilter) as any) : null,
97
143
  });
98
144
  closeModal();
99
145
  onRefresh();
@@ -123,7 +169,7 @@
123
169
  {#if selectedRoute}
124
170
  <Modal
125
171
  open={true}
126
- title="Route: {selectedRoute.pattern}"
172
+ title="Route: {selectedRoute.name}"
127
173
  onClose={closeModal}
128
174
  >
129
175
  {#snippet children()}
@@ -146,7 +192,7 @@
146
192
 
147
193
  {#if showDeleteConfirm}
148
194
  <div class="rounded-md border border-destructive/50 bg-destructive/5 p-4 space-y-3">
149
- <p class="text-sm">Are you sure you want to delete route <span class="font-mono font-semibold">{selectedRoute.pattern}</span>?</p>
195
+ <p class="text-sm">Are you sure you want to delete route <span class="font-mono font-semibold">{selectedRoute.name}</span>?</p>
150
196
  <p class="text-xs text-muted-foreground">This action cannot be undone.</p>
151
197
  <div class="flex gap-2">
152
198
  <Button variant="destructive" size="sm" onclick={handleDelete} disabled={loading}>
@@ -187,8 +233,32 @@
187
233
  </div>
188
234
  </div>
189
235
 
236
+ <div class="space-y-1">
237
+ <label class="text-xs text-muted-foreground" for="edit-payload">Custom Payload (JSON)</label>
238
+ <textarea
239
+ id="edit-payload"
240
+ bind:value={editPayload}
241
+ class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs font-mono resize-y min-h-16"
242
+ placeholder={'{"context": "value"}'}
243
+ rows="3"
244
+ ></textarea>
245
+ </div>
246
+
247
+ <div class="space-y-1">
248
+ <label class="text-xs text-muted-foreground" for="edit-filter">Filter (JSON)</label>
249
+ <textarea
250
+ id="edit-filter"
251
+ bind:value={editFilter}
252
+ class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs font-mono resize-y min-h-16"
253
+ placeholder={'{"logic": "and", "conditions": [{"field": "status", "op": "eq", "value": "active"}]}'}
254
+ rows="3"
255
+ ></textarea>
256
+ </div>
257
+
190
258
  <div class="text-xs text-muted-foreground pt-2 space-y-1">
259
+ <div>Pattern: <span class="font-mono font-medium text-foreground">{selectedRoute.pattern}</span></div>
191
260
  <div>Deliveries: <span class="font-medium text-foreground">{selectedRoute.deliveryCount}</span></div>
261
+ <div>Filtered out: <span class="font-medium text-foreground">{selectedRoute.filterDropCount}</span></div>
192
262
  <div>Last delivered: <span class="font-medium text-foreground">{selectedRoute.lastDeliveredAt ? relativeAge(new Date(selectedRoute.lastDeliveredAt).getTime()) : '\u2014'}</span></div>
193
263
  {#if selectedRoute.lastEventSubject}
194
264
  <div>Last subject: <span class="font-mono font-medium text-foreground">{selectedRoute.lastEventSubject}</span></div>
@@ -236,15 +306,27 @@
236
306
  {#if formError}
237
307
  <div class="rounded-md bg-destructive/10 p-2 text-xs text-destructive">{formError}</div>
238
308
  {/if}
239
- <div class="space-y-1">
240
- <label class="text-xs text-muted-foreground" for="route-pattern">Pattern</label>
241
- <input
242
- id="route-pattern"
243
- type="text"
244
- bind:value={formPattern}
245
- class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
246
- placeholder="agent.events.>"
247
- />
309
+ <div class="grid grid-cols-2 gap-3">
310
+ <div class="space-y-1">
311
+ <label class="text-xs text-muted-foreground" for="route-pattern">Pattern</label>
312
+ <input
313
+ id="route-pattern"
314
+ type="text"
315
+ bind:value={formPattern}
316
+ class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
317
+ placeholder="agent.events.>"
318
+ />
319
+ </div>
320
+ <div class="space-y-1">
321
+ <label class="text-xs text-muted-foreground" for="route-name">Name <span class="text-muted-foreground">(optional)</span></label>
322
+ <input
323
+ id="route-name"
324
+ type="text"
325
+ bind:value={formName}
326
+ class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
327
+ placeholder="auto from pattern"
328
+ />
329
+ </div>
248
330
  </div>
249
331
  <div class="grid grid-cols-2 gap-3">
250
332
  <div class="space-y-1">
@@ -269,6 +351,26 @@
269
351
  />
270
352
  </div>
271
353
  </div>
354
+ <div class="space-y-1">
355
+ <label class="text-xs text-muted-foreground" for="route-payload">Custom Payload (JSON)</label>
356
+ <textarea
357
+ id="route-payload"
358
+ bind:value={formPayload}
359
+ class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs font-mono resize-y min-h-16"
360
+ placeholder={'{"context": "value"}'}
361
+ rows="2"
362
+ ></textarea>
363
+ </div>
364
+ <div class="space-y-1">
365
+ <label class="text-xs text-muted-foreground" for="route-filter">Filter (JSON)</label>
366
+ <textarea
367
+ id="route-filter"
368
+ bind:value={formFilter}
369
+ class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs font-mono resize-y min-h-16"
370
+ placeholder={'{"logic": "and", "conditions": [{"field": "amount", "op": "gt", "value": 10000}]}'}
371
+ rows="2"
372
+ ></textarea>
373
+ </div>
272
374
  <div class="flex gap-2">
273
375
  <Button size="sm" onclick={handleCreate} disabled={loading}>
274
376
  {loading ? 'Creating...' : 'Create'}
@@ -284,6 +386,7 @@
284
386
  <Table.Root>
285
387
  <Table.Header>
286
388
  <Table.Row>
389
+ <Table.Head>Name</Table.Head>
287
390
  <Table.Head>Pattern</Table.Head>
288
391
  <Table.Head>Target</Table.Head>
289
392
  <Table.Head>Priority</Table.Head>
@@ -296,6 +399,12 @@
296
399
  <Table.Body>
297
400
  {#each routes as route}
298
401
  <Table.Row class="cursor-pointer hover:bg-muted/50" onclick={() => openRouteModal(route)}>
402
+ <Table.Cell class="font-mono text-xs">
403
+ {route.name}
404
+ {#if route.filter}
405
+ <Badge variant="outline" class="ml-1 text-[10px] px-1 py-0">filter</Badge>
406
+ {/if}
407
+ </Table.Cell>
299
408
  <Table.Cell class="font-mono text-xs">{route.pattern}</Table.Cell>
300
409
  <Table.Cell>{route.target}</Table.Cell>
301
410
  <Table.Cell>
@@ -47,12 +47,27 @@ export async function markDelivered(ids: string[]): Promise<void> {
47
47
 
48
48
  // ── Routes ──────────────────────────────────────────────────────────
49
49
 
50
+ export interface FilterCondition {
51
+ field: string;
52
+ op: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'contains' | 'exists';
53
+ value: unknown;
54
+ }
55
+
56
+ export interface FilterExpression {
57
+ logic: 'and' | 'or';
58
+ conditions: FilterCondition[];
59
+ }
60
+
50
61
  export interface EventRoute {
51
62
  id: string;
63
+ name: string;
52
64
  pattern: string;
53
65
  target: string;
54
66
  priority: number;
55
67
  enabled: boolean;
68
+ filter: FilterExpression | null;
69
+ filterDropCount: number;
70
+ customPayload: unknown;
56
71
  deliveryCount: number;
57
72
  lastDeliveredAt: string | null;
58
73
  lastEventSubject: string | null;
@@ -66,8 +81,11 @@ export async function getRoutes(): Promise<EventRoute[]> {
66
81
 
67
82
  export async function createRoute(body: {
68
83
  pattern: string;
84
+ name?: string;
69
85
  target?: string;
70
86
  priority?: number;
87
+ payload?: unknown;
88
+ filter?: FilterExpression;
71
89
  }): Promise<EventRoute> {
72
90
  return fetchJSON('/routes', {
73
91
  method: 'POST',
@@ -80,6 +98,8 @@ export interface UpdateRouteBody {
80
98
  target?: string;
81
99
  priority?: number;
82
100
  enabled?: boolean;
101
+ payload?: unknown;
102
+ filter?: FilterExpression | null;
83
103
  }
84
104
 
85
105
  export async function updateRoute(id: string, body: UpdateRouteBody): Promise<EventRoute> {
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.17",
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[] = [];
@@ -164,19 +164,31 @@ export default function (api: any) {
164
164
 
165
165
  api.registerTool({
166
166
  name: 'nats_subscribe',
167
- description: 'Subscribe to events matching a pattern. Matched events will be delivered to the target session as messages.',
167
+ description: 'Subscribe to events matching a pattern. Matched events will be delivered to the target session as messages. Multiple subscriptions on the same pattern are possible by specifying different names with payload filters.',
168
168
  parameters: {
169
169
  type: 'object',
170
170
  properties: {
171
171
  pattern: { type: 'string', description: 'Subject pattern (exact, or wildcard with * for one level, > for all descendants)' },
172
+ name: { type: 'string', description: 'Unique route name (default: same as pattern). Use different names to create multiple subscriptions on the same pattern.' },
172
173
  target: { type: 'string', description: 'Session key to deliver to (default: main)' },
174
+ payload: { type: 'object', description: 'Additional context payload that will be merged with event data on delivery' },
175
+ filter: {
176
+ type: 'object',
177
+ description: 'Payload filter expression. Only events matching the filter will be delivered. Format: { logic: "and"|"or", conditions: [{ field: "dot.path", op: "eq"|"neq"|"gt"|"gte"|"lt"|"lte"|"in"|"nin"|"contains"|"exists", value: ... }] }',
178
+ },
173
179
  },
174
180
  required: ['pattern'],
175
181
  },
176
182
  async execute(_id: string, params: any) {
177
183
  const result = await sidecarFetch('/api/routes', {
178
184
  method: 'POST',
179
- body: JSON.stringify({ pattern: params.pattern, target: params.target ?? 'main' }),
185
+ body: JSON.stringify({
186
+ pattern: params.pattern,
187
+ name: params.name,
188
+ target: params.target ?? 'main',
189
+ payload: params.payload,
190
+ filter: params.filter,
191
+ }),
180
192
  });
181
193
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
182
194
  },
@@ -342,7 +354,7 @@ export default function (api: any) {
342
354
 
343
355
  api.registerTool({
344
356
  name: 'nats_route_update',
345
- description: 'Update an existing route subscription. Can change target session, priority, or enabled status.',
357
+ description: 'Update an existing route subscription. Can change target session, priority, enabled status, custom payload, or filter.',
346
358
  parameters: {
347
359
  type: 'object',
348
360
  properties: {
@@ -350,6 +362,8 @@ export default function (api: any) {
350
362
  target: { type: 'string', description: 'New target session' },
351
363
  priority: { type: 'number', description: 'New priority (1-10)' },
352
364
  enabled: { type: 'boolean', description: 'Enable or disable the route' },
365
+ payload: { type: 'object', description: 'New custom payload to merge with event data on delivery' },
366
+ filter: { type: ['object', 'null'], description: 'New payload filter expression (null to clear). Format: { logic: "and"|"or", conditions: [...] }' },
353
367
  },
354
368
  required: ['id'],
355
369
  },
@@ -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
  },
@@ -5,6 +5,7 @@ import { PendingService } from '../pending/pending.service';
5
5
  import { RouterService } from '../router/router.service';
6
6
  import { MetricsService } from '../metrics/metrics.service';
7
7
  import { LogService } from '../logs/log.service';
8
+ import { RouteFilterService } from '../route-filter/route-filter.service';
8
9
  import type { NatsEventEnvelope } from '../publisher/envelope';
9
10
 
10
11
  @Controller('/consumer')
@@ -16,6 +17,7 @@ export class ConsumerController extends BaseController {
16
17
  private routerService: RouterService,
17
18
  private metrics: MetricsService,
18
19
  private logService: LogService,
20
+ private routeFilter: RouteFilterService,
19
21
  ) {
20
22
  super();
21
23
  }
@@ -55,16 +57,25 @@ export class ConsumerController extends BaseController {
55
57
  // Deliver to each matching target
56
58
  if (this.gatewayClient.isAlive()) {
57
59
  for (const route of routes) {
60
+ if (route.filter) {
61
+ const passed = this.routeFilter.evaluate(envelope.payload, route.filter);
62
+ if (!passed) {
63
+ this.logger.debug(`Event ${envelope.id} filtered out by route ${route.name} (${route.pattern})`);
64
+ await this.routerService.incrementFilterDropCount(route.id);
65
+ continue;
66
+ }
67
+ }
58
68
  try {
69
+ const composedPayload = this.composePayload(envelope, route);
59
70
  const injectStart = performance.now();
60
71
  await this.gatewayClient.inject({
61
- message: this.formatMessage(envelope),
72
+ message: this.formatMessage(envelope.subject, composedPayload),
62
73
  eventId: envelope.id,
63
74
  });
64
75
  const lagMs = Math.round(performance.now() - injectStart);
65
76
  await this.routerService.recordDelivery(route.id, envelope.subject, lagMs);
66
77
  this.metrics.recordConsume(envelope.subject);
67
- await this.logService.logDelivery(route.id, envelope.subject, JSON.stringify({ eventId: envelope.id, target: route.target }));
78
+ await this.logService.logDelivery(route.id, envelope.subject, JSON.stringify(composedPayload));
68
79
  } catch (routeErr) {
69
80
  await this.logService.logError('route', route.id, envelope.subject, routeErr);
70
81
  // Gateway rejected the request (e.g. missing scope) — store in pending, don't nack
@@ -100,7 +111,33 @@ export class ConsumerController extends BaseController {
100
111
  throw new Error('Unable to extract envelope from message');
101
112
  }
102
113
 
103
- private formatMessage(envelope: NatsEventEnvelope): string {
104
- return `[NATS:${envelope.subject}] ${JSON.stringify(envelope.payload)}`;
114
+ private composePayload(envelope: NatsEventEnvelope, route: import('../db/schema').DbEventRoute): Record<string, unknown> {
115
+ const eventPayload = (envelope.payload && typeof envelope.payload === 'object' && !Array.isArray(envelope.payload))
116
+ ? (envelope.payload as Record<string, unknown>)
117
+ : { data: envelope.payload };
118
+
119
+ const customPayload = (route.customPayload && typeof route.customPayload === 'object' && !Array.isArray(route.customPayload))
120
+ ? (route.customPayload as Record<string, unknown>)
121
+ : {};
122
+
123
+ let source: 'cron' | 'timer' | 'external' = 'external';
124
+ if ('_cron' in eventPayload) source = 'cron';
125
+ else if ('_timer' in eventPayload) source = 'timer';
126
+
127
+ return {
128
+ ...eventPayload,
129
+ ...customPayload,
130
+ _delivery: {
131
+ pattern: route.pattern,
132
+ source,
133
+ eventSubject: envelope.subject,
134
+ eventId: envelope.id,
135
+ deliveredAt: new Date().toISOString(),
136
+ },
137
+ };
138
+ }
139
+
140
+ private formatMessage(subject: string, composedPayload: Record<string, unknown>): string {
141
+ return `[NATS:${subject}] ${JSON.stringify(composedPayload)}`;
105
142
  }
106
143
  }
@@ -5,9 +5,10 @@ import { PendingModule } from '../pending/pending.module';
5
5
  import { RouterModule } from '../router/router.module';
6
6
  import { MetricsModule } from '../metrics/metrics.module';
7
7
  import { LogModule } from '../logs/log.module';
8
+ import { RouteFilterModule } from '../route-filter/route-filter.module';
8
9
 
9
10
  @Module({
10
- imports: [PreHandlersModule, PendingModule, RouterModule, MetricsModule, LogModule],
11
+ imports: [PreHandlersModule, PendingModule, RouterModule, MetricsModule, LogModule, RouteFilterModule],
11
12
  controllers: [ConsumerController],
12
13
  })
13
14
  export class ConsumerModule {}