@omnixal/openclaw-nats-plugin 0.2.10 → 0.2.12

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 CHANGED
@@ -53,6 +53,7 @@ Plugin hooks make lightweight HTTP calls to a NATS sidecar service for event pub
53
53
  | Subject | Trigger |
54
54
  |---|---|
55
55
  | `agent.events.gateway.startup` | Gateway starts |
56
+ | `agent.events.gateway.stopped` | Gateway stops |
56
57
  | `agent.events.session.new` | `/new` command |
57
58
  | `agent.events.session.reset` | `/reset` command |
58
59
  | `agent.events.session.stop` | `/stop` command |
@@ -60,10 +61,13 @@ Plugin hooks make lightweight HTTP calls to a NATS sidecar service for event pub
60
61
  | `agent.events.session.ended` | Session ends |
61
62
  | `agent.events.tool.{name}.completed` | Tool succeeds |
62
63
  | `agent.events.tool.{name}.failed` | Tool fails |
64
+ | `agent.events.message.received` | Inbound message received |
65
+ | `agent.events.message.sent` | Message delivered |
66
+ | `agent.events.llm.output` | LLM response received |
67
+ | `agent.events.subagent.spawning` | Subagent about to spawn |
63
68
  | `agent.events.subagent.spawned` | Subagent created |
64
69
  | `agent.events.subagent.ended` | Subagent finished |
65
70
  | `agent.events.agent.run_ended` | Agent run completes |
66
- | `agent.events.message.sent` | Message delivered |
67
71
  | `agent.events.context.compacted` | Context history compressed |
68
72
 
69
73
  ## Dashboard
@@ -90,6 +94,8 @@ Environment variables (auto-configured by setup):
90
94
  - `NATS_SIDECAR_URL` — Sidecar URL (default: `http://127.0.0.1:3104`)
91
95
  - `NATS_PLUGIN_API_KEY` — API key for sidecar auth (auto-generated)
92
96
  - `NATS_SERVERS` — NATS server URL (default: `nats://127.0.0.1:4222`)
97
+ - `OPENCLAW_GATEWAY_URL` — Gateway HTTP URL (default: `http://127.0.0.1:18789`)
98
+ - `OPENCLAW_HOOK_TOKEN` — Webhook token for event delivery to agent session (from `hooks.token` in gateway config)
93
99
 
94
100
  ## Requirements
95
101
 
package/cli/bun-setup.ts CHANGED
@@ -54,18 +54,18 @@ export async function bunSetup(): Promise<void> {
54
54
  NATS_SIDECAR_URL: 'http://127.0.0.1:3104',
55
55
  NATS_PLUGIN_API_KEY: apiKey,
56
56
  NATS_SERVERS: 'nats://127.0.0.1:4222',
57
- OPENCLAW_WS_URL: 'ws://127.0.0.1:18789',
57
+ OPENCLAW_GATEWAY_URL: 'http://127.0.0.1:18789',
58
58
  };
59
59
  writeEnvVariables(envVars);
60
60
 
61
61
  // Write .env into sidecar dir so loadDotEnv picks it up
62
- // Explicit localhost values override any container-level env (e.g. OPENCLAW_WS_URL=ws://openclaw:...)
62
+ // Explicit localhost values override any container-level env
63
63
  const sidecarEnv = [
64
64
  `PORT=3104`,
65
65
  `DB_PATH=${join(DATA_DIR, 'nats-sidecar.db')}`,
66
66
  `NATS_SERVERS=nats://127.0.0.1:4222`,
67
67
  `NATS_PLUGIN_API_KEY=${apiKey}`,
68
- `OPENCLAW_WS_URL=ws://127.0.0.1:18789`,
68
+ `OPENCLAW_GATEWAY_URL=http://127.0.0.1:18789`,
69
69
  ].join('\n');
70
70
  writeFileSync(join(SIDECAR_DIR, '.env'), sidecarEnv, 'utf-8');
71
71
 
@@ -0,0 +1,59 @@
1
+ <script lang="ts">
2
+ import { Modal } from '$lib/components/ui/modal';
3
+ import * as Table from '$lib/components/ui/table';
4
+
5
+ interface Props {
6
+ open: boolean;
7
+ onClose: () => void;
8
+ }
9
+
10
+ let { open, onClose }: Props = $props();
11
+
12
+ const events = [
13
+ { subject: 'agent.events.gateway.startup', trigger: 'Gateway started', payload: '{}' },
14
+ { subject: 'agent.events.gateway.stopped', trigger: 'Gateway stopped', payload: '{ reason }' },
15
+ { subject: 'agent.events.session.new', trigger: '/new command', payload: '{ sessionKey, command }' },
16
+ { subject: 'agent.events.session.reset', trigger: '/reset command', payload: '{ sessionKey, command }' },
17
+ { subject: 'agent.events.session.stop', trigger: '/stop command', payload: '{ sessionKey, command }' },
18
+ { subject: 'agent.events.session.started', trigger: 'Session began', payload: '{ sessionKey, sessionId, channel }' },
19
+ { subject: 'agent.events.session.ended', trigger: 'Session ended', payload: '{ sessionKey, sessionId, channel }' },
20
+ { subject: 'agent.events.agent.run_ended', trigger: 'Agent run completed', payload: '{ sessionKey, runId, messageCount }' },
21
+ { subject: 'agent.events.tool.{name}.completed', trigger: 'Tool succeeded', payload: '{ sessionKey, toolName, durationMs }' },
22
+ { subject: 'agent.events.tool.{name}.failed', trigger: 'Tool failed', payload: '{ sessionKey, toolName, durationMs }' },
23
+ { subject: 'agent.events.message.received', trigger: 'Inbound message', payload: '{ from, content, metadata }' },
24
+ { subject: 'agent.events.message.sent', trigger: 'Message delivered', payload: '{ sessionKey, to, success, error }' },
25
+ { subject: 'agent.events.llm.output', trigger: 'LLM response', payload: '{ provider, model, usage }' },
26
+ { subject: 'agent.events.subagent.spawning', trigger: 'Subagent creating', payload: '{ childSessionKey, agentId, label, mode }' },
27
+ { subject: 'agent.events.subagent.spawned', trigger: 'Subagent created', payload: '{ sessionKey, subagentId, task }' },
28
+ { subject: 'agent.events.subagent.ended', trigger: 'Subagent finished', payload: '{ sessionKey, subagentId, result, durationMs }' },
29
+ { subject: 'agent.events.context.compacted', trigger: 'Context compressed', payload: '{ sessionKey }' },
30
+ { subject: 'agent.events.cron.*', trigger: 'Cron trigger', payload: '(user-defined)' },
31
+ { subject: 'agent.events.custom.*', trigger: 'Custom event', payload: '(user-defined)' },
32
+ ];
33
+ </script>
34
+
35
+ <Modal {open} title="Events Reference" {onClose} class="max-w-3xl">
36
+ <div class="max-h-[60vh] overflow-y-auto -mx-6 px-6">
37
+ <p class="text-xs text-muted-foreground mb-3">
38
+ All events published by the NATS plugin. Each event also includes a <code class="text-xs">timestamp</code> field.
39
+ </p>
40
+ <Table.Root>
41
+ <Table.Header>
42
+ <Table.Row>
43
+ <Table.Head>Subject</Table.Head>
44
+ <Table.Head>Trigger</Table.Head>
45
+ <Table.Head>Payload</Table.Head>
46
+ </Table.Row>
47
+ </Table.Header>
48
+ <Table.Body>
49
+ {#each events as ev}
50
+ <Table.Row>
51
+ <Table.Cell class="font-mono text-xs whitespace-nowrap">{ev.subject}</Table.Cell>
52
+ <Table.Cell class="text-xs">{ev.trigger}</Table.Cell>
53
+ <Table.Cell class="font-mono text-xs text-muted-foreground">{ev.payload}</Table.Cell>
54
+ </Table.Row>
55
+ {/each}
56
+ </Table.Body>
57
+ </Table.Root>
58
+ </div>
59
+ </Modal>
@@ -1,15 +1,19 @@
1
1
  <script lang="ts">
2
2
  import * as Table from '$lib/components/ui/table';
3
3
  import { Badge } from '$lib/components/ui/badge';
4
+ import { Button } from '$lib/components/ui/button';
4
5
  import * as Card from '$lib/components/ui/card';
5
6
  import type { SubjectMetric } from '$lib/api';
6
7
  import { relativeAge } from '$lib/utils';
8
+ import CircleHelp from '@lucide/svelte/icons/circle-help';
9
+ import EventsReferenceModal from './EventsReferenceModal.svelte';
7
10
 
8
11
  interface Props {
9
12
  metrics: SubjectMetric[];
10
13
  }
11
14
 
12
15
  let { metrics }: Props = $props();
16
+ let showEventsRef = $state(false);
13
17
 
14
18
  let totalPublished = $derived(metrics.reduce((acc, m) => acc + m.published, 0));
15
19
  let totalConsumed = $derived(metrics.reduce((acc, m) => acc + m.consumed, 0));
@@ -17,7 +21,12 @@
17
21
 
18
22
  <Card.Root>
19
23
  <Card.Header class="pb-2">
20
- <Card.Title class="text-sm font-medium">Queue Metrics</Card.Title>
24
+ <div class="flex items-center justify-between">
25
+ <Card.Title class="text-sm font-medium">Queue Metrics</Card.Title>
26
+ <Button variant="ghost" size="icon-sm" onclick={() => showEventsRef = true} title="Events reference">
27
+ <CircleHelp size={14} />
28
+ </Button>
29
+ </div>
21
30
  </Card.Header>
22
31
  <Card.Content>
23
32
  <div class="flex items-center gap-2 mb-3">
@@ -58,3 +67,5 @@
58
67
  {/if}
59
68
  </Card.Content>
60
69
  </Card.Root>
70
+
71
+ <EventsReferenceModal open={showEventsRef} onClose={() => showEventsRef = false} />
@@ -8,6 +8,7 @@
8
8
  onClose: () => void;
9
9
  children: Snippet;
10
10
  actions?: Snippet;
11
+ class?: string;
11
12
  }
12
13
 
13
14
  let {
@@ -16,6 +17,7 @@
16
17
  onClose,
17
18
  children,
18
19
  actions,
20
+ class: className,
19
21
  }: Props = $props();
20
22
 
21
23
  function handleKeydown(e: KeyboardEvent) {
@@ -31,7 +33,7 @@
31
33
  >
32
34
  <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
33
35
  <div class="fixed inset-0" onclick={onClose}></div>
34
- <div class="relative z-10 w-full max-w-lg rounded-lg border bg-background p-6 shadow-lg">
36
+ <div class="relative z-10 w-full {className ?? 'max-w-lg'} rounded-lg border bg-background p-6 shadow-lg">
35
37
  <div class="flex items-center justify-between mb-4">
36
38
  <h3 class="text-lg font-semibold">{title}</h3>
37
39
  <Button variant="ghost" size="icon-sm" onclick={onClose}>
@@ -29,7 +29,7 @@ services:
29
29
  - PORT=3104
30
30
  - DB_PATH=/app/data/nats-sidecar.db
31
31
  - NATS_SERVERS=nats://nats:4222
32
- - OPENCLAW_WS_URL=ws://host.docker.internal:18789
32
+ - OPENCLAW_GATEWAY_URL=http://host.docker.internal:18789
33
33
  - NATS_PLUGIN_API_KEY=${NATS_PLUGIN_API_KEY}
34
34
  volumes:
35
35
  - sidecar-data:/app/data
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnixal/openclaw-nats-plugin",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "NATS JetStream event-driven plugin for OpenClaw",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -78,6 +78,52 @@ export default function (api: any) {
78
78
  }, { priority: 8 });
79
79
  }, { priority: 99 });
80
80
 
81
+ // ── Gateway stop ───────────────────────────────────────────────
82
+
83
+ api.on('gateway_stop', (event: any) => {
84
+ void publishToSidecar('agent.events.gateway.stopped', {
85
+ reason: event.reason,
86
+ timestamp: new Date().toISOString(),
87
+ });
88
+ }, { priority: 99 });
89
+
90
+ // ── Message received ─────────────────────────────────────────────
91
+
92
+ api.on('message_received', (event: any) => {
93
+ void publishToSidecar('agent.events.message.received', {
94
+ from: event.from,
95
+ content: event.content,
96
+ metadata: event.metadata,
97
+ timestamp: new Date().toISOString(),
98
+ });
99
+ }, { priority: 99 });
100
+
101
+ // ── LLM output ───────────────────────────────────────────────────
102
+
103
+ api.on('llm_output', (event: any) => {
104
+ void publishToSidecar('agent.events.llm.output', {
105
+ sessionKey: event.sessionId,
106
+ runId: event.runId,
107
+ provider: event.provider,
108
+ model: event.model,
109
+ usage: event.usage,
110
+ timestamp: new Date().toISOString(),
111
+ });
112
+ }, { priority: 99 });
113
+
114
+ // ── Subagent spawning ────────────────────────────────────────────
115
+
116
+ api.on('subagent_spawning', (event: any) => {
117
+ void publishToSidecar('agent.events.subagent.spawning', {
118
+ childSessionKey: event.childSessionKey,
119
+ agentId: event.agentId,
120
+ label: event.label,
121
+ mode: event.mode,
122
+ timestamp: new Date().toISOString(),
123
+ });
124
+ return { status: 'ok' };
125
+ }, { priority: 99 });
126
+
81
127
  // ── Agent Tools ─────────────────────────────────────────────────
82
128
 
83
129
  const SIDECAR_URL = process.env.NATS_SIDECAR_URL || 'http://127.0.0.1:3104';
package/sidecar/bun.lock CHANGED
@@ -10,6 +10,7 @@
10
10
  "@onebun/envs": "^0.2.2",
11
11
  "@onebun/logger": "^0.2.1",
12
12
  "@onebun/nats": "^0.2.6",
13
+ "@onebun/requests": "^0.2.1",
13
14
  "arktype": "^2.2.0",
14
15
  "ulid": "^2.3.0",
15
16
  },
@@ -11,7 +11,7 @@
11
11
  "typecheck": "bunx tsc --noEmit",
12
12
  "db:generate": "bunx onebun-drizzle generate",
13
13
  "db:push": "bunx onebun-drizzle push",
14
- "db:studio": "bunx onebun-drizzle studio",
14
+ "db:studio": "bunx onebun-drizzle studio"
15
15
  },
16
16
  "dependencies": {
17
17
  "@onebun/core": "^0.2.15",
@@ -19,6 +19,7 @@
19
19
  "@onebun/envs": "^0.2.2",
20
20
  "@onebun/logger": "^0.2.1",
21
21
  "@onebun/nats": "^0.2.6",
22
+ "@onebun/requests": "^0.2.1",
22
23
  "arktype": "^2.2.0",
23
24
  "ulid": "^2.3.0"
24
25
  },
@@ -16,8 +16,8 @@ export const envSchema = {
16
16
  maxReconnectAttempts: Env.number({ default: -1, env: 'NATS_MAX_RECONNECT_ATTEMPTS' }),
17
17
  },
18
18
  gateway: {
19
- wsUrl: Env.string({ default: 'ws://localhost:18789', env: 'OPENCLAW_WS_URL' }),
20
- token: Env.string({ default: '', env: 'OPENCLAW_DEVICE_TOKEN' }),
19
+ url: Env.string({ default: 'http://localhost:18789', env: 'OPENCLAW_GATEWAY_URL' }),
20
+ hookToken: Env.string({ default: '', env: 'OPENCLAW_HOOK_TOKEN' }),
21
21
  },
22
22
  consumer: {
23
23
  name: Env.string({ default: 'openclaw-main', env: 'NATS_CONSUMER_NAME' }),
@@ -58,14 +58,8 @@ export class ConsumerController extends BaseController {
58
58
  try {
59
59
  const injectStart = performance.now();
60
60
  await this.gatewayClient.inject({
61
- target: route.target,
62
61
  message: this.formatMessage(envelope),
63
- metadata: {
64
- source: 'nats',
65
- eventId: envelope.id,
66
- subject: envelope.subject,
67
- priority: (ctx.enrichments['priority'] as number) ?? envelope.meta?.priority ?? 5,
68
- },
62
+ eventId: envelope.id,
69
63
  });
70
64
  const lagMs = Math.round(performance.now() - injectStart);
71
65
  await this.routerService.recordDelivery(route.id, envelope.subject, lagMs);
@@ -1,16 +1,9 @@
1
- import { Service, BaseService, type OnModuleInit, type OnModuleDestroy } from '@onebun/core';
2
- import path from 'node:path';
3
- import { loadOrCreateIdentity, publicKeyToBase64Url, signChallenge, type DeviceIdentity } from './device-identity';
1
+ import { Service, BaseService, type OnModuleInit } from '@onebun/core';
2
+ import { HttpClient, isErrorResponse } from '@onebun/requests';
4
3
 
5
4
  export interface GatewayInjectPayload {
6
- target: string;
7
5
  message: string;
8
- metadata?: {
9
- source: 'nats';
10
- eventId: string;
11
- subject: string;
12
- priority: number;
13
- };
6
+ eventId?: string;
14
7
  }
15
8
 
16
9
  export class GatewayRpcError extends Error {
@@ -24,305 +17,52 @@ export class GatewayRpcError extends Error {
24
17
  }
25
18
  }
26
19
 
27
- interface PendingRequest {
28
- resolve: () => void;
29
- reject: (err: Error) => void;
30
- timer: Timer;
31
- }
32
-
33
- const RPC_TIMEOUT_MS = 10_000;
34
-
35
20
  @Service()
36
- export class GatewayClientService extends BaseService implements OnModuleInit, OnModuleDestroy {
37
- private ws: WebSocket | null = null;
38
- private connected = false;
39
- private connectSent = false;
40
- private reconnectAttempt = 0;
41
- private reconnectTimer: Timer | null = null;
21
+ export class GatewayClientService extends BaseService implements OnModuleInit {
22
+ private client!: HttpClient;
23
+ private configured = false;
42
24
  private requestId = 0;
43
- private wsUrl!: string;
44
- private token!: string;
45
- private identity!: DeviceIdentity;
46
- private publicKeyBase64Url!: string;
47
- private challengeNonce = '';
48
- private pendingRequests = new Map<string, PendingRequest>();
49
25
 
50
26
  async onModuleInit(): Promise<void> {
51
- this.wsUrl = this.config.get('gateway.wsUrl');
52
- this.token = this.config.get('gateway.token');
53
- if (this.wsUrl && this.token) {
54
- const dbPath = this.config.get('database.url');
55
- const identityPath = path.join(path.dirname(dbPath), 'device-identity.json');
56
- this.identity = loadOrCreateIdentity(identityPath);
57
- this.publicKeyBase64Url = publicKeyToBase64Url(this.identity.publicKeyPem);
58
- this.logger.info('Device identity loaded', { deviceId: this.identity.deviceId });
59
- this.connect();
60
- } else {
61
- this.logger.warn('Gateway WebSocket not configured — skipping connection (need wsUrl + deviceToken)');
62
- }
63
- }
64
-
65
- private connect(): void {
66
- try {
67
- this.connectSent = false;
68
- this.ws = new WebSocket(this.wsUrl);
69
-
70
- this.ws.onopen = () => {
71
- this.reconnectAttempt = 0;
72
- this.logger.info('Gateway WebSocket opened, waiting for connect.challenge');
73
- };
74
-
75
- this.ws.onmessage = (event) => {
76
- this.handleMessage(String(event.data));
77
- };
78
-
79
- this.ws.onclose = () => {
80
- this.connected = false;
81
- this.connectSent = false;
82
- // Reject all in-flight requests immediately — don't make callers wait for timeout
83
- for (const [id, pending] of this.pendingRequests) {
84
- clearTimeout(pending.timer);
85
- pending.reject(new Error('Gateway WebSocket closed'));
86
- }
87
- this.pendingRequests.clear();
88
- this.scheduleReconnect();
89
- };
90
-
91
- this.ws.onerror = () => {
92
- this.logger.warn('Gateway WebSocket error');
93
- this.connected = false;
94
- };
95
- } catch (err) {
96
- this.logger.warn('Failed to connect to Gateway WebSocket', err);
97
- this.scheduleReconnect();
98
- }
99
- }
100
-
101
- private handleMessage(data: string): void {
102
- let frame: any;
103
- try {
104
- frame = JSON.parse(data);
105
- } catch {
106
- this.logger.warn(`Failed to parse WebSocket message: ${data.slice(0, 200)}`);
107
- return;
108
- }
109
-
110
- // Server challenge — respond with connect frame
111
- if (frame.type === 'event' && frame.event === 'connect.challenge') {
112
- this.challengeNonce = frame.payload?.nonce ?? '';
113
- this.logger.debug('Received connect.challenge from server', { hasNonce: !!this.challengeNonce });
114
- this.sendConnectFrame();
115
- return;
116
- }
117
-
118
- // Some gateway versions send an event before challenge; treat any pre-connect event as trigger
119
- if (!this.connectSent && frame.type === 'event') {
120
- this.logger.debug('Received event before connect sent, sending connect frame');
121
- this.sendConnectFrame();
122
- return;
123
- }
124
-
125
- // Successful connect response — must be hello-ok
126
- if (frame.type === 'res' && frame.ok === true) {
127
- const payload = frame.payload;
128
- if (payload?.type === 'hello-ok') {
129
- if (!this.connected) {
130
- this.connected = true;
131
- const grantedScopes = payload.auth?.scopes ?? [];
132
- const serverVersion = payload.server?.version ?? 'unknown';
133
- this.logger.info('OpenClaw handshake complete', {
134
- protocol: payload.protocol,
135
- serverVersion,
136
- grantedScopes,
137
- connId: payload.server?.connId,
138
- });
139
- if (grantedScopes.length > 0 && !grantedScopes.includes('operator.write')) {
140
- this.logger.error(
141
- `Gateway did NOT grant operator.write scope! Granted: [${grantedScopes.join(', ')}]. ` +
142
- 'Message delivery will fail. Rotate the device token with --scope operator.write',
143
- );
144
- }
145
- }
146
- return;
147
- }
148
- // Regular RPC ok response (e.g. for inject calls)
149
- this.logger.debug('Received RPC ok response', { id: frame.id });
150
- this.resolvePending(frame.id);
151
- return;
152
- }
153
-
154
- // Error response
155
- if (frame.type === 'res' && frame.ok === false) {
156
- const errorCode = frame.error?.code ?? frame.error?.errorCode ?? 'UNKNOWN';
157
- const errorMessage = frame.error?.message ?? frame.error?.errorMessage ?? 'Unknown gateway error';
158
- this.logger.error('Gateway RPC error', { id: frame.id, errorCode, errorMessage });
159
-
160
- // If this is a connect error, close and reconnect
161
- if (frame.id?.startsWith('connect-')) {
162
- this.logger.error(`Gateway rejected connection: ${errorCode} — ${errorMessage}`);
163
- this.connected = false;
164
- this.connectSent = false;
165
- this.ws?.close();
166
- return;
167
- }
168
-
169
- this.rejectPending(frame.id, new GatewayRpcError(frame.id, String(errorCode), String(errorMessage)));
170
- }
171
- }
172
-
173
- private sendConnectFrame(): void {
174
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN || this.connectSent) return;
175
- this.connectSent = true;
176
-
177
- const signedAt = Date.now();
178
- const scopes = ['operator.read', 'operator.write'];
179
- const client = {
180
- id: 'gateway-client' as const,
181
- displayName: 'nats-sidecar',
182
- version: '1.0.0',
183
- platform: 'linux',
184
- mode: 'backend' as const,
185
- };
186
-
187
- const signature = signChallenge(this.identity.privateKeyPem, {
188
- deviceId: this.identity.deviceId,
189
- clientId: client.id,
190
- clientMode: client.mode,
191
- role: 'operator',
192
- scopes,
193
- signedAtMs: signedAt,
194
- token: this.token,
195
- nonce: this.challengeNonce,
196
- platform: client.platform,
197
- });
198
-
199
- this.logger.info('Sending connect frame with device identity', {
200
- deviceId: this.identity.deviceId,
201
- });
202
-
203
- try {
204
- this.send({
205
- type: 'req',
206
- id: `connect-${++this.requestId}`,
207
- method: 'connect',
208
- params: {
209
- minProtocol: 3,
210
- maxProtocol: 3,
211
- client,
212
- role: 'operator',
213
- scopes,
214
- caps: [],
215
- commands: [],
216
- permissions: {},
217
- auth: { token: this.token },
218
- device: {
219
- id: this.identity.deviceId,
220
- publicKey: this.publicKeyBase64Url,
221
- signature,
222
- signedAt,
223
- nonce: this.challengeNonce,
224
- },
225
- locale: 'en-US',
226
- userAgent: 'nats-sidecar/1.0.0',
27
+ const gatewayUrl = this.config.get('gateway.url');
28
+ const hookToken = this.config.get('gateway.hookToken');
29
+
30
+ if (gatewayUrl && hookToken) {
31
+ this.client = new HttpClient({
32
+ baseUrl: gatewayUrl,
33
+ timeout: 10_000,
34
+ auth: { type: 'bearer', token: hookToken },
35
+ retries: {
36
+ max: 2,
37
+ backoff: 'exponential',
38
+ delay: 500,
39
+ retryOn: [502, 503, 504],
227
40
  },
228
41
  });
229
- } catch (err) {
230
- this.logger.error('Failed to send connect frame', err);
231
- this.connectSent = false;
232
- }
233
- }
234
-
235
- private send(frame: unknown): void {
236
- if (this.ws?.readyState !== WebSocket.OPEN) {
237
- throw new Error('WebSocket is not open');
238
- }
239
- try {
240
- this.ws.send(JSON.stringify(frame));
241
- } catch (err) {
242
- this.connected = false;
243
- throw new Error(`WebSocket send failed: ${err instanceof Error ? err.message : String(err)}`);
42
+ this.configured = true;
43
+ this.logger.info('Gateway webhook configured', { url: gatewayUrl });
44
+ } else {
45
+ this.logger.warn('Gateway webhook not configured — need url + hookToken');
244
46
  }
245
47
  }
246
48
 
247
- private scheduleReconnect(): void {
248
- if (this.reconnectTimer) return;
249
- const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempt), 30_000);
250
- this.reconnectAttempt++;
251
- this.logger.debug(`Reconnecting to Gateway in ${delay}ms (attempt ${this.reconnectAttempt})`);
252
- this.reconnectTimer = setTimeout(() => {
253
- this.reconnectTimer = null;
254
- this.connect();
255
- }, delay);
256
- }
257
-
258
49
  async inject(payload: GatewayInjectPayload): Promise<void> {
259
- if (!this.isAlive()) {
260
- throw new Error('Gateway WebSocket not connected');
50
+ if (!this.configured) {
51
+ throw new Error('Gateway webhook not configured');
261
52
  }
262
53
  const id = `rpc-${++this.requestId}`;
263
- const promise = this.trackRequest(id);
264
- this.send({
265
- type: 'req',
266
- id,
267
- method: 'send',
268
- params: {
269
- target: payload.target,
270
- message: payload.message,
271
- metadata: payload.metadata,
272
- idempotencyKey: payload.metadata?.eventId ?? String(this.requestId),
273
- },
274
- });
275
- return promise;
276
- }
277
54
 
278
- private trackRequest(id: string): Promise<void> {
279
- return new Promise<void>((resolve, reject) => {
280
- const timer = setTimeout(() => {
281
- this.pendingRequests.delete(id);
282
- reject(new Error(`Gateway RPC timeout after ${RPC_TIMEOUT_MS}ms [${id}]`));
283
- }, RPC_TIMEOUT_MS);
284
- this.pendingRequests.set(id, { resolve, reject, timer });
55
+ const response = await this.client.post('/hooks/wake', {
56
+ text: payload.message,
57
+ mode: 'now',
285
58
  });
286
- }
287
-
288
- private resolvePending(id: string): void {
289
- const pending = this.pendingRequests.get(id);
290
- if (pending) {
291
- clearTimeout(pending.timer);
292
- this.pendingRequests.delete(id);
293
- pending.resolve();
294
- }
295
- }
296
59
 
297
- private rejectPending(id: string, err: Error): void {
298
- const pending = this.pendingRequests.get(id);
299
- if (pending) {
300
- clearTimeout(pending.timer);
301
- this.pendingRequests.delete(id);
302
- pending.reject(err);
60
+ if (isErrorResponse(response)) {
61
+ throw new GatewayRpcError(id, String(response.code), response.message ?? response.error);
303
62
  }
304
63
  }
305
64
 
306
65
  isAlive(): boolean {
307
- return this.connected && this.ws?.readyState === WebSocket.OPEN;
308
- }
309
-
310
- async onModuleDestroy(): Promise<void> {
311
- if (this.reconnectTimer) {
312
- clearTimeout(this.reconnectTimer);
313
- this.reconnectTimer = null;
314
- }
315
- // Reject all pending requests
316
- for (const [id, pending] of this.pendingRequests) {
317
- clearTimeout(pending.timer);
318
- pending.reject(new Error('Gateway client shutting down'));
319
- }
320
- this.pendingRequests.clear();
321
- if (this.ws) {
322
- this.ws.close();
323
- this.ws = null;
324
- }
325
- this.connected = false;
326
- this.connectSent = false;
66
+ return this.configured;
327
67
  }
328
68
  }
@@ -44,7 +44,7 @@ export class HealthService extends BaseService {
44
44
  },
45
45
  gateway: {
46
46
  connected: this.gateway.isAlive(),
47
- url: this.config.get('gateway.wsUrl'),
47
+ url: this.config.get('gateway.url'),
48
48
  },
49
49
  pendingCount,
50
50
  uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000),
@@ -56,7 +56,7 @@ export class LogRepository extends BaseService {
56
56
  .select({ count: sql<number>`count(*)` })
57
57
  .from(executionLogs)
58
58
  .where(this.buildWhereConditions(filters));
59
- return result[0]?.count ?? 0;
59
+ return (result[0] as unknown as { count: number })?.count ?? 0;
60
60
  }
61
61
 
62
62
  async findRecent(limit: number = 20): Promise<DbExecutionLog[]> {
@@ -58,7 +58,7 @@ export class LogService extends BaseService {
58
58
  }
59
59
  }
60
60
 
61
- async logError(entityType: 'route' | 'cron', entityId: string, subject: string, error: unknown): Promise<void> {
61
+ async logError(entityType: 'route' | 'cron' | 'timer', entityId: string, subject: string, error: unknown): Promise<void> {
62
62
  try {
63
63
  const detail = error instanceof Error
64
64
  ? JSON.stringify({ message: error.message, stack: error.stack })
@@ -49,7 +49,7 @@ export class PendingRepository extends BaseService {
49
49
  .select({ count: sql<number>`count(*)` })
50
50
  .from(pendingEvents)
51
51
  .where(isNull(pendingEvents.deliveredAt));
52
- return result[0]?.count ?? 0;
52
+ return (result[0] as unknown as { count: number })?.count ?? 0;
53
53
  }
54
54
 
55
55
  async cleanup(ttlSeconds: number): Promise<number> {
@@ -82,6 +82,6 @@ export class RouterRepository extends BaseService {
82
82
  const result = await this.db
83
83
  .select({ count: sql<number>`count(*)` })
84
84
  .from(eventRoutes);
85
- return result[0]?.count ?? 0;
85
+ return (result[0] as unknown as { count: number })?.count ?? 0;
86
86
  }
87
87
  }