@aforoai/mqtt-metering 0.0.0-stage → 1.2.2

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 CHANGED
@@ -1,3 +1,154 @@
1
- # Temporary Holding Version
1
+ # @aforoai/mqtt-metering
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Meter MQTT traffic into Aforo two ways: hook an Aedes broker you operate to meter every PUBLISH/SUBSCRIBE/CONNECT/DISCONNECT, or wrap an `mqtt.js` client to meter what it publishes and receives against a third-party broker (AWS IoT, HiveMQ Cloud, EMQ X Cloud).
4
+
5
+ **Version:** 1.2.2 · Apache-2.0 · [Changelog](CHANGELOG.md) · [User guide](USER_GUIDE.md)
6
+
7
+ | Mode | When to use | Entry point |
8
+ |---|---|---|
9
+ | **Broker hook** | You run the broker (Aedes) and want to meter every client's events | `wrapAedesBroker(broker, options)` |
10
+ | **Client proxy** | You consume a third-party broker and want client-side billing | `wrapMqttClient(client, options)` |
11
+
12
+ ## Install
13
+
14
+ Intended public install (once published):
15
+
16
+ ```bash
17
+ npm i @aforoai/mqtt-metering aedes # broker mode
18
+ # or
19
+ npm i @aforoai/mqtt-metering mqtt # client mode
20
+ ```
21
+
22
+ > **Install `1.2.2` or later. `1.0.0` on npm was built from an older copy of this code and lacks the fixes listed in the changelog.** If `1.2.2` is not on npm yet, install from source with the steps below. `aedes` and `mqtt` are **optional** peer dependencies — install only the one your mode needs.
23
+
24
+ ```bash
25
+ # from the SDKs repo root
26
+ cd SDKs/aforo-metering-sdks/node-mqtt
27
+ npm install
28
+ npm run build # tsc → dist/
29
+
30
+ # then, from YOUR app
31
+ npm install /absolute/path/to/aforo-metering-sdks/node-mqtt
32
+ npm install aedes # broker mode, OR:
33
+ npm install mqtt # client mode
34
+ ```
35
+
36
+ ## Quickstart
37
+
38
+ **Broker mode (Aedes):**
39
+
40
+ ```ts
41
+ import aedes from 'aedes';
42
+ import { createServer } from 'net';
43
+ import { AforoMqttBilling } from '@aforoai/mqtt-metering';
44
+
45
+ const billing = new AforoMqttBilling({
46
+ tenantId: 'tenant_acme',
47
+ productId: 'prod_mqtt_001',
48
+ apiKey: process.env.AFORO_API_KEY!,
49
+ ingestorUrl: 'https://api.aforo.ai', // SDK appends /v1/ingest/batch
50
+ });
51
+
52
+ const broker = aedes();
53
+ billing.wrapAedesBroker(broker, {
54
+ resolveCustomerId: async (clientId, username) => customerStore.byClientId(clientId),
55
+ resolveMetadata: (clientId) => ({ deviceClass: deviceRegistry.classOf(clientId) }),
56
+ });
57
+
58
+ createServer(broker.handle).listen(1883);
59
+ process.on('SIGTERM', async () => { await billing.shutdown(); });
60
+ ```
61
+
62
+ **Client mode (`mqtt.js`):**
63
+
64
+ ```ts
65
+ import mqtt from 'mqtt';
66
+ import { AforoMqttBilling } from '@aforoai/mqtt-metering';
67
+
68
+ const billing = new AforoMqttBilling({ tenantId: 'tenant_acme', productId: 'prod_mqtt_001', apiKey: process.env.AFORO_API_KEY!, ingestorUrl: 'https://api.aforo.ai' });
69
+ const client = mqtt.connect('mqtts://broker.example.com', { clientId: `device-${deviceId}` });
70
+
71
+ billing.wrapMqttClient(client, { customerId: 'cust_acme_001' });
72
+ ```
73
+
74
+ Each event uses `metricName: "mqtt_broker.<event>"` (`mqtt_broker.publish`, `mqtt_broker.subscribe`, …) with `quantity: 1`, and carries `mqttTopic`, `mqttQos`, `mqttRetained`, `mqttClientId`, and `dataBytes`. Events ship to `POST https://api.aforo.ai/v1/ingest/batch` with `X-API-Key: <api_key>` and `X-Tenant-Id: <tenant_id>`.
75
+
76
+ > **`DELIVER` (fan-out) events are dropped unless `emitDeliverEvents: true`.** This gate applies in **both** modes — client-mode inbound `message` deliveries and any broker delivery path are skipped by default because fan-out is high-volume. `PUBLISH`, `SUBSCRIBE`, `UNSUBSCRIBE`, `CONNECT`, and `DISCONNECT` are always metered.
77
+
78
+ ## Configuration
79
+
80
+ `new AforoMqttBilling(config)` — `tenantId`, `productId`, `apiKey`, and `ingestorUrl` are required.
81
+
82
+ | Option | Type | Default | What it does |
83
+ |---|---|---|---|
84
+ | `tenantId` | `string` | — (required) | Aforo tenant. Sent as `X-Tenant-Id`. Never read from a client header. |
85
+ | `productId` | `string` | — (required) | Aforo product id; into each event's `metadata.productId`. |
86
+ | `apiKey` | `string` | — (required) | Sent as `X-API-Key: <apiKey>`. |
87
+ | `ingestorUrl` | `string` | — (required) | Ingestion base URL. SDK appends `/v1/ingest/batch`. Use `https://api.aforo.ai`. |
88
+ | `productType` | `string` | `'MQTT_BROKER'` | Aforo product type sent as top-level `productType` on every event (trimmed + uppercased; unknown values pass through). Override via `wrapAedesBroker(broker, { productType })` or `wrapMqttClient(client, { customerId, productType })`. |
89
+ | `emitDeliverEvents` | `boolean` | `false` | Emit a `DELIVER` event per fan-out delivery. Off → `DELIVER` events are dropped (both modes). |
90
+ | `flushCount` | `number` | `200` | Buffered events that trigger an immediate flush. Highest default of the SDKs — MQTT is very high-volume. |
91
+ | `flushIntervalMs` | `number` | `2000` | Max ms before a partial batch is flushed. |
92
+ | `onError` | `(error: Error) => void` | logs to `console.error` | Called once per delivery problem: a batch dropped after 3 attempts (network errors / 408 / 429 honouring `Retry-After` / 5xx), a batch refused with any other 4xx (not retried; the message includes the ingestor's `errors[].message`), per-event failures reported in a 2xx response, and an unusable `executionStatus`. Exceptions it throws are swallowed. |
93
+ | `onDrop` | `(events, reason) => void` | none | Receives events that were permanently dropped, with reason `invalid`, `rejected` or `retry_exhausted`. See [Dropped events](#dropped-events). |
94
+
95
+ Mode-specific options:
96
+
97
+ | Option | Mode | Type | What it does |
98
+ |---|---|---|---|
99
+ | `resolveCustomerId` | broker | `(clientId, username?) => string \| undefined \| Promise<…>` | Required. Map an MQTT client id to a customer. `undefined` → that client's events are not metered. |
100
+ | `resolveMetadata` | broker | `(clientId) => Record<string, unknown> \| undefined` | Optional per-client tags. |
101
+ | `customerId` | client | `string` | Customer to attribute all traffic on this client to. |
102
+ | `clientId` | client | `string` | Fixed client id (defaults to `client.options.clientId`, then `'mqtt-client'`). |
103
+ | `productType` | both | `string` | Product type for this broker's / client's events. Default: the client-level `productType`. |
104
+
105
+ Every event carries `mqttQos` (0/1/2) and `mqttRetained` — use them in Aforo rate-plan filter conditions to price QoS ≥ 1 or retained messages separately.
106
+
107
+ Exported symbols: `AforoMqttBilling` (with `wrapAedesBroker` / `wrapMqttClient` / `shutdown` / `droppedCount`), `mqttConnectTopic` / `mqttDisconnectTopic`, `DEFAULT_PRODUCT_TYPE`, and the `AforoMqttConfig` / `AedesBrokerOptions` / `MqttClientOptions` / `MqttUsageEvent` / `DropReason` types.
108
+
109
+ ## Execution status
110
+
111
+ Outcome-based pricing bills each event at the weight set for its `executionStatus`; events without one bill at full price. The SDK trims and upper-cases the value and leaves the key out of the event when it is unset or blank. Accepted values: `SUCCESS`, `PARTIAL`, `TIMEOUT`, `ERROR`, `VALIDATION_FAILED`, `FAILED`, `FAILURE`, `CANCELLED`, `PENDING`, `BLOCKED`, `HITL_REQUIRED`. Any other value (or a Promise returned by an async resolver) is reported through `onError` and left off the event — the event itself is still sent — because the server would reject the event. Resolvers must be synchronous.
112
+
113
+ The SDK never sets `executionStatus` on its own: the broker hook sees no success or failure signal, and client publishes are metered before the broker acknowledges them. Pass it yourself, as a string or as a function called for each event:
114
+
115
+ ```typescript
116
+ billing.wrapAedesBroker(broker, {
117
+ resolveCustomerId: (clientId) => customerIdLookup(clientId),
118
+ // (event, clientId) => status | undefined
119
+ executionStatus: (event) => (event.mqttTopic.startsWith('alerts/') ? 'SUCCESS' : undefined),
120
+ });
121
+
122
+ billing.wrapMqttClient(client, { customerId: 'cust_42', executionStatus: 'SUCCESS' });
123
+ ```
124
+
125
+ ## Dropped events
126
+
127
+ The SDK does not throw into your broker or client handlers for event content. An event that cannot be delivered is counted in `billing.droppedCount`, logged with `console.warn`, and passed to the optional `onDrop(events, reason)` hook. Events handed to the hook keep their `idempotencyKey`, so storing them and re-submitting later is dedup-safe. Exceptions thrown by the hook are swallowed.
128
+
129
+ | Reason | When |
130
+ |---|---|
131
+ | `invalid` | The event failed a client-side check and was never buffered or sent: `customerId` longer than 64 characters, a missing or whitespace-only `mqttTopic`, or `productType` longer than 20. These are never truncated. A topic longer than 500 characters, or a client id longer than 128, is not a drop: it is cut to the limit and the event is sent (one warning per label). The warning names the field, the limit and the value (first 80 characters); it is logged for the first occurrence per field and for every 1000th after that. |
132
+ | `rejected` | The ingestor refused the batch with a 4xx other than 408 / 429 (not retried), or named individual events in the `errors[]` of a 2xx response — then only those events are dropped. When a 2xx reports `failed > 0` without a usable `index`, the count is added to `droppedCount` and `onDrop` is not called. |
133
+ | `retry_exhausted` | Network errors, 408, 429 or 5xx persisted through 3 attempts (1s / 2s / 4s backoff; `Retry-After` honoured up to 30 s). |
134
+
135
+ A call with no resolvable customer id is not billable; it is skipped and is not counted as a drop.
136
+
137
+ ```ts
138
+ const billing = new AforoMqttBilling({
139
+ // …
140
+ onDrop: (events, reason) => deadLetterQueue.push({ reason, events }),
141
+ });
142
+ ```
143
+
144
+ ## Walk me through it
145
+
146
+ Step-by-step from install to a verified event in Aforo: [USER_GUIDE.md](USER_GUIDE.md).
147
+
148
+ ## What this doesn't cover
149
+
150
+ - **Fan-out billing is opt-in.** `DELIVER` events are dropped unless `emitDeliverEvents: true`. Default metering counts the PUBLISH, not the per-subscriber fan-out.
151
+ - **CONNECT/DISCONNECT have no MQTT topic.** Because the ingestor requires `mqttTopic`, they are sent with `$SYS/clients/<clientId>/connected` and `$SYS/clients/<clientId>/disconnected`. `$SYS/` is reserved for brokers, so these topics can't collide with a real one.
152
+ - **Client-mode CONNECT/DISCONNECT use socket lifecycle.** `wrapMqttClient` maps `connect`/`close`; a client that reconnects emits a CONNECT each time.
153
+ - **No persistent buffer.** Events are in memory until flushed; a hard crash before flush drops the buffered batch. `shutdown()` covers graceful exit only.
154
+ - **The SDK does not enforce or read pricing.** It emits usage; rating/billing happens in Aforo.
@@ -0,0 +1,227 @@
1
+ /**
2
+ * @aforoai/mqtt-metering — Aforo MQTT Metering SDK
3
+ *
4
+ * Two integration modes:
5
+ *
6
+ * 1. **Broker hook** (preferred) — wrapAedesBroker() attaches to every PUBLISH,
7
+ * SUBSCRIBE, UNSUBSCRIBE, CONNECT, DISCONNECT event flowing through an
8
+ * Aedes broker instance. Use this when you operate the broker yourself.
9
+ *
10
+ * 2. **Client proxy** — wrapMqttClient() wraps an mqtt.js client to meter
11
+ * outbound publishes + inbound deliveries from the client's perspective.
12
+ * Use this when you consume a third-party broker (AWS IoT, HiveMQ Cloud,
13
+ * EMQ X Cloud) and need client-side billing.
14
+ *
15
+ * Usage (Aedes broker):
16
+ * import aedes from 'aedes';
17
+ * import { AforoMqttBilling } from '@aforoai/mqtt-metering';
18
+ *
19
+ * const billing = new AforoMqttBilling({
20
+ * tenantId: 'tenant_acme',
21
+ * productId: 'prod_mqtt_telemetry',
22
+ * apiKey: process.env.AFORO_API_KEY!,
23
+ * ingestorUrl: 'https://api.aforo.ai',
24
+ * // productType defaults to 'MQTT_BROKER'
25
+ * });
26
+ *
27
+ * const broker = aedes();
28
+ * billing.wrapAedesBroker(broker, {
29
+ * resolveCustomerId: (clientId) => customerIdLookup(clientId),
30
+ * });
31
+ */
32
+ export interface AforoMqttConfig {
33
+ tenantId: string;
34
+ productId: string;
35
+ apiKey: string;
36
+ ingestorUrl: string;
37
+ /**
38
+ * Aforo product type sent as top-level `productType` on every event (trimmed + uppercased).
39
+ * Default: `MQTT_BROKER`. Override per integration via `wrapAedesBroker(broker, { productType })` / `wrapMqttClient(client, { productType })`.
40
+ */
41
+ productType?: string;
42
+ /** How many events to buffer before flushing (default 200 — MQTT is very high-volume). */
43
+ flushCount?: number;
44
+ /** Max interval in ms before a partial batch is flushed (default 2000). */
45
+ flushIntervalMs?: number;
46
+ /** Emit one event per fanout delivery in addition to the publish (default false — costly). */
47
+ emitDeliverEvents?: boolean;
48
+ /** Callback for terminal flush failures. */
49
+ onError?: (error: Error) => void;
50
+ /**
51
+ * Opt-in hook receiving events that were permanently dropped (retry
52
+ * exhaustion, a rejection by the ingestor, or a failed client-side check —
53
+ * see DropReason). Events keep their idempotency keys,
54
+ * so persisting and re-submitting them after recovery is dedup-safe.
55
+ * Exceptions thrown by the hook are swallowed. Default: none (drops are
56
+ * still counted in droppedCount and WARN-logged).
57
+ */
58
+ onDrop?: (events: MqttUsageEvent[], reason: DropReason) => void;
59
+ }
60
+ /**
61
+ * Why events were permanently dropped. `retry_exhausted`: the ingestor stayed
62
+ * unreachable / kept answering 408, 429 or 5xx. `rejected`: the ingestor
63
+ * refused the batch (non-retryable 4xx) or individual events of an accepted
64
+ * batch. `invalid`: the event failed a client-side check and was never sent.
65
+ * The buffer is unbounded (drained at flush start), so unlike the core SDK
66
+ * there is no 'overflow' reason here.
67
+ */
68
+ export type DropReason = 'retry_exhausted' | 'rejected' | 'invalid';
69
+ export interface AedesBrokerOptions {
70
+ /** Map an MQTT client ID to an Aforo customer ID. Required. */
71
+ resolveCustomerId: (clientId: string, username?: string) => string | undefined | Promise<string | undefined>;
72
+ /** Optional per-client metadata (tags, device type). */
73
+ resolveMetadata?: (clientId: string) => Record<string, unknown> | undefined;
74
+ /**
75
+ * Optional outcome of the request (used by OUTCOME_BASED pricing, which bills
76
+ * each event at the weight set for its status). Events without a status bill
77
+ * at full price. Trimmed and upper-cased by the SDK; blank is treated as absent.
78
+ * Accepted values: SUCCESS, PARTIAL, TIMEOUT, ERROR, VALIDATION_FAILED, FAILED,
79
+ * FAILURE, CANCELLED, PENDING, BLOCKED, HITL_REQUIRED (max 20 chars). Any other
80
+ * value — or a Promise from an async resolver — is reported through `onError`
81
+ * and left off the event (the event is still sent), because the server would
82
+ * reject the event. Resolvers must be synchronous.
83
+ *
84
+ * MQTT packets carry no success/failure signal the broker hook sees, so the
85
+ * SDK never derives one: pass a fixed string, or a function called for each
86
+ * event (with the MQTT client ID) that returns the status or undefined.
87
+ */
88
+ executionStatus?: string | ((event: MqttUsageEvent, clientId: string) => string | undefined);
89
+ /** Product type for events from this broker. Default: the client-level `productType`. */
90
+ productType?: string;
91
+ }
92
+ export interface MqttClientOptions {
93
+ /** Customer ID to attribute all traffic on this client to. */
94
+ customerId: string;
95
+ /** Fixed client identifier (defaults to mqtt.js connection options.clientId). */
96
+ clientId?: string;
97
+ /**
98
+ * Optional outcome of the request (used by OUTCOME_BASED pricing, which bills
99
+ * each event at the weight set for its status). Events without a status bill
100
+ * at full price. Trimmed and upper-cased by the SDK; blank is treated as absent.
101
+ * Accepted values: SUCCESS, PARTIAL, TIMEOUT, ERROR, VALIDATION_FAILED, FAILED,
102
+ * FAILURE, CANCELLED, PENDING, BLOCKED, HITL_REQUIRED (max 20 chars). Any other
103
+ * value — or a Promise from an async resolver — is reported through `onError`
104
+ * and left off the event (the event is still sent), because the server would
105
+ * reject the event. Resolvers must be synchronous.
106
+ *
107
+ * Never derived by the SDK (publishes are metered before the broker
108
+ * acknowledges them): pass a fixed string, or a function called for each
109
+ * event that returns the status or undefined.
110
+ */
111
+ executionStatus?: string | ((event: MqttUsageEvent) => string | undefined);
112
+ /** Product type for events from this client. Default: the client-level `productType`. */
113
+ productType?: string;
114
+ }
115
+ /** Default top-level `productType` for this SDK. */
116
+ export declare const DEFAULT_PRODUCT_TYPE = "MQTT_BROKER";
117
+ /**
118
+ * mqttTopic is required on every MQTT_BROKER event, but CONNECT / DISCONNECT
119
+ * have no topic. These per-client `$SYS/clients/<id>/connected|disconnected`
120
+ * topics stand in for them (`$SYS/` is reserved for the broker by the MQTT
121
+ * spec, so they can never collide with a client topic). Same topics as the
122
+ * Python, Go and Java SDKs.
123
+ */
124
+ export declare const mqttConnectTopic: (clientId: string) => string;
125
+ export declare const mqttDisconnectTopic: (clientId: string) => string;
126
+ type MqttEventType = 'PUBLISH' | 'DELIVER' | 'SUBSCRIBE' | 'UNSUBSCRIBE' | 'CONNECT' | 'DISCONNECT';
127
+ export interface MqttUsageEvent {
128
+ customerId: string;
129
+ metricName: string;
130
+ quantity: number;
131
+ occurredAt: string;
132
+ idempotencyKey: string;
133
+ productType: string;
134
+ mqttTopic: string;
135
+ mqttQos: number;
136
+ mqttRetained: boolean;
137
+ mqttEventType: MqttEventType;
138
+ mqttClientId: string;
139
+ dataBytes: number;
140
+ metadata?: Record<string, unknown>;
141
+ /** Normalized (trimmed, upper-cased) execution status; omitted when not set. */
142
+ executionStatus?: string;
143
+ }
144
+ /** Minimal Aedes broker surface — matches the `aedes` package. */
145
+ interface MinimalAedes {
146
+ on(event: 'publish', fn: (packet: any, client: any) => void): void;
147
+ on(event: 'subscribe', fn: (subs: any[] | any, client: any) => void): void;
148
+ on(event: 'unsubscribe', fn: (unsubs: any[] | any, client: any) => void): void;
149
+ on(event: 'client', fn: (client: any) => void): void;
150
+ on(event: 'clientDisconnect', fn: (client: any) => void): void;
151
+ }
152
+ /** Minimal mqtt.js client surface. */
153
+ interface MinimalMqttClient {
154
+ on(event: 'message', fn: (topic: string, payload: Buffer, packet: any) => void): void;
155
+ on(event: 'connect', fn: () => void): void;
156
+ on(event: 'close', fn: () => void): void;
157
+ publish: (topic: string, message: any, opts?: any, cb?: any) => any;
158
+ options?: {
159
+ clientId?: string;
160
+ };
161
+ }
162
+ export declare class AforoMqttBilling {
163
+ private readonly config;
164
+ private readonly productType;
165
+ private readonly flushCount;
166
+ private readonly flushIntervalMs;
167
+ private readonly emitDeliverEvents;
168
+ private readonly onError;
169
+ private readonly onDrop?;
170
+ private buffer;
171
+ private flushTimer;
172
+ private dropped;
173
+ /** Invalid-event count per field, for WARN throttling. */
174
+ private readonly invalidSeen;
175
+ /** Request-derived labels already reported as truncated (one WARN per label). */
176
+ private readonly truncationWarned;
177
+ constructor(config: AforoMqttConfig);
178
+ wrapAedesBroker(broker: MinimalAedes, options: AedesBrokerOptions): void;
179
+ wrapMqttClient(client: MinimalMqttClient, options: MqttClientOptions): void;
180
+ /**
181
+ * Bound an MQTT label (topic, client id) to the ingestor's limit. The event
182
+ * is still sent; one WARN per label name per client. Identity fields
183
+ * (customerId, productType) never go through here — an over-long one drops
184
+ * the event as 'invalid'.
185
+ */
186
+ private requestLabel;
187
+ /** Topic off an incoming packet, bound to the ingestor's mqttTopic limit. */
188
+ private topicLabel;
189
+ /** Client id a connecting client chose, bound to the ingestor's mqttClientId limit. */
190
+ private clientIdLabel;
191
+ private push;
192
+ private flush;
193
+ private send;
194
+ /**
195
+ * A 2xx can still carry per-event rejections:
196
+ * `{accepted, duplicates, failed, errors:[{index, message}]}`. Events the
197
+ * response names by index are dropped with reason 'rejected'; a `failed`
198
+ * count the response does not attribute to an index is counted only.
199
+ */
200
+ private handlePartialFailures;
201
+ /** Invoke onError; a hook bug must never break metering or flushing. */
202
+ private reportError;
203
+ /** Number of events permanently dropped since this instance was created. */
204
+ get droppedCount(): number;
205
+ /**
206
+ * Account for permanently lost events: bump the counter, WARN-log, and
207
+ * invoke the opt-in onDrop hook. The buffer is drained at flush start, so
208
+ * drops here are bounded by flush cadence — no log throttle needed.
209
+ */
210
+ private recordDrop;
211
+ /**
212
+ * Account for an event that failed a client-side check: never buffered,
213
+ * never sent. Runs on the hot path, so the WARN is throttled per field
214
+ * (first occurrence, then every 1000th).
215
+ */
216
+ private recordInvalid;
217
+ private notifyDrop;
218
+ private startTimer;
219
+ /** Flush any buffered events and stop the background timer. Call before process exit. */
220
+ shutdown(): Promise<void>;
221
+ }
222
+ /**
223
+ * Cut `value` to at most `max` UTF-16 code units — how the ingestor counts
224
+ * (`String.length()` in Java) — without leaving half a surrogate pair.
225
+ */
226
+ export declare function truncateToLimit(value: string, max: number): string;
227
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,640 @@
1
+ "use strict";
2
+ /**
3
+ * @aforoai/mqtt-metering — Aforo MQTT Metering SDK
4
+ *
5
+ * Two integration modes:
6
+ *
7
+ * 1. **Broker hook** (preferred) — wrapAedesBroker() attaches to every PUBLISH,
8
+ * SUBSCRIBE, UNSUBSCRIBE, CONNECT, DISCONNECT event flowing through an
9
+ * Aedes broker instance. Use this when you operate the broker yourself.
10
+ *
11
+ * 2. **Client proxy** — wrapMqttClient() wraps an mqtt.js client to meter
12
+ * outbound publishes + inbound deliveries from the client's perspective.
13
+ * Use this when you consume a third-party broker (AWS IoT, HiveMQ Cloud,
14
+ * EMQ X Cloud) and need client-side billing.
15
+ *
16
+ * Usage (Aedes broker):
17
+ * import aedes from 'aedes';
18
+ * import { AforoMqttBilling } from '@aforoai/mqtt-metering';
19
+ *
20
+ * const billing = new AforoMqttBilling({
21
+ * tenantId: 'tenant_acme',
22
+ * productId: 'prod_mqtt_telemetry',
23
+ * apiKey: process.env.AFORO_API_KEY!,
24
+ * ingestorUrl: 'https://api.aforo.ai',
25
+ * // productType defaults to 'MQTT_BROKER'
26
+ * });
27
+ *
28
+ * const broker = aedes();
29
+ * billing.wrapAedesBroker(broker, {
30
+ * resolveCustomerId: (clientId) => customerIdLookup(clientId),
31
+ * });
32
+ */
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.AforoMqttBilling = exports.mqttDisconnectTopic = exports.mqttConnectTopic = exports.DEFAULT_PRODUCT_TYPE = void 0;
35
+ exports.truncateToLimit = truncateToLimit;
36
+ const node_crypto_1 = require("node:crypto");
37
+ const SDK_VERSION = '1.2.2';
38
+ /** Default top-level `productType` for this SDK. */
39
+ 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
+ /**
43
+ * mqttTopic is required on every MQTT_BROKER event, but CONNECT / DISCONNECT
44
+ * have no topic. These per-client `$SYS/clients/<id>/connected|disconnected`
45
+ * topics stand in for them (`$SYS/` is reserved for the broker by the MQTT
46
+ * spec, so they can never collide with a client topic). Same topics as the
47
+ * Python, Go and Java SDKs.
48
+ */
49
+ const mqttConnectTopic = (clientId) => `$SYS/clients/${clientId || 'unknown'}/connected`;
50
+ exports.mqttConnectTopic = mqttConnectTopic;
51
+ const mqttDisconnectTopic = (clientId) => `$SYS/clients/${clientId || 'unknown'}/disconnected`;
52
+ exports.mqttDisconnectTopic = mqttDisconnectTopic;
53
+ /** The ingestor rejects batch requests with more than 1000 events. */
54
+ const MAX_BATCH_EVENTS = 1000;
55
+ /** usage-ingestor limits (IngestUsageEventRequest). */
56
+ const MAX_CUSTOMER_ID = 64;
57
+ const MAX_IDEMPOTENCY_KEY = 255;
58
+ const MAX_PRODUCT_TYPE = 20;
59
+ const MAX_MQTT_TOPIC = 500;
60
+ const MAX_MQTT_CLIENT_ID = 128;
61
+ class AforoMqttBilling {
62
+ config;
63
+ productType;
64
+ flushCount;
65
+ flushIntervalMs;
66
+ emitDeliverEvents;
67
+ onError;
68
+ onDrop;
69
+ buffer = [];
70
+ flushTimer = null;
71
+ dropped = 0;
72
+ /** Invalid-event count per field, for WARN throttling. */
73
+ invalidSeen = new Map();
74
+ /** Request-derived labels already reported as truncated (one WARN per label). */
75
+ truncationWarned = new Set();
76
+ constructor(config) {
77
+ this.config = {
78
+ tenantId: config.tenantId,
79
+ productId: config.productId,
80
+ apiKey: config.apiKey,
81
+ ingestorUrl: config.ingestorUrl,
82
+ };
83
+ this.productType = normalizeProductType(config.productType) ?? exports.DEFAULT_PRODUCT_TYPE;
84
+ this.flushCount = config.flushCount ?? 200;
85
+ this.flushIntervalMs = config.flushIntervalMs ?? 2000;
86
+ this.emitDeliverEvents = config.emitDeliverEvents ?? false;
87
+ this.onError = config.onError ?? ((err) => console.error('[aforo-mqtt]', err.message));
88
+ this.onDrop = config.onDrop;
89
+ this.startTimer();
90
+ }
91
+ // ── Broker-side integration (Aedes) ─────────────────────────────
92
+ wrapAedesBroker(broker, options) {
93
+ const resolveCustomer = async (clientId, username) => Promise.resolve(options.resolveCustomerId(clientId, username));
94
+ const status = options.executionStatus;
95
+ const brokerStatus = (clientId) => typeof status === 'function' ? (event) => status(event, clientId) : status;
96
+ broker.on('publish', async (packet, client) => {
97
+ if (!client)
98
+ 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);
116
+ });
117
+ broker.on('subscribe', async (subs, client) => {
118
+ const clientId = client?.id;
119
+ if (!clientId)
120
+ return;
121
+ const customerId = await resolveCustomer(clientId, client.username);
122
+ if (!customerId)
123
+ return;
124
+ const arr = Array.isArray(subs) ? subs : [subs];
125
+ for (const s of arr) {
126
+ this.push({
127
+ customerId,
128
+ mqttTopic: this.topicLabel(s.topic),
129
+ mqttQos: s.qos ?? 0,
130
+ mqttRetained: false,
131
+ mqttEventType: 'SUBSCRIBE',
132
+ mqttClientId: this.clientIdLabel(clientId),
133
+ productType: options.productType,
134
+ dataBytes: 0,
135
+ metadata: options.resolveMetadata?.(clientId),
136
+ }, brokerStatus(clientId), clientId);
137
+ }
138
+ });
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) {
148
+ this.push({
149
+ customerId,
150
+ mqttTopic: this.topicLabel(typeof topic === 'string' ? topic : topic?.topic ?? ''),
151
+ mqttQos: 0,
152
+ mqttRetained: false,
153
+ mqttEventType: 'UNSUBSCRIBE',
154
+ mqttClientId: this.clientIdLabel(clientId),
155
+ productType: options.productType,
156
+ dataBytes: 0,
157
+ metadata: options.resolveMetadata?.(clientId),
158
+ }, brokerStatus(clientId), clientId);
159
+ }
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);
196
+ });
197
+ }
198
+ // ── Client-side integration (mqtt.js) ───────────────────────────
199
+ wrapMqttClient(client, options) {
200
+ const clientId = options.clientId ?? client.options?.clientId ?? 'mqtt-client';
201
+ client.on('connect', () => {
202
+ this.push({
203
+ customerId: options.customerId,
204
+ mqttTopic: (0, exports.mqttConnectTopic)(this.clientIdLabel(clientId)),
205
+ mqttQos: 0,
206
+ mqttRetained: false,
207
+ mqttEventType: 'CONNECT',
208
+ mqttClientId: this.clientIdLabel(clientId),
209
+ productType: options.productType,
210
+ dataBytes: 0,
211
+ }, options.executionStatus, clientId);
212
+ });
213
+ client.on('close', () => {
214
+ this.push({
215
+ customerId: options.customerId,
216
+ mqttTopic: (0, exports.mqttDisconnectTopic)(this.clientIdLabel(clientId)),
217
+ mqttQos: 0,
218
+ mqttRetained: false,
219
+ mqttEventType: 'DISCONNECT',
220
+ mqttClientId: this.clientIdLabel(clientId),
221
+ productType: options.productType,
222
+ dataBytes: 0,
223
+ }, options.executionStatus, clientId);
224
+ });
225
+ client.on('message', (topic, payload, packet) => {
226
+ this.push({
227
+ customerId: options.customerId,
228
+ mqttTopic: this.topicLabel(topic),
229
+ mqttQos: packet?.qos ?? 0,
230
+ mqttRetained: !!packet?.retain,
231
+ mqttEventType: 'DELIVER',
232
+ mqttClientId: this.clientIdLabel(clientId),
233
+ productType: options.productType,
234
+ dataBytes: estimateBytes(payload),
235
+ }, options.executionStatus, clientId);
236
+ });
237
+ const origPublish = client.publish.bind(client);
238
+ 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);
250
+ };
251
+ }
252
+ // ── Event pipeline ──────────────────────────────────────────────
253
+ /**
254
+ * Bound an MQTT label (topic, client id) to the ingestor's limit. The event
255
+ * is still sent; one WARN per label name per client. Identity fields
256
+ * (customerId, productType) never go through here — an over-long one drops
257
+ * the event as 'invalid'.
258
+ */
259
+ requestLabel(field, value, max) {
260
+ if (typeof value !== 'string' || value.length <= max)
261
+ return value;
262
+ if (!this.truncationWarned.has(field)) {
263
+ this.truncationWarned.add(field);
264
+ console.warn(`[aforo-mqtt] ${field} was longer than the ingestor's limit and was ` +
265
+ `truncated to ${max} characters; the event is still sent. Logged once per label.`);
266
+ }
267
+ return truncateToLimit(value, max);
268
+ }
269
+ /** Topic off an incoming packet, bound to the ingestor's mqttTopic limit. */
270
+ topicLabel(topic) {
271
+ return typeof topic === 'string' ? this.requestLabel('mqttTopic', topic, MAX_MQTT_TOPIC) : topic;
272
+ }
273
+ /** Client id a connecting client chose, bound to the ingestor's mqttClientId limit. */
274
+ clientIdLabel(clientId) {
275
+ return typeof clientId === 'string' ? this.requestLabel('mqttClientId', clientId, MAX_MQTT_CLIENT_ID) : clientId;
276
+ }
277
+ push(partial, executionStatus,
278
+ /** Untruncated client id for the idempotency key, when `partial.mqttClientId` was cut to the limit. */
279
+ keyClientId) {
280
+ if (!this.emitDeliverEvents && partial.mqttEventType === 'DELIVER')
281
+ 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();
286
+ const mqttClientId = typeof partial.mqttClientId === 'string' ? partial.mqttClientId : String(partial.mqttClientId ?? '');
287
+ const event = {
288
+ ...partial,
289
+ mqttQos: partial.mqttQos === 1 || partial.mqttQos === 2 ? partial.mqttQos : 0,
290
+ mqttClientId,
291
+ metricName: `mqtt_broker.${partial.mqttEventType.toLowerCase()}`,
292
+ quantity: 1,
293
+ occurredAt: now.toISOString(),
294
+ // Minted once, here, from the untruncated client id; the topic is left
295
+ // out. When the client id makes the key longer than the ingestor allows,
296
+ // it is replaced by its SHA-256 digest; the key itself is never cut.
297
+ idempotencyKey: boundedIdempotencyKey(`mqtt:${this.config.tenantId}:`, typeof keyClientId === 'string' ? keyClientId : mqttClientId, `:${partial.mqttEventType}:${now.getTime()}:${randomSuffix()}`),
298
+ productType: normalizeProductType(partial.productType) ?? this.productType,
299
+ metadata: {
300
+ ...(partial.metadata ?? {}),
301
+ sdkVersion: SDK_VERSION,
302
+ productId: this.config.productId,
303
+ },
304
+ };
305
+ const checked = checkExecutionStatus(resolveExecutionStatus(executionStatus, event));
306
+ if (checked.problem)
307
+ this.reportError(new Error(`[aforo-mqtt] ${checked.problem}`));
308
+ if (checked.status)
309
+ event.executionStatus = checked.status;
310
+ // An event the ingestor would reject is not sent (one bad event fails its
311
+ // whole batch): it is dropped here with reason 'invalid', never thrown
312
+ // into the broker / client handlers. mqttTopic is required on every
313
+ // MQTT_BROKER event.
314
+ const invalid = tooLong('customerId', event.customerId, MAX_CUSTOMER_ID) ??
315
+ requiredText('mqttTopic', event.mqttTopic) ??
316
+ tooLong('mqttTopic', event.mqttTopic, MAX_MQTT_TOPIC) ??
317
+ tooLong('mqttClientId', event.mqttClientId, MAX_MQTT_CLIENT_ID) ??
318
+ tooLong('productType', event.productType, MAX_PRODUCT_TYPE);
319
+ 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)
330
+ 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
+ }
336
+ }
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}` : ''}`));
403
+ }
404
+ /** Invoke onError; a hook bug must never break metering or flushing. */
405
+ reportError(err) {
406
+ try {
407
+ this.onError(err);
408
+ }
409
+ catch {
410
+ // ignore
411
+ }
412
+ }
413
+ /** Number of events permanently dropped since this instance was created. */
414
+ get droppedCount() {
415
+ return this.dropped;
416
+ }
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);
426
+ }
427
+ /**
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).
431
+ */
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
+ }
451
+ }
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();
459
+ }
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();
467
+ }
468
+ }
469
+ exports.AforoMqttBilling = AforoMqttBilling;
470
+ function estimateBytes(data) {
471
+ if (data == null)
472
+ return 0;
473
+ if (typeof data === 'string')
474
+ return Buffer.byteLength(data, 'utf8');
475
+ if (Buffer.isBuffer?.(data))
476
+ return data.length;
477
+ if (data?.byteLength != null)
478
+ return data.byteLength;
479
+ return 0;
480
+ }
481
+ /** Statuses the Aforo ingestor accepts (contract/ingest-contract.json, max 20 chars). */
482
+ const CANONICAL_EXECUTION_STATUSES = new Set([
483
+ 'SUCCESS', 'PARTIAL', 'TIMEOUT', 'ERROR', 'VALIDATION_FAILED', 'FAILED',
484
+ 'FAILURE', 'CANCELLED', 'PENDING', 'BLOCKED', 'HITL_REQUIRED',
485
+ ]);
486
+ /**
487
+ * Check a caller-supplied status. `{ status }` when canonical; `{ problem }`
488
+ * when it has to be left off the event (an unknown value would make the
489
+ * ingestor reject the event; a Promise from an async resolver can't be
490
+ * awaited on this path); `{}` when blank or absent.
491
+ */
492
+ function checkExecutionStatus(value) {
493
+ if (value !== null && typeof value === 'object' && typeof value.then === 'function') {
494
+ // Swallow a later rejection so it doesn't surface as an unhandled rejection.
495
+ value.then(undefined, () => { });
496
+ return { problem: 'executionStatus resolver returned a Promise — resolvers must be synchronous; field omitted' };
497
+ }
498
+ const status = normalizeExecutionStatus(value);
499
+ if (!status)
500
+ return {};
501
+ if (!CANONICAL_EXECUTION_STATUSES.has(status)) {
502
+ return {
503
+ problem: `unknown executionStatus "${status.slice(0, 40)}" — field omitted. ` +
504
+ `Expected one of: ${[...CANONICAL_EXECUTION_STATUSES].join(', ')}`,
505
+ };
506
+ }
507
+ return { status };
508
+ }
509
+ /** Trim + upper-case; blank/absent → undefined (key omitted from the wire body). */
510
+ function normalizeExecutionStatus(value) {
511
+ if (typeof value !== 'string')
512
+ return undefined;
513
+ const trimmed = value.trim();
514
+ return trimmed ? trimmed.toUpperCase() : undefined;
515
+ }
516
+ function resolveExecutionStatus(option, event) {
517
+ if (typeof option !== 'function')
518
+ return option;
519
+ try {
520
+ return option(event);
521
+ }
522
+ catch {
523
+ return undefined; // a resolver bug must never break metering
524
+ }
525
+ }
526
+ /** Trim + uppercase a product type; blank/non-string → undefined (unknown values pass through). */
527
+ function normalizeProductType(value) {
528
+ if (typeof value !== 'string')
529
+ return undefined;
530
+ const trimmed = value.trim();
531
+ return trimmed ? trimmed.toUpperCase() : undefined;
532
+ }
533
+ /** The offending value for a WARN line, cut to 80 chars. */
534
+ function preview(value) {
535
+ const text = typeof value === 'string' ? value : String(value);
536
+ return JSON.stringify(text.length > 80 ? `${text.slice(0, 80)}…` : text);
537
+ }
538
+ /** Limits mirror the ingestor's IngestUsageEventRequest; identity values are never truncated. */
539
+ function tooLong(field, value, max) {
540
+ if (typeof value !== 'string' || value.length <= max)
541
+ return undefined;
542
+ return { field, problem: `${field} is ${value.length} chars, limit ${max}: ${preview(value)}` };
543
+ }
544
+ function requiredText(field, value) {
545
+ if (typeof value === 'string' && value.trim())
546
+ return undefined;
547
+ return { field, problem: `${field} is required and must not be blank: ${preview(value)}` };
548
+ }
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
+ /**
593
+ * Cut `value` to at most `max` UTF-16 code units — how the ingestor counts
594
+ * (`String.length()` in Java) — without leaving half a surrogate pair.
595
+ */
596
+ function truncateToLimit(value, max) {
597
+ if (value.length <= max)
598
+ return value;
599
+ let end = Math.max(0, max);
600
+ const last = end > 0 ? value.charCodeAt(end - 1) : 0;
601
+ // A high surrogate at the cut means its low half was cut off: drop it too.
602
+ if (last >= 0xd800 && last <= 0xdbff)
603
+ end -= 1;
604
+ return value.slice(0, end);
605
+ }
606
+ /**
607
+ * Build `head + component + tail`, where `component` is text taken from the
608
+ * incoming request and may be any length. Returned unchanged when it fits the
609
+ * ingestor's idempotencyKey limit; otherwise the component is replaced by the
610
+ * SHA-256 hex digest of its full, untruncated value (and, if the fixed parts
611
+ * alone are too long, `head + component` is). The key is never cut, so the
612
+ * unique tail always survives and two different components never share a key
613
+ * prefix by accident.
614
+ */
615
+ function boundedIdempotencyKey(head, component, tail) {
616
+ const key = `${head}${component}${tail}`;
617
+ if (key.length <= MAX_IDEMPOTENCY_KEY)
618
+ return key;
619
+ const hashed = `${head}${sha256Hex(component)}${tail}`;
620
+ if (hashed.length <= MAX_IDEMPOTENCY_KEY)
621
+ return hashed;
622
+ return `${sha256Hex(head + component)}${tail}`;
623
+ }
624
+ function sha256Hex(text) {
625
+ return (0, node_crypto_1.createHash)('sha256').update(text, 'utf8').digest('hex');
626
+ }
627
+ function randomSuffix() {
628
+ return Math.random().toString(36).slice(2, 10);
629
+ }
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
+ }
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@aforoai/mqtt-metering",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.2.2",
4
+ "description": "Aforo MQTT Metering SDK — aedes broker hooks + mqtt.js client wrapper that meters PUBLISH/DELIVER/SUBSCRIBE/CONNECT/DISCONNECT events to Aforo.",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist",
9
+ "README.md",
10
+ "LICENSE"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "test": "jest --testEnvironment=node",
15
+ "prepublishOnly": "npm run build"
16
+ },
17
+ "keywords": [
18
+ "aforo",
19
+ "billing",
20
+ "mqtt",
21
+ "aedes",
22
+ "iot",
23
+ "metering",
24
+ "pub-sub"
25
+ ],
26
+ "author": "Aforo, Inc.",
27
+ "license": "Apache-2.0",
28
+ "peerDependencies": {
29
+ "mqtt": "^5.0.0"
30
+ },
31
+ "peerDependenciesMeta": {
32
+ "aedes": {
33
+ "optional": true
34
+ },
35
+ "mqtt": {
36
+ "optional": true
37
+ }
38
+ },
39
+ "devDependencies": {
40
+ "@types/jest": "^29.5.0",
41
+ "@types/node": "^20.0.0",
42
+ "aedes": "^0.51.3",
43
+ "jest": "^29.7.0",
44
+ "mqtt": "^5.15.1",
45
+ "ts-jest": "^29.1.0",
46
+ "typescript": "^5.0.0"
47
+ },
48
+ "engines": {
49
+ "node": ">=18"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "git+https://github.com/aforoai/SDKs.git",
57
+ "directory": "aforo-metering-sdks/node-mqtt"
58
+ },
59
+ "homepage": "https://github.com/aforoai/SDKs/tree/main/aforo-metering-sdks/node-mqtt#readme",
60
+ "bugs": {
61
+ "url": "https://github.com/aforoai/SDKs/issues"
62
+ }
63
+ }