@aforoai/ws-metering 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,22 +28,29 @@
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.0.0';
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;
53
+ const MAX_PRODUCT_TYPE = 20;
44
54
  // Close reason code → descriptor enum label
45
55
  const CLOSE_REASONS = {
46
56
  1000: 'NORMAL_CLOSURE',
@@ -59,64 +69,179 @@ const CLOSE_REASONS = {
59
69
  class AforoWsBilling {
60
70
  config;
61
71
  productType;
62
- flushCount;
63
- flushIntervalMs;
64
72
  perFrameEvents;
65
73
  onError;
66
- buffer = [];
67
- flushTimer = null;
74
+ core;
75
+ idleTimeoutMs;
76
+ connections = new Map();
77
+ sweepTimer;
78
+ shutdownPromise = null;
68
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;
69
86
  this.config = {
70
87
  tenantId: config.tenantId,
71
88
  productId: config.productId,
72
- apiKey: config.apiKey,
73
- ingestorUrl: config.ingestorUrl,
74
89
  };
75
90
  this.productType = normalizeProductType(config.productType) ?? exports.DEFAULT_PRODUCT_TYPE;
76
- this.flushCount = config.flushCount ?? 100;
77
- this.flushIntervalMs = config.flushIntervalMs ?? 3000;
78
91
  this.perFrameEvents = config.perFrameEvents ?? false;
79
92
  this.onError = config.onError ?? ((err) => console.error('[aforo-ws]', err.message));
80
- 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;
81
133
  }
82
- /** 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
+ */
83
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
+ }
84
146
  wss.on('connection', (ws, req) => {
85
- const customerId = options.extractCustomerId(req);
86
- if (!customerId || !customerId.trim())
87
- 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
+ }
88
156
  const metadata = options.extractMetadata?.(req);
89
- this.trackConnection(ws, { customerId, metadata, productType: options.productType });
157
+ const status = options.executionStatus;
158
+ this.trackConnection(ws, {
159
+ customerId: customerId ?? '',
160
+ metadata,
161
+ executionStatus: typeof status === 'function' ? (event) => status(event, req) : status,
162
+ productType: options.productType,
163
+ });
90
164
  });
91
165
  }
92
166
  /** Track a single WebSocket connection. Returns an unsubscribe function. */
93
167
  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
168
  const connectionId = (0, node_crypto_1.randomUUID)();
101
169
  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({
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 = {
109
186
  customerId: opts.customerId,
110
187
  wsConnectionId: connectionId,
111
188
  productType,
112
189
  wsDirection: 'SERVER_TO_CLIENT',
113
- wsFrameType: 'PING', // "handshake complete" marker; not an actual frame
190
+ wsFrameType: 'OPEN', // handshake complete; not a frame on the wire
114
191
  messageCount: 0,
115
192
  dataBytes: 0,
116
193
  executionDurationMs: 0,
117
194
  metadata: { ...(opts.metadata ?? {}), event: 'CONNECTION_OPENED' },
118
- });
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);
119
241
  ws.on('message', (data, isBinary) => {
242
+ if (closed)
243
+ return;
244
+ conn.lastActivity = Date.now();
120
245
  recvCount++;
121
246
  const bytes = estimateBytes(data);
122
247
  recvBytes += bytes;
@@ -134,51 +259,56 @@ class AforoWsBilling {
134
259
  });
135
260
  }
136
261
  });
137
- // 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.
138
266
  const origSend = ws.send.bind(ws);
139
- ws.send = (data, cb) => {
140
- 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;
141
272
  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);
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);
157
298
  };
299
+ // opts.executionStatus is the connection's outcome: only the closing events
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).
158
303
  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
- });
304
+ // CONNECTION_CLOSED with aggregated counters — this is the billing anchor.
305
+ conn.close(code);
180
306
  });
181
307
  ws.on('error', (err) => {
308
+ if (closed)
309
+ return;
310
+ closed = true;
311
+ this.connections.delete(connectionId);
182
312
  this.push({
183
313
  customerId: opts.customerId,
184
314
  wsConnectionId: connectionId,
@@ -189,23 +319,27 @@ class AforoWsBilling {
189
319
  messageCount: sentCount + recvCount,
190
320
  dataBytes: sentBytes + recvBytes,
191
321
  executionDurationMs: Date.now() - start,
192
- metadata: { ...(opts.metadata ?? {}), event: 'CONNECTION_ERROR', error: err.message },
193
- });
322
+ metadata: { ...(opts.metadata ?? {}), event: 'CONNECTION_ERROR', error: String(err?.message ?? err).slice(0, 200) },
323
+ }, opts.executionStatus);
194
324
  });
195
325
  return () => {
196
- // 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().
197
327
  };
198
328
  }
199
- push(partial) {
329
+ /** Fill in the fields every event carries. */
330
+ buildEvent(partial) {
200
331
  const now = new Date();
201
- const event = {
332
+ return {
202
333
  ...partial,
203
334
  metricName: partial.wsFrameType === 'CLOSE'
204
335
  ? 'websocket_api.connection_closed'
205
- : 'websocket_api.message',
336
+ : partial.wsFrameType === 'OPEN'
337
+ ? 'websocket_api.connection_opened'
338
+ : 'websocket_api.message',
206
339
  quantity: 1,
207
340
  occurredAt: now.toISOString(),
208
- // Tail-trimmed to the ingestor's limit, keeping the unique millis:random suffix.
341
+ // Minted once, here. SDK-generated; tail-trimmed to the ingestor's limit,
342
+ // keeping the unique millis:random suffix.
209
343
  idempotencyKey: `ws:${this.config.tenantId}:${partial.wsConnectionId}:${partial.wsFrameType}:${now.getTime()}:${randomSuffix()}`.slice(-MAX_IDEMPOTENCY_KEY),
210
344
  metadata: {
211
345
  ...(partial.metadata ?? {}),
@@ -213,82 +347,72 @@ class AforoWsBilling {
213
347
  productId: this.config.productId,
214
348
  },
215
349
  };
216
- this.buffer.push(event);
217
- if (this.buffer.length >= this.flushCount) {
218
- void this.flush();
219
- }
220
350
  }
221
- async flush() {
222
- if (this.buffer.length === 0)
351
+ push(partial, executionStatus) {
352
+ const event = this.buildEvent(partial);
353
+ const checked = checkExecutionStatus(resolveExecutionStatus(executionStatus, event));
354
+ if (checked.problem)
355
+ this.reportError(new Error(`[aforo-ws] ${checked.problem}`));
356
+ if (checked.status)
357
+ event.executionStatus = checked.status;
358
+ // An event the ingestor would reject is not sent (one bad event fails its
359
+ // whole batch): it is dropped here with reason 'invalid', never thrown
360
+ // into the socket handlers.
361
+ const invalid = tooLong('customerId', event.customerId, MAX_CUSTOMER_ID) ??
362
+ tooLong('productType', event.productType, MAX_PRODUCT_TYPE);
363
+ if (invalid) {
364
+ this.core.reportDrop([event], 'invalid', invalid.problem);
223
365
  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
366
  }
367
+ // Queue only; the core's single background sender does the HTTP.
368
+ this.core.enqueue(event);
229
369
  }
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);
370
+ /** Invoke onError; a hook bug must never break metering or flushing. */
371
+ reportError(err) {
372
+ try {
373
+ this.onError(err);
265
374
  }
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}` : ''}`));
375
+ catch (hookErr) {
376
+ console.debug('[aforo-ws] onError hook threw', hookErr);
273
377
  }
274
378
  }
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();
379
+ /** Number of events permanently dropped since this instance was created (every DropReason). */
380
+ get droppedCount() {
381
+ return this.core.droppedCount;
285
382
  }
286
- async shutdown() {
287
- if (this.flushTimer) {
288
- clearInterval(this.flushTimer);
289
- this.flushTimer = null;
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();
390
+ }
391
+ /**
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.
399
+ */
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
+ })();
290
408
  }
291
- await this.flush();
409
+ return this.shutdownPromise;
410
+ }
411
+ toJSON() {
412
+ return { tenantId: this.config.tenantId, productId: this.config.productId, queued: this.queuedCount, dropped: this.droppedCount };
413
+ }
414
+ [Symbol.for('nodejs.util.inspect.custom')]() {
415
+ return `AforoWsBilling { tenantId: '${this.config.tenantId}', productId: '${this.config.productId}', queued: ${this.queuedCount}, dropped: ${this.droppedCount} }`;
292
416
  }
293
417
  }
294
418
  exports.AforoWsBilling = AforoWsBilling;
@@ -306,6 +430,51 @@ function estimateBytes(data) {
306
430
  return data.reduce((s, d) => s + estimateBytes(d), 0);
307
431
  return 0;
308
432
  }
433
+ /** Statuses the Aforo ingestor accepts (contract/ingest-contract.json, max 20 chars). */
434
+ const CANONICAL_EXECUTION_STATUSES = new Set([
435
+ 'SUCCESS', 'PARTIAL', 'TIMEOUT', 'ERROR', 'VALIDATION_FAILED', 'FAILED',
436
+ 'FAILURE', 'CANCELLED', 'PENDING', 'BLOCKED', 'HITL_REQUIRED',
437
+ ]);
438
+ /**
439
+ * Check a caller-supplied status. `{ status }` when canonical; `{ problem }`
440
+ * when it has to be left off the event (an unknown value would make the
441
+ * ingestor reject the event; a Promise from an async resolver can't be
442
+ * awaited on this path); `{}` when blank or absent.
443
+ */
444
+ function checkExecutionStatus(value) {
445
+ if (value !== null && typeof value === 'object' && typeof value.then === 'function') {
446
+ // Swallow a later rejection so it doesn't surface as an unhandled rejection.
447
+ value.then(undefined, () => { });
448
+ return { problem: 'executionStatus resolver returned a Promise — resolvers must be synchronous; field omitted' };
449
+ }
450
+ const status = normalizeExecutionStatus(value);
451
+ if (!status)
452
+ return {};
453
+ if (!CANONICAL_EXECUTION_STATUSES.has(status)) {
454
+ return {
455
+ problem: `unknown executionStatus "${status.slice(0, 40)}" — field omitted. ` +
456
+ `Expected one of: ${[...CANONICAL_EXECUTION_STATUSES].join(', ')}`,
457
+ };
458
+ }
459
+ return { status };
460
+ }
461
+ /** Trim + upper-case; blank/absent → undefined (key omitted from the wire body). */
462
+ function normalizeExecutionStatus(value) {
463
+ if (typeof value !== 'string')
464
+ return undefined;
465
+ const trimmed = value.trim();
466
+ return trimmed ? trimmed.toUpperCase() : undefined;
467
+ }
468
+ function resolveExecutionStatus(option, event) {
469
+ if (typeof option !== 'function')
470
+ return option;
471
+ try {
472
+ return option(event);
473
+ }
474
+ catch {
475
+ return undefined; // a resolver bug must never break metering
476
+ }
477
+ }
309
478
  /** Trim + uppercase a product type; blank/non-string → undefined (unknown values pass through). */
310
479
  function normalizeProductType(value) {
311
480
  if (typeof value !== 'string')
@@ -313,42 +482,27 @@ function normalizeProductType(value) {
313
482
  const trimmed = value.trim();
314
483
  return trimmed ? trimmed.toUpperCase() : undefined;
315
484
  }
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;
485
+ /** The offending value for a WARN line, cut to 80 chars. */
486
+ function preview(value) {
487
+ const text = typeof value === 'string' ? value : String(value);
488
+ return JSON.stringify(text.length > 80 ? `${text.slice(0, 80)}…` : text);
319
489
  }
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)
490
+ /** Limits mirror the ingestor's IngestUsageEventRequest; over-limit values are never truncated. */
491
+ function tooLong(field, value, max) {
492
+ if (typeof value !== 'string' || value.length <= max)
324
493
  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;
494
+ return { field, problem: `${field} is ${value.length} chars, limit ${max}: ${preview(value)}` };
332
495
  }
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
- }
496
+ function textOrUndefined(value) {
497
+ if (value === undefined || value === null)
498
+ return undefined;
499
+ const text = String(value).trim();
500
+ return text ? text : undefined;
501
+ }
502
+ function headerValue(value) {
503
+ return Array.isArray(value) ? value[0] : value;
347
504
  }
348
505
  function randomSuffix() {
349
506
  return Math.random().toString(36).slice(2, 10);
350
507
  }
351
- function sleep(ms) {
352
- return new Promise((r) => setTimeout(r, ms));
353
- }
354
508
  exports.WS_CLOSE_REASONS = CLOSE_REASONS;