@aforoai/ws-metering 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 ADDED
@@ -0,0 +1,98 @@
1
+ # @aforoai/ws-metering
2
+
3
+ Meter WebSocket connections into Aforo — open, close, bytes, frame counts, and duration — by wrapping a `ws` server, or by tracking any connection that exposes the standard WebSocket event surface (Fastify-WebSocket, Socket.io, Deno, Bun).
4
+
5
+ **Version:** 1.0.0 · Apache-2.0 · [Changelog](CHANGELOG.md) · [User guide](USER_GUIDE.md)
6
+
7
+ ## Install
8
+
9
+ Intended public install (once published):
10
+
11
+ ```bash
12
+ npm i @aforoai/ws-metering ws
13
+ ```
14
+
15
+ > **Not yet on the public npm registry — install from source for now.** `ws` (`^8`) is an **optional** peer dependency — needed only if you use `wrapServer`. `trackConnection` works with any compatible socket.
16
+
17
+ ```bash
18
+ # from the SDKs repo root
19
+ cd SDKs/aforo-metering-sdks/node-ws
20
+ npm install
21
+ npm run build # tsc → dist/
22
+
23
+ # then, from YOUR app
24
+ npm install /absolute/path/to/aforo-metering-sdks/node-ws
25
+ npm install ws # only if you use wrapServer
26
+ ```
27
+
28
+ ## Quickstart
29
+
30
+ ```ts
31
+ import { WebSocketServer } from 'ws';
32
+ import { AforoWsBilling } from '@aforoai/ws-metering';
33
+
34
+ const billing = new AforoWsBilling({
35
+ tenantId: 'tenant_acme',
36
+ productId: 'prod_ws_001',
37
+ apiKey: process.env.AFORO_API_KEY!,
38
+ ingestorUrl: 'https://api.aforo.ai', // SDK appends /v1/ingest/batch
39
+ });
40
+
41
+ const wss = new WebSocketServer({ port: 8080 });
42
+ billing.wrapServer(wss, {
43
+ extractCustomerId: (req) => req.headers['x-customer-id'] as string,
44
+ });
45
+
46
+ process.on('SIGTERM', async () => { await billing.shutdown(); });
47
+ ```
48
+
49
+ For frameworks that don't expose a `ws`-style server, track each socket directly:
50
+
51
+ ```ts
52
+ billing.trackConnection(socket, { customerId: 'cust_001', metadata: { feed: 'market' } });
53
+ ```
54
+
55
+ By default the SDK emits two events per connection — `CONNECTION_OPENED` on connect and `CONNECTION_CLOSED` on close (the billing anchor, carrying aggregated sent/recv counts + bytes + duration). The close event uses `metricName: "websocket_api.connection_closed"`; open and per-frame events use `websocket_api.message`. Events ship to `POST https://api.aforo.ai/v1/ingest/batch` with `X-API-Key: <api_key>` and `X-Tenant-Id: <tenant_id>`.
56
+
57
+ > **Per-frame metering is off by default.** Set `perFrameEvents: true` to emit one event per inbound and outbound frame — high volume, so size your batching accordingly. With it off, individual frames are still counted and rolled into the `CONNECTION_CLOSED` event.
58
+
59
+ ## Configuration
60
+
61
+ `new AforoWsBilling(config)` — `tenantId`, `productId`, `apiKey`, and `ingestorUrl` are required.
62
+
63
+ | Option | Type | Default | What it does |
64
+ |---|---|---|---|
65
+ | `tenantId` | `string` | — (required) | Aforo tenant. Sent as `X-Tenant-Id`. Never read from a client header. |
66
+ | `productId` | `string` | — (required) | Aforo product id; into each event's `metadata.productId`. |
67
+ | `apiKey` | `string` | — (required) | Sent as `X-API-Key: <apiKey>`. |
68
+ | `ingestorUrl` | `string` | — (required) | Ingestion base URL. SDK appends `/v1/ingest/batch`. Use `https://api.aforo.ai`. |
69
+ | `productType` | `string` | `'WEBSOCKET_API'` | Aforo product type sent as top-level `productType` on every event (trimmed + uppercased; unknown values pass through). Override via `wrapServer(wss, { productType })` or `trackConnection(ws, { customerId, productType })`. |
70
+ | `perFrameEvents` | `boolean` | `false` | Emit one event per frame (each direction). Off → frames are aggregated into the close event only. |
71
+ | `flushCount` | `number` | `100` | Buffered events that trigger an immediate flush. Higher default than the base SDK — WS is high-volume. |
72
+ | `flushIntervalMs` | `number` | `3000` | Max ms before a partial batch is flushed. |
73
+ | `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`). |
74
+
75
+ `wrapServer(wss, options)` / `trackConnection(ws, opts)` take the customer resolver:
76
+
77
+ | Option | Where | Type | What it does |
78
+ |---|---|---|---|
79
+ | `extractCustomerId` | `wrapServer` | `(req) => string \| undefined` | Resolve the customer from the upgrade request. `undefined` → connection is not metered. |
80
+ | `extractMetadata` | `wrapServer` | `(req) => Record<string, unknown> \| undefined` | Optional per-connection tags. |
81
+ | `customerId` | `trackConnection` | `string` | Customer to attribute all traffic on this socket to. |
82
+ | `metadata` | `trackConnection` | `Record<string, unknown>` | Optional per-connection tags. |
83
+ | `productType` | both | `string` | Product type for this server's / socket's events. Default: the client-level `productType`. |
84
+
85
+ Close codes map to labels via `WS_CLOSE_REASONS` — `1000 → NORMAL_CLOSURE`, `1006 → ABNORMAL_CLOSURE`, `1009 → MESSAGE_TOO_BIG`, `4000 → IDLE_TIMEOUT`, etc. A socket `error` emits a synthetic `CONNECTION_CLOSED` with `wsCloseReason: INTERNAL_ERROR` and `metadata.event: CONNECTION_ERROR`.
86
+
87
+ Exported symbols: `AforoWsBilling` (with `wrapServer` / `trackConnection` / `shutdown`), the `WS_CLOSE_REASONS` map, and the `AforoWsConfig` / `WrapServerOptions` types.
88
+
89
+ ## Walk me through it
90
+
91
+ Step-by-step from install to a verified event in Aforo: [USER_GUIDE.md](USER_GUIDE.md).
92
+
93
+ ## What this doesn't cover
94
+
95
+ - **`send()` is wrapped by reference.** `trackConnection` reassigns `ws.send` to count outbound frames. If another layer wraps `send` afterward, ordering matters.
96
+ - **No persistent buffer.** Events are in memory until flushed; a hard crash before flush drops the buffered batch. `shutdown()` covers graceful exit only.
97
+ - **Connection identity is per-process.** `wsConnectionId` is a fresh UUID per `trackConnection` call; it does not survive reconnects or correlate across processes.
98
+ - **The SDK does not enforce or read pricing.** It emits usage; rating/billing happens in Aforo.
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @aforoai/ws-metering — Aforo WebSocket Metering SDK
3
+ *
4
+ * Wraps WebSocket server connections to emit three classes of billing events:
5
+ * - CONNECTION_OPENED — once on upgrade completion
6
+ * - MESSAGE — one event per frame (direction + bytes + type)
7
+ * - CONNECTION_CLOSED — once on close, carrying aggregated counters + duration
8
+ *
9
+ * Works with the `ws` library out of the box (wrap WebSocketServer), or use
10
+ * trackConnection() directly for Fastify-WebSocket, Socket.io, Deno, Bun, or
11
+ * anything else that exposes the standard WebSocket event surface.
12
+ *
13
+ * Usage:
14
+ * import { WebSocketServer } from 'ws';
15
+ * import { AforoWsBilling } from '@aforoai/ws-metering';
16
+ *
17
+ * const billing = new AforoWsBilling({
18
+ * tenantId: 'tenant_acme',
19
+ * productId: 'prod_ws_market_feed',
20
+ * apiKey: process.env.AFORO_API_KEY!,
21
+ * ingestorUrl: 'https://api.aforo.ai',
22
+ * // productType defaults to 'WEBSOCKET_API'
23
+ * });
24
+ *
25
+ * const wss = new WebSocketServer({ port: 8080 });
26
+ * billing.wrapServer(wss, {
27
+ * extractCustomerId: (req) => req.headers['x-customer-id'] as string,
28
+ * });
29
+ */
30
+ export interface AforoWsConfig {
31
+ tenantId: string;
32
+ productId: string;
33
+ apiKey: string;
34
+ ingestorUrl: string;
35
+ /**
36
+ * Aforo product type sent as top-level `productType` on every event (trimmed + uppercased).
37
+ * Default: `WEBSOCKET_API`. Override per integration via `wrapServer(wss, { productType })` / `trackConnection(ws, { productType })`.
38
+ */
39
+ productType?: string;
40
+ /** How many events to buffer before flushing (default 100 — WS is high-volume). */
41
+ flushCount?: number;
42
+ /** Max interval in ms before a partial batch is flushed (default 3000). */
43
+ flushIntervalMs?: number;
44
+ /** If true, emit one MESSAGE event per frame. If false (default) only aggregate on close. */
45
+ perFrameEvents?: boolean;
46
+ /** Callback for terminal flush failures. */
47
+ onError?: (error: Error) => void;
48
+ }
49
+ export interface WrapServerOptions {
50
+ /** Extract Aforo customer ID from the upgrade request. */
51
+ extractCustomerId: (req: any) => string | undefined;
52
+ /** Optional per-connection metadata (product-defined tags). */
53
+ extractMetadata?: (req: any) => Record<string, unknown> | undefined;
54
+ /** Product type for connections accepted by this server. Default: the client-level `productType`. */
55
+ productType?: string;
56
+ }
57
+ /** Minimal WebSocket surface — matches `ws` WebSocket, Fastify socket, Deno, Bun. */
58
+ interface MinimalWs {
59
+ on(event: 'message', fn: (data: any, isBinary?: boolean) => void): void;
60
+ on(event: 'close', fn: (code: number, reason: Buffer | string) => void): void;
61
+ on(event: 'error', fn: (err: Error) => void): void;
62
+ send(data: any, cb?: (err?: Error) => void): void;
63
+ readyState?: number;
64
+ }
65
+ /** Minimal WebSocketServer surface — matches `ws` WebSocketServer. */
66
+ interface MinimalWss {
67
+ on(event: 'connection', fn: (ws: MinimalWs, req: any) => void): void;
68
+ }
69
+ /** Default top-level `productType` for this SDK. */
70
+ export declare const DEFAULT_PRODUCT_TYPE = "WEBSOCKET_API";
71
+ export declare class AforoWsBilling {
72
+ private readonly config;
73
+ private readonly productType;
74
+ private readonly flushCount;
75
+ private readonly flushIntervalMs;
76
+ private readonly perFrameEvents;
77
+ private readonly onError;
78
+ private buffer;
79
+ private flushTimer;
80
+ constructor(config: AforoWsConfig);
81
+ /** Wrap a `ws` WebSocketServer (or any object emitting 'connection' events). */
82
+ wrapServer(wss: MinimalWss, options: WrapServerOptions): void;
83
+ /** Track a single WebSocket connection. Returns an unsubscribe function. */
84
+ trackConnection(ws: MinimalWs, opts: {
85
+ customerId: string;
86
+ metadata?: Record<string, unknown>;
87
+ productType?: string;
88
+ }): () => void;
89
+ private push;
90
+ private flush;
91
+ private send;
92
+ /** A 202 can still carry per-event rejections: `{accepted, duplicates, failed, errors:[{index, message}]}`. */
93
+ private reportPartialFailures;
94
+ private startTimer;
95
+ shutdown(): Promise<void>;
96
+ }
97
+ export declare const WS_CLOSE_REASONS: Record<number, string>;
98
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,354 @@
1
+ "use strict";
2
+ /**
3
+ * @aforoai/ws-metering — Aforo WebSocket Metering SDK
4
+ *
5
+ * Wraps WebSocket server connections to emit three classes of billing events:
6
+ * - CONNECTION_OPENED — once on upgrade completion
7
+ * - MESSAGE — one event per frame (direction + bytes + type)
8
+ * - CONNECTION_CLOSED — once on close, carrying aggregated counters + duration
9
+ *
10
+ * Works with the `ws` library out of the box (wrap WebSocketServer), or use
11
+ * trackConnection() directly for Fastify-WebSocket, Socket.io, Deno, Bun, or
12
+ * anything else that exposes the standard WebSocket event surface.
13
+ *
14
+ * Usage:
15
+ * import { WebSocketServer } from 'ws';
16
+ * import { AforoWsBilling } from '@aforoai/ws-metering';
17
+ *
18
+ * const billing = new AforoWsBilling({
19
+ * tenantId: 'tenant_acme',
20
+ * productId: 'prod_ws_market_feed',
21
+ * apiKey: process.env.AFORO_API_KEY!,
22
+ * ingestorUrl: 'https://api.aforo.ai',
23
+ * // productType defaults to 'WEBSOCKET_API'
24
+ * });
25
+ *
26
+ * const wss = new WebSocketServer({ port: 8080 });
27
+ * billing.wrapServer(wss, {
28
+ * extractCustomerId: (req) => req.headers['x-customer-id'] as string,
29
+ * });
30
+ */
31
+ Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.WS_CLOSE_REASONS = exports.AforoWsBilling = exports.DEFAULT_PRODUCT_TYPE = void 0;
33
+ const node_crypto_1 = require("node:crypto");
34
+ const SDK_VERSION = '1.0.0';
35
+ /** Default top-level `productType` for this SDK. */
36
+ exports.DEFAULT_PRODUCT_TYPE = 'WEBSOCKET_API';
37
+ /** Upper bound on a server-requested Retry-After wait. */
38
+ const MAX_RETRY_AFTER_MS = 30_000;
39
+ /** The ingestor rejects batch requests with more than 1000 events. */
40
+ const MAX_BATCH_EVENTS = 1000;
41
+ /** usage-ingestor limits (IngestUsageEventRequest). */
42
+ const MAX_CUSTOMER_ID = 64;
43
+ const MAX_IDEMPOTENCY_KEY = 255;
44
+ // Close reason code → descriptor enum label
45
+ const CLOSE_REASONS = {
46
+ 1000: 'NORMAL_CLOSURE',
47
+ 1001: 'GOING_AWAY',
48
+ 1002: 'PROTOCOL_ERROR',
49
+ 1003: 'UNSUPPORTED_DATA',
50
+ 1005: 'NORMAL_CLOSURE', // no status
51
+ 1006: 'ABNORMAL_CLOSURE',
52
+ 1007: 'PROTOCOL_ERROR',
53
+ 1008: 'POLICY_VIOLATION',
54
+ 1009: 'MESSAGE_TOO_BIG',
55
+ 1011: 'INTERNAL_ERROR',
56
+ 1012: 'GOING_AWAY',
57
+ 4000: 'IDLE_TIMEOUT', // common app-level range
58
+ };
59
+ class AforoWsBilling {
60
+ config;
61
+ productType;
62
+ flushCount;
63
+ flushIntervalMs;
64
+ perFrameEvents;
65
+ onError;
66
+ buffer = [];
67
+ flushTimer = null;
68
+ constructor(config) {
69
+ this.config = {
70
+ tenantId: config.tenantId,
71
+ productId: config.productId,
72
+ apiKey: config.apiKey,
73
+ ingestorUrl: config.ingestorUrl,
74
+ };
75
+ this.productType = normalizeProductType(config.productType) ?? exports.DEFAULT_PRODUCT_TYPE;
76
+ this.flushCount = config.flushCount ?? 100;
77
+ this.flushIntervalMs = config.flushIntervalMs ?? 3000;
78
+ this.perFrameEvents = config.perFrameEvents ?? false;
79
+ this.onError = config.onError ?? ((err) => console.error('[aforo-ws]', err.message));
80
+ this.startTimer();
81
+ }
82
+ /** Wrap a `ws` WebSocketServer (or any object emitting 'connection' events). */
83
+ wrapServer(wss, options) {
84
+ wss.on('connection', (ws, req) => {
85
+ const customerId = options.extractCustomerId(req);
86
+ if (!customerId || !customerId.trim())
87
+ return; // no customer resolved → skip metering
88
+ const metadata = options.extractMetadata?.(req);
89
+ this.trackConnection(ws, { customerId, metadata, productType: options.productType });
90
+ });
91
+ }
92
+ /** Track a single WebSocket connection. Returns an unsubscribe function. */
93
+ trackConnection(ws, opts) {
94
+ if (!opts.customerId || !opts.customerId.trim())
95
+ return () => { };
96
+ if (opts.customerId.length > MAX_CUSTOMER_ID) {
97
+ this.onError(new Error(`WebSocket metering: customerId longer than ${MAX_CUSTOMER_ID} chars; connection not metered`));
98
+ return () => { };
99
+ }
100
+ const connectionId = (0, node_crypto_1.randomUUID)();
101
+ const productType = normalizeProductType(opts.productType) ?? this.productType;
102
+ const start = Date.now();
103
+ let sentCount = 0;
104
+ let recvCount = 0;
105
+ let sentBytes = 0;
106
+ let recvBytes = 0;
107
+ // Emit CONNECTION_OPENED immediately
108
+ this.push({
109
+ customerId: opts.customerId,
110
+ wsConnectionId: connectionId,
111
+ productType,
112
+ wsDirection: 'SERVER_TO_CLIENT',
113
+ wsFrameType: 'PING', // "handshake complete" marker; not an actual frame
114
+ messageCount: 0,
115
+ dataBytes: 0,
116
+ executionDurationMs: 0,
117
+ metadata: { ...(opts.metadata ?? {}), event: 'CONNECTION_OPENED' },
118
+ });
119
+ ws.on('message', (data, isBinary) => {
120
+ recvCount++;
121
+ const bytes = estimateBytes(data);
122
+ recvBytes += bytes;
123
+ if (this.perFrameEvents) {
124
+ this.push({
125
+ customerId: opts.customerId,
126
+ wsConnectionId: connectionId,
127
+ productType,
128
+ wsDirection: 'CLIENT_TO_SERVER',
129
+ wsFrameType: isBinary ? 'BINARY' : 'TEXT',
130
+ messageCount: 1,
131
+ dataBytes: bytes,
132
+ executionDurationMs: Date.now() - start,
133
+ metadata: opts.metadata,
134
+ });
135
+ }
136
+ });
137
+ // Wrap send() to count outbound frames
138
+ const origSend = ws.send.bind(ws);
139
+ ws.send = (data, cb) => {
140
+ sentCount++;
141
+ const bytes = estimateBytes(data);
142
+ sentBytes += bytes;
143
+ if (this.perFrameEvents) {
144
+ this.push({
145
+ customerId: opts.customerId,
146
+ wsConnectionId: connectionId,
147
+ productType,
148
+ wsDirection: 'SERVER_TO_CLIENT',
149
+ wsFrameType: typeof data === 'string' ? 'TEXT' : 'BINARY',
150
+ messageCount: 1,
151
+ dataBytes: bytes,
152
+ executionDurationMs: Date.now() - start,
153
+ metadata: opts.metadata,
154
+ });
155
+ }
156
+ return origSend(data, cb);
157
+ };
158
+ ws.on('close', (code) => {
159
+ // Emit CONNECTION_CLOSED with aggregated counters — this is the billing anchor.
160
+ this.push({
161
+ customerId: opts.customerId,
162
+ wsConnectionId: connectionId,
163
+ productType,
164
+ wsDirection: 'SERVER_TO_CLIENT',
165
+ wsFrameType: 'CLOSE',
166
+ wsCloseReason: CLOSE_REASONS[code] ?? 'NORMAL_CLOSURE',
167
+ messageCount: sentCount + recvCount,
168
+ dataBytes: sentBytes + recvBytes,
169
+ executionDurationMs: Date.now() - start,
170
+ metadata: {
171
+ ...(opts.metadata ?? {}),
172
+ event: 'CONNECTION_CLOSED',
173
+ sentCount,
174
+ recvCount,
175
+ sentBytes,
176
+ recvBytes,
177
+ closeCode: code,
178
+ },
179
+ });
180
+ });
181
+ ws.on('error', (err) => {
182
+ this.push({
183
+ customerId: opts.customerId,
184
+ wsConnectionId: connectionId,
185
+ productType,
186
+ wsDirection: 'SERVER_TO_CLIENT',
187
+ wsFrameType: 'CLOSE',
188
+ wsCloseReason: 'INTERNAL_ERROR',
189
+ messageCount: sentCount + recvCount,
190
+ dataBytes: sentBytes + recvBytes,
191
+ executionDurationMs: Date.now() - start,
192
+ metadata: { ...(opts.metadata ?? {}), event: 'CONNECTION_ERROR', error: err.message },
193
+ });
194
+ });
195
+ return () => {
196
+ // Tracking lifetime is managed by the close handler — no manual cleanup needed.
197
+ };
198
+ }
199
+ push(partial) {
200
+ const now = new Date();
201
+ const event = {
202
+ ...partial,
203
+ metricName: partial.wsFrameType === 'CLOSE'
204
+ ? 'websocket_api.connection_closed'
205
+ : 'websocket_api.message',
206
+ quantity: 1,
207
+ occurredAt: now.toISOString(),
208
+ // Tail-trimmed to the ingestor's limit, keeping the unique millis:random suffix.
209
+ idempotencyKey: `ws:${this.config.tenantId}:${partial.wsConnectionId}:${partial.wsFrameType}:${now.getTime()}:${randomSuffix()}`.slice(-MAX_IDEMPOTENCY_KEY),
210
+ metadata: {
211
+ ...(partial.metadata ?? {}),
212
+ sdkVersion: SDK_VERSION,
213
+ productId: this.config.productId,
214
+ },
215
+ };
216
+ this.buffer.push(event);
217
+ if (this.buffer.length >= this.flushCount) {
218
+ void this.flush();
219
+ }
220
+ }
221
+ async flush() {
222
+ if (this.buffer.length === 0)
223
+ return;
224
+ const pending = this.buffer.splice(0, this.buffer.length);
225
+ // The ingestor accepts at most MAX_BATCH_EVENTS events per request.
226
+ for (let i = 0; i < pending.length; i += MAX_BATCH_EVENTS) {
227
+ await this.send(pending.slice(i, i + MAX_BATCH_EVENTS));
228
+ }
229
+ }
230
+ async send(batch) {
231
+ // Serialized once, so every retry re-sends the same idempotencyKeys.
232
+ const body = JSON.stringify({ events: batch });
233
+ const maxRetries = 3;
234
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
235
+ let delayMs = Math.pow(2, attempt - 1) * 1000;
236
+ try {
237
+ const res = await fetch(this.config.ingestorUrl.replace(/\/$/, '') + '/v1/ingest/batch', {
238
+ method: 'POST',
239
+ headers: {
240
+ 'Content-Type': 'application/json',
241
+ 'X-API-Key': this.config.apiKey,
242
+ 'X-Tenant-Id': this.config.tenantId,
243
+ },
244
+ body,
245
+ });
246
+ if (res.ok) {
247
+ await this.reportPartialFailures(res);
248
+ return;
249
+ }
250
+ if (!isRetryableStatus(res.status)) {
251
+ const { details } = await readErrorMessages(res);
252
+ this.onError(new Error(`WebSocket metering batch rejected with HTTP ${res.status}${details ? ` — ${details}` : ''} (dropped ${batch.length} events, not retried)`));
253
+ return;
254
+ }
255
+ delayMs = parseRetryAfter(res) ?? delayMs;
256
+ }
257
+ catch (err) {
258
+ if (attempt === maxRetries) {
259
+ this.onError(err);
260
+ return;
261
+ }
262
+ }
263
+ if (attempt < maxRetries)
264
+ await sleep(delayMs);
265
+ }
266
+ this.onError(new Error(`WebSocket metering flush failed after ${maxRetries} attempts (dropped ${batch.length} events)`));
267
+ }
268
+ /** A 202 can still carry per-event rejections: `{accepted, duplicates, failed, errors:[{index, message}]}`. */
269
+ async reportPartialFailures(res) {
270
+ const { failed, details } = await readErrorMessages(res);
271
+ if (failed && failed > 0) {
272
+ this.onError(new Error(`Aforo ingestor rejected ${failed} event(s)${details ? ` — ${details}` : ''}`));
273
+ }
274
+ }
275
+ startTimer() {
276
+ if (this.flushTimer)
277
+ return;
278
+ this.flushTimer = setInterval(() => { void this.flush(); }, this.flushIntervalMs);
279
+ // Unref so the background timer never blocks host-process exit (final flush still needs shutdown()).
280
+ if (this.flushTimer && typeof this.flushTimer === 'object' && 'unref' in this.flushTimer) {
281
+ this.flushTimer.unref();
282
+ }
283
+ if (typeof this.flushTimer.unref === 'function')
284
+ this.flushTimer.unref();
285
+ }
286
+ async shutdown() {
287
+ if (this.flushTimer) {
288
+ clearInterval(this.flushTimer);
289
+ this.flushTimer = null;
290
+ }
291
+ await this.flush();
292
+ }
293
+ }
294
+ exports.AforoWsBilling = AforoWsBilling;
295
+ // ── Helpers ──────────────────────────────────────────────────────
296
+ function estimateBytes(data) {
297
+ if (data == null)
298
+ return 0;
299
+ if (typeof data === 'string')
300
+ return Buffer.byteLength(data, 'utf8');
301
+ if (Buffer.isBuffer?.(data))
302
+ return data.length;
303
+ if (data?.byteLength != null)
304
+ return data.byteLength;
305
+ if (Array.isArray(data))
306
+ return data.reduce((s, d) => s + estimateBytes(d), 0);
307
+ return 0;
308
+ }
309
+ /** Trim + uppercase a product type; blank/non-string → undefined (unknown values pass through). */
310
+ function normalizeProductType(value) {
311
+ if (typeof value !== 'string')
312
+ return undefined;
313
+ const trimmed = value.trim();
314
+ return trimmed ? trimmed.toUpperCase() : undefined;
315
+ }
316
+ /** Network errors, 408, 429 and 5xx are transient; every other 4xx (400/401/403/422...) is not. */
317
+ function isRetryableStatus(status) {
318
+ return status === 408 || status === 429 || status >= 500;
319
+ }
320
+ /** Retry-After (delta-seconds or HTTP date) in ms, capped at MAX_RETRY_AFTER_MS. */
321
+ function parseRetryAfter(res) {
322
+ const raw = res?.headers?.get?.('retry-after');
323
+ if (!raw)
324
+ return undefined;
325
+ const secs = Number(raw);
326
+ if (Number.isFinite(secs) && secs >= 0)
327
+ return Math.min(secs * 1000, MAX_RETRY_AFTER_MS);
328
+ const date = Date.parse(raw);
329
+ if (!Number.isNaN(date))
330
+ return Math.min(Math.max(date - Date.now(), 0), MAX_RETRY_AFTER_MS);
331
+ return undefined;
332
+ }
333
+ /** Reads the ingestor's `errors[].message` entries (first 5) from a response body, if any. */
334
+ async function readErrorMessages(res) {
335
+ if (typeof res?.json !== 'function')
336
+ return { details: '' };
337
+ try {
338
+ const body = await res.json();
339
+ const details = Array.isArray(body?.errors)
340
+ ? body.errors.slice(0, 5).map((e) => `#${e?.index}: ${e?.message}`).join('; ')
341
+ : typeof body?.message === 'string' ? body.message : '';
342
+ return { failed: typeof body?.failed === 'number' ? body.failed : undefined, details };
343
+ }
344
+ catch {
345
+ return { details: '' }; // Non-JSON or empty body — nothing to report.
346
+ }
347
+ }
348
+ function randomSuffix() {
349
+ return Math.random().toString(36).slice(2, 10);
350
+ }
351
+ function sleep(ms) {
352
+ return new Promise((r) => setTimeout(r, ms));
353
+ }
354
+ exports.WS_CLOSE_REASONS = CLOSE_REASONS;
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@aforoai/ws-metering",
3
+ "version": "1.0.0",
4
+ "description": "Aforo WebSocket Metering SDK — wraps ws/Fastify-WebSocket/Socket.io connections to meter frames, bytes, and connection duration.",
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
+ "websocket",
21
+ "ws",
22
+ "metering",
23
+ "usage-based",
24
+ "streaming"
25
+ ],
26
+ "author": "Aforo, Inc.",
27
+ "license": "Apache-2.0",
28
+ "peerDependencies": {
29
+ "ws": "^8.0.0"
30
+ },
31
+ "peerDependenciesMeta": {
32
+ "ws": {
33
+ "optional": true
34
+ }
35
+ },
36
+ "devDependencies": {
37
+ "@types/jest": "^29.5.0",
38
+ "@types/node": "^20.0.0",
39
+ "@types/ws": "^8.18.1",
40
+ "jest": "^29.7.0",
41
+ "ts-jest": "^29.1.0",
42
+ "typescript": "^5.0.0",
43
+ "ws": "^8.20.0"
44
+ },
45
+ "engines": {
46
+ "node": ">=18"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/aforoai/SDKs.git",
54
+ "directory": "aforo-metering-sdks/node-ws"
55
+ },
56
+ "homepage": "https://github.com/aforoai/SDKs/tree/main/aforo-metering-sdks/node-ws#readme",
57
+ "bugs": {
58
+ "url": "https://github.com/aforoai/SDKs/issues"
59
+ }
60
+ }