@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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
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
4
 
5
- **Version:** 1.2.1 · Apache-2.0 · [Changelog](CHANGELOG.md) · [User guide](USER_GUIDE.md)
5
+ **Version:** 2.0.0 · Apache-2.0 · [Changelog](CHANGELOG.md) · [User guide](USER_GUIDE.md)
6
6
 
7
7
  ## Install
8
8
 
@@ -12,7 +12,7 @@ Intended public install (once published):
12
12
  npm i @aforoai/ws-metering ws
13
13
  ```
14
14
 
15
- > **Install `1.2.1` or later. `1.0.0` on npm was built from an older copy of this code and lacks the fixes listed in the changelog.** If `1.2.1` is not on npm yet, install from source with the steps below. `ws` (`^8`) is an **optional** peer dependency — needed only if you use `wrapServer`. `trackConnection` works with any compatible socket.
15
+ > **Install `2.0.0` or later. `1.0.0` on npm was built from an older copy of this code and lacks the fixes listed in the changelog.** If `2.0.0` is not on npm yet, install from source with the steps below. `ws` (`^8`) is an **optional** peer dependency — needed only if you use `wrapServer`. `trackConnection` works with any compatible socket.
16
16
 
17
17
  ```bash
18
18
  # from the SDKs repo root
@@ -40,10 +40,15 @@ const billing = new AforoWsBilling({
40
40
 
41
41
  const wss = new WebSocketServer({ port: 8080 });
42
42
  billing.wrapServer(wss, {
43
- extractCustomerId: (req) => req.headers['x-customer-id'] as string,
43
+ // Who is billed: from identity your app verified on the upgrade request.
44
+ customerResolver: (req) => sessions.customerIdFor(req),
44
45
  });
45
46
 
46
- process.on('SIGTERM', async () => { await billing.shutdown(); });
47
+ // The SDK installs no signal handlers. Flush from your own:
48
+ process.once('SIGTERM', async () => {
49
+ await billing.shutdown();
50
+ process.exit(0);
51
+ });
47
52
  ```
48
53
 
49
54
  For frameworks that don't expose a `ws`-style server, track each socket directly:
@@ -52,7 +57,7 @@ For frameworks that don't expose a `ws`-style server, track each socket directly
52
57
  billing.trackConnection(socket, { customerId: 'cust_001', metadata: { feed: 'market' } });
53
58
  ```
54
59
 
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>`.
60
+ 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 open event uses `metricName: "websocket_api.connection_opened"` with `wsFrameType: "OPEN"`, the close event `websocket_api.connection_closed`, and per-frame events `websocket_api.message`. A connection still open at `shutdown()` is billed a `CONNECTION_CLOSED` (`GOING_AWAY`, `closedBy: "sdk_shutdown"`), and one with no frame for `connectionIdleTimeoutMs` (default 1 hour) is closed by a sweep (`ABNORMAL_CLOSURE`, `closedBy: "sdk_idle_timeout"`), so every billed open has a close. Events ship to `POST https://api.aforo.ai/v1/ingest/batch` with `X-API-Key: <api_key>` and `X-Tenant-Id: <tenant_id>`.
56
61
 
57
62
  > **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
63
 
@@ -64,28 +69,40 @@ By default the SDK emits two events per connection — `CONNECTION_OPENED` on co
64
69
  |---|---|---|---|
65
70
  | `tenantId` | `string` | — (required) | Aforo tenant. Sent as `X-Tenant-Id`. Never read from a client header. |
66
71
  | `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`. |
72
+ | `apiKey` | `string` | — (required) | Sent only as `X-API-Key: <apiKey>`; never logged or shown by `inspect()` / `JSON.stringify`. |
73
+ | `ingestorUrl` | `string` | — (required) | Ingestion base URL, `https://` (see `allowInsecureHttp`). SDK appends `/v1/ingest/batch`. Use `https://api.aforo.ai`. |
69
74
  | `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
75
  | `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 once per delivery problem: a batch dropped after 3 attempts (network errors / 408 / 429 honouring `Retry-After` / 5xx), a batch refused with any other 4xx (not retried; the message includes the ingestor's `errors[].message`), per-event failures reported in a 2xx response, and an unusable `executionStatus`. Exceptions it throws are swallowed. |
74
- | `onDrop` | `(events, reason) => void` | none | Receives events that were permanently dropped, with reason `invalid`, `rejected` or `retry_exhausted`. See [Dropped events](#dropped-events). |
75
-
76
- `wrapServer(wss, options)` / `trackConnection(ws, opts)` take the customer resolver:
76
+ | `connectionIdleTimeoutMs` | `number` | `3600000` | Close (and bill `CONNECTION_CLOSED` for) a connection with no frame for this long, so a socket that died without a close event is not tracked forever. Must be positive. |
77
+ | `allowInsecureHttp` | `boolean` | `false` | Accept a plain `http://` `ingestorUrl` on a non-local host. `https://` is required otherwise (plain http is fine for `localhost`, `127.0.0.1`, `::1`). |
78
+ | `flushCount` | `number` | `500` | Events per request (max 1000). Reaching it wakes the background sender; recording an event never sends by itself. |
79
+ | `flushIntervalMs` | `number` | `5000` | The background sender also runs this often. |
80
+ | `maxQueueSize` | `number` | `10000` | Queue capacity. When it is full — usually during a long outage — the oldest event is dropped with reason `overflow`. |
81
+ | `maxRetries` | `number` | `3` | Retries per batch on network errors, timeouts, 408, 429 and 5xx, with full-jitter backoff (`Retry-After` in seconds or as a date is honoured, capped at 60 s). |
82
+ | `timeoutMs` | `number` | `10000` | Total deadline per request. |
83
+ | `shutdownTimeoutMs` | `number` | `5000` | How long `shutdown()` keeps sending. Events still queued then are dropped with reason `shutdown`. |
84
+ | `flushOnExit` | `boolean` | `true` | Send queued events on the process `beforeExit` event, within `shutdownTimeoutMs`. The SDK installs no signal handlers. |
85
+ | `onError` | `(error: Error) => void` | logs to `console.error` | Called for delivery problems: a batch the ingestor refused (the message includes its `errors[].message`), an ingestor outage (once per outage), events lost at the shutdown deadline, and an unusable `executionStatus`. Exceptions it throws are caught. Delivery reports are a `CoreTransportError`: branch on `err.kind` (`outage`, `auth`, `rejected`, `shutdown`, `closed`) and read `err.events`, not the message. |
86
+ | `onDrop` | `(events, reason) => void` | none | Receives events lost for good. See [Dropped events](#dropped-events). |
87
+
88
+ `wrapServer(wss, options)` / `trackConnection(ws, opts)` take the customer:
77
89
 
78
90
  | Option | Where | Type | What it does |
79
91
  |---|---|---|---|
80
- | `extractCustomerId` | `wrapServer` | `(req) => string \| undefined` | Resolve the customer from the upgrade request. `undefined` → connection is not metered. |
92
+ | `customerResolver` | `wrapServer` | `(req) => string \| undefined` | Required (or `trustCustomerHeader`). The Aforo customer id from identity your app verified on the upgrade request. Returning nothing: the connection is not metered and one `invalid` drop is counted; the connection itself is not affected. `extractCustomerId` is a deprecated alias. |
93
+ | `trustCustomerHeader` | `wrapServer` | `boolean` | Bill the `x-customer-id` header of the upgrade request. Any client can send that header, so only turn this on behind a proxy you control that sets it. Default `false`; `wrapServer` throws when neither this nor `customerResolver` is given. |
81
94
  | `extractMetadata` | `wrapServer` | `(req) => Record<string, unknown> \| undefined` | Optional per-connection tags. |
82
95
  | `customerId` | `trackConnection` | `string` | Customer to attribute all traffic on this socket to. |
83
96
  | `metadata` | `trackConnection` | `Record<string, unknown>` | Optional per-connection tags. |
84
97
  | `productType` | both | `string` | Product type for this server's / socket's events. Default: the client-level `productType`. |
85
98
 
86
- 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`.
99
+ 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`; the `close` that follows it is not billed again (one closing event per connection).
100
+
101
+ Exported symbols: `AforoWsBilling` (with `wrapServer` / `trackConnection` / `flush` / `shutdown` / `droppedCount` / `queuedCount`), the `WS_CLOSE_REASONS` map, `DEFAULT_PRODUCT_TYPE`, and the `AforoWsConfig` / `WrapServerOptions` / `TrackConnectionOptions` / `WsUsageEvent` / `DropReason` types.
102
+
103
+ ## Delivery
87
104
 
88
- Exported symbols: `AforoWsBilling` (with `wrapServer` / `trackConnection` / `shutdown` / `droppedCount`), the `WS_CLOSE_REASONS` map, `DEFAULT_PRODUCT_TYPE`, and the `AforoWsConfig` / `WrapServerOptions` / `TrackConnectionOptions` / `WsUsageEvent` / `DropReason` types.
105
+ One background sender per client sends one request at a time; recording an event only queues it. If the ingestor is unreachable, keeps answering 408/429/5xx after the retries, or answers 401/403 (a wrong or revoked key — logged as an error), the batch goes back to the front of the queue with its idempotency keys and sending pauses (5 s, doubling to 5 min, randomized). New events keep queueing up to `maxQueueSize`; the first successful request resumes sending, and the ingestor deduplicates the re-sent keys. A 413 splits the batch. Request bodies are plain JSON (the ingestor does not decode gzip). `flush()` sends what is queued now; `shutdown()` sends for at most `shutdownTimeoutMs` — call it from your own SIGTERM handler.
89
106
 
90
107
  ## Execution status
91
108
 
@@ -97,7 +114,7 @@ WebSocket frames carry no success or failure signal, so the SDK never sets `exec
97
114
 
98
115
  ```typescript
99
116
  billing.wrapServer(wss, {
100
- extractCustomerId: (req) => req.headers['x-customer-id'] as string,
117
+ customerResolver: (req) => sessions.customerIdFor(req),
101
118
  // (event, req) => status | undefined
102
119
  executionStatus: (event, req) =>
103
120
  event.wsFrameType === 'CLOSE' && event.wsCloseReason !== 'NORMAL_CLOSURE' ? 'ERROR' : undefined,
@@ -112,11 +129,14 @@ The SDK does not throw into your socket handlers for event content. An event tha
112
129
 
113
130
  | Reason | When |
114
131
  |---|---|
115
- | `invalid` | The event failed a client-side check and was never buffered or sent: `customerId` longer than 64 characters or `productType` longer than 20 (every event of that connection is counted). Values are never truncated. The warning names the field, the limit and the value (first 80 characters); it is logged for the first occurrence per field and for every 1000th after that. |
132
+ | `invalid` | The customer resolver returned nothing (one event per connection), or the event failed a client-side check and was never queued or sent: `customerId` longer than 64 characters or `productType` longer than 20 (every event of that connection is counted). Values are never truncated. The warning names the field, the limit and the value (first 80 characters); it is logged for the first invalid event and for every 1000th after that. |
116
133
  | `rejected` | The ingestor refused the batch with a 4xx other than 408 / 429 (not retried), or named individual events in the `errors[]` of a 2xx response — then only those events are dropped. When a 2xx reports `failed > 0` without a usable `index`, the count is added to `droppedCount` and `onDrop` is not called. |
117
- | `retry_exhausted` | Network errors, 408, 429 or 5xx persisted through 3 attempts (1s / 2s / 4s backoff; `Retry-After` honoured up to 30 s). |
134
+ | `overflow` | The queue (`maxQueueSize`) was full and the oldest event was evicted. |
135
+ | `shutdown` | Still queued when `shutdownTimeoutMs` ran out (also reported through `onError`). |
136
+ | `closed` | Recorded after `shutdown()`. |
137
+ | `retry_exhausted` | Not produced by 2.x (kept in the type): an outage keeps events queued instead of dropping them. |
118
138
 
119
- A call with no resolvable customer id is not billable; it is skipped and is not counted as a drop.
139
+ A connection with no resolvable customer is not metered and is counted as one `invalid` drop.
120
140
 
121
141
  ```ts
122
142
  const billing = new AforoWsBilling({
@@ -132,6 +152,6 @@ Step-by-step from install to a verified event in Aforo: [USER_GUIDE.md](USER_GUI
132
152
  ## What this doesn't cover
133
153
 
134
154
  - **`send()` is wrapped by reference.** `trackConnection` reassigns `ws.send` to count outbound frames. If another layer wraps `send` afterward, ordering matters.
135
- - **No persistent buffer.** Events are in memory until flushed; a hard crash before flush drops the buffered batch. `shutdown()` covers graceful exit only.
155
+ - **No persistent buffer.** Events are in memory until sent; a hard crash loses what is queued. `shutdown()` covers graceful exit only.
136
156
  - **Connection identity is per-process.** `wsConnectionId` is a fresh UUID per `trackConnection` call; it does not survive reconnects or correlate across processes.
137
157
  - **The SDK does not enforce or read pricing.** It emits usage; rating/billing happens in Aforo.
package/dist/index.d.ts CHANGED
@@ -2,9 +2,12 @@
2
2
  * @aforoai/ws-metering — Aforo WebSocket Metering SDK
3
3
  *
4
4
  * Wraps WebSocket server connections to emit three classes of billing events:
5
- * - CONNECTION_OPENED — once on upgrade completion
5
+ * - CONNECTION_OPENED — once on upgrade completion (wsFrameType OPEN,
6
+ * metric websocket_api.connection_opened)
6
7
  * - MESSAGE — one event per frame (direction + bytes + type)
7
- * - CONNECTION_CLOSED — once on close, carrying aggregated counters + duration
8
+ * - CONNECTION_CLOSED — once per connection, carrying aggregated counters +
9
+ * duration: on close, on a socket error, when an idle
10
+ * connection is swept, or at shutdown
8
11
  *
9
12
  * Works with the `ws` library out of the box (wrap WebSocketServer), or use
10
13
  * trackConnection() directly for Fastify-WebSocket, Socket.io, Deno, Bun, or
@@ -24,9 +27,15 @@
24
27
  *
25
28
  * const wss = new WebSocketServer({ port: 8080 });
26
29
  * billing.wrapServer(wss, {
27
- * extractCustomerId: (req) => req.headers['x-customer-id'] as string,
30
+ * // Who is billed, from identity your app verified at upgrade time.
31
+ * customerResolver: (req) => sessionStore.customerFor(req),
28
32
  * });
33
+ *
34
+ * // The SDK installs no signal handlers. Flush from your own:
35
+ * process.once('SIGTERM', async () => { await billing.shutdown(); process.exit(0); });
29
36
  */
37
+ /** What onError receives for delivery problems: branch on `kind`, read `events`. */
38
+ export { CoreTransportError, type CoreErrorKind } from './transport-core/core';
30
39
  export interface AforoWsConfig {
31
40
  tenantId: string;
32
41
  productId: string;
@@ -37,18 +46,37 @@ export interface AforoWsConfig {
37
46
  * Default: `WEBSOCKET_API`. Override per integration via `wrapServer(wss, { productType })` / `trackConnection(ws, { productType })`.
38
47
  */
39
48
  productType?: string;
40
- /** How many events to buffer before flushing (default 100 — WS is high-volume). */
49
+ /** Allow a plain http:// ingestorUrl on a non-local host. Default false (https required). */
50
+ allowInsecureHttp?: boolean;
51
+ /** Events per request (default 500, max 1000). Reaching it wakes the background sender. */
41
52
  flushCount?: number;
42
- /** Max interval in ms before a partial batch is flushed (default 3000). */
53
+ /** The background sender also runs this often, in ms (default 5000). */
43
54
  flushIntervalMs?: number;
55
+ /** Queue capacity (default 10 000). The oldest event is dropped on overflow. */
56
+ maxQueueSize?: number;
57
+ /** Retries per batch before the breaker opens (default 3). */
58
+ maxRetries?: number;
59
+ /** Total deadline per request, in ms (default 10 000). */
60
+ timeoutMs?: number;
61
+ /** How long shutdown() keeps sending, in ms (default 5000). */
62
+ shutdownTimeoutMs?: number;
63
+ /** Send queued events on the process 'beforeExit' event (default true). */
64
+ flushOnExit?: boolean;
44
65
  /** If true, emit one MESSAGE event per frame. If false (default) only aggregate on close. */
45
66
  perFrameEvents?: boolean;
46
- /** Callback for terminal flush failures. */
67
+ /**
68
+ * Close a tracked connection that saw no frame for this long, in ms
69
+ * (default 3 600 000 = 1 hour): it gets a CONNECTION_CLOSED with close
70
+ * reason ABNORMAL_CLOSURE and metadata `closedBy: "sdk_idle_timeout"`. This
71
+ * keeps the open-connection map bounded when a socket dies without a close
72
+ * event. Must be a positive number; 0 means the default.
73
+ */
74
+ connectionIdleTimeoutMs?: number;
75
+ /** Callback for delivery problems (rejected batches, ingestor outages) and bad executionStatus values. */
47
76
  onError?: (error: Error) => void;
48
77
  /**
49
- * Opt-in hook receiving events that were permanently dropped (retry
50
- * exhaustion, a rejection by the ingestor, or a failed client-side check —
51
- * see DropReason). Events keep their idempotency keys,
78
+ * Opt-in hook receiving events that were permanently dropped (see
79
+ * DropReason). Events keep their idempotency keys,
52
80
  * so persisting and re-submitting them after recovery is dedup-safe.
53
81
  * Exceptions thrown by the hook are swallowed. Default: none (drops are
54
82
  * still counted in droppedCount and WARN-logged).
@@ -56,17 +84,31 @@ export interface AforoWsConfig {
56
84
  onDrop?: (events: WsUsageEvent[], reason: DropReason) => void;
57
85
  }
58
86
  /**
59
- * Why events were permanently dropped. `retry_exhausted`: the ingestor stayed
60
- * unreachable / kept answering 408, 429 or 5xx. `rejected`: the ingestor
61
- * refused the batch (non-retryable 4xx) or individual events of an accepted
62
- * batch. `invalid`: the event failed a client-side check and was never sent.
63
- * The buffer is unbounded (drained at flush start), so unlike the core SDK
64
- * there is no 'overflow' reason here.
87
+ * Why events were permanently dropped. `overflow`: the queue was full and the
88
+ * oldest event was evicted. `rejected`: the ingestor refused the batch
89
+ * (non-retryable 4xx) or individual events of an accepted batch. `invalid`:
90
+ * the event failed a client-side check, or the customer resolver returned no
91
+ * customer. `shutdown`: still queued when the shutdown deadline passed.
92
+ * `closed`: recorded after shutdown(). `retry_exhausted`: kept for
93
+ * compatibility, not produced by 2.x — an outage keeps events queued.
65
94
  */
66
- export type DropReason = 'retry_exhausted' | 'rejected' | 'invalid';
95
+ export type DropReason = 'overflow' | 'retry_exhausted' | 'rejected' | 'invalid' | 'shutdown' | 'closed';
67
96
  export interface WrapServerOptions {
68
- /** Extract Aforo customer ID from the upgrade request. */
69
- extractCustomerId: (req: any) => string | undefined;
97
+ /**
98
+ * Who is billed for a connection, from identity your app verified on the
99
+ * upgrade request (session cookie, verified token). Required unless
100
+ * `trustCustomerHeader` is true. Returning nothing drops the connection's
101
+ * opening event as 'invalid' (counted); the connection is not affected.
102
+ */
103
+ customerResolver?: (req: any) => string | undefined | null;
104
+ /** @deprecated Use `customerResolver` (same signature). */
105
+ extractCustomerId?: (req: any) => string | undefined | null;
106
+ /**
107
+ * Bill the `x-customer-id` header of the upgrade request. Any client can
108
+ * set that header, so only turn this on behind a proxy you control that
109
+ * sets it. Default false.
110
+ */
111
+ trustCustomerHeader?: boolean;
70
112
  /** Optional per-connection metadata (product-defined tags). */
71
113
  extractMetadata?: (req: any) => Record<string, unknown> | undefined;
72
114
  /**
@@ -121,13 +163,16 @@ interface MinimalWs {
121
163
  on(event: 'message', fn: (data: any, isBinary?: boolean) => void): void;
122
164
  on(event: 'close', fn: (code: number, reason: Buffer | string) => void): void;
123
165
  on(event: 'error', fn: (err: Error) => void): void;
124
- send(data: any, cb?: (err?: Error) => void): void;
166
+ /** `ws` takes (data, cb) or (data, options, cb); every argument is passed through. */
167
+ send(data: any, ...rest: any[]): void;
125
168
  readyState?: number;
126
169
  }
127
170
  /** Minimal WebSocketServer surface — matches `ws` WebSocketServer. */
128
171
  interface MinimalWss {
129
172
  on(event: 'connection', fn: (ws: MinimalWs, req: any) => void): void;
130
173
  }
174
+ /** Default for AforoWsConfig.connectionIdleTimeoutMs: one hour. */
175
+ export declare const DEFAULT_CONNECTION_IDLE_TIMEOUT_MS: number;
131
176
  /** Default top-level `productType` for this SDK. */
132
177
  export declare const DEFAULT_PRODUCT_TYPE = "WEBSOCKET_API";
133
178
  export interface WsUsageEvent {
@@ -139,7 +184,7 @@ export interface WsUsageEvent {
139
184
  productType: string;
140
185
  wsConnectionId: string;
141
186
  wsDirection: 'CLIENT_TO_SERVER' | 'SERVER_TO_CLIENT';
142
- wsFrameType: 'TEXT' | 'BINARY' | 'PING' | 'PONG' | 'CLOSE';
187
+ wsFrameType: 'OPEN' | 'TEXT' | 'BINARY' | 'PING' | 'PONG' | 'CLOSE';
143
188
  wsCloseReason?: string;
144
189
  messageCount: number;
145
190
  dataBytes: number;
@@ -151,51 +196,55 @@ export interface WsUsageEvent {
151
196
  export declare class AforoWsBilling {
152
197
  private readonly config;
153
198
  private readonly productType;
154
- private readonly flushCount;
155
- private readonly flushIntervalMs;
156
199
  private readonly perFrameEvents;
157
200
  private readonly onError;
158
- private readonly onDrop?;
159
- private buffer;
160
- private flushTimer;
161
- private dropped;
162
- /** Invalid-event count per field, for WARN throttling. */
163
- private readonly invalidSeen;
201
+ private readonly core;
202
+ private readonly idleTimeoutMs;
203
+ private readonly connections;
204
+ private readonly sweepTimer;
205
+ private shutdownPromise;
164
206
  constructor(config: AforoWsConfig);
165
- /** Wrap a `ws` WebSocketServer (or any object emitting 'connection' events). */
207
+ /** Connections being tracked: opened and not yet closed by the socket, the idle sweep or shutdown(). */
208
+ get openConnections(): number;
209
+ /**
210
+ * Close every tracked connection idle longer than `connectionIdleTimeoutMs`
211
+ * at `now` (CONNECTION_CLOSED, ABNORMAL_CLOSURE, `closedBy: "sdk_idle_timeout"`).
212
+ * Runs on a timer; callable directly. Returns how many it closed.
213
+ */
214
+ sweepIdleConnections(now?: number): number;
215
+ /**
216
+ * Wrap a `ws` WebSocketServer (or any object emitting 'connection' events).
217
+ * Throws when neither `customerResolver` nor `trustCustomerHeader: true` is given.
218
+ */
166
219
  wrapServer(wss: MinimalWss, options: WrapServerOptions): void;
167
220
  /** Track a single WebSocket connection. Returns an unsubscribe function. */
168
221
  trackConnection(ws: MinimalWs, opts: TrackConnectionOptions): () => void;
222
+ /** Fill in the fields every event carries. */
223
+ private buildEvent;
169
224
  private push;
170
- private flush;
171
- private send;
172
- /**
173
- * A 2xx can still carry per-event rejections:
174
- * `{accepted, duplicates, failed, errors:[{index, message}]}`. Events the
175
- * response names by index are dropped with reason 'rejected'; a `failed`
176
- * count the response does not attribute to an index is counted only.
177
- */
178
- private handlePartialFailures;
179
225
  /** Invoke onError; a hook bug must never break metering or flushing. */
180
226
  private reportError;
181
- /** Number of events permanently dropped since this instance was created. */
227
+ /** Number of events permanently dropped since this instance was created (every DropReason). */
182
228
  get droppedCount(): number;
229
+ /** Number of events waiting to be sent. */
230
+ get queuedCount(): number;
231
+ /** Send what is queued now and wait for it (one request at a time; never concurrent). */
232
+ flush(): Promise<{
233
+ sent: number;
234
+ failed: number;
235
+ requeued: number;
236
+ queued: number;
237
+ }>;
183
238
  /**
184
- * Account for permanently lost events: bump the counter, WARN-log, and
185
- * invoke the opt-in onDrop hook. The buffer is drained at flush start, so
186
- * drops here are bounded by flush cadence — no log throttle needed.
187
- */
188
- private recordDrop;
189
- /**
190
- * Account for an event that failed a client-side check: never buffered,
191
- * never sent. Runs on the hot path, so the WARN is throttled per field
192
- * (first occurrence, then every 1000th).
239
+ * Bill every connection still open with a CONNECTION_CLOSED (close code
240
+ * 1001, GOING_AWAY, metadata `closedBy: "sdk_shutdown"`), stop accepting
241
+ * events and send what is queued, for at most `shutdownTimeoutMs`. Events
242
+ * still queued then are dropped with reason 'shutdown' (counted, passed to
243
+ * onDrop). A connection opened from now on is reported as 'closed' and not
244
+ * tracked. Calling it again returns the first call's promise. Call it from
245
+ * your SIGTERM handler.
193
246
  */
194
- private recordInvalid;
195
- private notifyDrop;
196
- private startTimer;
197
- /** Flush any buffered events and stop the background timer. Call before process exit. */
198
247
  shutdown(): Promise<void>;
248
+ toJSON(): Record<string, unknown>;
199
249
  }
200
250
  export declare const WS_CLOSE_REASONS: Record<number, string>;
201
- export {};