@aforoai/mqtt-metering 1.0.0 → 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 +71 -13
- package/dist/index.d.ts +187 -16
- package/dist/index.js +488 -225
- 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,16 +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;
|
|
35
|
-
|
|
38
|
+
exports.AforoMqttBilling = exports.mqttDisconnectTopic = exports.mqttConnectTopic = exports.DEFAULT_PRODUCT_TYPE = exports.DEFAULT_RECONNECT_DEBOUNCE_MS = exports.CoreTransportError = void 0;
|
|
39
|
+
exports.truncateToLimit = truncateToLimit;
|
|
40
|
+
const node_crypto_1 = require("node:crypto");
|
|
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';
|
|
36
56
|
/** Default top-level `productType` for this SDK. */
|
|
37
57
|
exports.DEFAULT_PRODUCT_TYPE = 'MQTT_BROKER';
|
|
38
|
-
/** Upper bound on a server-requested Retry-After wait. */
|
|
39
|
-
const MAX_RETRY_AFTER_MS = 30_000;
|
|
40
58
|
/**
|
|
41
59
|
* mqttTopic is required on every MQTT_BROKER event, but CONNECT / DISCONNECT
|
|
42
60
|
* have no topic. These per-client `$SYS/clients/<id>/connected|disconnected`
|
|
@@ -48,216 +66,382 @@ const mqttConnectTopic = (clientId) => `$SYS/clients/${clientId || 'unknown'}/co
|
|
|
48
66
|
exports.mqttConnectTopic = mqttConnectTopic;
|
|
49
67
|
const mqttDisconnectTopic = (clientId) => `$SYS/clients/${clientId || 'unknown'}/disconnected`;
|
|
50
68
|
exports.mqttDisconnectTopic = mqttDisconnectTopic;
|
|
51
|
-
/** The ingestor rejects batch requests with more than 1000 events. */
|
|
52
|
-
const MAX_BATCH_EVENTS = 1000;
|
|
53
69
|
/** usage-ingestor limits (IngestUsageEventRequest). */
|
|
54
70
|
const MAX_CUSTOMER_ID = 64;
|
|
55
71
|
const MAX_IDEMPOTENCY_KEY = 255;
|
|
72
|
+
const MAX_PRODUCT_TYPE = 20;
|
|
73
|
+
const MAX_MQTT_TOPIC = 500;
|
|
74
|
+
const MAX_MQTT_CLIENT_ID = 128;
|
|
56
75
|
class AforoMqttBilling {
|
|
57
76
|
config;
|
|
58
77
|
productType;
|
|
59
|
-
flushCount;
|
|
60
|
-
flushIntervalMs;
|
|
61
78
|
emitDeliverEvents;
|
|
62
79
|
onError;
|
|
63
|
-
|
|
64
|
-
|
|
80
|
+
core;
|
|
81
|
+
/** Request-derived labels already reported as truncated (one WARN per label). */
|
|
82
|
+
truncationWarned = new Set();
|
|
83
|
+
reconnectDebounceMs;
|
|
84
|
+
/** DISCONNECTs inside their reconnect window; billed at shutdown at the latest. */
|
|
85
|
+
pendingDisconnects = new Set();
|
|
65
86
|
constructor(config) {
|
|
66
87
|
this.config = {
|
|
67
88
|
tenantId: config.tenantId,
|
|
68
89
|
productId: config.productId,
|
|
69
|
-
apiKey: config.apiKey,
|
|
70
|
-
ingestorUrl: config.ingestorUrl,
|
|
71
90
|
};
|
|
72
91
|
this.productType = normalizeProductType(config.productType) ?? exports.DEFAULT_PRODUCT_TYPE;
|
|
73
|
-
this.flushCount = config.flushCount ?? 200;
|
|
74
|
-
this.flushIntervalMs = config.flushIntervalMs ?? 2000;
|
|
75
92
|
this.emitDeliverEvents = config.emitDeliverEvents ?? false;
|
|
93
|
+
this.reconnectDebounceMs = checkDebounce(config.reconnectDebounceMs, exports.DEFAULT_RECONNECT_DEBOUNCE_MS, 'AforoMqttBilling');
|
|
76
94
|
this.onError = config.onError ?? ((err) => console.error('[aforo-mqtt]', err.message));
|
|
77
|
-
|
|
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
|
+
});
|
|
78
114
|
}
|
|
79
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
|
+
*/
|
|
80
122
|
wrapAedesBroker(broker, options) {
|
|
81
|
-
const
|
|
82
|
-
|
|
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();
|
|
139
|
+
const status = options.executionStatus;
|
|
140
|
+
const brokerStatus = (clientId) => typeof status === 'function' ? (event) => status(event, clientId) : status;
|
|
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) => {
|
|
83
159
|
if (!client)
|
|
84
160
|
return; // broker-originated publishes skip billing
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
dataBytes: bytes,
|
|
100
|
-
metadata: options.resolveMetadata?.(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);
|
|
101
175
|
});
|
|
102
176
|
});
|
|
103
|
-
broker.on('subscribe',
|
|
177
|
+
broker.on('subscribe', (subs, client) => {
|
|
104
178
|
const clientId = client?.id;
|
|
105
179
|
if (!clientId)
|
|
106
180
|
return;
|
|
107
|
-
|
|
108
|
-
|
|
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)
|
|
109
202
|
return;
|
|
110
|
-
|
|
111
|
-
|
|
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);
|
|
112
240
|
this.push({
|
|
113
241
|
customerId,
|
|
114
|
-
mqttTopic
|
|
115
|
-
|
|
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,
|
|
116
246
|
mqttRetained: false,
|
|
117
|
-
mqttEventType: '
|
|
118
|
-
mqttClientId: clientId,
|
|
247
|
+
mqttEventType: 'CONNECT',
|
|
248
|
+
mqttClientId: this.clientIdLabel(clientId),
|
|
119
249
|
productType: options.productType,
|
|
120
250
|
dataBytes: 0,
|
|
121
251
|
metadata: options.resolveMetadata?.(clientId),
|
|
122
|
-
});
|
|
123
|
-
}
|
|
252
|
+
}, brokerStatus(clientId), clientId);
|
|
253
|
+
});
|
|
124
254
|
});
|
|
125
|
-
broker.on('
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (!customerId)
|
|
131
|
-
return;
|
|
132
|
-
const arr = Array.isArray(unsubs) ? unsubs : [unsubs];
|
|
133
|
-
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);
|
|
134
260
|
this.push({
|
|
135
261
|
customerId,
|
|
136
|
-
mqttTopic:
|
|
262
|
+
mqttTopic: (0, exports.mqttDisconnectTopic)(this.clientIdLabel(clientId)),
|
|
137
263
|
mqttQos: 0,
|
|
138
264
|
mqttRetained: false,
|
|
139
|
-
mqttEventType: '
|
|
140
|
-
mqttClientId: clientId,
|
|
265
|
+
mqttEventType: 'DISCONNECT',
|
|
266
|
+
mqttClientId: this.clientIdLabel(clientId),
|
|
141
267
|
productType: options.productType,
|
|
142
268
|
dataBytes: 0,
|
|
143
269
|
metadata: options.resolveMetadata?.(clientId),
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
});
|
|
147
|
-
broker.on('client', async (client) => {
|
|
148
|
-
const clientId = client.id;
|
|
149
|
-
const customerId = await resolveCustomer(clientId, client.username);
|
|
150
|
-
if (!customerId)
|
|
151
|
-
return;
|
|
152
|
-
this.push({
|
|
153
|
-
customerId,
|
|
154
|
-
mqttTopic: (0, exports.mqttConnectTopic)(clientId), // CONNECT has no topic; mqttTopic is required
|
|
155
|
-
mqttQos: 0,
|
|
156
|
-
mqttRetained: false,
|
|
157
|
-
mqttEventType: 'CONNECT',
|
|
158
|
-
mqttClientId: clientId,
|
|
159
|
-
productType: options.productType,
|
|
160
|
-
dataBytes: 0,
|
|
161
|
-
metadata: options.resolveMetadata?.(clientId),
|
|
270
|
+
}, brokerStatus(clientId), clientId, at);
|
|
162
271
|
});
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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();
|
|
283
|
+
}
|
|
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();
|
|
179
290
|
});
|
|
180
291
|
});
|
|
181
292
|
}
|
|
182
293
|
// ── Client-side integration (mqtt.js) ───────────────────────────
|
|
183
294
|
wrapMqttClient(client, options) {
|
|
184
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;
|
|
185
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;
|
|
186
310
|
this.push({
|
|
187
311
|
customerId: options.customerId,
|
|
188
|
-
mqttTopic: (0, exports.mqttConnectTopic)(clientId),
|
|
312
|
+
mqttTopic: (0, exports.mqttConnectTopic)(this.clientIdLabel(clientId)),
|
|
189
313
|
mqttQos: 0,
|
|
190
314
|
mqttRetained: false,
|
|
191
315
|
mqttEventType: 'CONNECT',
|
|
192
|
-
mqttClientId: clientId,
|
|
316
|
+
mqttClientId: this.clientIdLabel(clientId),
|
|
193
317
|
productType: options.productType,
|
|
194
318
|
dataBytes: 0,
|
|
195
|
-
});
|
|
319
|
+
}, options.executionStatus, clientId);
|
|
196
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.
|
|
197
323
|
client.on('close', () => {
|
|
198
|
-
|
|
324
|
+
if (!connected)
|
|
325
|
+
return;
|
|
326
|
+
connected = false;
|
|
327
|
+
const at = new Date();
|
|
328
|
+
const emit = () => this.push({
|
|
199
329
|
customerId: options.customerId,
|
|
200
|
-
mqttTopic: (0, exports.mqttDisconnectTopic)(clientId),
|
|
330
|
+
mqttTopic: (0, exports.mqttDisconnectTopic)(this.clientIdLabel(clientId)),
|
|
201
331
|
mqttQos: 0,
|
|
202
332
|
mqttRetained: false,
|
|
203
333
|
mqttEventType: 'DISCONNECT',
|
|
204
|
-
mqttClientId: clientId,
|
|
334
|
+
mqttClientId: this.clientIdLabel(clientId),
|
|
205
335
|
productType: options.productType,
|
|
206
336
|
dataBytes: 0,
|
|
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();
|
|
207
345
|
});
|
|
208
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();
|
|
353
|
+
});
|
|
209
354
|
client.on('message', (topic, payload, packet) => {
|
|
210
355
|
this.push({
|
|
211
356
|
customerId: options.customerId,
|
|
212
|
-
mqttTopic: topic,
|
|
357
|
+
mqttTopic: this.topicLabel(topic),
|
|
213
358
|
mqttQos: packet?.qos ?? 0,
|
|
214
359
|
mqttRetained: !!packet?.retain,
|
|
215
360
|
mqttEventType: 'DELIVER',
|
|
216
|
-
mqttClientId: clientId,
|
|
361
|
+
mqttClientId: this.clientIdLabel(clientId),
|
|
217
362
|
productType: options.productType,
|
|
218
363
|
dataBytes: estimateBytes(payload),
|
|
219
|
-
});
|
|
364
|
+
}, options.executionStatus, clientId);
|
|
220
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.
|
|
221
370
|
const origPublish = client.publish.bind(client);
|
|
222
371
|
client.publish = (topic, message, opts, cb) => {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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);
|
|
234
398
|
};
|
|
235
399
|
}
|
|
236
400
|
// ── Event pipeline ──────────────────────────────────────────────
|
|
237
|
-
|
|
401
|
+
/**
|
|
402
|
+
* Bound an MQTT label (topic, client id) to the ingestor's limit. The event
|
|
403
|
+
* is still sent; one WARN per label name per client. Identity fields
|
|
404
|
+
* (customerId, productType) never go through here — an over-long one drops
|
|
405
|
+
* the event as 'invalid'.
|
|
406
|
+
*/
|
|
407
|
+
requestLabel(field, value, max) {
|
|
408
|
+
if (typeof value !== 'string' || value.length <= max)
|
|
409
|
+
return value;
|
|
410
|
+
if (!this.truncationWarned.has(field)) {
|
|
411
|
+
this.truncationWarned.add(field);
|
|
412
|
+
console.warn(`[aforo-mqtt] ${field} was longer than the ingestor's limit and was ` +
|
|
413
|
+
`truncated to ${max} characters; the event is still sent. Logged once per label.`);
|
|
414
|
+
}
|
|
415
|
+
return truncateToLimit(value, max);
|
|
416
|
+
}
|
|
417
|
+
/** Topic off an incoming packet, bound to the ingestor's mqttTopic limit. */
|
|
418
|
+
topicLabel(topic) {
|
|
419
|
+
return typeof topic === 'string' ? this.requestLabel('mqttTopic', topic, MAX_MQTT_TOPIC) : topic;
|
|
420
|
+
}
|
|
421
|
+
/** Client id a connecting client chose, bound to the ingestor's mqttClientId limit. */
|
|
422
|
+
clientIdLabel(clientId) {
|
|
423
|
+
return typeof clientId === 'string' ? this.requestLabel('mqttClientId', clientId, MAX_MQTT_CLIENT_ID) : clientId;
|
|
424
|
+
}
|
|
425
|
+
push(partial, executionStatus,
|
|
426
|
+
/** Untruncated client id for the idempotency key, when `partial.mqttClientId` was cut to the limit. */
|
|
427
|
+
keyClientId,
|
|
428
|
+
/** When it happened, if not now (a debounced DISCONNECT, a confirmed PUBLISH). */
|
|
429
|
+
at) {
|
|
238
430
|
if (!this.emitDeliverEvents && partial.mqttEventType === 'DELIVER')
|
|
239
431
|
return;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (partial.customerId.length > MAX_CUSTOMER_ID) {
|
|
243
|
-
this.onError(new Error(`MQTT metering: customerId longer than ${MAX_CUSTOMER_ID} chars; event dropped`));
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
const mqttTopic = partial.mqttTopic;
|
|
247
|
-
if (!mqttTopic || !mqttTopic.trim())
|
|
248
|
-
return; // mqttTopic is required on every MQTT_BROKER event
|
|
249
|
-
const now = new Date();
|
|
250
|
-
const mqttClientId = partial.mqttClientId.slice(0, 128);
|
|
432
|
+
const now = at ?? new Date();
|
|
433
|
+
const mqttClientId = typeof partial.mqttClientId === 'string' ? partial.mqttClientId : String(partial.mqttClientId ?? '');
|
|
251
434
|
const event = {
|
|
252
435
|
...partial,
|
|
253
|
-
mqttTopic: mqttTopic.slice(0, 500),
|
|
254
436
|
mqttQos: partial.mqttQos === 1 || partial.mqttQos === 2 ? partial.mqttQos : 0,
|
|
255
437
|
mqttClientId,
|
|
256
438
|
metricName: `mqtt_broker.${partial.mqttEventType.toLowerCase()}`,
|
|
257
439
|
quantity: 1,
|
|
258
440
|
occurredAt: now.toISOString(),
|
|
259
|
-
//
|
|
260
|
-
|
|
441
|
+
// Minted once, here, from the untruncated client id; the topic is left
|
|
442
|
+
// out. When the client id makes the key longer than the ingestor allows,
|
|
443
|
+
// it is replaced by its SHA-256 digest; the key itself is never cut.
|
|
444
|
+
idempotencyKey: boundedIdempotencyKey(`mqtt:${this.config.tenantId}:`, typeof keyClientId === 'string' ? keyClientId : mqttClientId, `:${partial.mqttEventType}:${now.getTime()}:${randomSuffix()}`),
|
|
261
445
|
productType: normalizeProductType(partial.productType) ?? this.productType,
|
|
262
446
|
metadata: {
|
|
263
447
|
...(partial.metadata ?? {}),
|
|
@@ -265,82 +449,94 @@ class AforoMqttBilling {
|
|
|
265
449
|
productId: this.config.productId,
|
|
266
450
|
},
|
|
267
451
|
};
|
|
268
|
-
|
|
269
|
-
if (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
452
|
+
const checked = checkExecutionStatus(resolveExecutionStatus(executionStatus, event));
|
|
453
|
+
if (checked.problem)
|
|
454
|
+
this.reportError(new Error(`[aforo-mqtt] ${checked.problem}`));
|
|
455
|
+
if (checked.status)
|
|
456
|
+
event.executionStatus = checked.status;
|
|
457
|
+
// An event the ingestor would reject is not sent (one bad event fails its
|
|
458
|
+
// whole batch): it is dropped here with reason 'invalid', never thrown
|
|
459
|
+
// into the broker / client handlers. mqttTopic is required on every
|
|
460
|
+
// MQTT_BROKER event.
|
|
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) ??
|
|
465
|
+
requiredText('mqttTopic', event.mqttTopic) ??
|
|
466
|
+
tooLong('mqttTopic', event.mqttTopic, MAX_MQTT_TOPIC) ??
|
|
467
|
+
tooLong('mqttClientId', event.mqttClientId, MAX_MQTT_CLIENT_ID) ??
|
|
468
|
+
tooLong('productType', event.productType, MAX_PRODUCT_TYPE);
|
|
469
|
+
if (invalid) {
|
|
470
|
+
this.core.reportDrop([event], 'invalid', invalid.problem);
|
|
275
471
|
return;
|
|
276
|
-
const pending = this.buffer.splice(0, this.buffer.length);
|
|
277
|
-
// The ingestor accepts at most MAX_BATCH_EVENTS events per request.
|
|
278
|
-
for (let i = 0; i < pending.length; i += MAX_BATCH_EVENTS) {
|
|
279
|
-
await this.send(pending.slice(i, i + MAX_BATCH_EVENTS));
|
|
280
472
|
}
|
|
473
|
+
// Queue only; the core's single background sender does the HTTP.
|
|
474
|
+
this.core.enqueue(event);
|
|
281
475
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
catch (err) {
|
|
310
|
-
if (attempt === maxRetries) {
|
|
311
|
-
this.onError(err);
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
if (attempt < maxRetries)
|
|
316
|
-
await sleep(delayMs);
|
|
317
|
-
}
|
|
318
|
-
this.onError(new Error(`MQTT metering flush failed after ${maxRetries} attempts (dropped ${batch.length} events)`));
|
|
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;
|
|
319
503
|
}
|
|
320
|
-
/**
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
this.onError(new Error(`Aforo ingestor rejected ${failed} event(s)${details ? ` — ${details}` : ''}`));
|
|
504
|
+
/** Invoke onError; a hook bug must never break metering or flushing. */
|
|
505
|
+
reportError(err) {
|
|
506
|
+
try {
|
|
507
|
+
this.onError(err);
|
|
325
508
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if (this.flushTimer)
|
|
329
|
-
return;
|
|
330
|
-
this.flushTimer = setInterval(() => { void this.flush(); }, this.flushIntervalMs);
|
|
331
|
-
// Unref so the background timer never blocks host-process exit (final flush still needs shutdown()).
|
|
332
|
-
if (this.flushTimer && typeof this.flushTimer === 'object' && 'unref' in this.flushTimer) {
|
|
333
|
-
this.flushTimer.unref();
|
|
509
|
+
catch (hookErr) {
|
|
510
|
+
console.debug('[aforo-mqtt] onError hook threw', hookErr);
|
|
334
511
|
}
|
|
335
|
-
if (typeof this.flushTimer.unref === 'function')
|
|
336
|
-
this.flushTimer.unref();
|
|
337
512
|
}
|
|
513
|
+
/** Number of events permanently dropped since this instance was created (every DropReason). */
|
|
514
|
+
get droppedCount() {
|
|
515
|
+
return this.core.droppedCount;
|
|
516
|
+
}
|
|
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();
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
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.
|
|
529
|
+
*/
|
|
338
530
|
async shutdown() {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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();
|
|
534
|
+
}
|
|
535
|
+
toJSON() {
|
|
536
|
+
return { tenantId: this.config.tenantId, productId: this.config.productId, queued: this.queuedCount, dropped: this.droppedCount };
|
|
537
|
+
}
|
|
538
|
+
[Symbol.for('nodejs.util.inspect.custom')]() {
|
|
539
|
+
return `AforoMqttBilling { tenantId: '${this.config.tenantId}', productId: '${this.config.productId}', queued: ${this.queuedCount}, dropped: ${this.droppedCount} }`;
|
|
344
540
|
}
|
|
345
541
|
}
|
|
346
542
|
exports.AforoMqttBilling = AforoMqttBilling;
|
|
@@ -355,6 +551,51 @@ function estimateBytes(data) {
|
|
|
355
551
|
return data.byteLength;
|
|
356
552
|
return 0;
|
|
357
553
|
}
|
|
554
|
+
/** Statuses the Aforo ingestor accepts (contract/ingest-contract.json, max 20 chars). */
|
|
555
|
+
const CANONICAL_EXECUTION_STATUSES = new Set([
|
|
556
|
+
'SUCCESS', 'PARTIAL', 'TIMEOUT', 'ERROR', 'VALIDATION_FAILED', 'FAILED',
|
|
557
|
+
'FAILURE', 'CANCELLED', 'PENDING', 'BLOCKED', 'HITL_REQUIRED',
|
|
558
|
+
]);
|
|
559
|
+
/**
|
|
560
|
+
* Check a caller-supplied status. `{ status }` when canonical; `{ problem }`
|
|
561
|
+
* when it has to be left off the event (an unknown value would make the
|
|
562
|
+
* ingestor reject the event; a Promise from an async resolver can't be
|
|
563
|
+
* awaited on this path); `{}` when blank or absent.
|
|
564
|
+
*/
|
|
565
|
+
function checkExecutionStatus(value) {
|
|
566
|
+
if (value !== null && typeof value === 'object' && typeof value.then === 'function') {
|
|
567
|
+
// Swallow a later rejection so it doesn't surface as an unhandled rejection.
|
|
568
|
+
value.then(undefined, () => { });
|
|
569
|
+
return { problem: 'executionStatus resolver returned a Promise — resolvers must be synchronous; field omitted' };
|
|
570
|
+
}
|
|
571
|
+
const status = normalizeExecutionStatus(value);
|
|
572
|
+
if (!status)
|
|
573
|
+
return {};
|
|
574
|
+
if (!CANONICAL_EXECUTION_STATUSES.has(status)) {
|
|
575
|
+
return {
|
|
576
|
+
problem: `unknown executionStatus "${status.slice(0, 40)}" — field omitted. ` +
|
|
577
|
+
`Expected one of: ${[...CANONICAL_EXECUTION_STATUSES].join(', ')}`,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
return { status };
|
|
581
|
+
}
|
|
582
|
+
/** Trim + upper-case; blank/absent → undefined (key omitted from the wire body). */
|
|
583
|
+
function normalizeExecutionStatus(value) {
|
|
584
|
+
if (typeof value !== 'string')
|
|
585
|
+
return undefined;
|
|
586
|
+
const trimmed = value.trim();
|
|
587
|
+
return trimmed ? trimmed.toUpperCase() : undefined;
|
|
588
|
+
}
|
|
589
|
+
function resolveExecutionStatus(option, event) {
|
|
590
|
+
if (typeof option !== 'function')
|
|
591
|
+
return option;
|
|
592
|
+
try {
|
|
593
|
+
return option(event);
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
return undefined; // a resolver bug must never break metering
|
|
597
|
+
}
|
|
598
|
+
}
|
|
358
599
|
/** Trim + uppercase a product type; blank/non-string → undefined (unknown values pass through). */
|
|
359
600
|
function normalizeProductType(value) {
|
|
360
601
|
if (typeof value !== 'string')
|
|
@@ -362,41 +603,63 @@ function normalizeProductType(value) {
|
|
|
362
603
|
const trimmed = value.trim();
|
|
363
604
|
return trimmed ? trimmed.toUpperCase() : undefined;
|
|
364
605
|
}
|
|
365
|
-
/**
|
|
366
|
-
function
|
|
367
|
-
|
|
606
|
+
/** The offending value for a WARN line, cut to 80 chars. */
|
|
607
|
+
function preview(value) {
|
|
608
|
+
const text = typeof value === 'string' ? value : String(value);
|
|
609
|
+
return JSON.stringify(text.length > 80 ? `${text.slice(0, 80)}…` : text);
|
|
368
610
|
}
|
|
369
|
-
/**
|
|
370
|
-
function
|
|
371
|
-
|
|
372
|
-
if (!raw)
|
|
611
|
+
/** Limits mirror the ingestor's IngestUsageEventRequest; identity values are never truncated. */
|
|
612
|
+
function tooLong(field, value, max) {
|
|
613
|
+
if (typeof value !== 'string' || value.length <= max)
|
|
373
614
|
return undefined;
|
|
374
|
-
|
|
375
|
-
if (Number.isFinite(secs) && secs >= 0)
|
|
376
|
-
return Math.min(secs * 1000, MAX_RETRY_AFTER_MS);
|
|
377
|
-
const date = Date.parse(raw);
|
|
378
|
-
if (!Number.isNaN(date))
|
|
379
|
-
return Math.min(Math.max(date - Date.now(), 0), MAX_RETRY_AFTER_MS);
|
|
380
|
-
return undefined;
|
|
615
|
+
return { field, problem: `${field} is ${value.length} chars, limit ${max}: ${preview(value)}` };
|
|
381
616
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
617
|
+
function requiredText(field, value) {
|
|
618
|
+
if (typeof value === 'string' && value.trim())
|
|
619
|
+
return undefined;
|
|
620
|
+
return { field, problem: `${field} is required and must not be blank: ${preview(value)}` };
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Cut `value` to at most `max` UTF-16 code units — how the ingestor counts
|
|
624
|
+
* (`String.length()` in Java) — without leaving half a surrogate pair.
|
|
625
|
+
*/
|
|
626
|
+
function truncateToLimit(value, max) {
|
|
627
|
+
if (value.length <= max)
|
|
628
|
+
return value;
|
|
629
|
+
let end = Math.max(0, max);
|
|
630
|
+
const last = end > 0 ? value.charCodeAt(end - 1) : 0;
|
|
631
|
+
// A high surrogate at the cut means its low half was cut off: drop it too.
|
|
632
|
+
if (last >= 0xd800 && last <= 0xdbff)
|
|
633
|
+
end -= 1;
|
|
634
|
+
return value.slice(0, end);
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Build `head + component + tail`, where `component` is text taken from the
|
|
638
|
+
* incoming request and may be any length. Returned unchanged when it fits the
|
|
639
|
+
* ingestor's idempotencyKey limit; otherwise the component is replaced by the
|
|
640
|
+
* SHA-256 hex digest of its full, untruncated value (and, if the fixed parts
|
|
641
|
+
* alone are too long, `head + component` is). The key is never cut, so the
|
|
642
|
+
* unique tail always survives and two different components never share a key
|
|
643
|
+
* prefix by accident.
|
|
644
|
+
*/
|
|
645
|
+
function boundedIdempotencyKey(head, component, tail) {
|
|
646
|
+
const key = `${head}${component}${tail}`;
|
|
647
|
+
if (key.length <= MAX_IDEMPOTENCY_KEY)
|
|
648
|
+
return key;
|
|
649
|
+
const hashed = `${head}${sha256Hex(component)}${tail}`;
|
|
650
|
+
if (hashed.length <= MAX_IDEMPOTENCY_KEY)
|
|
651
|
+
return hashed;
|
|
652
|
+
return `${sha256Hex(head + component)}${tail}`;
|
|
653
|
+
}
|
|
654
|
+
function sha256Hex(text) {
|
|
655
|
+
return (0, node_crypto_1.createHash)('sha256').update(text, 'utf8').digest('hex');
|
|
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;
|
|
396
662
|
}
|
|
397
663
|
function randomSuffix() {
|
|
398
664
|
return Math.random().toString(36).slice(2, 10);
|
|
399
665
|
}
|
|
400
|
-
function sleep(ms) {
|
|
401
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
402
|
-
}
|