@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/README.md +41 -19
- package/dist/index.d.ts +111 -51
- package/dist/index.js +324 -299
- package/dist/transport-core/core.d.ts +354 -0
- package/dist/transport-core/core.js +1077 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -27,18 +27,34 @@
|
|
|
27
27
|
*
|
|
28
28
|
* const broker = aedes();
|
|
29
29
|
* billing.wrapAedesBroker(broker, {
|
|
30
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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',
|
|
177
|
+
broker.on('subscribe', (subs, client) => {
|
|
118
178
|
const clientId = client?.id;
|
|
119
179
|
if (!clientId)
|
|
120
180
|
return;
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
|
129
|
-
|
|
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: '
|
|
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('
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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.
|
|
262
|
+
mqttTopic: (0, exports.mqttDisconnectTopic)(this.clientIdLabel(clientId)),
|
|
151
263
|
mqttQos: 0,
|
|
152
264
|
mqttRetained: false,
|
|
153
|
-
mqttEventType: '
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
return;
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
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.
|
|
515
|
+
return this.core.droppedCount;
|
|
416
516
|
}
|
|
417
|
-
/**
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
this.
|
|
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
|
-
*
|
|
429
|
-
*
|
|
430
|
-
* (
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
this.
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
|
|
461
|
-
|
|
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
|
-
}
|