@aforoai/mqtt-metering 0.0.0-stage → 1.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 +117 -2
- package/dist/index.d.ts +116 -0
- package/dist/index.js +402 -0
- package/package.json +61 -4
package/README.md
CHANGED
|
@@ -1,3 +1,118 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @aforoai/mqtt-metering
|
|
2
2
|
|
|
3
|
-
|
|
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.0.0 · 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
|
+
> **Not yet on the public npm registry — install from source for now.** `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 when a batch is dropped: after 3 attempts on network errors / 408 / 429 (honouring `Retry-After`) / 5xx, immediately on any other 4xx (not retried), and when a 202 reports per-event failures (`errors[].message`). |
|
|
93
|
+
|
|
94
|
+
Mode-specific options:
|
|
95
|
+
|
|
96
|
+
| Option | Mode | Type | What it does |
|
|
97
|
+
|---|---|---|---|
|
|
98
|
+
| `resolveCustomerId` | broker | `(clientId, username?) => string \| undefined \| Promise<…>` | Required. Map an MQTT client id to a customer. `undefined` → that client's events are not metered. |
|
|
99
|
+
| `resolveMetadata` | broker | `(clientId) => Record<string, unknown> \| undefined` | Optional per-client tags. |
|
|
100
|
+
| `customerId` | client | `string` | Customer to attribute all traffic on this client to. |
|
|
101
|
+
| `clientId` | client | `string` | Fixed client id (defaults to `client.options.clientId`, then `'mqtt-client'`). |
|
|
102
|
+
| `productType` | both | `string` | Product type for this broker's / client's events. Default: the client-level `productType`. |
|
|
103
|
+
|
|
104
|
+
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.
|
|
105
|
+
|
|
106
|
+
Exported symbols: `AforoMqttBilling` (with `wrapAedesBroker` / `wrapMqttClient` / `shutdown`) and the `AforoMqttConfig` / `AedesBrokerOptions` / `MqttClientOptions` types.
|
|
107
|
+
|
|
108
|
+
## Walk me through it
|
|
109
|
+
|
|
110
|
+
Step-by-step from install to a verified event in Aforo: [USER_GUIDE.md](USER_GUIDE.md).
|
|
111
|
+
|
|
112
|
+
## What this doesn't cover
|
|
113
|
+
|
|
114
|
+
- **Fan-out billing is opt-in.** `DELIVER` events are dropped unless `emitDeliverEvents: true`. Default metering counts the PUBLISH, not the per-subscriber fan-out.
|
|
115
|
+
- **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.
|
|
116
|
+
- **Client-mode CONNECT/DISCONNECT use socket lifecycle.** `wrapMqttClient` maps `connect`/`close`; a client that reconnects emits a CONNECT each time.
|
|
117
|
+
- **No persistent buffer.** Events are in memory until flushed; a hard crash before flush drops the buffered batch. `shutdown()` covers graceful exit only.
|
|
118
|
+
- **The SDK does not enforce or read pricing.** It emits usage; rating/billing happens in Aforo.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
export interface AedesBrokerOptions {
|
|
52
|
+
/** Map an MQTT client ID to an Aforo customer ID. Required. */
|
|
53
|
+
resolveCustomerId: (clientId: string, username?: string) => string | undefined | Promise<string | undefined>;
|
|
54
|
+
/** Optional per-client metadata (tags, device type). */
|
|
55
|
+
resolveMetadata?: (clientId: string) => Record<string, unknown> | undefined;
|
|
56
|
+
/** Product type for events from this broker. Default: the client-level `productType`. */
|
|
57
|
+
productType?: string;
|
|
58
|
+
}
|
|
59
|
+
export interface MqttClientOptions {
|
|
60
|
+
/** Customer ID to attribute all traffic on this client to. */
|
|
61
|
+
customerId: string;
|
|
62
|
+
/** Fixed client identifier (defaults to mqtt.js connection options.clientId). */
|
|
63
|
+
clientId?: string;
|
|
64
|
+
/** Product type for events from this client. Default: the client-level `productType`. */
|
|
65
|
+
productType?: string;
|
|
66
|
+
}
|
|
67
|
+
/** Default top-level `productType` for this SDK. */
|
|
68
|
+
export declare const DEFAULT_PRODUCT_TYPE = "MQTT_BROKER";
|
|
69
|
+
/**
|
|
70
|
+
* mqttTopic is required on every MQTT_BROKER event, but CONNECT / DISCONNECT
|
|
71
|
+
* have no topic. These per-client `$SYS/clients/<id>/connected|disconnected`
|
|
72
|
+
* topics stand in for them (`$SYS/` is reserved for the broker by the MQTT
|
|
73
|
+
* spec, so they can never collide with a client topic). Same topics as the
|
|
74
|
+
* Python, Go and Java SDKs.
|
|
75
|
+
*/
|
|
76
|
+
export declare const mqttConnectTopic: (clientId: string) => string;
|
|
77
|
+
export declare const mqttDisconnectTopic: (clientId: string) => string;
|
|
78
|
+
/** Minimal Aedes broker surface — matches the `aedes` package. */
|
|
79
|
+
interface MinimalAedes {
|
|
80
|
+
on(event: 'publish', fn: (packet: any, client: any) => void): void;
|
|
81
|
+
on(event: 'subscribe', fn: (subs: any[] | any, client: any) => void): void;
|
|
82
|
+
on(event: 'unsubscribe', fn: (unsubs: any[] | any, client: any) => void): void;
|
|
83
|
+
on(event: 'client', fn: (client: any) => void): void;
|
|
84
|
+
on(event: 'clientDisconnect', fn: (client: any) => void): void;
|
|
85
|
+
}
|
|
86
|
+
/** Minimal mqtt.js client surface. */
|
|
87
|
+
interface MinimalMqttClient {
|
|
88
|
+
on(event: 'message', fn: (topic: string, payload: Buffer, packet: any) => void): void;
|
|
89
|
+
on(event: 'connect', fn: () => void): void;
|
|
90
|
+
on(event: 'close', fn: () => void): void;
|
|
91
|
+
publish: (topic: string, message: any, opts?: any, cb?: any) => any;
|
|
92
|
+
options?: {
|
|
93
|
+
clientId?: string;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export declare class AforoMqttBilling {
|
|
97
|
+
private readonly config;
|
|
98
|
+
private readonly productType;
|
|
99
|
+
private readonly flushCount;
|
|
100
|
+
private readonly flushIntervalMs;
|
|
101
|
+
private readonly emitDeliverEvents;
|
|
102
|
+
private readonly onError;
|
|
103
|
+
private buffer;
|
|
104
|
+
private flushTimer;
|
|
105
|
+
constructor(config: AforoMqttConfig);
|
|
106
|
+
wrapAedesBroker(broker: MinimalAedes, options: AedesBrokerOptions): void;
|
|
107
|
+
wrapMqttClient(client: MinimalMqttClient, options: MqttClientOptions): void;
|
|
108
|
+
private push;
|
|
109
|
+
private flush;
|
|
110
|
+
private send;
|
|
111
|
+
/** A 202 can still carry per-event rejections: `{accepted, duplicates, failed, errors:[{index, message}]}`. */
|
|
112
|
+
private reportPartialFailures;
|
|
113
|
+
private startTimer;
|
|
114
|
+
shutdown(): Promise<void>;
|
|
115
|
+
}
|
|
116
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
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
|
+
const SDK_VERSION = '1.0.0';
|
|
36
|
+
/** Default top-level `productType` for this SDK. */
|
|
37
|
+
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
|
+
/**
|
|
41
|
+
* mqttTopic is required on every MQTT_BROKER event, but CONNECT / DISCONNECT
|
|
42
|
+
* have no topic. These per-client `$SYS/clients/<id>/connected|disconnected`
|
|
43
|
+
* topics stand in for them (`$SYS/` is reserved for the broker by the MQTT
|
|
44
|
+
* spec, so they can never collide with a client topic). Same topics as the
|
|
45
|
+
* Python, Go and Java SDKs.
|
|
46
|
+
*/
|
|
47
|
+
const mqttConnectTopic = (clientId) => `$SYS/clients/${clientId || 'unknown'}/connected`;
|
|
48
|
+
exports.mqttConnectTopic = mqttConnectTopic;
|
|
49
|
+
const mqttDisconnectTopic = (clientId) => `$SYS/clients/${clientId || 'unknown'}/disconnected`;
|
|
50
|
+
exports.mqttDisconnectTopic = mqttDisconnectTopic;
|
|
51
|
+
/** The ingestor rejects batch requests with more than 1000 events. */
|
|
52
|
+
const MAX_BATCH_EVENTS = 1000;
|
|
53
|
+
/** usage-ingestor limits (IngestUsageEventRequest). */
|
|
54
|
+
const MAX_CUSTOMER_ID = 64;
|
|
55
|
+
const MAX_IDEMPOTENCY_KEY = 255;
|
|
56
|
+
class AforoMqttBilling {
|
|
57
|
+
config;
|
|
58
|
+
productType;
|
|
59
|
+
flushCount;
|
|
60
|
+
flushIntervalMs;
|
|
61
|
+
emitDeliverEvents;
|
|
62
|
+
onError;
|
|
63
|
+
buffer = [];
|
|
64
|
+
flushTimer = null;
|
|
65
|
+
constructor(config) {
|
|
66
|
+
this.config = {
|
|
67
|
+
tenantId: config.tenantId,
|
|
68
|
+
productId: config.productId,
|
|
69
|
+
apiKey: config.apiKey,
|
|
70
|
+
ingestorUrl: config.ingestorUrl,
|
|
71
|
+
};
|
|
72
|
+
this.productType = normalizeProductType(config.productType) ?? exports.DEFAULT_PRODUCT_TYPE;
|
|
73
|
+
this.flushCount = config.flushCount ?? 200;
|
|
74
|
+
this.flushIntervalMs = config.flushIntervalMs ?? 2000;
|
|
75
|
+
this.emitDeliverEvents = config.emitDeliverEvents ?? false;
|
|
76
|
+
this.onError = config.onError ?? ((err) => console.error('[aforo-mqtt]', err.message));
|
|
77
|
+
this.startTimer();
|
|
78
|
+
}
|
|
79
|
+
// ── Broker-side integration (Aedes) ─────────────────────────────
|
|
80
|
+
wrapAedesBroker(broker, options) {
|
|
81
|
+
const resolveCustomer = async (clientId, username) => Promise.resolve(options.resolveCustomerId(clientId, username));
|
|
82
|
+
broker.on('publish', async (packet, client) => {
|
|
83
|
+
if (!client)
|
|
84
|
+
return; // broker-originated publishes skip billing
|
|
85
|
+
const clientId = client.id;
|
|
86
|
+
const customerId = await resolveCustomer(clientId, client.username);
|
|
87
|
+
if (!customerId)
|
|
88
|
+
return;
|
|
89
|
+
const payload = packet.payload;
|
|
90
|
+
const bytes = estimateBytes(payload);
|
|
91
|
+
this.push({
|
|
92
|
+
customerId,
|
|
93
|
+
mqttTopic: packet.topic,
|
|
94
|
+
mqttQos: packet.qos ?? 0,
|
|
95
|
+
mqttRetained: !!packet.retain,
|
|
96
|
+
mqttEventType: 'PUBLISH',
|
|
97
|
+
mqttClientId: clientId,
|
|
98
|
+
productType: options.productType,
|
|
99
|
+
dataBytes: bytes,
|
|
100
|
+
metadata: options.resolveMetadata?.(clientId),
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
broker.on('subscribe', async (subs, client) => {
|
|
104
|
+
const clientId = client?.id;
|
|
105
|
+
if (!clientId)
|
|
106
|
+
return;
|
|
107
|
+
const customerId = await resolveCustomer(clientId, client.username);
|
|
108
|
+
if (!customerId)
|
|
109
|
+
return;
|
|
110
|
+
const arr = Array.isArray(subs) ? subs : [subs];
|
|
111
|
+
for (const s of arr) {
|
|
112
|
+
this.push({
|
|
113
|
+
customerId,
|
|
114
|
+
mqttTopic: s.topic,
|
|
115
|
+
mqttQos: s.qos ?? 0,
|
|
116
|
+
mqttRetained: false,
|
|
117
|
+
mqttEventType: 'SUBSCRIBE',
|
|
118
|
+
mqttClientId: clientId,
|
|
119
|
+
productType: options.productType,
|
|
120
|
+
dataBytes: 0,
|
|
121
|
+
metadata: options.resolveMetadata?.(clientId),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
broker.on('unsubscribe', async (unsubs, client) => {
|
|
126
|
+
const clientId = client?.id;
|
|
127
|
+
if (!clientId)
|
|
128
|
+
return;
|
|
129
|
+
const customerId = await resolveCustomer(clientId, client.username);
|
|
130
|
+
if (!customerId)
|
|
131
|
+
return;
|
|
132
|
+
const arr = Array.isArray(unsubs) ? unsubs : [unsubs];
|
|
133
|
+
for (const topic of arr) {
|
|
134
|
+
this.push({
|
|
135
|
+
customerId,
|
|
136
|
+
mqttTopic: typeof topic === 'string' ? topic : topic?.topic ?? '',
|
|
137
|
+
mqttQos: 0,
|
|
138
|
+
mqttRetained: false,
|
|
139
|
+
mqttEventType: 'UNSUBSCRIBE',
|
|
140
|
+
mqttClientId: clientId,
|
|
141
|
+
productType: options.productType,
|
|
142
|
+
dataBytes: 0,
|
|
143
|
+
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),
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
broker.on('clientDisconnect', async (client) => {
|
|
165
|
+
const clientId = client.id;
|
|
166
|
+
const customerId = await resolveCustomer(clientId, client.username);
|
|
167
|
+
if (!customerId)
|
|
168
|
+
return;
|
|
169
|
+
this.push({
|
|
170
|
+
customerId,
|
|
171
|
+
mqttTopic: (0, exports.mqttDisconnectTopic)(clientId),
|
|
172
|
+
mqttQos: 0,
|
|
173
|
+
mqttRetained: false,
|
|
174
|
+
mqttEventType: 'DISCONNECT',
|
|
175
|
+
mqttClientId: clientId,
|
|
176
|
+
productType: options.productType,
|
|
177
|
+
dataBytes: 0,
|
|
178
|
+
metadata: options.resolveMetadata?.(clientId),
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
// ── Client-side integration (mqtt.js) ───────────────────────────
|
|
183
|
+
wrapMqttClient(client, options) {
|
|
184
|
+
const clientId = options.clientId ?? client.options?.clientId ?? 'mqtt-client';
|
|
185
|
+
client.on('connect', () => {
|
|
186
|
+
this.push({
|
|
187
|
+
customerId: options.customerId,
|
|
188
|
+
mqttTopic: (0, exports.mqttConnectTopic)(clientId),
|
|
189
|
+
mqttQos: 0,
|
|
190
|
+
mqttRetained: false,
|
|
191
|
+
mqttEventType: 'CONNECT',
|
|
192
|
+
mqttClientId: clientId,
|
|
193
|
+
productType: options.productType,
|
|
194
|
+
dataBytes: 0,
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
client.on('close', () => {
|
|
198
|
+
this.push({
|
|
199
|
+
customerId: options.customerId,
|
|
200
|
+
mqttTopic: (0, exports.mqttDisconnectTopic)(clientId),
|
|
201
|
+
mqttQos: 0,
|
|
202
|
+
mqttRetained: false,
|
|
203
|
+
mqttEventType: 'DISCONNECT',
|
|
204
|
+
mqttClientId: clientId,
|
|
205
|
+
productType: options.productType,
|
|
206
|
+
dataBytes: 0,
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
client.on('message', (topic, payload, packet) => {
|
|
210
|
+
this.push({
|
|
211
|
+
customerId: options.customerId,
|
|
212
|
+
mqttTopic: topic,
|
|
213
|
+
mqttQos: packet?.qos ?? 0,
|
|
214
|
+
mqttRetained: !!packet?.retain,
|
|
215
|
+
mqttEventType: 'DELIVER',
|
|
216
|
+
mqttClientId: clientId,
|
|
217
|
+
productType: options.productType,
|
|
218
|
+
dataBytes: estimateBytes(payload),
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
const origPublish = client.publish.bind(client);
|
|
222
|
+
client.publish = (topic, message, opts, cb) => {
|
|
223
|
+
this.push({
|
|
224
|
+
customerId: options.customerId,
|
|
225
|
+
mqttTopic: topic,
|
|
226
|
+
mqttQos: opts?.qos ?? 0,
|
|
227
|
+
mqttRetained: !!opts?.retain,
|
|
228
|
+
mqttEventType: 'PUBLISH',
|
|
229
|
+
mqttClientId: clientId,
|
|
230
|
+
productType: options.productType,
|
|
231
|
+
dataBytes: estimateBytes(message),
|
|
232
|
+
});
|
|
233
|
+
return origPublish(topic, message, opts, cb);
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
// ── Event pipeline ──────────────────────────────────────────────
|
|
237
|
+
push(partial) {
|
|
238
|
+
if (!this.emitDeliverEvents && partial.mqttEventType === 'DELIVER')
|
|
239
|
+
return;
|
|
240
|
+
if (!partial.customerId || !partial.customerId.trim())
|
|
241
|
+
return;
|
|
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);
|
|
251
|
+
const event = {
|
|
252
|
+
...partial,
|
|
253
|
+
mqttTopic: mqttTopic.slice(0, 500),
|
|
254
|
+
mqttQos: partial.mqttQos === 1 || partial.mqttQos === 2 ? partial.mqttQos : 0,
|
|
255
|
+
mqttClientId,
|
|
256
|
+
metricName: `mqtt_broker.${partial.mqttEventType.toLowerCase()}`,
|
|
257
|
+
quantity: 1,
|
|
258
|
+
occurredAt: now.toISOString(),
|
|
259
|
+
// Topic left out (it can be 500 chars); tail-trimmed to keep the unique millis:random suffix.
|
|
260
|
+
idempotencyKey: `mqtt:${this.config.tenantId}:${mqttClientId}:${partial.mqttEventType}:${now.getTime()}:${randomSuffix()}`.slice(-MAX_IDEMPOTENCY_KEY),
|
|
261
|
+
productType: normalizeProductType(partial.productType) ?? this.productType,
|
|
262
|
+
metadata: {
|
|
263
|
+
...(partial.metadata ?? {}),
|
|
264
|
+
sdkVersion: SDK_VERSION,
|
|
265
|
+
productId: this.config.productId,
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
this.buffer.push(event);
|
|
269
|
+
if (this.buffer.length >= this.flushCount) {
|
|
270
|
+
void this.flush();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async flush() {
|
|
274
|
+
if (this.buffer.length === 0)
|
|
275
|
+
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
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async send(batch) {
|
|
283
|
+
// Serialized once, so every retry re-sends the same idempotencyKeys.
|
|
284
|
+
const body = JSON.stringify({ events: batch });
|
|
285
|
+
const maxRetries = 3;
|
|
286
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
287
|
+
let delayMs = Math.pow(2, attempt - 1) * 1000;
|
|
288
|
+
try {
|
|
289
|
+
const res = await fetch(this.config.ingestorUrl.replace(/\/$/, '') + '/v1/ingest/batch', {
|
|
290
|
+
method: 'POST',
|
|
291
|
+
headers: {
|
|
292
|
+
'Content-Type': 'application/json',
|
|
293
|
+
'X-API-Key': this.config.apiKey,
|
|
294
|
+
'X-Tenant-Id': this.config.tenantId,
|
|
295
|
+
},
|
|
296
|
+
body,
|
|
297
|
+
});
|
|
298
|
+
if (res.ok) {
|
|
299
|
+
await this.reportPartialFailures(res);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (!isRetryableStatus(res.status)) {
|
|
303
|
+
const { details } = await readErrorMessages(res);
|
|
304
|
+
this.onError(new Error(`MQTT metering batch rejected with HTTP ${res.status}${details ? ` — ${details}` : ''} (dropped ${batch.length} events, not retried)`));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
delayMs = parseRetryAfter(res) ?? delayMs;
|
|
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)`));
|
|
319
|
+
}
|
|
320
|
+
/** A 202 can still carry per-event rejections: `{accepted, duplicates, failed, errors:[{index, message}]}`. */
|
|
321
|
+
async reportPartialFailures(res) {
|
|
322
|
+
const { failed, details } = await readErrorMessages(res);
|
|
323
|
+
if (failed && failed > 0) {
|
|
324
|
+
this.onError(new Error(`Aforo ingestor rejected ${failed} event(s)${details ? ` — ${details}` : ''}`));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
startTimer() {
|
|
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();
|
|
334
|
+
}
|
|
335
|
+
if (typeof this.flushTimer.unref === 'function')
|
|
336
|
+
this.flushTimer.unref();
|
|
337
|
+
}
|
|
338
|
+
async shutdown() {
|
|
339
|
+
if (this.flushTimer) {
|
|
340
|
+
clearInterval(this.flushTimer);
|
|
341
|
+
this.flushTimer = null;
|
|
342
|
+
}
|
|
343
|
+
await this.flush();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
exports.AforoMqttBilling = AforoMqttBilling;
|
|
347
|
+
function estimateBytes(data) {
|
|
348
|
+
if (data == null)
|
|
349
|
+
return 0;
|
|
350
|
+
if (typeof data === 'string')
|
|
351
|
+
return Buffer.byteLength(data, 'utf8');
|
|
352
|
+
if (Buffer.isBuffer?.(data))
|
|
353
|
+
return data.length;
|
|
354
|
+
if (data?.byteLength != null)
|
|
355
|
+
return data.byteLength;
|
|
356
|
+
return 0;
|
|
357
|
+
}
|
|
358
|
+
/** Trim + uppercase a product type; blank/non-string → undefined (unknown values pass through). */
|
|
359
|
+
function normalizeProductType(value) {
|
|
360
|
+
if (typeof value !== 'string')
|
|
361
|
+
return undefined;
|
|
362
|
+
const trimmed = value.trim();
|
|
363
|
+
return trimmed ? trimmed.toUpperCase() : undefined;
|
|
364
|
+
}
|
|
365
|
+
/** Network errors, 408, 429 and 5xx are transient; every other 4xx (400/401/403/422...) is not. */
|
|
366
|
+
function isRetryableStatus(status) {
|
|
367
|
+
return status === 408 || status === 429 || status >= 500;
|
|
368
|
+
}
|
|
369
|
+
/** Retry-After (delta-seconds or HTTP date) in ms, capped at MAX_RETRY_AFTER_MS. */
|
|
370
|
+
function parseRetryAfter(res) {
|
|
371
|
+
const raw = res?.headers?.get?.('retry-after');
|
|
372
|
+
if (!raw)
|
|
373
|
+
return undefined;
|
|
374
|
+
const secs = Number(raw);
|
|
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;
|
|
381
|
+
}
|
|
382
|
+
/** Reads the ingestor's `errors[].message` entries (first 5) from a response body, if any. */
|
|
383
|
+
async function readErrorMessages(res) {
|
|
384
|
+
if (typeof res?.json !== 'function')
|
|
385
|
+
return { details: '' };
|
|
386
|
+
try {
|
|
387
|
+
const body = await res.json();
|
|
388
|
+
const details = Array.isArray(body?.errors)
|
|
389
|
+
? body.errors.slice(0, 5).map((e) => `#${e?.index}: ${e?.message}`).join('; ')
|
|
390
|
+
: typeof body?.message === 'string' ? body.message : '';
|
|
391
|
+
return { failed: typeof body?.failed === 'number' ? body.failed : undefined, details };
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return { details: '' }; // Non-JSON or empty body — nothing to report.
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
function randomSuffix() {
|
|
398
|
+
return Math.random().toString(36).slice(2, 10);
|
|
399
|
+
}
|
|
400
|
+
function sleep(ms) {
|
|
401
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
402
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aforoai/mqtt-metering",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
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
|
+
}
|