tina4-nodejs 3.13.79 → 3.13.82
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/CLAUDE.md +6 -6
- package/README.md +4 -4
- package/package.json +1 -1
- package/packages/cli/src/bin.ts +163 -13
- package/packages/core/public/css/tina4.css +56 -130
- package/packages/core/public/css/tina4.min.css +1 -1
- package/packages/core/src/devAdmin.ts +9 -11
- package/packages/core/src/index.ts +4 -0
- package/packages/core/src/mqtt.ts +859 -0
- package/packages/core/src/mqttMessage.ts +104 -0
- package/packages/core/src/scss.ts +48 -1
- package/packages/core/src/testClient.ts +72 -7
- package/packages/orm/src/adapters/firebird.ts +1 -1
- package/packages/orm/src/adapters/mssql.ts +1 -1
- package/packages/orm/src/adapters/mysql.ts +1 -1
- package/packages/orm/src/adapters/postgres.ts +1 -1
- package/packages/orm/src/adapters/sqlite.ts +1 -1
- package/packages/orm/src/baseModel.ts +1 -1
- package/packages/orm/src/cachedDatabase.ts +1 -1
- package/packages/orm/src/database.ts +1 -1
- package/packages/orm/src/index.ts +1 -1
- /package/packages/orm/src/{sqlTranslation.ts → sqlTranslator.ts} +0 -0
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-dependency MQTT 3.1.1 client — the protocol every broker and every IoT
|
|
3
|
+
* device already speaks.
|
|
4
|
+
*
|
|
5
|
+
* Built on Node's `node:net` and `node:tls` stdlib modules only: no npm package,
|
|
6
|
+
* so an app that talks to Mosquitto / EMQX / HiveMQ / AWS IoT adds nothing to its
|
|
7
|
+
* dependency tree. Shaped like the Queue on purpose — publish / subscribe /
|
|
8
|
+
* consume:
|
|
9
|
+
*
|
|
10
|
+
* import { Mqtt } from "@tina4stack/tina4-node";
|
|
11
|
+
*
|
|
12
|
+
* const mqtt = new Mqtt({ url: "mqtt://broker:1883" }); // TINA4_MQTT_URL
|
|
13
|
+
* await mqtt.connect();
|
|
14
|
+
* await mqtt.publish("fleet/meter-42/telemetry", '{"kwh":12.5}', 1);
|
|
15
|
+
*
|
|
16
|
+
* for await (const message of mqtt.consume("fleet/+/telemetry", 1)) {
|
|
17
|
+
* if (message.isDuplicate()) continue; // QoS 1 is at-least-once
|
|
18
|
+
* store(message.topic, message.payload);
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* Environment: TINA4_MQTT_URL (default mqtt://127.0.0.1:1883),
|
|
22
|
+
* TINA4_MQTT_CLIENT_ID, TINA4_MQTT_KEEPALIVE (seconds, default 60),
|
|
23
|
+
* TINA4_MQTT_CA_FILE, TINA4_MQTT_TLS_VERIFY.
|
|
24
|
+
*
|
|
25
|
+
* Node has no synchronous blocking socket read, so connect()/publish()/
|
|
26
|
+
* subscribe()/receive() are async and consume() is an async generator — the same
|
|
27
|
+
* idiom as the Queue. No background task runs by default; opt in to the
|
|
28
|
+
* cooperative keepalive with startKeepalive(), which registers a background()
|
|
29
|
+
* task exactly like the queue consumers do.
|
|
30
|
+
*
|
|
31
|
+
* Single reader: like every MQTT client the socket has ONE network reader. Call
|
|
32
|
+
* receive()/consume() from one place.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import net from "node:net";
|
|
36
|
+
import tls from "node:tls";
|
|
37
|
+
import { randomBytes } from "node:crypto";
|
|
38
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
39
|
+
import { Env } from "./env.js";
|
|
40
|
+
import { Log } from "./logger.js";
|
|
41
|
+
import { background } from "./background.js";
|
|
42
|
+
import { MqttMessage } from "./mqttMessage.js";
|
|
43
|
+
|
|
44
|
+
/** Any MQTT protocol / connection failure. */
|
|
45
|
+
export class MqttError extends Error {
|
|
46
|
+
constructor(message: string) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "MqttError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The broker did not answer inside the timeout. */
|
|
53
|
+
export class MqttTimeoutError extends MqttError {
|
|
54
|
+
constructor(message: string) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = "MqttTimeoutError";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface MqttOptions {
|
|
61
|
+
url?: string;
|
|
62
|
+
clientId?: string;
|
|
63
|
+
username?: string;
|
|
64
|
+
password?: string;
|
|
65
|
+
caFile?: string;
|
|
66
|
+
tlsVerify?: boolean;
|
|
67
|
+
keepalive?: number;
|
|
68
|
+
cleanSession?: boolean;
|
|
69
|
+
willTopic?: string;
|
|
70
|
+
willPayload?: unknown;
|
|
71
|
+
willQos?: number;
|
|
72
|
+
willRetain?: boolean;
|
|
73
|
+
/** Seconds to wait for a control-packet answer (CONNACK / PUBACK / SUBACK). */
|
|
74
|
+
timeout?: number;
|
|
75
|
+
/** Seconds to wait for an application message; null/undefined blocks. */
|
|
76
|
+
readTimeout?: number | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ParsedMqttUrl {
|
|
80
|
+
host: string;
|
|
81
|
+
port: number;
|
|
82
|
+
tls: boolean;
|
|
83
|
+
username: string | null;
|
|
84
|
+
password: string | null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Control packet types. The low nibble of SUBSCRIBE is 0x2 because MQTT 3.1.1
|
|
88
|
+
// mandates QoS 1 on that packet.
|
|
89
|
+
const CONNECT = 0x10;
|
|
90
|
+
const CONNACK = 0x20;
|
|
91
|
+
const PUBLISH = 0x30;
|
|
92
|
+
const PUBACK = 0x40;
|
|
93
|
+
const SUBSCRIBE = 0x82;
|
|
94
|
+
const SUBACK = 0x90;
|
|
95
|
+
const PINGREQ = 0xc0;
|
|
96
|
+
const PINGRESP = 0xd0;
|
|
97
|
+
const DISCONNECT = 0xe0;
|
|
98
|
+
|
|
99
|
+
const PROTOCOL_LEVEL = 0x04; // 4 == MQTT 3.1.1
|
|
100
|
+
const DEFAULT_PORT = 1883;
|
|
101
|
+
const DEFAULT_TLS_PORT = 8883;
|
|
102
|
+
const DEFAULT_URL = "mqtt://127.0.0.1:1883";
|
|
103
|
+
const DEFAULT_KEEPALIVE = 60;
|
|
104
|
+
const SUBSCRIPTION_REFUSED = 0x80;
|
|
105
|
+
const MAX_REMAINING_LENGTH = 268_435_455; // 4 varint bytes
|
|
106
|
+
|
|
107
|
+
// QoS 2 is refused, never silently downgraded. A caller who asked for
|
|
108
|
+
// exactly-once and quietly got at-least-once would double-process every
|
|
109
|
+
// duplicate forever without ever seeing an error.
|
|
110
|
+
const QOS2_REFUSED_MESSAGE =
|
|
111
|
+
"MQTT QoS 2 (exactly-once delivery) is not supported by Tina4 -- this " +
|
|
112
|
+
"client speaks QoS 0 and QoS 1 only, and refuses QoS 2 rather than " +
|
|
113
|
+
"silently downgrading it to QoS 1. Use QoS 1 with an idempotent consumer " +
|
|
114
|
+
"keyed on (device_id, device_timestamp): duplicates are then harmless, " +
|
|
115
|
+
"which is what exactly-once was for.";
|
|
116
|
+
|
|
117
|
+
const CONNACK_RETURN_CODES: Record<number, string> = {
|
|
118
|
+
1: "unacceptable protocol version",
|
|
119
|
+
2: "client identifier rejected",
|
|
120
|
+
3: "server unavailable",
|
|
121
|
+
4: "bad user name or password",
|
|
122
|
+
5: "not authorised",
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
type Waiter = {
|
|
126
|
+
need: number;
|
|
127
|
+
resolve: (buf: Buffer) => void;
|
|
128
|
+
reject: (err: Error) => void;
|
|
129
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export class Mqtt {
|
|
133
|
+
public readonly host: string;
|
|
134
|
+
public readonly port: number;
|
|
135
|
+
public readonly clientId: string;
|
|
136
|
+
public readonly keepalive: number;
|
|
137
|
+
public readonly cleanSession: boolean;
|
|
138
|
+
public readonly username: string | null;
|
|
139
|
+
|
|
140
|
+
private readonly secure: boolean;
|
|
141
|
+
private readonly password: string | null;
|
|
142
|
+
private readonly caFile: string | null;
|
|
143
|
+
private readonly tlsVerify: boolean;
|
|
144
|
+
private readonly willTopic: string | null;
|
|
145
|
+
private readonly willPayload: unknown;
|
|
146
|
+
private readonly willQos: number;
|
|
147
|
+
private readonly willRetain: boolean;
|
|
148
|
+
private readonly timeout: number;
|
|
149
|
+
private readonly readTimeout: number | null;
|
|
150
|
+
|
|
151
|
+
private packetId = 0;
|
|
152
|
+
private inbox: MqttMessage[] = [];
|
|
153
|
+
private lastWriteAt = 0;
|
|
154
|
+
private socket: net.Socket | tls.TLSSocket | null = null;
|
|
155
|
+
private keepaliveTask: { stop: () => void } | null = null;
|
|
156
|
+
private readBuffer: Buffer = Buffer.alloc(0);
|
|
157
|
+
private waiter: Waiter | null = null;
|
|
158
|
+
private socketError: Error | null = null;
|
|
159
|
+
|
|
160
|
+
constructor(options: MqttOptions = {}) {
|
|
161
|
+
const parsed = Mqtt.parseUrl(options.url ?? (Env.str("TINA4_MQTT_URL") || DEFAULT_URL));
|
|
162
|
+
this.host = parsed.host;
|
|
163
|
+
this.port = parsed.port;
|
|
164
|
+
this.secure = parsed.tls;
|
|
165
|
+
|
|
166
|
+
// Explicit options win over the url's userinfo: the more specific source.
|
|
167
|
+
this.username = options.username ?? parsed.username;
|
|
168
|
+
let pw = options.password ?? parsed.password;
|
|
169
|
+
if (pw === "") pw = null;
|
|
170
|
+
this.password = pw;
|
|
171
|
+
if (this.password !== null && (this.username === null || this.username === "")) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
"MQTT password without a username is not allowed by MQTT 3.1.1 -- " +
|
|
174
|
+
"supply both (mqtt://user:pass@host, or username/password) or neither",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
this.caFile = options.caFile ?? (Env.str("TINA4_MQTT_CA_FILE") || null);
|
|
179
|
+
this.tlsVerify = options.tlsVerify ?? Env.bool("TINA4_MQTT_TLS_VERIFY", true);
|
|
180
|
+
|
|
181
|
+
let cid = options.clientId ?? (Env.str("TINA4_MQTT_CLIENT_ID") || null);
|
|
182
|
+
if (cid === null || cid === "") cid = "tina4-" + randomBytes(8).toString("hex");
|
|
183
|
+
this.clientId = cid;
|
|
184
|
+
|
|
185
|
+
this.keepalive = options.keepalive ?? Env.int("TINA4_MQTT_KEEPALIVE", DEFAULT_KEEPALIVE);
|
|
186
|
+
this.cleanSession = options.cleanSession ?? true;
|
|
187
|
+
this.willTopic = options.willTopic ?? null;
|
|
188
|
+
this.willPayload = options.willPayload ?? null;
|
|
189
|
+
this.willQos = options.willQos ?? 0;
|
|
190
|
+
this.willRetain = options.willRetain ?? false;
|
|
191
|
+
this.timeout = options.timeout ?? 5;
|
|
192
|
+
this.readTimeout = options.readTimeout ?? null;
|
|
193
|
+
|
|
194
|
+
if (this.willTopic !== null) this.refuseUnsupportedQos(this.willQos);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// -- url parsing --------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Split an MQTT url into { host, port, tls, username, password }.
|
|
201
|
+
*
|
|
202
|
+
* "mqtt://host:port" and "tcp://host:port" are plain TCP (default port 1883);
|
|
203
|
+
* "mqtts://host:port" is TLS (default 8883). A bare "host" or "host:port"
|
|
204
|
+
* works too, and an IPv6 literal is bracketed ("mqtt://[::1]:1883").
|
|
205
|
+
* Credentials ride in the userinfo and are percent-decoded, so a password
|
|
206
|
+
* containing @ : or / survives.
|
|
207
|
+
*/
|
|
208
|
+
static parseUrl(url: string): ParsedMqttUrl {
|
|
209
|
+
const raw = (url ?? "").trim();
|
|
210
|
+
if (raw === "") {
|
|
211
|
+
throw new Error(`MQTT url is empty -- set TINA4_MQTT_URL (e.g. ${DEFAULT_URL})`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let scheme: string | null = null;
|
|
215
|
+
let rest = raw;
|
|
216
|
+
const schemeMatch = raw.match(/^([A-Za-z][A-Za-z0-9+.-]*):\/\//);
|
|
217
|
+
if (schemeMatch) {
|
|
218
|
+
scheme = schemeMatch[1].toLowerCase();
|
|
219
|
+
if (!["mqtt", "tcp", "mqtts"].includes(scheme)) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`unsupported MQTT url scheme '${scheme}' in '${raw}' -- this client speaks ` +
|
|
222
|
+
"mqtt://, tcp:// or mqtts:// (TLS). WebSocket transports are not implemented.",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
rest = raw.slice(schemeMatch[0].length);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const tlsFlag = scheme === "mqtts";
|
|
229
|
+
|
|
230
|
+
// Split on the LAST "@" so a password containing an un-encoded "@" still
|
|
231
|
+
// leaves the host intact.
|
|
232
|
+
let username: string | null = null;
|
|
233
|
+
let password: string | null = null;
|
|
234
|
+
const atPos = rest.lastIndexOf("@");
|
|
235
|
+
if (atPos !== -1) {
|
|
236
|
+
const userinfo = rest.slice(0, atPos);
|
|
237
|
+
rest = rest.slice(atPos + 1);
|
|
238
|
+
const colon = userinfo.indexOf(":");
|
|
239
|
+
if (colon === -1) {
|
|
240
|
+
username = Mqtt.percentDecode(userinfo);
|
|
241
|
+
} else {
|
|
242
|
+
username = Mqtt.percentDecode(userinfo.slice(0, colon));
|
|
243
|
+
password = Mqtt.percentDecode(userinfo.slice(colon + 1));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// host is a [bracketed ipv6] or a run without : or /
|
|
248
|
+
const hostPort = rest.split("/", 1)[0];
|
|
249
|
+
let host = hostPort;
|
|
250
|
+
let portStr: string | null = null;
|
|
251
|
+
if (hostPort.startsWith("[")) {
|
|
252
|
+
const close = hostPort.indexOf("]");
|
|
253
|
+
if (close === -1) throw new Error(`malformed MQTT url '${raw}' -- unclosed IPv6 bracket`);
|
|
254
|
+
host = hostPort.slice(1, close);
|
|
255
|
+
const after = hostPort.slice(close + 1);
|
|
256
|
+
if (after.startsWith(":")) portStr = after.slice(1);
|
|
257
|
+
} else {
|
|
258
|
+
const colon = hostPort.indexOf(":");
|
|
259
|
+
if (colon !== -1) {
|
|
260
|
+
host = hostPort.slice(0, colon);
|
|
261
|
+
portStr = hostPort.slice(colon + 1);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (host === "") throw new Error(`malformed MQTT url '${raw}' -- expected mqtt://host:port`);
|
|
266
|
+
if (portStr !== null && portStr !== "" && !/^\d+$/.test(portStr)) {
|
|
267
|
+
throw new Error(`malformed MQTT url '${raw}' -- port must be numeric`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
host,
|
|
272
|
+
port: portStr !== null && portStr !== "" ? parseInt(portStr, 10) : tlsFlag ? DEFAULT_TLS_PORT : DEFAULT_PORT,
|
|
273
|
+
tls: tlsFlag,
|
|
274
|
+
username: username !== null && username !== "" ? username : null,
|
|
275
|
+
password,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Decode %XX in url userinfo. NOT decodeURI-style "+"-to-space: a "+" in a
|
|
281
|
+
* password must survive verbatim, and an invalid "%" is left as-is (never throws).
|
|
282
|
+
*/
|
|
283
|
+
private static percentDecode(value: string): string {
|
|
284
|
+
return value.replace(/%([0-9A-Fa-f]{2})/g, (_m, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Remaining Length varint: 7 bits per byte, high bit means "another byte
|
|
289
|
+
* follows". A single-byte assumption works for every packet under 128 bytes
|
|
290
|
+
* and then fails, so this is exercised directly at 0 / 127 / 128 / 16383.
|
|
291
|
+
*/
|
|
292
|
+
static encodeRemainingLength(value: number): Buffer {
|
|
293
|
+
if (!Number.isInteger(value) || value < 0 || value > MAX_REMAINING_LENGTH) {
|
|
294
|
+
throw new Error(`remaining length ${value} is outside 0..${MAX_REMAINING_LENGTH}`);
|
|
295
|
+
}
|
|
296
|
+
const out: number[] = [];
|
|
297
|
+
let length = value;
|
|
298
|
+
do {
|
|
299
|
+
const byte = length & 0x7f;
|
|
300
|
+
length = Math.floor(length / 128);
|
|
301
|
+
out.push(length > 0 ? byte | 0x80 : byte);
|
|
302
|
+
} while (length > 0);
|
|
303
|
+
return Buffer.from(out);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// -- connection ---------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Open the socket and complete the CONNECT / CONNACK handshake. Also the
|
|
310
|
+
* reconnect path: an existing socket is closed first, and a durable session
|
|
311
|
+
* (cleanSession false) resumes with the same clientId. Returns this for
|
|
312
|
+
* chaining (`const c = await new Mqtt(opts).connect()`).
|
|
313
|
+
*/
|
|
314
|
+
async connect(): Promise<this> {
|
|
315
|
+
this.closeSocket();
|
|
316
|
+
|
|
317
|
+
if (this.secure && this.tlsVerify && this.caFile && !existsSync(this.caFile)) {
|
|
318
|
+
throw new MqttError(
|
|
319
|
+
`MQTT CA file not found: ${this.caFile} -- TINA4_MQTT_CA_FILE (or caFile) ` +
|
|
320
|
+
"must point at the broker's CA certificate in PEM form",
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
if (this.secure && !this.tlsVerify) {
|
|
324
|
+
Log.warning(
|
|
325
|
+
`MQTT TLS certificate verification is DISABLED for mqtts://${this.host}:${this.port} -- ` +
|
|
326
|
+
"the connection is encrypted but the broker's identity is NOT verified, so a " +
|
|
327
|
+
"man in the middle can read and rewrite this traffic. Set TINA4_MQTT_CA_FILE " +
|
|
328
|
+
"(or caFile) to the broker's CA and drop TINA4_MQTT_TLS_VERIFY=false.",
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const socket = await this.openSocket();
|
|
333
|
+
socket.setNoDelay(true);
|
|
334
|
+
socket.on("data", (chunk: Buffer) => this.onData(chunk));
|
|
335
|
+
socket.on("error", (err: Error) => this.onSocketGone(new MqttError(`MQTT socket error: ${err.message}`)));
|
|
336
|
+
socket.on("close", () => this.onSocketGone(new MqttError("broker closed the connection")));
|
|
337
|
+
this.socket = socket;
|
|
338
|
+
this.inbox = [];
|
|
339
|
+
this.readBuffer = Buffer.alloc(0);
|
|
340
|
+
this.socketError = null;
|
|
341
|
+
|
|
342
|
+
// Payload order is FIXED: client id, will topic, will message, username,
|
|
343
|
+
// password. Emitting them in any other order shifts every field after it.
|
|
344
|
+
const parts: Buffer[] = [
|
|
345
|
+
Mqtt.mqttString("MQTT"),
|
|
346
|
+
Buffer.from([PROTOCOL_LEVEL, this.connectFlags()]),
|
|
347
|
+
Mqtt.uint16(this.keepalive),
|
|
348
|
+
Mqtt.mqttString(this.clientId),
|
|
349
|
+
];
|
|
350
|
+
if (this.willTopic !== null) {
|
|
351
|
+
parts.push(Mqtt.mqttString(this.willTopic));
|
|
352
|
+
const willBytes = Mqtt.payloadBytes(this.willPayload);
|
|
353
|
+
parts.push(Mqtt.uint16(willBytes.length), willBytes);
|
|
354
|
+
}
|
|
355
|
+
if (this.username !== null) parts.push(Mqtt.mqttString(this.username));
|
|
356
|
+
if (this.password !== null) parts.push(Mqtt.mqttString(this.password));
|
|
357
|
+
await this.writePacket(CONNECT, Buffer.concat(parts));
|
|
358
|
+
|
|
359
|
+
const [header, payload] = await this.readPacket(this.deadlineIn(this.timeout));
|
|
360
|
+
if (header !== CONNACK || payload.length < 2) {
|
|
361
|
+
throw new MqttError(`expected CONNACK, got 0x${header.toString(16).padStart(2, "0")}`);
|
|
362
|
+
}
|
|
363
|
+
const returnCode = payload[1];
|
|
364
|
+
if (returnCode !== 0) {
|
|
365
|
+
const reason = CONNACK_RETURN_CODES[returnCode] ?? "unknown return code";
|
|
366
|
+
throw new MqttError(`broker refused the connection: ${reason} (CONNACK return code ${returnCode})`);
|
|
367
|
+
}
|
|
368
|
+
return this;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Whether a socket is currently open. */
|
|
372
|
+
connected(): boolean {
|
|
373
|
+
return this.socket !== null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** True when this connection runs over TLS (mqtts://). */
|
|
377
|
+
tls(): boolean {
|
|
378
|
+
return this.secure;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The negotiated cipher suite name, or null on a plain connection. A real name
|
|
383
|
+
* here is proof the TLS handshake actually completed.
|
|
384
|
+
*/
|
|
385
|
+
cipher(): string | null {
|
|
386
|
+
if (!this.secure || this.socket === null) return null;
|
|
387
|
+
const info = (this.socket as tls.TLSSocket).getCipher?.();
|
|
388
|
+
return info ? info.name : null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** The negotiated TLS protocol version ("TLSv1.3"), or null when plain. */
|
|
392
|
+
tlsVersion(): string | null {
|
|
393
|
+
if (!this.secure || this.socket === null) return null;
|
|
394
|
+
return (this.socket as tls.TLSSocket).getProtocol?.() ?? null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// -- publish / subscribe / receive -------------------------------------
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Publish an application message. Resolves to the packet identifier for QoS 1
|
|
401
|
+
* (the broker's PUBACK must carry it back) and null for QoS 0.
|
|
402
|
+
*
|
|
403
|
+
* retain=true tells the broker to keep this as the topic's last known value and
|
|
404
|
+
* hand it to every FUTURE subscriber. Publishing an EMPTY payload with
|
|
405
|
+
* retain=true clears a retained value.
|
|
406
|
+
*/
|
|
407
|
+
async publish(topic: string, payload: unknown, qos = 0, retain = false): Promise<number | null> {
|
|
408
|
+
this.refuseUnsupportedQos(qos);
|
|
409
|
+
const payloadBytes = Mqtt.payloadBytes(payload);
|
|
410
|
+
const topicField = Mqtt.mqttString(topic);
|
|
411
|
+
// The packet identifier exists ONLY when QoS > 0.
|
|
412
|
+
const packetId = qos > 0 ? this.nextPacketId() : null;
|
|
413
|
+
|
|
414
|
+
const parts: Buffer[] = [topicField];
|
|
415
|
+
if (packetId !== null) parts.push(Mqtt.uint16(packetId));
|
|
416
|
+
parts.push(payloadBytes);
|
|
417
|
+
await this.writePacket(PUBLISH | (qos << 1) | (retain ? 0x01 : 0x00), Buffer.concat(parts));
|
|
418
|
+
|
|
419
|
+
if (qos === 1) await this.waitForAcknowledgement(PUBACK, packetId, "PUBACK");
|
|
420
|
+
return packetId;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Subscribe to a topic filter ("fleet/+/telemetry", "fleet/#"). Resolves to the
|
|
425
|
+
* QoS the broker GRANTED, which can be lower than requested.
|
|
426
|
+
*
|
|
427
|
+
* A SUBACK carrying 0x80 is a REFUSAL, not a success -- treating any SUBACK as
|
|
428
|
+
* success means sitting on a dead subscription receiving nothing, so it throws.
|
|
429
|
+
*/
|
|
430
|
+
async subscribe(topicFilter: string, qos = 1): Promise<number> {
|
|
431
|
+
this.refuseUnsupportedQos(qos);
|
|
432
|
+
const packetId = this.nextPacketId();
|
|
433
|
+
const body = Buffer.concat([Mqtt.uint16(packetId), Mqtt.mqttString(topicFilter), Buffer.from([qos])]);
|
|
434
|
+
await this.writePacket(SUBSCRIBE, body);
|
|
435
|
+
|
|
436
|
+
const payload = await this.waitForAcknowledgement(SUBACK, packetId, "SUBACK");
|
|
437
|
+
if (payload.length < 3) throw new MqttError(`malformed SUBACK: no return code for '${topicFilter}'`);
|
|
438
|
+
const granted = payload[2];
|
|
439
|
+
if (granted === SUBSCRIPTION_REFUSED) {
|
|
440
|
+
throw new MqttError(
|
|
441
|
+
`broker refused the subscription to '${topicFilter}' (SUBACK return ` +
|
|
442
|
+
"code 0x80) -- check the topic filter and the broker ACLs",
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
return granted;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Read the next application message.
|
|
450
|
+
*
|
|
451
|
+
* ack=true (the default) acknowledges a QoS 1 delivery immediately, which is
|
|
452
|
+
* right for a synchronous read. Pass ack=false when the message must be stored
|
|
453
|
+
* before the broker is allowed to forget it -- an unacknowledged QoS 1 message
|
|
454
|
+
* is redelivered with DUP set. consume() does exactly that.
|
|
455
|
+
*/
|
|
456
|
+
async receive(timeout?: number | null, ack = true): Promise<MqttMessage> {
|
|
457
|
+
const message = this.inbox.shift() ?? (await this.readPublish(this.deadlineIn(timeout ?? this.readTimeout)));
|
|
458
|
+
if (ack) await message.acknowledge();
|
|
459
|
+
return message;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Long-running consumer, mirroring Queue.consume().
|
|
464
|
+
*
|
|
465
|
+
* for await (const message of mqtt.consume("fleet/+/telemetry", 1)) {
|
|
466
|
+
* store(message);
|
|
467
|
+
* }
|
|
468
|
+
*
|
|
469
|
+
* The message is acknowledged AFTER the loop body hands control back to the
|
|
470
|
+
* generator (the next iteration), so a body that throws leaves the message
|
|
471
|
+
* unacknowledged and the broker redelivers it with DUP set -- at-least-once,
|
|
472
|
+
* the point of QoS 1. iterations > 0 stops after that many messages.
|
|
473
|
+
*/
|
|
474
|
+
async *consume(
|
|
475
|
+
topicFilter?: string | null,
|
|
476
|
+
qos = 1,
|
|
477
|
+
iterations = 0,
|
|
478
|
+
timeout?: number | null,
|
|
479
|
+
): AsyncGenerator<MqttMessage> {
|
|
480
|
+
if (topicFilter !== undefined && topicFilter !== null) await this.subscribe(topicFilter, qos);
|
|
481
|
+
let consumed = 0;
|
|
482
|
+
while (true) {
|
|
483
|
+
const message = await this.receive(timeout, false);
|
|
484
|
+
yield message;
|
|
485
|
+
await message.acknowledge();
|
|
486
|
+
consumed++;
|
|
487
|
+
if (iterations > 0 && consumed >= iterations) break;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** PUBACK a QoS 1 delivery. Called by MqttMessage.acknowledge(). */
|
|
492
|
+
async acknowledge(packetId: number): Promise<boolean> {
|
|
493
|
+
await this.writePacket(PUBACK, Mqtt.uint16(packetId));
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// -- keepalive ----------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* PINGREQ and wait for the PINGRESP. Use this when nothing else is reading the
|
|
501
|
+
* socket; under a consume loop use startKeepalive() instead.
|
|
502
|
+
*/
|
|
503
|
+
async ping(timeout?: number | null): Promise<boolean> {
|
|
504
|
+
await this.sendKeepalive();
|
|
505
|
+
const deadline = this.deadlineIn(timeout ?? this.timeout);
|
|
506
|
+
while (true) {
|
|
507
|
+
const [header, payload] = await this.readPacket(deadline);
|
|
508
|
+
if (header === PINGRESP) return true;
|
|
509
|
+
if (this.stashPublish(header, payload)) continue;
|
|
510
|
+
throw new MqttError(`expected PINGRESP, got 0x${header.toString(16).padStart(2, "0")}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Write a PINGREQ without waiting for the answer. The PINGRESP is absorbed by
|
|
516
|
+
* whatever is reading the socket (receive() skips it).
|
|
517
|
+
*/
|
|
518
|
+
async sendKeepalive(): Promise<boolean> {
|
|
519
|
+
await this.writePacket(PINGREQ, Buffer.alloc(0));
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Opt in to the cooperative keepalive. Registers a background() task -- the
|
|
525
|
+
* same mechanism the queue consumers use -- that sends a PINGREQ only when the
|
|
526
|
+
* connection has gone quiet, so an actively publishing client costs no extra
|
|
527
|
+
* packets.
|
|
528
|
+
*/
|
|
529
|
+
startKeepalive(intervalSeconds?: number): { stop: () => void } {
|
|
530
|
+
if (this.keepaliveTask !== null) return this.keepaliveTask;
|
|
531
|
+
if (this.keepalive <= 0) throw new MqttError("keepalive is disabled (keepalive=0) -- nothing to schedule");
|
|
532
|
+
const seconds = intervalSeconds ?? Math.max(this.keepalive / 2, 1);
|
|
533
|
+
this.keepaliveTask = background(async () => {
|
|
534
|
+
if (this.connected() && this.idleFor(seconds)) await this.sendKeepalive();
|
|
535
|
+
}, seconds);
|
|
536
|
+
return this.keepaliveTask;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Stop the cooperative keepalive registered by startKeepalive(). */
|
|
540
|
+
stopKeepalive(): boolean {
|
|
541
|
+
if (this.keepaliveTask === null) return false;
|
|
542
|
+
this.keepaliveTask.stop();
|
|
543
|
+
this.keepaliveTask = null;
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Say goodbye properly: DISCONNECT then close. The broker discards the Last
|
|
549
|
+
* Will on a graceful disconnect.
|
|
550
|
+
*/
|
|
551
|
+
async disconnect(): Promise<boolean> {
|
|
552
|
+
this.stopKeepalive();
|
|
553
|
+
try {
|
|
554
|
+
if (this.connected()) await this.writePacket(DISCONNECT, Buffer.alloc(0));
|
|
555
|
+
} catch {
|
|
556
|
+
// Already gone -- closing is still the right outcome.
|
|
557
|
+
} finally {
|
|
558
|
+
this.closeSocket();
|
|
559
|
+
}
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Drop the socket WITHOUT a DISCONNECT -- what a crashed or unplugged device
|
|
565
|
+
* looks like to the broker, and therefore what fires the Last Will.
|
|
566
|
+
*/
|
|
567
|
+
kill(): boolean {
|
|
568
|
+
this.stopKeepalive();
|
|
569
|
+
this.closeSocket();
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// -- internals ----------------------------------------------------------
|
|
574
|
+
|
|
575
|
+
private connectFlags(): number {
|
|
576
|
+
let flags = this.cleanSession ? 0x02 : 0x00;
|
|
577
|
+
if (this.willTopic !== null) {
|
|
578
|
+
// Will flag 0x04, will QoS at bits 3-4, will retain 0x20.
|
|
579
|
+
flags |= 0x04 | (this.willQos << 3);
|
|
580
|
+
if (this.willRetain) flags |= 0x20;
|
|
581
|
+
}
|
|
582
|
+
if (this.username !== null) flags |= 0x80;
|
|
583
|
+
if (this.password !== null) flags |= 0x40;
|
|
584
|
+
return flags;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Open a connected socket, upgrading to TLS when mqtts://. Rejects with an
|
|
589
|
+
* MqttError on a connect timeout or a TLS verification failure (the cert error
|
|
590
|
+
* message is preserved so a rejected cert reports WHY). Each connection builds
|
|
591
|
+
* its OWN tls options object, so a CA supplied for one client never leaks into
|
|
592
|
+
* a later client.
|
|
593
|
+
*/
|
|
594
|
+
private openSocket(): Promise<net.Socket | tls.TLSSocket> {
|
|
595
|
+
return new Promise((resolve, reject) => {
|
|
596
|
+
let settled = false;
|
|
597
|
+
const settle = (fn: () => void) => {
|
|
598
|
+
if (settled) return;
|
|
599
|
+
settled = true;
|
|
600
|
+
clearTimeout(timer);
|
|
601
|
+
fn();
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
const timer = setTimeout(() => {
|
|
605
|
+
settle(() => {
|
|
606
|
+
try {
|
|
607
|
+
sock.destroy();
|
|
608
|
+
} catch {
|
|
609
|
+
/* ignore */
|
|
610
|
+
}
|
|
611
|
+
reject(new MqttError(`could not connect to MQTT broker at ${this.host}:${this.port}: timed out`));
|
|
612
|
+
});
|
|
613
|
+
}, this.timeout * 1000);
|
|
614
|
+
|
|
615
|
+
let sock: net.Socket | tls.TLSSocket;
|
|
616
|
+
if (this.secure) {
|
|
617
|
+
const opts: tls.ConnectionOptions = {
|
|
618
|
+
host: this.host,
|
|
619
|
+
port: this.port,
|
|
620
|
+
servername: this.host,
|
|
621
|
+
rejectUnauthorized: this.tlsVerify,
|
|
622
|
+
};
|
|
623
|
+
if (this.tlsVerify && this.caFile) opts.ca = readFileSync(this.caFile);
|
|
624
|
+
sock = tls.connect(opts, () => settle(() => resolve(sock)));
|
|
625
|
+
} else {
|
|
626
|
+
sock = net.createConnection({ host: this.host, port: this.port }, () => settle(() => resolve(sock)));
|
|
627
|
+
}
|
|
628
|
+
sock.once("error", (err: Error) => {
|
|
629
|
+
settle(() => {
|
|
630
|
+
const label = this.secure ? `MQTT TLS handshake with ${this.host}:${this.port} failed` : `could not connect to MQTT broker at ${this.host}:${this.port}`;
|
|
631
|
+
reject(new MqttError(`${label}: ${err.message}`));
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private refuseUnsupportedQos(qos: number): void {
|
|
638
|
+
if (qos === 2) throw new Error(QOS2_REFUSED_MESSAGE);
|
|
639
|
+
if (qos !== 0 && qos !== 1) throw new Error(`qos must be 0 or 1 (got ${qos})`);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Encode an MQTT string: a 2-byte big-endian length followed by UTF-8 bytes. */
|
|
643
|
+
private static mqttString(value: string): Buffer {
|
|
644
|
+
const bytes = Buffer.from(value, "utf-8");
|
|
645
|
+
if (bytes.length > 0xffff) throw new Error("MQTT string is longer than 65535 bytes");
|
|
646
|
+
return Buffer.concat([Mqtt.uint16(bytes.length), bytes]);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private static uint16(value: number): Buffer {
|
|
650
|
+
const b = Buffer.alloc(2);
|
|
651
|
+
b.writeUInt16BE(value & 0xffff, 0);
|
|
652
|
+
return b;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private static payloadBytes(payload: unknown): Buffer {
|
|
656
|
+
if (payload === null || payload === undefined) return Buffer.alloc(0);
|
|
657
|
+
if (Buffer.isBuffer(payload)) return payload;
|
|
658
|
+
if (typeof payload === "string") return Buffer.from(payload, "utf-8");
|
|
659
|
+
return Buffer.from(String(payload), "utf-8");
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** The next packet identifier (1..65535; 0 is invalid). */
|
|
663
|
+
private nextPacketId(): number {
|
|
664
|
+
this.packetId = (this.packetId % 0xffff) + 1;
|
|
665
|
+
return this.packetId;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
private writePacket(header: number, body: Buffer): Promise<void> {
|
|
669
|
+
if (this.socket === null) return Promise.reject(new MqttError("not connected to an MQTT broker"));
|
|
670
|
+
const packet = Buffer.concat([Buffer.from([header]), Mqtt.encodeRemainingLength(body.length), body]);
|
|
671
|
+
return new Promise((resolve, reject) => {
|
|
672
|
+
this.socket!.write(packet, (err) => {
|
|
673
|
+
if (err) {
|
|
674
|
+
reject(new MqttError(`MQTT write failed: ${err.message}`));
|
|
675
|
+
} else {
|
|
676
|
+
this.lastWriteAt = Date.now();
|
|
677
|
+
resolve();
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Read one control packet. The fixed header is read in exactly 1 + N bytes
|
|
685
|
+
* (N <= 4 for the varint) so the next packet's header is never consumed by a
|
|
686
|
+
* speculative over-read.
|
|
687
|
+
*/
|
|
688
|
+
private async readPacket(deadline: number | null): Promise<[number, Buffer]> {
|
|
689
|
+
const header = (await this.readExact(1, deadline))[0];
|
|
690
|
+
let multiplier = 1;
|
|
691
|
+
let length = 0;
|
|
692
|
+
while (true) {
|
|
693
|
+
const byte = (await this.readExact(1, deadline))[0];
|
|
694
|
+
length += (byte & 0x7f) * multiplier;
|
|
695
|
+
if ((byte & 0x80) === 0) break;
|
|
696
|
+
multiplier <<= 7;
|
|
697
|
+
if (multiplier > 0x200000) throw new MqttError("malformed Remaining Length (more than 4 varint bytes)");
|
|
698
|
+
}
|
|
699
|
+
return [header, length === 0 ? Buffer.alloc(0) : await this.readExact(length, deadline)];
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Read exactly `need` bytes from the socket buffer, awaiting more data when
|
|
704
|
+
* short. Only one read is ever outstanding (the protocol reads sequentially),
|
|
705
|
+
* so a single waiter slot is enough. The 'data' handler feeds the buffer and
|
|
706
|
+
* services the waiter; a deadline arms a timer that rejects with a timeout.
|
|
707
|
+
*/
|
|
708
|
+
private readExact(need: number, deadline: number | null): Promise<Buffer> {
|
|
709
|
+
if (this.readBuffer.length >= need) return Promise.resolve(this.take(need));
|
|
710
|
+
if (this.socket === null) return Promise.reject(this.socketError ?? new MqttError("not connected to an MQTT broker"));
|
|
711
|
+
if (this.socketError !== null) return Promise.reject(this.socketError);
|
|
712
|
+
|
|
713
|
+
return new Promise<Buffer>((resolve, reject) => {
|
|
714
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
715
|
+
if (deadline !== null) {
|
|
716
|
+
const remaining = deadline - Date.now();
|
|
717
|
+
if (remaining <= 0) {
|
|
718
|
+
reject(new MqttTimeoutError("timed out waiting for the MQTT broker"));
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
timer = setTimeout(() => {
|
|
722
|
+
if (this.waiter) {
|
|
723
|
+
this.waiter = null;
|
|
724
|
+
reject(new MqttTimeoutError("timed out waiting for the MQTT broker"));
|
|
725
|
+
}
|
|
726
|
+
}, remaining);
|
|
727
|
+
}
|
|
728
|
+
this.waiter = { need, resolve, reject, timer };
|
|
729
|
+
this.serviceWaiter();
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
private take(need: number): Buffer {
|
|
734
|
+
const out = this.readBuffer.subarray(0, need);
|
|
735
|
+
this.readBuffer = this.readBuffer.subarray(need);
|
|
736
|
+
return Buffer.from(out);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
private serviceWaiter(): void {
|
|
740
|
+
const w = this.waiter;
|
|
741
|
+
if (w === null || this.readBuffer.length < w.need) return;
|
|
742
|
+
this.waiter = null;
|
|
743
|
+
if (w.timer) clearTimeout(w.timer);
|
|
744
|
+
w.resolve(this.take(w.need));
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
private onData(chunk: Buffer): void {
|
|
748
|
+
this.readBuffer = this.readBuffer.length === 0 ? chunk : Buffer.concat([this.readBuffer, chunk]);
|
|
749
|
+
this.serviceWaiter();
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private onSocketGone(err: Error): void {
|
|
753
|
+
if (this.socket === null) return; // already closed by us
|
|
754
|
+
this.socketError = err;
|
|
755
|
+
this.socket = null;
|
|
756
|
+
const w = this.waiter;
|
|
757
|
+
if (w !== null) {
|
|
758
|
+
this.waiter = null;
|
|
759
|
+
if (w.timer) clearTimeout(w.timer);
|
|
760
|
+
w.reject(err);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** Read packets until the next PUBLISH, skipping keepalive PINGRESPs. */
|
|
765
|
+
private async readPublish(deadline: number | null): Promise<MqttMessage> {
|
|
766
|
+
while (true) {
|
|
767
|
+
const [header, payload] = await this.readPacket(deadline);
|
|
768
|
+
if (header === PINGRESP) continue;
|
|
769
|
+
const message = this.parsePublish(header, payload);
|
|
770
|
+
if (message !== null) return message;
|
|
771
|
+
throw new MqttError(`expected PUBLISH, got 0x${header.toString(16).padStart(2, "0")}`);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Park a PUBLISH that arrives while we wait for a PUBACK/SUBACK/PINGRESP
|
|
777
|
+
* (normal when the same connection both publishes and subscribes) so receive()
|
|
778
|
+
* still delivers it, in order, instead of it being mistaken for the ack.
|
|
779
|
+
*/
|
|
780
|
+
private stashPublish(header: number, payload: Buffer): boolean {
|
|
781
|
+
const message = this.parsePublish(header, payload);
|
|
782
|
+
if (message === null) return false;
|
|
783
|
+
this.inbox.push(message);
|
|
784
|
+
return true;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
private parsePublish(header: number, payload: Buffer): MqttMessage | null {
|
|
788
|
+
if ((header & 0xf0) !== PUBLISH) return null;
|
|
789
|
+
const qos = (header & 0x06) >> 1;
|
|
790
|
+
const topicLength = payload.readUInt16BE(0);
|
|
791
|
+
let offset = 2 + topicLength;
|
|
792
|
+
const topic = payload.toString("utf-8", 2, 2 + topicLength);
|
|
793
|
+
let packetId: number | null = null;
|
|
794
|
+
if (qos > 0) {
|
|
795
|
+
packetId = payload.readUInt16BE(offset);
|
|
796
|
+
offset += 2;
|
|
797
|
+
}
|
|
798
|
+
return new MqttMessage(
|
|
799
|
+
topic,
|
|
800
|
+
Buffer.from(payload.subarray(offset)),
|
|
801
|
+
qos,
|
|
802
|
+
(header & 0x01) === 0x01,
|
|
803
|
+
(header & 0x08) === 0x08,
|
|
804
|
+
packetId,
|
|
805
|
+
this,
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Wait for a specific acknowledgement, tolerating interleaved PUBLISH and
|
|
811
|
+
* PINGRESP packets. A mismatched packet identifier is silent data loss if
|
|
812
|
+
* ignored, so it throws.
|
|
813
|
+
*/
|
|
814
|
+
private async waitForAcknowledgement(expectedHeader: number, packetId: number | null, name: string): Promise<Buffer> {
|
|
815
|
+
const deadline = this.deadlineIn(this.timeout);
|
|
816
|
+
while (true) {
|
|
817
|
+
const [header, payload] = await this.readPacket(deadline);
|
|
818
|
+
if (header === PINGRESP) continue;
|
|
819
|
+
if (this.stashPublish(header, payload)) continue;
|
|
820
|
+
if (header !== expectedHeader) {
|
|
821
|
+
throw new MqttError(`expected ${name}, got 0x${header.toString(16).padStart(2, "0")}`);
|
|
822
|
+
}
|
|
823
|
+
const receivedId = payload.readUInt16BE(0);
|
|
824
|
+
if (receivedId !== packetId) {
|
|
825
|
+
throw new MqttError(
|
|
826
|
+
`${name} packet identifier mismatch: broker acknowledged ${receivedId} but we sent ${packetId}`,
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
return payload;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
private idleFor(seconds: number): boolean {
|
|
834
|
+
return (Date.now() - this.lastWriteAt) / 1000 >= seconds;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
private deadlineIn(seconds: number | null | undefined): number | null {
|
|
838
|
+
return seconds !== null && seconds !== undefined ? Date.now() + seconds * 1000 : null;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
private closeSocket(): void {
|
|
842
|
+
const sock = this.socket;
|
|
843
|
+
this.socket = null;
|
|
844
|
+
if (sock !== null) {
|
|
845
|
+
sock.removeAllListeners();
|
|
846
|
+
try {
|
|
847
|
+
sock.destroy();
|
|
848
|
+
} catch {
|
|
849
|
+
/* ignore */
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
const w = this.waiter;
|
|
853
|
+
if (w !== null) {
|
|
854
|
+
this.waiter = null;
|
|
855
|
+
if (w.timer) clearTimeout(w.timer);
|
|
856
|
+
w.reject(new MqttError("connection closed"));
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|