@aforoai/mqtt-metering 1.2.2 → 2.0.0

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/dist/index.js CHANGED
@@ -27,18 +27,34 @@
27
27
  *
28
28
  * const broker = aedes();
29
29
  * billing.wrapAedesBroker(broker, {
30
- * resolveCustomerId: (clientId) => customerIdLookup(clientId),
30
+ * // Who is billed: map the client your broker authenticated to an Aforo customer.
31
+ * customerResolver: (clientId, username) => deviceRegistry.customerFor(clientId),
31
32
  * });
33
+ *
34
+ * // The SDK installs no signal handlers. Flush from your own:
35
+ * process.once('SIGTERM', async () => { await billing.shutdown(); process.exit(0); });
32
36
  */
33
37
  Object.defineProperty(exports, "__esModule", { value: true });
34
- exports.AforoMqttBilling = exports.mqttDisconnectTopic = exports.mqttConnectTopic = exports.DEFAULT_PRODUCT_TYPE = void 0;
38
+ exports.AforoMqttBilling = exports.mqttDisconnectTopic = exports.mqttConnectTopic = exports.DEFAULT_PRODUCT_TYPE = exports.DEFAULT_RECONNECT_DEBOUNCE_MS = exports.CoreTransportError = void 0;
35
39
  exports.truncateToLimit = truncateToLimit;
36
40
  const node_crypto_1 = require("node:crypto");
37
- const SDK_VERSION = '1.2.2';
41
+ const core_1 = require("./transport-core/core");
42
+ /** What onError receives for delivery problems: branch on `kind`, read `events`. */
43
+ var core_2 = require("./transport-core/core");
44
+ Object.defineProperty(exports, "CoreTransportError", { enumerable: true, get: function () { return core_2.CoreTransportError; } });
45
+ /** Default reconnect window, the same as python-mqtt's `reconnect_debounce_sec`. */
46
+ exports.DEFAULT_RECONNECT_DEBOUNCE_MS = 30_000;
47
+ function checkDebounce(value, fallback, where) {
48
+ if (value === undefined)
49
+ return fallback;
50
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
51
+ throw new Error(`[aforo-mqtt] ${where}: reconnectDebounceMs must be a non-negative number of milliseconds (got ${String(value)})`);
52
+ }
53
+ return value;
54
+ }
55
+ const SDK_VERSION = '2.0.0';
38
56
  /** Default top-level `productType` for this SDK. */
39
57
  exports.DEFAULT_PRODUCT_TYPE = 'MQTT_BROKER';
40
- /** Upper bound on a server-requested Retry-After wait. */
41
- const MAX_RETRY_AFTER_MS = 30_000;
42
58
  /**
43
59
  * mqttTopic is required on every MQTT_BROKER event, but CONNECT / DISCONNECT
44
60
  * have no topic. These per-client `$SYS/clients/<id>/connected|disconnected`
@@ -50,8 +66,6 @@ const mqttConnectTopic = (clientId) => `$SYS/clients/${clientId || 'unknown'}/co
50
66
  exports.mqttConnectTopic = mqttConnectTopic;
51
67
  const mqttDisconnectTopic = (clientId) => `$SYS/clients/${clientId || 'unknown'}/disconnected`;
52
68
  exports.mqttDisconnectTopic = mqttDisconnectTopic;
53
- /** The ingestor rejects batch requests with more than 1000 events. */
54
- const MAX_BATCH_EVENTS = 1000;
55
69
  /** usage-ingestor limits (IngestUsageEventRequest). */
56
70
  const MAX_CUSTOMER_ID = 64;
57
71
  const MAX_IDEMPOTENCY_KEY = 255;
@@ -61,144 +75,238 @@ const MAX_MQTT_CLIENT_ID = 128;
61
75
  class AforoMqttBilling {
62
76
  config;
63
77
  productType;
64
- flushCount;
65
- flushIntervalMs;
66
78
  emitDeliverEvents;
67
79
  onError;
68
- onDrop;
69
- buffer = [];
70
- flushTimer = null;
71
- dropped = 0;
72
- /** Invalid-event count per field, for WARN throttling. */
73
- invalidSeen = new Map();
80
+ core;
74
81
  /** Request-derived labels already reported as truncated (one WARN per label). */
75
82
  truncationWarned = new Set();
83
+ reconnectDebounceMs;
84
+ /** DISCONNECTs inside their reconnect window; billed at shutdown at the latest. */
85
+ pendingDisconnects = new Set();
76
86
  constructor(config) {
77
87
  this.config = {
78
88
  tenantId: config.tenantId,
79
89
  productId: config.productId,
80
- apiKey: config.apiKey,
81
- ingestorUrl: config.ingestorUrl,
82
90
  };
83
91
  this.productType = normalizeProductType(config.productType) ?? exports.DEFAULT_PRODUCT_TYPE;
84
- this.flushCount = config.flushCount ?? 200;
85
- this.flushIntervalMs = config.flushIntervalMs ?? 2000;
86
92
  this.emitDeliverEvents = config.emitDeliverEvents ?? false;
93
+ this.reconnectDebounceMs = checkDebounce(config.reconnectDebounceMs, exports.DEFAULT_RECONNECT_DEBOUNCE_MS, 'AforoMqttBilling');
87
94
  this.onError = config.onError ?? ((err) => console.error('[aforo-mqtt]', err.message));
88
- this.onDrop = config.onDrop;
89
- this.startTimer();
95
+ const onDrop = config.onDrop;
96
+ // The core is the only holder of the API key.
97
+ this.core = new core_1.TransportCore({
98
+ baseUrl: config.ingestorUrl,
99
+ apiKey: config.apiKey,
100
+ allowInsecureHttp: config.allowInsecureHttp,
101
+ headers: { 'X-Tenant-Id': config.tenantId },
102
+ userAgent: `aforo-mqtt-metering/${SDK_VERSION}`,
103
+ flushCount: config.flushCount,
104
+ flushIntervalMs: config.flushIntervalMs,
105
+ maxQueueSize: config.maxQueueSize,
106
+ maxRetries: config.maxRetries,
107
+ timeoutMs: config.timeoutMs,
108
+ shutdownTimeoutMs: config.shutdownTimeoutMs,
109
+ flushOnExit: config.flushOnExit,
110
+ logPrefix: '[aforo-mqtt]',
111
+ onError: (err) => this.reportError(err),
112
+ onDrop: onDrop ? (events, reason) => onDrop(events, reason) : undefined,
113
+ });
90
114
  }
91
115
  // ── Broker-side integration (Aedes) ─────────────────────────────
116
+ /**
117
+ * Attach to an Aedes broker. Throws when neither `customerResolver` nor
118
+ * `trustCustomerHeader: true` is given. Every listener catches its own
119
+ * errors (a resolver rejection is reported through onError), so metering can
120
+ * never produce an unhandled rejection in the broker process.
121
+ */
92
122
  wrapAedesBroker(broker, options) {
93
- const resolveCustomer = async (clientId, username) => Promise.resolve(options.resolveCustomerId(clientId, username));
123
+ const resolver = options?.customerResolver ?? options?.resolveCustomerId;
124
+ const trustUsername = options?.trustCustomerHeader === true;
125
+ if (typeof resolver !== 'function' && !trustUsername) {
126
+ throw new Error('[aforo-mqtt] wrapAedesBroker needs a customerResolver: (clientId, username) => the Aforo customer id ' +
127
+ 'for the client your broker authenticated. To bill the MQTT username instead — only when your broker ' +
128
+ 'authenticates it — pass trustCustomerHeader: true.');
129
+ }
130
+ const resolveCustomer = async (clientId, username) => {
131
+ const fromResolver = typeof resolver === 'function' ? textOrUndefined(await resolver(clientId, username)) : undefined;
132
+ if (fromResolver)
133
+ return fromResolver;
134
+ return trustUsername ? textOrUndefined(username) : undefined;
135
+ };
136
+ const debounceMs = checkDebounce(options.reconnectDebounceMs, this.reconnectDebounceMs, 'wrapAedesBroker');
137
+ /** Per client id: live connections and a DISCONNECT inside its reconnect window. */
138
+ const sessions = new Map();
94
139
  const status = options.executionStatus;
95
140
  const brokerStatus = (clientId) => typeof status === 'function' ? (event) => status(event, clientId) : status;
96
- broker.on('publish', async (packet, client) => {
141
+ /**
142
+ * Run a listener body. The returned promise always resolves: a throw or
143
+ * rejection is reported through onError, never left unhandled.
144
+ */
145
+ const guarded = (hook, body) => body().catch((err) => {
146
+ this.reportError(new Error(`[aforo-mqtt] ${hook} metering failed: ${err?.message ?? err}`));
147
+ });
148
+ /** Resolve the customer; a resolver failure is reported and treated as "no customer". */
149
+ const customerFor = async (hook, clientId, username) => {
150
+ try {
151
+ return (await resolveCustomer(clientId, username)) ?? '';
152
+ }
153
+ catch (err) {
154
+ this.reportError(new Error(`[aforo-mqtt] customerResolver failed on ${hook}: ${err?.message ?? err}`));
155
+ return '';
156
+ }
157
+ };
158
+ broker.on('publish', (packet, client) => {
97
159
  if (!client)
98
160
  return; // broker-originated publishes skip billing
99
- const clientId = client.id;
100
- const customerId = await resolveCustomer(clientId, client.username);
101
- if (!customerId)
102
- return;
103
- const payload = packet.payload;
104
- const bytes = estimateBytes(payload);
105
- this.push({
106
- customerId,
107
- mqttTopic: this.topicLabel(packet.topic),
108
- mqttQos: packet.qos ?? 0,
109
- mqttRetained: !!packet.retain,
110
- mqttEventType: 'PUBLISH',
111
- mqttClientId: this.clientIdLabel(clientId),
112
- productType: options.productType,
113
- dataBytes: bytes,
114
- metadata: options.resolveMetadata?.(clientId),
115
- }, brokerStatus(clientId), clientId);
161
+ return guarded('publish', async () => {
162
+ const clientId = client.id;
163
+ const customerId = await customerFor('publish', clientId, client.username);
164
+ this.push({
165
+ customerId,
166
+ mqttTopic: this.topicLabel(packet?.topic),
167
+ mqttQos: packet?.qos ?? 0,
168
+ mqttRetained: !!packet?.retain,
169
+ mqttEventType: 'PUBLISH',
170
+ mqttClientId: this.clientIdLabel(clientId),
171
+ productType: options.productType,
172
+ dataBytes: estimateBytes(packet?.payload),
173
+ metadata: options.resolveMetadata?.(clientId),
174
+ }, brokerStatus(clientId), clientId);
175
+ });
116
176
  });
117
- broker.on('subscribe', async (subs, client) => {
177
+ broker.on('subscribe', (subs, client) => {
118
178
  const clientId = client?.id;
119
179
  if (!clientId)
120
180
  return;
121
- const customerId = await resolveCustomer(clientId, client.username);
122
- if (!customerId)
181
+ return guarded('subscribe', async () => {
182
+ const customerId = await customerFor('subscribe', clientId, client.username);
183
+ const arr = Array.isArray(subs) ? subs : [subs];
184
+ for (const s of arr) {
185
+ this.push({
186
+ customerId,
187
+ mqttTopic: this.topicLabel(s?.topic),
188
+ mqttQos: s?.qos ?? 0,
189
+ mqttRetained: false,
190
+ mqttEventType: 'SUBSCRIBE',
191
+ mqttClientId: this.clientIdLabel(clientId),
192
+ productType: options.productType,
193
+ dataBytes: 0,
194
+ metadata: options.resolveMetadata?.(clientId),
195
+ }, brokerStatus(clientId), clientId);
196
+ }
197
+ });
198
+ });
199
+ broker.on('unsubscribe', (unsubs, client) => {
200
+ const clientId = client?.id;
201
+ if (!clientId)
123
202
  return;
124
- const arr = Array.isArray(subs) ? subs : [subs];
125
- for (const s of arr) {
203
+ return guarded('unsubscribe', async () => {
204
+ const customerId = await customerFor('unsubscribe', clientId, client.username);
205
+ const arr = Array.isArray(unsubs) ? unsubs : [unsubs];
206
+ for (const topic of arr) {
207
+ this.push({
208
+ customerId,
209
+ mqttTopic: this.topicLabel(typeof topic === 'string' ? topic : topic?.topic ?? ''),
210
+ mqttQos: 0,
211
+ mqttRetained: false,
212
+ mqttEventType: 'UNSUBSCRIBE',
213
+ mqttClientId: this.clientIdLabel(clientId),
214
+ productType: options.productType,
215
+ dataBytes: 0,
216
+ metadata: options.resolveMetadata?.(clientId),
217
+ }, brokerStatus(clientId), clientId);
218
+ }
219
+ });
220
+ });
221
+ broker.on('client', (client) => {
222
+ if (debounceMs > 0) {
223
+ const key = String(client?.id ?? '');
224
+ let session = sessions.get(key);
225
+ if (!session) {
226
+ session = { live: 0, pending: null };
227
+ sessions.set(key, session);
228
+ }
229
+ // Back inside the window, or a takeover of its own live session: same session, no CONNECT.
230
+ const sameSession = session.pending !== null || session.live > 0;
231
+ session.pending?.cancel();
232
+ session.pending = null;
233
+ session.live += 1;
234
+ if (sameSession)
235
+ return;
236
+ }
237
+ return guarded('client', async () => {
238
+ const clientId = client?.id;
239
+ const customerId = await customerFor('client', clientId, client?.username);
126
240
  this.push({
127
241
  customerId,
128
- mqttTopic: this.topicLabel(s.topic),
129
- mqttQos: s.qos ?? 0,
242
+ // CONNECT has no topic; mqttTopic is required. Built from the bounded
243
+ // client id so the stand-in topic keeps its `/connected` suffix.
244
+ mqttTopic: (0, exports.mqttConnectTopic)(this.clientIdLabel(clientId)),
245
+ mqttQos: 0,
130
246
  mqttRetained: false,
131
- mqttEventType: 'SUBSCRIBE',
247
+ mqttEventType: 'CONNECT',
132
248
  mqttClientId: this.clientIdLabel(clientId),
133
249
  productType: options.productType,
134
250
  dataBytes: 0,
135
251
  metadata: options.resolveMetadata?.(clientId),
136
252
  }, brokerStatus(clientId), clientId);
137
- }
253
+ });
138
254
  });
139
- broker.on('unsubscribe', async (unsubs, client) => {
140
- const clientId = client?.id;
141
- if (!clientId)
142
- return;
143
- const customerId = await resolveCustomer(clientId, client.username);
144
- if (!customerId)
145
- return;
146
- const arr = Array.isArray(unsubs) ? unsubs : [unsubs];
147
- for (const topic of arr) {
255
+ broker.on('clientDisconnect', (client) => {
256
+ const at = new Date();
257
+ const emit = () => guarded('clientDisconnect', async () => {
258
+ const clientId = client?.id;
259
+ const customerId = await customerFor('clientDisconnect', clientId, client?.username);
148
260
  this.push({
149
261
  customerId,
150
- mqttTopic: this.topicLabel(typeof topic === 'string' ? topic : topic?.topic ?? ''),
262
+ mqttTopic: (0, exports.mqttDisconnectTopic)(this.clientIdLabel(clientId)),
151
263
  mqttQos: 0,
152
264
  mqttRetained: false,
153
- mqttEventType: 'UNSUBSCRIBE',
265
+ mqttEventType: 'DISCONNECT',
154
266
  mqttClientId: this.clientIdLabel(clientId),
155
267
  productType: options.productType,
156
268
  dataBytes: 0,
157
269
  metadata: options.resolveMetadata?.(clientId),
158
- }, brokerStatus(clientId), clientId);
270
+ }, brokerStatus(clientId), clientId, at);
271
+ });
272
+ if (debounceMs === 0)
273
+ return emit();
274
+ const key = String(client?.id ?? '');
275
+ const session = sessions.get(key) ?? { live: 1, pending: null };
276
+ session.live = Math.max(0, session.live - 1);
277
+ if (session.live > 0)
278
+ return; // a takeover: the session lives on in the new connection
279
+ // aedes sets _disconnected only for a DISCONNECT packet: a clean disconnect is billed at once.
280
+ if (client?._disconnected === true) {
281
+ sessions.delete(key);
282
+ return emit();
159
283
  }
160
- });
161
- broker.on('client', async (client) => {
162
- const clientId = client.id;
163
- const customerId = await resolveCustomer(clientId, client.username);
164
- if (!customerId)
165
- return;
166
- this.push({
167
- customerId,
168
- // CONNECT has no topic; mqttTopic is required. Built from the bounded
169
- // client id so the stand-in topic keeps its `/connected` suffix.
170
- mqttTopic: (0, exports.mqttConnectTopic)(this.clientIdLabel(clientId)),
171
- mqttQos: 0,
172
- mqttRetained: false,
173
- mqttEventType: 'CONNECT',
174
- mqttClientId: this.clientIdLabel(clientId),
175
- productType: options.productType,
176
- dataBytes: 0,
177
- metadata: options.resolveMetadata?.(clientId),
178
- }, brokerStatus(clientId), clientId);
179
- });
180
- broker.on('clientDisconnect', async (client) => {
181
- const clientId = client.id;
182
- const customerId = await resolveCustomer(clientId, client.username);
183
- if (!customerId)
184
- return;
185
- this.push({
186
- customerId,
187
- mqttTopic: (0, exports.mqttDisconnectTopic)(this.clientIdLabel(clientId)),
188
- mqttQos: 0,
189
- mqttRetained: false,
190
- mqttEventType: 'DISCONNECT',
191
- mqttClientId: this.clientIdLabel(clientId),
192
- productType: options.productType,
193
- dataBytes: 0,
194
- metadata: options.resolveMetadata?.(clientId),
195
- }, brokerStatus(clientId), clientId);
284
+ sessions.set(key, session);
285
+ session.pending = this.scheduleDisconnect(debounceMs, () => {
286
+ session.pending = null;
287
+ if (session.live === 0)
288
+ sessions.delete(key);
289
+ return emit();
290
+ });
196
291
  });
197
292
  }
198
293
  // ── Client-side integration (mqtt.js) ───────────────────────────
199
294
  wrapMqttClient(client, options) {
200
295
  const clientId = options.clientId ?? client.options?.clientId ?? 'mqtt-client';
296
+ const debounceMs = checkDebounce(options?.reconnectDebounceMs, this.reconnectDebounceMs, 'wrapMqttClient');
297
+ let connected = false;
298
+ let pendingDisconnect = null;
201
299
  client.on('connect', () => {
300
+ if (pendingDisconnect) {
301
+ // Reconnected inside the window: the same session, nothing new to bill.
302
+ pendingDisconnect.cancel();
303
+ pendingDisconnect = null;
304
+ connected = true;
305
+ return;
306
+ }
307
+ if (connected && debounceMs > 0)
308
+ return;
309
+ connected = true;
202
310
  this.push({
203
311
  customerId: options.customerId,
204
312
  mqttTopic: (0, exports.mqttConnectTopic)(this.clientIdLabel(clientId)),
@@ -210,8 +318,14 @@ class AforoMqttBilling {
210
318
  dataBytes: 0,
211
319
  }, options.executionStatus, clientId);
212
320
  });
321
+ // mqtt.js emits 'close' for every lost connection and for every failed
322
+ // reconnect attempt; only the end of a live connection can be a DISCONNECT.
213
323
  client.on('close', () => {
214
- this.push({
324
+ if (!connected)
325
+ return;
326
+ connected = false;
327
+ const at = new Date();
328
+ const emit = () => this.push({
215
329
  customerId: options.customerId,
216
330
  mqttTopic: (0, exports.mqttDisconnectTopic)(this.clientIdLabel(clientId)),
217
331
  mqttQos: 0,
@@ -220,7 +334,22 @@ class AforoMqttBilling {
220
334
  mqttClientId: this.clientIdLabel(clientId),
221
335
  productType: options.productType,
222
336
  dataBytes: 0,
223
- }, options.executionStatus, clientId);
337
+ }, options.executionStatus, clientId, at);
338
+ if (debounceMs === 0 || client.disconnecting === true) {
339
+ emit(); // the caller ended it: a clean disconnect
340
+ return;
341
+ }
342
+ pendingDisconnect = this.scheduleDisconnect(debounceMs, () => {
343
+ pendingDisconnect = null;
344
+ emit();
345
+ });
346
+ });
347
+ // end() while waiting for a reconnect: the session is over now.
348
+ client.on('end', () => {
349
+ const pending = pendingDisconnect;
350
+ pendingDisconnect = null;
351
+ if (pending)
352
+ void pending.fire();
224
353
  });
225
354
  client.on('message', (topic, payload, packet) => {
226
355
  this.push({
@@ -234,19 +363,38 @@ class AforoMqttBilling {
234
363
  dataBytes: estimateBytes(payload),
235
364
  }, options.executionStatus, clientId);
236
365
  });
366
+ // PUBLISH is billed when mqtt.js reports success: the broker's PUBACK /
367
+ // PUBCOMP for QoS 1 / 2, the socket write for QoS 0. A publish that
368
+ // throws or whose callback gets an error is not billed. publishAsync()
369
+ // goes through publish(), so it is covered too.
237
370
  const origPublish = client.publish.bind(client);
238
371
  client.publish = (topic, message, opts, cb) => {
239
- this.push({
240
- customerId: options.customerId,
241
- mqttTopic: this.topicLabel(topic),
242
- mqttQos: opts?.qos ?? 0,
243
- mqttRetained: !!opts?.retain,
244
- mqttEventType: 'PUBLISH',
245
- mqttClientId: this.clientIdLabel(clientId),
246
- productType: options.productType,
247
- dataBytes: estimateBytes(message),
248
- }, options.executionStatus, clientId);
249
- return origPublish(topic, message, opts, cb);
372
+ const optsIsCallback = typeof opts === 'function';
373
+ const userCallback = optsIsCallback ? opts : cb;
374
+ const publishOpts = optsIsCallback ? undefined : opts;
375
+ const at = new Date();
376
+ const done = (err, ...rest) => {
377
+ if (!err) {
378
+ try {
379
+ this.push({
380
+ customerId: options.customerId,
381
+ mqttTopic: this.topicLabel(topic),
382
+ mqttQos: publishOpts?.qos ?? 0,
383
+ mqttRetained: !!publishOpts?.retain,
384
+ mqttEventType: 'PUBLISH',
385
+ mqttClientId: this.clientIdLabel(clientId),
386
+ productType: options.productType,
387
+ dataBytes: estimateBytes(message),
388
+ }, options.executionStatus, clientId, at);
389
+ }
390
+ catch (meterErr) {
391
+ this.reportError(new Error(`[aforo-mqtt] publish metering failed: ${meterErr?.message ?? meterErr}`));
392
+ }
393
+ }
394
+ if (typeof userCallback === 'function')
395
+ return userCallback(err, ...rest);
396
+ };
397
+ return optsIsCallback ? origPublish(topic, message, done) : origPublish(topic, message, opts, done);
250
398
  };
251
399
  }
252
400
  // ── Event pipeline ──────────────────────────────────────────────
@@ -276,13 +424,12 @@ class AforoMqttBilling {
276
424
  }
277
425
  push(partial, executionStatus,
278
426
  /** Untruncated client id for the idempotency key, when `partial.mqttClientId` was cut to the limit. */
279
- keyClientId) {
427
+ keyClientId,
428
+ /** When it happened, if not now (a debounced DISCONNECT, a confirmed PUBLISH). */
429
+ at) {
280
430
  if (!this.emitDeliverEvents && partial.mqttEventType === 'DELIVER')
281
431
  return;
282
- // No customer resolved — not billable, not a drop.
283
- if (typeof partial.customerId !== 'string' || !partial.customerId.trim())
284
- return;
285
- const now = new Date();
432
+ const now = at ?? new Date();
286
433
  const mqttClientId = typeof partial.mqttClientId === 'string' ? partial.mqttClientId : String(partial.mqttClientId ?? '');
287
434
  const event = {
288
435
  ...partial,
@@ -311,159 +458,85 @@ class AforoMqttBilling {
311
458
  // whole batch): it is dropped here with reason 'invalid', never thrown
312
459
  // into the broker / client handlers. mqttTopic is required on every
313
460
  // MQTT_BROKER event.
314
- const invalid = tooLong('customerId', event.customerId, MAX_CUSTOMER_ID) ??
461
+ const invalid = (typeof event.customerId !== 'string' || !event.customerId.trim()
462
+ ? { field: 'customerId', problem: 'no customer: the customer resolver returned nothing (or customerId is blank); the event is not metered' }
463
+ : undefined) ??
464
+ tooLong('customerId', event.customerId, MAX_CUSTOMER_ID) ??
315
465
  requiredText('mqttTopic', event.mqttTopic) ??
316
466
  tooLong('mqttTopic', event.mqttTopic, MAX_MQTT_TOPIC) ??
317
467
  tooLong('mqttClientId', event.mqttClientId, MAX_MQTT_CLIENT_ID) ??
318
468
  tooLong('productType', event.productType, MAX_PRODUCT_TYPE);
319
469
  if (invalid) {
320
- this.recordInvalid(event, invalid);
321
- return;
322
- }
323
- this.buffer.push(event);
324
- if (this.buffer.length >= this.flushCount) {
325
- void this.flush();
326
- }
327
- }
328
- async flush() {
329
- if (this.buffer.length === 0)
470
+ this.core.reportDrop([event], 'invalid', invalid.problem);
330
471
  return;
331
- const pending = this.buffer.splice(0, this.buffer.length);
332
- // The ingestor accepts at most MAX_BATCH_EVENTS events per request.
333
- for (let i = 0; i < pending.length; i += MAX_BATCH_EVENTS) {
334
- await this.send(pending.slice(i, i + MAX_BATCH_EVENTS));
335
472
  }
473
+ // Queue only; the core's single background sender does the HTTP.
474
+ this.core.enqueue(event);
336
475
  }
337
- async send(batch) {
338
- // Serialized once, so every retry re-sends the same idempotencyKeys.
339
- const body = JSON.stringify({ events: batch });
340
- const maxRetries = 3;
341
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
342
- let delayMs = Math.pow(2, attempt - 1) * 1000; // 1s, 2s, 4s
343
- let res;
344
- let networkError;
345
- try {
346
- res = await fetch(this.config.ingestorUrl.replace(/\/$/, '') + '/v1/ingest/batch', {
347
- method: 'POST',
348
- headers: {
349
- 'Content-Type': 'application/json',
350
- 'X-API-Key': this.config.apiKey,
351
- 'X-Tenant-Id': this.config.tenantId,
352
- },
353
- body,
354
- });
355
- }
356
- catch (err) {
357
- networkError = err;
358
- }
359
- if (res) {
360
- if (res.ok) {
361
- await this.handlePartialFailures(res, batch);
362
- return;
363
- }
364
- if (!isRetryableStatus(res.status)) {
365
- // 4xx other than 408/429: the same body would be refused again.
366
- const { details } = await readBatchResult(res, batch.length);
367
- this.recordDrop(batch, 'rejected');
368
- this.reportError(new Error(`MQTT metering batch rejected with HTTP ${res.status}${details ? ` — ${details}` : ''} (dropped ${batch.length} events, not retried)`));
369
- return;
370
- }
371
- delayMs = parseRetryAfter(res) ?? delayMs;
372
- }
373
- else if (attempt === maxRetries) {
374
- this.recordDrop(batch, 'retry_exhausted');
375
- this.reportError(networkError ?? new Error('MQTT metering request failed'));
376
- return;
377
- }
378
- if (attempt < maxRetries)
379
- await sleep(delayMs);
380
- }
381
- // Not re-queued: dropping avoids unbounded memory growth.
382
- this.recordDrop(batch, 'retry_exhausted');
383
- this.reportError(new Error(`MQTT metering flush failed after ${maxRetries} attempts (dropped ${batch.length} events)`));
384
- }
385
- /**
386
- * A 2xx can still carry per-event rejections:
387
- * `{accepted, duplicates, failed, errors:[{index, message}]}`. Events the
388
- * response names by index are dropped with reason 'rejected'; a `failed`
389
- * count the response does not attribute to an index is counted only.
390
- */
391
- async handlePartialFailures(res, batch) {
392
- const { failed, details, indexes } = await readBatchResult(res, batch.length);
393
- if (failed <= 0)
394
- return;
395
- if (indexes.length > 0)
396
- this.recordDrop(indexes.map((i) => batch[i]), 'rejected');
397
- const unattributed = Math.min(failed, batch.length) - indexes.length;
398
- if (unattributed > 0) {
399
- this.dropped += unattributed;
400
- console.warn(`[aforo-mqtt] Dropped ${unattributed} event(s) — rejected, not identified by the ingestor (${this.dropped} total dropped).`);
401
- }
402
- this.reportError(new Error(`Aforo ingestor rejected ${failed} event(s)${details ? ` — ${details}` : ''}`));
476
+ /** Hold a DISCONNECT for `ms`; it is billed when the window passes unless cancelled. */
477
+ scheduleDisconnect(ms, emit) {
478
+ let done = false;
479
+ let timer;
480
+ const pending = {
481
+ fire: () => {
482
+ if (done)
483
+ return Promise.resolve();
484
+ done = true;
485
+ if (timer)
486
+ clearTimeout(timer);
487
+ this.pendingDisconnects.delete(pending);
488
+ return Promise.resolve()
489
+ .then(emit)
490
+ .catch((err) => this.reportError(new Error(`[aforo-mqtt] DISCONNECT metering failed: ${err?.message ?? err}`)));
491
+ },
492
+ cancel: () => {
493
+ done = true;
494
+ if (timer)
495
+ clearTimeout(timer);
496
+ this.pendingDisconnects.delete(pending);
497
+ },
498
+ };
499
+ timer = setTimeout(() => void pending.fire(), ms);
500
+ timer.unref?.();
501
+ this.pendingDisconnects.add(pending);
502
+ return pending;
403
503
  }
404
504
  /** Invoke onError; a hook bug must never break metering or flushing. */
405
505
  reportError(err) {
406
506
  try {
407
507
  this.onError(err);
408
508
  }
409
- catch {
410
- // ignore
509
+ catch (hookErr) {
510
+ console.debug('[aforo-mqtt] onError hook threw', hookErr);
411
511
  }
412
512
  }
413
- /** Number of events permanently dropped since this instance was created. */
513
+ /** Number of events permanently dropped since this instance was created (every DropReason). */
414
514
  get droppedCount() {
415
- return this.dropped;
515
+ return this.core.droppedCount;
416
516
  }
417
- /**
418
- * Account for permanently lost events: bump the counter, WARN-log, and
419
- * invoke the opt-in onDrop hook. The buffer is drained at flush start, so
420
- * drops here are bounded by flush cadence — no log throttle needed.
421
- */
422
- recordDrop(events, reason) {
423
- this.dropped += events.length;
424
- console.warn(`[aforo-mqtt] Dropped ${events.length} event(s) — ${reason} (${this.dropped} total dropped).`);
425
- this.notifyDrop(events, reason);
517
+ /** Number of events waiting to be sent. */
518
+ get queuedCount() {
519
+ return this.core.queued;
520
+ }
521
+ /** Send what is queued now and wait for it (one request at a time; never concurrent). */
522
+ async flush() {
523
+ return this.core.flush();
426
524
  }
427
525
  /**
428
- * Account for an event that failed a client-side check: never buffered,
429
- * never sent. Runs on the hot path, so the WARN is throttled per field
430
- * (first occurrence, then every 1000th).
526
+ * Stop accepting events and send what is queued, for at most
527
+ * `shutdownTimeoutMs`. Events still queued then are dropped with reason
528
+ * 'shutdown' (counted, passed to onDrop). Call it from your SIGTERM handler.
431
529
  */
432
- recordInvalid(event, invalid) {
433
- this.dropped += 1;
434
- const seen = (this.invalidSeen.get(invalid.field) ?? 0) + 1;
435
- this.invalidSeen.set(invalid.field, seen);
436
- if (seen === 1 || seen % 1000 === 0) {
437
- console.warn(`[aforo-mqtt] Dropped 1 event — invalid: ${invalid.problem} ` +
438
- `(${seen} for ${invalid.field}, ${this.dropped} total dropped).`);
439
- }
440
- this.notifyDrop([event], 'invalid');
441
- }
442
- notifyDrop(events, reason) {
443
- if (!this.onDrop)
444
- return;
445
- try {
446
- this.onDrop(events, reason);
447
- }
448
- catch {
449
- // A hook bug must never break metering or flushing.
450
- }
530
+ async shutdown() {
531
+ // A DISCONNECT still inside its reconnect window is billed now, not lost.
532
+ await Promise.all(Array.from(this.pendingDisconnects, (p) => p.fire()));
533
+ await this.core.shutdown();
451
534
  }
452
- startTimer() {
453
- if (this.flushTimer)
454
- return;
455
- this.flushTimer = setInterval(() => { void this.flush(); }, this.flushIntervalMs);
456
- // Unref so the background timer never blocks host-process exit (final flush still needs shutdown()).
457
- if (typeof this.flushTimer.unref === 'function')
458
- this.flushTimer.unref();
535
+ toJSON() {
536
+ return { tenantId: this.config.tenantId, productId: this.config.productId, queued: this.queuedCount, dropped: this.droppedCount };
459
537
  }
460
- /** Flush any buffered events and stop the background timer. Call before process exit. */
461
- async shutdown() {
462
- if (this.flushTimer) {
463
- clearInterval(this.flushTimer);
464
- this.flushTimer = null;
465
- }
466
- await this.flush();
538
+ [Symbol.for('nodejs.util.inspect.custom')]() {
539
+ return `AforoMqttBilling { tenantId: '${this.config.tenantId}', productId: '${this.config.productId}', queued: ${this.queuedCount}, dropped: ${this.droppedCount} }`;
467
540
  }
468
541
  }
469
542
  exports.AforoMqttBilling = AforoMqttBilling;
@@ -546,49 +619,6 @@ function requiredText(field, value) {
546
619
  return undefined;
547
620
  return { field, problem: `${field} is required and must not be blank: ${preview(value)}` };
548
621
  }
549
- /** Network errors, 408, 429 and 5xx are transient; every other 4xx (400/401/403/422...) is not. */
550
- function isRetryableStatus(status) {
551
- return status === 408 || status === 429 || status >= 500;
552
- }
553
- /** Retry-After (delta-seconds or HTTP date) in ms, capped at MAX_RETRY_AFTER_MS. */
554
- function parseRetryAfter(res) {
555
- const raw = res?.headers?.get?.('retry-after');
556
- if (!raw)
557
- return undefined;
558
- const secs = Number(raw);
559
- if (Number.isFinite(secs) && secs >= 0)
560
- return Math.min(secs * 1000, MAX_RETRY_AFTER_MS);
561
- const date = Date.parse(raw);
562
- if (!Number.isNaN(date))
563
- return Math.min(Math.max(date - Date.now(), 0), MAX_RETRY_AFTER_MS);
564
- return undefined;
565
- }
566
- /**
567
- * Reads the ingestor's batch response body, if any: the `failed` count, the
568
- * first 5 `errors[].message` entries, and the batch indexes `errors[]` names.
569
- */
570
- async function readBatchResult(res, batchSize) {
571
- const none = { failed: 0, details: '', indexes: [] };
572
- if (typeof res?.json !== 'function')
573
- return none;
574
- try {
575
- const body = unwrapEnvelope(await res.json());
576
- const errors = Array.isArray(body?.errors) ? body.errors : undefined;
577
- const details = errors
578
- ? errors.slice(0, 5).map((e) => `#${e?.index}: ${e?.message}`).join('; ')
579
- : typeof body?.message === 'string' ? body.message : '';
580
- const indexes = [
581
- ...new Set((errors ?? [])
582
- .map((e) => e?.index)
583
- .filter((i) => Number.isInteger(i) && i >= 0 && i < batchSize)),
584
- ].sort((a, b) => a - b);
585
- const failed = typeof body?.failed === 'number' && body.failed > 0 ? body.failed : 0;
586
- return { failed, details, indexes };
587
- }
588
- catch {
589
- return none; // Non-JSON or empty body — nothing to report.
590
- }
591
- }
592
622
  /**
593
623
  * Cut `value` to at most `max` UTF-16 code units — how the ingestor counts
594
624
  * (`String.length()` in Java) — without leaving half a surrogate pair.
@@ -624,17 +654,12 @@ function boundedIdempotencyKey(head, component, tail) {
624
654
  function sha256Hex(text) {
625
655
  return (0, node_crypto_1.createHash)('sha256').update(text, 'utf8').digest('hex');
626
656
  }
657
+ function textOrUndefined(value) {
658
+ if (value === undefined || value === null)
659
+ return undefined;
660
+ const text = String(value).trim();
661
+ return text ? text : undefined;
662
+ }
627
663
  function randomSuffix() {
628
664
  return Math.random().toString(36).slice(2, 10);
629
665
  }
630
- function sleep(ms) {
631
- return new Promise((r) => setTimeout(r, ms));
632
- }
633
- /**
634
- * The ingestor wraps every 2xx JSON body in `{success, data, meta}`. Returns
635
- * the inner `data` object when present, else the body unchanged (bare shape).
636
- */
637
- function unwrapEnvelope(body) {
638
- const data = body && typeof body === 'object' && !Array.isArray(body) ? body.data : undefined;
639
- return data && typeof data === 'object' && !Array.isArray(data) ? data : body;
640
- }