@aforoai/ws-metering 1.2.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,9 +3,12 @@
3
3
  * @aforoai/ws-metering — Aforo WebSocket Metering SDK
4
4
  *
5
5
  * Wraps WebSocket server connections to emit three classes of billing events:
6
- * - CONNECTION_OPENED — once on upgrade completion
6
+ * - CONNECTION_OPENED — once on upgrade completion (wsFrameType OPEN,
7
+ * metric websocket_api.connection_opened)
7
8
  * - MESSAGE — one event per frame (direction + bytes + type)
8
- * - CONNECTION_CLOSED — once on close, carrying aggregated counters + duration
9
+ * - CONNECTION_CLOSED — once per connection, carrying aggregated counters +
10
+ * duration: on close, on a socket error, when an idle
11
+ * connection is swept, or at shutdown
9
12
  *
10
13
  * Works with the `ws` library out of the box (wrap WebSocketServer), or use
11
14
  * trackConnection() directly for Fastify-WebSocket, Socket.io, Deno, Bun, or
@@ -25,19 +28,25 @@
25
28
  *
26
29
  * const wss = new WebSocketServer({ port: 8080 });
27
30
  * billing.wrapServer(wss, {
28
- * extractCustomerId: (req) => req.headers['x-customer-id'] as string,
31
+ * // Who is billed, from identity your app verified at upgrade time.
32
+ * customerResolver: (req) => sessionStore.customerFor(req),
29
33
  * });
34
+ *
35
+ * // The SDK installs no signal handlers. Flush from your own:
36
+ * process.once('SIGTERM', async () => { await billing.shutdown(); process.exit(0); });
30
37
  */
31
38
  Object.defineProperty(exports, "__esModule", { value: true });
32
- exports.WS_CLOSE_REASONS = exports.AforoWsBilling = exports.DEFAULT_PRODUCT_TYPE = void 0;
39
+ exports.WS_CLOSE_REASONS = exports.AforoWsBilling = exports.DEFAULT_PRODUCT_TYPE = exports.DEFAULT_CONNECTION_IDLE_TIMEOUT_MS = exports.CoreTransportError = void 0;
33
40
  const node_crypto_1 = require("node:crypto");
34
- const SDK_VERSION = '1.2.1';
41
+ const core_1 = require("./transport-core/core");
42
+ /** What onError receives for delivery problems: branch on `kind`, read `events`. */
43
+ var core_2 = require("./transport-core/core");
44
+ Object.defineProperty(exports, "CoreTransportError", { enumerable: true, get: function () { return core_2.CoreTransportError; } });
45
+ const SDK_VERSION = '2.0.0';
46
+ /** Default for AforoWsConfig.connectionIdleTimeoutMs: one hour. */
47
+ exports.DEFAULT_CONNECTION_IDLE_TIMEOUT_MS = 60 * 60 * 1000;
35
48
  /** Default top-level `productType` for this SDK. */
36
49
  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
50
  /** usage-ingestor limits (IngestUsageEventRequest). */
42
51
  const MAX_CUSTOMER_ID = 64;
43
52
  const MAX_IDEMPOTENCY_KEY = 255;
@@ -60,41 +69,94 @@ const CLOSE_REASONS = {
60
69
  class AforoWsBilling {
61
70
  config;
62
71
  productType;
63
- flushCount;
64
- flushIntervalMs;
65
72
  perFrameEvents;
66
73
  onError;
67
- onDrop;
68
- buffer = [];
69
- flushTimer = null;
70
- dropped = 0;
71
- /** Invalid-event count per field, for WARN throttling. */
72
- invalidSeen = new Map();
74
+ core;
75
+ idleTimeoutMs;
76
+ connections = new Map();
77
+ sweepTimer;
78
+ shutdownPromise = null;
73
79
  constructor(config) {
80
+ const idle = config.connectionIdleTimeoutMs;
81
+ if (idle !== undefined && (typeof idle !== 'number' || !Number.isFinite(idle) || idle < 0)) {
82
+ throw new Error(`[aforo-ws] connectionIdleTimeoutMs must be a positive number of milliseconds (got ${String(idle)}); ` +
83
+ 'without it connections that die without a close event are never released.');
84
+ }
85
+ this.idleTimeoutMs = idle ? idle : exports.DEFAULT_CONNECTION_IDLE_TIMEOUT_MS;
74
86
  this.config = {
75
87
  tenantId: config.tenantId,
76
88
  productId: config.productId,
77
- apiKey: config.apiKey,
78
- ingestorUrl: config.ingestorUrl,
79
89
  };
80
90
  this.productType = normalizeProductType(config.productType) ?? exports.DEFAULT_PRODUCT_TYPE;
81
- this.flushCount = config.flushCount ?? 100;
82
- this.flushIntervalMs = config.flushIntervalMs ?? 3000;
83
91
  this.perFrameEvents = config.perFrameEvents ?? false;
84
92
  this.onError = config.onError ?? ((err) => console.error('[aforo-ws]', err.message));
85
- this.onDrop = config.onDrop;
86
- this.startTimer();
93
+ const onDrop = config.onDrop;
94
+ // The core is the only holder of the API key.
95
+ this.core = new core_1.TransportCore({
96
+ baseUrl: config.ingestorUrl,
97
+ apiKey: config.apiKey,
98
+ allowInsecureHttp: config.allowInsecureHttp,
99
+ headers: { 'X-Tenant-Id': config.tenantId },
100
+ userAgent: `aforo-ws-metering/${SDK_VERSION}`,
101
+ flushCount: config.flushCount,
102
+ flushIntervalMs: config.flushIntervalMs,
103
+ maxQueueSize: config.maxQueueSize,
104
+ maxRetries: config.maxRetries,
105
+ timeoutMs: config.timeoutMs,
106
+ shutdownTimeoutMs: config.shutdownTimeoutMs,
107
+ flushOnExit: config.flushOnExit,
108
+ logPrefix: '[aforo-ws]',
109
+ onError: (err) => this.reportError(err),
110
+ onDrop: onDrop ? (events, reason) => onDrop(events, reason) : undefined,
111
+ });
112
+ const every = Math.min(60_000, Math.max(1_000, Math.floor(this.idleTimeoutMs / 4)));
113
+ this.sweepTimer = setInterval(() => this.sweepIdleConnections(), every);
114
+ this.sweepTimer.unref?.();
115
+ }
116
+ /** Connections being tracked: opened and not yet closed by the socket, the idle sweep or shutdown(). */
117
+ get openConnections() {
118
+ return this.connections.size;
119
+ }
120
+ /**
121
+ * Close every tracked connection idle longer than `connectionIdleTimeoutMs`
122
+ * at `now` (CONNECTION_CLOSED, ABNORMAL_CLOSURE, `closedBy: "sdk_idle_timeout"`).
123
+ * Runs on a timer; callable directly. Returns how many it closed.
124
+ */
125
+ sweepIdleConnections(now = Date.now()) {
126
+ const cutoff = now - this.idleTimeoutMs;
127
+ let closed = 0;
128
+ for (const conn of Array.from(this.connections.values())) {
129
+ if (conn.lastActivity <= cutoff && conn.close(1006, 'sdk_idle_timeout'))
130
+ closed++;
131
+ }
132
+ return closed;
87
133
  }
88
- /** Wrap a `ws` WebSocketServer (or any object emitting 'connection' events). */
134
+ /**
135
+ * Wrap a `ws` WebSocketServer (or any object emitting 'connection' events).
136
+ * Throws when neither `customerResolver` nor `trustCustomerHeader: true` is given.
137
+ */
89
138
  wrapServer(wss, options) {
139
+ const resolver = options?.customerResolver ?? options?.extractCustomerId;
140
+ const trustHeader = options?.trustCustomerHeader === true;
141
+ if (typeof resolver !== 'function' && !trustHeader) {
142
+ throw new Error('[aforo-ws] wrapServer needs a customerResolver: (req) => the Aforo customer id from identity your app ' +
143
+ 'verified on the upgrade request. To bill the x-customer-id header instead — only safe behind a proxy ' +
144
+ 'that sets it — pass trustCustomerHeader: true.');
145
+ }
90
146
  wss.on('connection', (ws, req) => {
91
- const customerId = options.extractCustomerId(req);
92
- if (!customerId || !customerId.trim())
93
- return; // no customer resolved → skip metering
147
+ let customerId;
148
+ try {
149
+ customerId = textOrUndefined(typeof resolver === 'function' ? resolver(req) : undefined);
150
+ if (!customerId && trustHeader)
151
+ customerId = textOrUndefined(headerValue(req?.headers?.['x-customer-id']));
152
+ }
153
+ catch (err) {
154
+ this.reportError(new Error(`[aforo-ws] customerResolver threw: ${err?.message ?? err}`));
155
+ }
94
156
  const metadata = options.extractMetadata?.(req);
95
157
  const status = options.executionStatus;
96
158
  this.trackConnection(ws, {
97
- customerId,
159
+ customerId: customerId ?? '',
98
160
  metadata,
99
161
  executionStatus: typeof status === 'function' ? (event) => status(event, req) : status,
100
162
  productType: options.productType,
@@ -103,29 +165,83 @@ class AforoWsBilling {
103
165
  }
104
166
  /** Track a single WebSocket connection. Returns an unsubscribe function. */
105
167
  trackConnection(ws, opts) {
106
- // No customer resolved — the connection is not billable, not a drop.
107
- if (typeof opts.customerId !== 'string' || !opts.customerId.trim())
108
- return () => { };
109
168
  const connectionId = (0, node_crypto_1.randomUUID)();
110
169
  const productType = normalizeProductType(opts.productType) ?? this.productType;
111
- const start = Date.now();
112
- let sentCount = 0;
113
- let recvCount = 0;
114
- let sentBytes = 0;
115
- let recvBytes = 0;
116
- // Emit CONNECTION_OPENED immediately
117
- this.push({
170
+ if (typeof opts.customerId !== 'string' || !opts.customerId.trim()) {
171
+ // No customer: the connection is not metered. Counted as one 'invalid' drop.
172
+ this.core.reportDrop([this.buildEvent({
173
+ customerId: typeof opts.customerId === 'string' ? opts.customerId : '',
174
+ wsConnectionId: connectionId,
175
+ productType,
176
+ wsDirection: 'SERVER_TO_CLIENT',
177
+ wsFrameType: 'OPEN',
178
+ messageCount: 0,
179
+ dataBytes: 0,
180
+ executionDurationMs: 0,
181
+ metadata: { ...(opts.metadata ?? {}), event: 'CONNECTION_OPENED' },
182
+ })], 'invalid', 'customer resolver returned no customer; the connection is not metered');
183
+ return () => { };
184
+ }
185
+ const openEvent = {
118
186
  customerId: opts.customerId,
119
187
  wsConnectionId: connectionId,
120
188
  productType,
121
189
  wsDirection: 'SERVER_TO_CLIENT',
122
- wsFrameType: 'PING', // "handshake complete" marker; not an actual frame
190
+ wsFrameType: 'OPEN', // handshake complete; not a frame on the wire
123
191
  messageCount: 0,
124
192
  dataBytes: 0,
125
193
  executionDurationMs: 0,
126
194
  metadata: { ...(opts.metadata ?? {}), event: 'CONNECTION_OPENED' },
127
- });
195
+ };
196
+ if (this.shutdownPromise) {
197
+ // Shutting down: a connection opened now could never get its CONNECTION_CLOSED.
198
+ this.core.reportDrop([this.buildEvent(openEvent)], 'closed', 'connection opened after shutdown(); not metered');
199
+ return () => { };
200
+ }
201
+ const start = Date.now();
202
+ let sentCount = 0;
203
+ let recvCount = 0;
204
+ let sentBytes = 0;
205
+ let recvBytes = 0;
206
+ let closed = false;
207
+ const conn = {
208
+ lastActivity: start,
209
+ close: (code, closedBy) => {
210
+ if (closed)
211
+ return false;
212
+ closed = true;
213
+ this.connections.delete(connectionId);
214
+ this.push({
215
+ customerId: opts.customerId,
216
+ wsConnectionId: connectionId,
217
+ productType,
218
+ wsDirection: 'SERVER_TO_CLIENT',
219
+ wsFrameType: 'CLOSE',
220
+ wsCloseReason: CLOSE_REASONS[code] ?? 'NORMAL_CLOSURE',
221
+ messageCount: sentCount + recvCount,
222
+ dataBytes: sentBytes + recvBytes,
223
+ executionDurationMs: Date.now() - start,
224
+ metadata: {
225
+ ...(opts.metadata ?? {}),
226
+ event: 'CONNECTION_CLOSED',
227
+ sentCount,
228
+ recvCount,
229
+ sentBytes,
230
+ recvBytes,
231
+ closeCode: code,
232
+ ...(closedBy ? { closedBy } : {}),
233
+ },
234
+ }, opts.executionStatus);
235
+ return true;
236
+ },
237
+ };
238
+ this.connections.set(connectionId, conn);
239
+ // Emit CONNECTION_OPENED immediately
240
+ this.push(openEvent);
128
241
  ws.on('message', (data, isBinary) => {
242
+ if (closed)
243
+ return;
244
+ conn.lastActivity = Date.now();
129
245
  recvCount++;
130
246
  const bytes = estimateBytes(data);
131
247
  recvBytes += bytes;
@@ -143,53 +259,56 @@ class AforoWsBilling {
143
259
  });
144
260
  }
145
261
  });
146
- // Wrap send() to count outbound frames
262
+ // Wrap send() to count outbound frames. A frame is billed only once the
263
+ // socket has accepted it (ws calls the send callback without an error), so a
264
+ // send to a closing or broken socket is never billed. The caller's own
265
+ // callback, if any, still runs with the same error.
147
266
  const origSend = ws.send.bind(ws);
148
- ws.send = (data, cb) => {
149
- sentCount++;
267
+ ws.send = (data, ...rest) => {
268
+ if (closed)
269
+ return origSend(data, ...rest);
270
+ conn.lastActivity = Date.now();
271
+ const callerCb = typeof rest[rest.length - 1] === 'function' ? rest.pop() : undefined;
150
272
  const bytes = estimateBytes(data);
151
- sentBytes += bytes;
152
- if (this.perFrameEvents) {
153
- this.push({
154
- customerId: opts.customerId,
155
- wsConnectionId: connectionId,
156
- productType,
157
- wsDirection: 'SERVER_TO_CLIENT',
158
- wsFrameType: typeof data === 'string' ? 'TEXT' : 'BINARY',
159
- messageCount: 1,
160
- dataBytes: bytes,
161
- executionDurationMs: Date.now() - start,
162
- metadata: opts.metadata,
163
- });
164
- }
165
- return origSend(data, cb);
273
+ const frameType = typeof data === 'string' ? 'TEXT' : 'BINARY';
274
+ const onSent = (err) => {
275
+ // A frame accepted after the connection was reported closed is not
276
+ // billed: the CONNECTION_CLOSED totals have already gone out.
277
+ if (!err && !closed) {
278
+ sentCount++;
279
+ sentBytes += bytes;
280
+ if (this.perFrameEvents) {
281
+ this.push({
282
+ customerId: opts.customerId,
283
+ wsConnectionId: connectionId,
284
+ productType,
285
+ wsDirection: 'SERVER_TO_CLIENT',
286
+ wsFrameType: frameType,
287
+ messageCount: 1,
288
+ dataBytes: bytes,
289
+ executionDurationMs: Date.now() - start,
290
+ metadata: opts.metadata,
291
+ });
292
+ }
293
+ }
294
+ if (callerCb)
295
+ callerCb(err);
296
+ };
297
+ return origSend(data, ...rest, onSent);
166
298
  };
167
299
  // opts.executionStatus is the connection's outcome: only the closing events
168
300
  // below carry it (same as the Python SDK), never OPENED or per-frame events.
301
+ // 'error' is normally followed by 'close': bill the connection once,
302
+ // with whichever of the two arrives first (or the idle sweep / shutdown).
169
303
  ws.on('close', (code) => {
170
- // Emit CONNECTION_CLOSED with aggregated counters — this is the billing anchor.
171
- this.push({
172
- customerId: opts.customerId,
173
- wsConnectionId: connectionId,
174
- productType,
175
- wsDirection: 'SERVER_TO_CLIENT',
176
- wsFrameType: 'CLOSE',
177
- wsCloseReason: CLOSE_REASONS[code] ?? 'NORMAL_CLOSURE',
178
- messageCount: sentCount + recvCount,
179
- dataBytes: sentBytes + recvBytes,
180
- executionDurationMs: Date.now() - start,
181
- metadata: {
182
- ...(opts.metadata ?? {}),
183
- event: 'CONNECTION_CLOSED',
184
- sentCount,
185
- recvCount,
186
- sentBytes,
187
- recvBytes,
188
- closeCode: code,
189
- },
190
- }, opts.executionStatus);
304
+ // CONNECTION_CLOSED with aggregated counters — this is the billing anchor.
305
+ conn.close(code);
191
306
  });
192
307
  ws.on('error', (err) => {
308
+ if (closed)
309
+ return;
310
+ closed = true;
311
+ this.connections.delete(connectionId);
193
312
  this.push({
194
313
  customerId: opts.customerId,
195
314
  wsConnectionId: connectionId,
@@ -200,20 +319,23 @@ class AforoWsBilling {
200
319
  messageCount: sentCount + recvCount,
201
320
  dataBytes: sentBytes + recvBytes,
202
321
  executionDurationMs: Date.now() - start,
203
- metadata: { ...(opts.metadata ?? {}), event: 'CONNECTION_ERROR', error: err.message },
322
+ metadata: { ...(opts.metadata ?? {}), event: 'CONNECTION_ERROR', error: String(err?.message ?? err).slice(0, 200) },
204
323
  }, opts.executionStatus);
205
324
  });
206
325
  return () => {
207
- // Tracking lifetime is managed by the close handler — no manual cleanup needed.
326
+ // Tracking lifetime is managed by the close handler, the idle sweep and shutdown().
208
327
  };
209
328
  }
210
- push(partial, executionStatus) {
329
+ /** Fill in the fields every event carries. */
330
+ buildEvent(partial) {
211
331
  const now = new Date();
212
- const event = {
332
+ return {
213
333
  ...partial,
214
334
  metricName: partial.wsFrameType === 'CLOSE'
215
335
  ? 'websocket_api.connection_closed'
216
- : 'websocket_api.message',
336
+ : partial.wsFrameType === 'OPEN'
337
+ ? 'websocket_api.connection_opened'
338
+ : 'websocket_api.message',
217
339
  quantity: 1,
218
340
  occurredAt: now.toISOString(),
219
341
  // Minted once, here. SDK-generated; tail-trimmed to the ingestor's limit,
@@ -225,6 +347,9 @@ class AforoWsBilling {
225
347
  productId: this.config.productId,
226
348
  },
227
349
  };
350
+ }
351
+ push(partial, executionStatus) {
352
+ const event = this.buildEvent(partial);
228
353
  const checked = checkExecutionStatus(resolveExecutionStatus(executionStatus, event));
229
354
  if (checked.problem)
230
355
  this.reportError(new Error(`[aforo-ws] ${checked.problem}`));
@@ -236,153 +361,58 @@ class AforoWsBilling {
236
361
  const invalid = tooLong('customerId', event.customerId, MAX_CUSTOMER_ID) ??
237
362
  tooLong('productType', event.productType, MAX_PRODUCT_TYPE);
238
363
  if (invalid) {
239
- this.recordInvalid(event, invalid);
364
+ this.core.reportDrop([event], 'invalid', invalid.problem);
240
365
  return;
241
366
  }
242
- this.buffer.push(event);
243
- if (this.buffer.length >= this.flushCount) {
244
- void this.flush();
245
- }
246
- }
247
- async flush() {
248
- if (this.buffer.length === 0)
249
- return;
250
- const pending = this.buffer.splice(0, this.buffer.length);
251
- // The ingestor accepts at most MAX_BATCH_EVENTS events per request.
252
- for (let i = 0; i < pending.length; i += MAX_BATCH_EVENTS) {
253
- await this.send(pending.slice(i, i + MAX_BATCH_EVENTS));
254
- }
255
- }
256
- async send(batch) {
257
- // Serialized once, so every retry re-sends the same idempotencyKeys.
258
- const body = JSON.stringify({ events: batch });
259
- const maxRetries = 3;
260
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
261
- let delayMs = Math.pow(2, attempt - 1) * 1000; // 1s, 2s, 4s
262
- let res;
263
- let networkError;
264
- try {
265
- res = await fetch(this.config.ingestorUrl.replace(/\/$/, '') + '/v1/ingest/batch', {
266
- method: 'POST',
267
- headers: {
268
- 'Content-Type': 'application/json',
269
- 'X-API-Key': this.config.apiKey,
270
- 'X-Tenant-Id': this.config.tenantId,
271
- },
272
- body,
273
- });
274
- }
275
- catch (err) {
276
- networkError = err;
277
- }
278
- if (res) {
279
- if (res.ok) {
280
- await this.handlePartialFailures(res, batch);
281
- return;
282
- }
283
- if (!isRetryableStatus(res.status)) {
284
- // 4xx other than 408/429: the same body would be refused again.
285
- const { details } = await readBatchResult(res, batch.length);
286
- this.recordDrop(batch, 'rejected');
287
- this.reportError(new Error(`WebSocket metering batch rejected with HTTP ${res.status}${details ? ` — ${details}` : ''} (dropped ${batch.length} events, not retried)`));
288
- return;
289
- }
290
- delayMs = parseRetryAfter(res) ?? delayMs;
291
- }
292
- else if (attempt === maxRetries) {
293
- this.recordDrop(batch, 'retry_exhausted');
294
- this.reportError(networkError ?? new Error('WebSocket metering request failed'));
295
- return;
296
- }
297
- if (attempt < maxRetries)
298
- await sleep(delayMs);
299
- }
300
- // Not re-queued: dropping avoids unbounded memory growth.
301
- this.recordDrop(batch, 'retry_exhausted');
302
- this.reportError(new Error(`WebSocket metering flush failed after ${maxRetries} attempts (dropped ${batch.length} events)`));
303
- }
304
- /**
305
- * A 2xx can still carry per-event rejections:
306
- * `{accepted, duplicates, failed, errors:[{index, message}]}`. Events the
307
- * response names by index are dropped with reason 'rejected'; a `failed`
308
- * count the response does not attribute to an index is counted only.
309
- */
310
- async handlePartialFailures(res, batch) {
311
- const { failed, details, indexes } = await readBatchResult(res, batch.length);
312
- if (failed <= 0)
313
- return;
314
- if (indexes.length > 0)
315
- this.recordDrop(indexes.map((i) => batch[i]), 'rejected');
316
- const unattributed = Math.min(failed, batch.length) - indexes.length;
317
- if (unattributed > 0) {
318
- this.dropped += unattributed;
319
- console.warn(`[aforo-ws] Dropped ${unattributed} event(s) — rejected, not identified by the ingestor (${this.dropped} total dropped).`);
320
- }
321
- this.reportError(new Error(`Aforo ingestor rejected ${failed} event(s)${details ? ` — ${details}` : ''}`));
367
+ // Queue only; the core's single background sender does the HTTP.
368
+ this.core.enqueue(event);
322
369
  }
323
370
  /** Invoke onError; a hook bug must never break metering or flushing. */
324
371
  reportError(err) {
325
372
  try {
326
373
  this.onError(err);
327
374
  }
328
- catch {
329
- // ignore
375
+ catch (hookErr) {
376
+ console.debug('[aforo-ws] onError hook threw', hookErr);
330
377
  }
331
378
  }
332
- /** Number of events permanently dropped since this instance was created. */
379
+ /** Number of events permanently dropped since this instance was created (every DropReason). */
333
380
  get droppedCount() {
334
- return this.dropped;
381
+ return this.core.droppedCount;
335
382
  }
336
- /**
337
- * Account for permanently lost events: bump the counter, WARN-log, and
338
- * invoke the opt-in onDrop hook. The buffer is drained at flush start, so
339
- * drops here are bounded by flush cadence — no log throttle needed.
340
- */
341
- recordDrop(events, reason) {
342
- this.dropped += events.length;
343
- console.warn(`[aforo-ws] Dropped ${events.length} event(s) — ${reason} (${this.dropped} total dropped).`);
344
- this.notifyDrop(events, reason);
383
+ /** Number of events waiting to be sent. */
384
+ get queuedCount() {
385
+ return this.core.queued;
386
+ }
387
+ /** Send what is queued now and wait for it (one request at a time; never concurrent). */
388
+ async flush() {
389
+ return this.core.flush();
345
390
  }
346
391
  /**
347
- * Account for an event that failed a client-side check: never buffered,
348
- * never sent. Runs on the hot path, so the WARN is throttled per field
349
- * (first occurrence, then every 1000th).
392
+ * Bill every connection still open with a CONNECTION_CLOSED (close code
393
+ * 1001, GOING_AWAY, metadata `closedBy: "sdk_shutdown"`), stop accepting
394
+ * events and send what is queued, for at most `shutdownTimeoutMs`. Events
395
+ * still queued then are dropped with reason 'shutdown' (counted, passed to
396
+ * onDrop). A connection opened from now on is reported as 'closed' and not
397
+ * tracked. Calling it again returns the first call's promise. Call it from
398
+ * your SIGTERM handler.
350
399
  */
351
- recordInvalid(event, invalid) {
352
- this.dropped += 1;
353
- const seen = (this.invalidSeen.get(invalid.field) ?? 0) + 1;
354
- this.invalidSeen.set(invalid.field, seen);
355
- if (seen === 1 || seen % 1000 === 0) {
356
- console.warn(`[aforo-ws] Dropped 1 event — invalid: ${invalid.problem} ` +
357
- `(${seen} for ${invalid.field}, ${this.dropped} total dropped).`);
358
- }
359
- this.notifyDrop([event], 'invalid');
360
- }
361
- notifyDrop(events, reason) {
362
- if (!this.onDrop)
363
- return;
364
- try {
365
- this.onDrop(events, reason);
366
- }
367
- catch {
368
- // A hook bug must never break metering or flushing.
400
+ shutdown() {
401
+ if (!this.shutdownPromise) {
402
+ this.shutdownPromise = (async () => {
403
+ clearInterval(this.sweepTimer);
404
+ for (const conn of Array.from(this.connections.values()))
405
+ conn.close(1001, 'sdk_shutdown');
406
+ await this.core.shutdown();
407
+ })();
369
408
  }
409
+ return this.shutdownPromise;
370
410
  }
371
- startTimer() {
372
- if (this.flushTimer)
373
- return;
374
- this.flushTimer = setInterval(() => { void this.flush(); }, this.flushIntervalMs);
375
- // Unref so the background timer never blocks host-process exit (final flush still needs shutdown()).
376
- if (typeof this.flushTimer.unref === 'function')
377
- this.flushTimer.unref();
411
+ toJSON() {
412
+ return { tenantId: this.config.tenantId, productId: this.config.productId, queued: this.queuedCount, dropped: this.droppedCount };
378
413
  }
379
- /** Flush any buffered events and stop the background timer. Call before process exit. */
380
- async shutdown() {
381
- if (this.flushTimer) {
382
- clearInterval(this.flushTimer);
383
- this.flushTimer = null;
384
- }
385
- await this.flush();
414
+ [Symbol.for('nodejs.util.inspect.custom')]() {
415
+ return `AforoWsBilling { tenantId: '${this.config.tenantId}', productId: '${this.config.productId}', queued: ${this.queuedCount}, dropped: ${this.droppedCount} }`;
386
416
  }
387
417
  }
388
418
  exports.AforoWsBilling = AforoWsBilling;
@@ -463,61 +493,16 @@ function tooLong(field, value, max) {
463
493
  return undefined;
464
494
  return { field, problem: `${field} is ${value.length} chars, limit ${max}: ${preview(value)}` };
465
495
  }
466
- /** Network errors, 408, 429 and 5xx are transient; every other 4xx (400/401/403/422...) is not. */
467
- function isRetryableStatus(status) {
468
- return status === 408 || status === 429 || status >= 500;
469
- }
470
- /** Retry-After (delta-seconds or HTTP date) in ms, capped at MAX_RETRY_AFTER_MS. */
471
- function parseRetryAfter(res) {
472
- const raw = res?.headers?.get?.('retry-after');
473
- if (!raw)
496
+ function textOrUndefined(value) {
497
+ if (value === undefined || value === null)
474
498
  return undefined;
475
- const secs = Number(raw);
476
- if (Number.isFinite(secs) && secs >= 0)
477
- return Math.min(secs * 1000, MAX_RETRY_AFTER_MS);
478
- const date = Date.parse(raw);
479
- if (!Number.isNaN(date))
480
- return Math.min(Math.max(date - Date.now(), 0), MAX_RETRY_AFTER_MS);
481
- return undefined;
499
+ const text = String(value).trim();
500
+ return text ? text : undefined;
482
501
  }
483
- /**
484
- * Reads the ingestor's batch response body, if any: the `failed` count, the
485
- * first 5 `errors[].message` entries, and the batch indexes `errors[]` names.
486
- */
487
- async function readBatchResult(res, batchSize) {
488
- const none = { failed: 0, details: '', indexes: [] };
489
- if (typeof res?.json !== 'function')
490
- return none;
491
- try {
492
- const body = unwrapEnvelope(await res.json());
493
- const errors = Array.isArray(body?.errors) ? body.errors : undefined;
494
- const details = errors
495
- ? errors.slice(0, 5).map((e) => `#${e?.index}: ${e?.message}`).join('; ')
496
- : typeof body?.message === 'string' ? body.message : '';
497
- const indexes = [
498
- ...new Set((errors ?? [])
499
- .map((e) => e?.index)
500
- .filter((i) => Number.isInteger(i) && i >= 0 && i < batchSize)),
501
- ].sort((a, b) => a - b);
502
- const failed = typeof body?.failed === 'number' && body.failed > 0 ? body.failed : 0;
503
- return { failed, details, indexes };
504
- }
505
- catch {
506
- return none; // Non-JSON or empty body — nothing to report.
507
- }
502
+ function headerValue(value) {
503
+ return Array.isArray(value) ? value[0] : value;
508
504
  }
509
505
  function randomSuffix() {
510
506
  return Math.random().toString(36).slice(2, 10);
511
507
  }
512
- function sleep(ms) {
513
- return new Promise((r) => setTimeout(r, ms));
514
- }
515
508
  exports.WS_CLOSE_REASONS = CLOSE_REASONS;
516
- /**
517
- * The ingestor wraps every 2xx JSON body in `{success, data, meta}`. Returns
518
- * the inner `data` object when present, else the body unchanged (bare shape).
519
- */
520
- function unwrapEnvelope(body) {
521
- const data = body && typeof body === 'object' && !Array.isArray(body) ? body.data : undefined;
522
- return data && typeof data === 'object' && !Array.isArray(data) ? data : body;
523
- }