@turquoisebay/mqtt 0.1.13 β†’ 0.1.15

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/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.15] - 2026-02-03
9
+
10
+ ### Added
11
+ - Echo `correlationId` from inbound JSON in outbound replies
12
+
13
+ ### Docs
14
+ - Clarify how `senderId` maps to sessions vs `correlationId`
15
+
16
+ ## [0.1.14] - 2026-02-03
17
+
18
+ ### Fixed
19
+ - Robust reconnection logic (handles broker down at startup and restarts)
20
+ - Clean reconnect scheduling + shutdown cleanup
21
+
8
22
  ## [0.1.13] - 2026-02-03
9
23
 
10
24
  ### Fixed
package/README.md CHANGED
@@ -10,6 +10,7 @@ MQTT channel plugin for [OpenClaw](https://github.com/openclaw/openclaw) β€” bid
10
10
 
11
11
  - πŸ”Œ **Bidirectional messaging** β€” subscribe and publish to MQTT topics
12
12
  - 🏠 **Home automation ready** β€” integrates with Home Assistant, Mosquitto, EMQX
13
+ - πŸ” **Robust reconnection** β€” recovers from broker restarts and cold starts
13
14
  - πŸ”’ **TLS support** β€” secure connections to cloud brokers
14
15
  - πŸ“Š **Service monitoring** β€” receive alerts from Uptime Kuma, healthchecks, etc.
15
16
  - ⚑ **QoS levels** β€” configurable delivery guarantees (0, 1, 2)
@@ -59,6 +60,13 @@ openclaw gateway restart
59
60
 
60
61
  ## Usage
61
62
 
63
+ ### Sessions & correlation IDs (important)
64
+
65
+ - **Sessions are keyed by `senderId`** β†’ OpenClaw uses `mqtt:{senderId}` as the SessionKey, so memory and conversation history are grouped by sender.
66
+ - **`correlationId` is request‑level only** β†’ if you include it in inbound JSON, it’s echoed back in the outbound reply for client-side matching. It does **not** create a new session or change memory.
67
+
68
+ If you want separate conversations, use distinct `senderId`s.
69
+
62
70
  ### Receiving messages (inbound)
63
71
 
64
72
  Messages published to your `inbound` topic will be processed by OpenClaw.
@@ -69,7 +77,7 @@ You can send either plain text or JSON (recommended):
69
77
  mosquitto_pub -t "openclaw/inbound" -m "Alert: Service down on playground"
70
78
 
71
79
  # JSON (recommended)
72
- mosquitto_pub -t "openclaw/inbound" -m '{"senderId":"pg-cli","text":"hello"}'
80
+ mosquitto_pub -t "openclaw/inbound" -m '{"senderId":"pg-cli","text":"hello","correlationId":"abc-123"}'
73
81
  ```
74
82
 
75
83
  ### Sending messages (outbound)
@@ -21,6 +21,7 @@ export declare class MockMqttClient extends EventEmitter {
21
21
  simulateMessage(topic: string, payload: Buffer | string): void;
22
22
  simulateError(err: Error): void;
23
23
  simulateDisconnect(): void;
24
+ reconnect(): this;
24
25
  simulateReconnect(): void;
25
26
  }
26
27
  export declare function connect(url: string, opts?: unknown): MockMqttClient;
@@ -1 +1 @@
1
- {"version":3,"file":"mqtt.d.ts","sourceRoot":"","sources":["../../../src/__mocks__/mqtt.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAEtC;;GAEG;AACH,qBAAa,cAAe,SAAQ,YAAY;IAC9C,SAAS,UAAS;IAClB,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAa;IACxD,SAAS,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC,CAAM;IAEzE,SAAS,CACP,KAAK,EAAE,MAAM,EACb,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,EACrB,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,GAAG,IAAI,KAAK,IAAI;IAOxC,OAAO,CACL,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,OAAO,EACb,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,GAAG,IAAI,KAAK,IAAI;IAOxC,GAAG,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,MAAM,IAAI;IAQxD,eAAe;IAKf,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM;IAKvD,aAAa,CAAC,GAAG,EAAE,KAAK;IAIxB,kBAAkB;IAKlB,iBAAiB;CAGlB;AAKD,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,cAAc,CAKnE;AAGD,wBAAgB,aAAa,IAAI,cAAc,GAAG,IAAI,CAErD;AAGD,wBAAgB,SAAS,SAExB;;;;;;AAED,wBAAqD"}
1
+ {"version":3,"file":"mqtt.d.ts","sourceRoot":"","sources":["../../../src/__mocks__/mqtt.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAEtC;;GAEG;AACH,qBAAa,cAAe,SAAQ,YAAY;IAC9C,SAAS,UAAS;IAClB,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAa;IACxD,SAAS,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC,CAAM;IAEzE,SAAS,CACP,KAAK,EAAE,MAAM,EACb,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,EACrB,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,GAAG,IAAI,KAAK,IAAI;IAOxC,OAAO,CACL,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,OAAO,EACb,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,GAAG,IAAI,KAAK,IAAI;IAOxC,GAAG,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,MAAM,IAAI;IAQxD,eAAe;IAKf,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM;IAKvD,aAAa,CAAC,GAAG,EAAE,KAAK;IAIxB,kBAAkB;IAKlB,SAAS;IAMT,iBAAiB;CAGlB;AAKD,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,cAAc,CAKnE;AAGD,wBAAgB,aAAa,IAAI,cAAc,GAAG,IAAI,CAErD;AAGD,wBAAgB,SAAS,SAExB;;;;;;AAED,wBAAqD"}
@@ -38,6 +38,11 @@ export class MockMqttClient extends EventEmitter {
38
38
  this.connected = false;
39
39
  this.emit("close");
40
40
  }
41
+ reconnect() {
42
+ this.emit("reconnect");
43
+ setTimeout(() => this.simulateConnect(), 10);
44
+ return this;
45
+ }
41
46
  simulateReconnect() {
42
47
  this.emit("reconnect");
43
48
  }
@@ -67,12 +67,12 @@ export const mqttPlugin = {
67
67
  gateway: {
68
68
  startAccount: async (ctx) => {
69
69
  const { cfg, account, accountId, abortSignal, log } = ctx;
70
- const runtime = getMqttRuntime();
71
70
  const mqtt = cfg.channels?.mqtt;
72
71
  if (!mqtt?.brokerUrl) {
73
72
  log?.debug?.("MQTT channel not configured, skipping");
74
73
  return;
75
74
  }
75
+ const runtime = getMqttRuntime();
76
76
  log?.info?.(`[${accountId}] starting MQTT provider (${mqtt.brokerUrl})`);
77
77
  // Create and connect client
78
78
  mqttClient = createMqttClient(mqtt, {
@@ -85,8 +85,7 @@ export const mqttPlugin = {
85
85
  await mqttClient.connect();
86
86
  }
87
87
  catch (err) {
88
- log?.error?.(`MQTT connection failed: ${err}`);
89
- throw err;
88
+ log?.error?.(`MQTT connection failed (will keep retrying): ${err}`);
90
89
  }
91
90
  // Subscribe to inbound topic
92
91
  const inboundTopic = mqtt.topics?.inbound ?? "openclaw/inbound";
@@ -145,6 +144,7 @@ async function handleInboundMessage(opts) {
145
144
  // Extract message body and sender from payload
146
145
  let messageBody;
147
146
  let senderId;
147
+ let correlationId;
148
148
  if (parsedPayload && typeof parsedPayload === "object") {
149
149
  messageBody =
150
150
  parsedPayload.message ??
@@ -160,6 +160,10 @@ async function handleInboundMessage(opts) {
160
160
  parsedPayload.from ??
161
161
  parsedPayload.service ??
162
162
  topic.replace(/\//g, "-");
163
+ correlationId =
164
+ parsedPayload.correlationId ??
165
+ parsedPayload.requestId ??
166
+ undefined;
163
167
  }
164
168
  else {
165
169
  messageBody = text;
@@ -203,6 +207,7 @@ async function handleInboundMessage(opts) {
203
207
  text: payload.text,
204
208
  kind: info.kind,
205
209
  ts: Date.now(),
210
+ ...(correlationId ? { correlationId } : {}),
206
211
  });
207
212
  await mqttClient.publish(outboundTopic, outboundPayload, qos);
208
213
  log?.info?.(`MQTT: sent reply to ${outboundTopic}`);
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/client.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAGrD,MAAM,WAAW,iBAAiB;IAChC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxE,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,GAAG,IAAI,CAAC;IACxD,WAAW,IAAI,OAAO,CAAC;CACxB;AAED,MAAM,MAAM,cAAc,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;AAEtE,UAAU,MAAM;IACd,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAKD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,OAAO,CAAC,UAAU,CAAC,EAC9B,MAAM,EAAE,MAAM,GACb,iBAAiB,CAoLnB"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/client.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAGrD,MAAM,WAAW,iBAAiB;IAChC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxE,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,GAAG,IAAI,CAAC;IACxD,WAAW,IAAI,OAAO,CAAC;CACxB;AAED,MAAM,MAAM,cAAc,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;AAEtE,UAAU,MAAM;IACd,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAOD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,OAAO,CAAC,UAAU,CAAC,EAC9B,MAAM,EAAE,MAAM,GACb,iBAAiB,CA4QnB"}
@@ -2,6 +2,8 @@ import mqtt from "mqtt";
2
2
  import { mergeWithEnv } from "./env.js";
3
3
  const DEFAULT_RECONNECT_MS = 5000;
4
4
  const MAX_RECONNECT_MS = 60000;
5
+ const INITIAL_CONNECT_GRACE_MS = 5000;
6
+ const RECONNECT_JITTER = 0.2;
5
7
  /**
6
8
  * MQTT Client Manager
7
9
  *
@@ -12,12 +14,15 @@ export function createMqttClient(rawConfig, logger) {
12
14
  let client = null;
13
15
  let messageHandlers = new Map();
14
16
  let reconnectAttempts = 0;
17
+ let reconnectTimer = null;
18
+ let connectPromise = null;
19
+ let manualDisconnect = false;
15
20
  function getClientOptions() {
16
21
  const options = {
17
22
  clientId: config.clientId ?? `openclaw-${Math.random().toString(36).slice(2, 10)}`,
18
23
  clean: true,
19
24
  connectTimeout: 10000,
20
- reconnectPeriod: DEFAULT_RECONNECT_MS,
25
+ reconnectPeriod: 0,
21
26
  };
22
27
  // Auth
23
28
  if (config.username) {
@@ -36,80 +41,156 @@ export function createMqttClient(rawConfig, logger) {
36
41
  }
37
42
  return options;
38
43
  }
44
+ function clearReconnectTimer() {
45
+ if (reconnectTimer) {
46
+ clearTimeout(reconnectTimer);
47
+ reconnectTimer = null;
48
+ }
49
+ }
50
+ function getBackoffDelay(attempt) {
51
+ const base = Math.min(DEFAULT_RECONNECT_MS * Math.pow(2, Math.max(0, attempt - 1)), MAX_RECONNECT_MS);
52
+ const jitter = base * RECONNECT_JITTER * Math.random();
53
+ return Math.round(base + jitter);
54
+ }
55
+ function scheduleReconnect(reason) {
56
+ if (manualDisconnect)
57
+ return;
58
+ if (reconnectTimer)
59
+ return;
60
+ reconnectAttempts += 1;
61
+ const delay = getBackoffDelay(reconnectAttempts);
62
+ logger.warn(`MQTT reconnect scheduled in ${delay}ms (${reason})`);
63
+ reconnectTimer = setTimeout(() => {
64
+ reconnectTimer = null;
65
+ if (manualDisconnect)
66
+ return;
67
+ if (!client) {
68
+ connect().catch((err) => logger.error(`MQTT reconnect failed: ${err}`));
69
+ return;
70
+ }
71
+ try {
72
+ logger.info("MQTT reconnecting...");
73
+ client.reconnect();
74
+ }
75
+ catch (err) {
76
+ logger.error(`MQTT reconnect error: ${err}`);
77
+ scheduleReconnect("reconnect error");
78
+ }
79
+ }, delay);
80
+ }
81
+ function attachClientHandlers(activeClient) {
82
+ activeClient.on("connect", () => {
83
+ logger.info("MQTT connected");
84
+ reconnectAttempts = 0;
85
+ clearReconnectTimer();
86
+ // Resubscribe to all topics
87
+ for (const topic of messageHandlers.keys()) {
88
+ activeClient.subscribe(topic, { qos: config.qos }, (err) => {
89
+ if (err) {
90
+ logger.error(`Failed to subscribe to ${topic}: ${err.message}`);
91
+ }
92
+ else {
93
+ logger.debug(`Subscribed to ${topic}`);
94
+ }
95
+ });
96
+ }
97
+ });
98
+ activeClient.on("message", (topic, payload) => {
99
+ logger.debug(`Received message on ${topic}: ${payload.length} bytes`);
100
+ const handlers = [...(messageHandlers.get(topic) ?? [])];
101
+ // Also check wildcard subscriptions (skip exact match to avoid duplicates)
102
+ for (const [pattern, patternHandlers] of messageHandlers) {
103
+ if (pattern === topic)
104
+ continue;
105
+ if (topicMatches(pattern, topic)) {
106
+ handlers.push(...patternHandlers);
107
+ }
108
+ }
109
+ for (const handler of handlers) {
110
+ try {
111
+ handler(topic, payload);
112
+ }
113
+ catch (err) {
114
+ logger.error(`Message handler error: ${err}`);
115
+ }
116
+ }
117
+ });
118
+ activeClient.on("error", (err) => {
119
+ logger.error(`MQTT error: ${err.message}`);
120
+ scheduleReconnect("error");
121
+ });
122
+ activeClient.on("close", () => {
123
+ logger.warn("MQTT connection closed");
124
+ scheduleReconnect("close");
125
+ });
126
+ activeClient.on("reconnect", () => {
127
+ logger.info("MQTT reconnect event");
128
+ });
129
+ activeClient.on("offline", () => {
130
+ logger.warn("MQTT client offline");
131
+ scheduleReconnect("offline");
132
+ });
133
+ }
39
134
  async function connect() {
40
135
  if (client?.connected) {
41
136
  logger.debug("MQTT already connected");
42
137
  return;
43
138
  }
44
- return new Promise((resolve, reject) => {
139
+ if (connectPromise) {
140
+ return connectPromise;
141
+ }
142
+ manualDisconnect = false;
143
+ if (!client) {
45
144
  logger.info(`Connecting to MQTT broker: ${config.brokerUrl}`);
46
145
  const options = getClientOptions();
47
146
  client = mqtt.connect(config.brokerUrl, options);
48
- client.on("connect", () => {
49
- logger.info("MQTT connected");
50
- reconnectAttempts = 0;
51
- // Resubscribe to all topics
52
- for (const topic of messageHandlers.keys()) {
53
- client?.subscribe(topic, { qos: config.qos }, (err) => {
54
- if (err) {
55
- logger.error(`Failed to subscribe to ${topic}: ${err.message}`);
56
- }
57
- else {
58
- logger.debug(`Subscribed to ${topic}`);
59
- }
60
- });
61
- }
147
+ attachClientHandlers(client);
148
+ }
149
+ else {
150
+ logger.info("MQTT connect requested; reconnecting existing client");
151
+ try {
152
+ client.reconnect();
153
+ }
154
+ catch (err) {
155
+ logger.error(`MQTT reconnect error: ${err}`);
156
+ scheduleReconnect("reconnect error");
157
+ }
158
+ }
159
+ connectPromise = new Promise((resolve) => {
160
+ let settled = false;
161
+ const settle = () => {
162
+ if (settled)
163
+ return;
164
+ settled = true;
165
+ connectPromise = null;
62
166
  resolve();
167
+ };
168
+ if (client?.connected) {
169
+ settle();
170
+ return;
171
+ }
172
+ const timer = setTimeout(() => {
173
+ logger.warn(`MQTT initial connect not ready after ${INITIAL_CONNECT_GRACE_MS}ms; continuing retries in background`);
174
+ settle();
175
+ }, INITIAL_CONNECT_GRACE_MS);
176
+ client?.once("connect", () => {
177
+ clearTimeout(timer);
178
+ settle();
63
179
  });
64
- client.on("message", (topic, payload) => {
65
- logger.debug(`Received message on ${topic}: ${payload.length} bytes`);
66
- const handlers = [...(messageHandlers.get(topic) ?? [])];
67
- // Also check wildcard subscriptions (skip exact match to avoid duplicates)
68
- for (const [pattern, patternHandlers] of messageHandlers) {
69
- if (pattern === topic)
70
- continue;
71
- if (topicMatches(pattern, topic)) {
72
- handlers.push(...patternHandlers);
73
- }
74
- }
75
- for (const handler of handlers) {
76
- try {
77
- handler(topic, payload);
78
- }
79
- catch (err) {
80
- logger.error(`Message handler error: ${err}`);
81
- }
82
- }
83
- });
84
- client.on("error", (err) => {
85
- logger.error(`MQTT error: ${err.message}`);
86
- reject(err);
87
- });
88
- client.on("close", () => {
89
- logger.warn("MQTT connection closed");
90
- });
91
- client.on("reconnect", () => {
92
- reconnectAttempts++;
93
- const backoff = Math.min(DEFAULT_RECONNECT_MS * Math.pow(2, reconnectAttempts), MAX_RECONNECT_MS);
94
- logger.info(`MQTT reconnecting (attempt ${reconnectAttempts}, backoff ${backoff}ms)`);
95
- });
96
- client.on("offline", () => {
97
- logger.warn("MQTT client offline");
98
- });
99
- // Timeout for initial connection
100
- setTimeout(() => {
101
- if (!client?.connected) {
102
- reject(new Error("MQTT connection timeout"));
103
- }
104
- }, 15000);
105
180
  });
181
+ return connectPromise;
106
182
  }
107
183
  async function disconnect() {
108
184
  if (!client)
109
185
  return;
186
+ manualDisconnect = true;
187
+ clearReconnectTimer();
188
+ reconnectAttempts = 0;
189
+ connectPromise = null;
110
190
  return new Promise((resolve) => {
111
191
  logger.info("Disconnecting from MQTT broker");
112
192
  client?.end(false, {}, () => {
193
+ client?.removeAllListeners();
113
194
  client = null;
114
195
  messageHandlers.clear();
115
196
  logger.info("MQTT disconnected");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turquoisebay/mqtt",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "MQTT channel plugin for OpenClaw - bidirectional messaging via MQTT brokers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",