@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/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.0.0 · 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
- > **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.
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,27 +69,81 @@ 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 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
+ | `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:
76
89
 
77
90
  | Option | Where | Type | What it does |
78
91
  |---|---|---|---|
79
- | `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. |
80
94
  | `extractMetadata` | `wrapServer` | `(req) => Record<string, unknown> \| undefined` | Optional per-connection tags. |
81
95
  | `customerId` | `trackConnection` | `string` | Customer to attribute all traffic on this socket to. |
82
96
  | `metadata` | `trackConnection` | `Record<string, unknown>` | Optional per-connection tags. |
83
97
  | `productType` | both | `string` | Product type for this server's / socket's events. Default: the client-level `productType`. |
84
98
 
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`.
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
104
+
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.
106
+
107
+ ## Execution status
108
+
109
+ Outcome-based pricing bills each event at the weight set for its `executionStatus`; events without one bill at full price. The SDK trims and upper-cases the value and leaves the key out of the event when it is unset or blank. Accepted values: `SUCCESS`, `PARTIAL`, `TIMEOUT`, `ERROR`, `VALIDATION_FAILED`, `FAILED`, `FAILURE`, `CANCELLED`, `PENDING`, `BLOCKED`, `HITL_REQUIRED`. Any other value (or a Promise returned by an async resolver) is reported through `onError` and left off the event — the event itself is still sent — because the server would reject the event. Resolvers must be synchronous.
110
+
111
+ `executionStatus` is the outcome of the **connection**: it is set on the closing event only — `CONNECTION_CLOSED`, and the synthetic close emitted on a socket error. `CONNECTION_OPENED` and per-frame `MESSAGE` events never carry it, so a status such as `ERROR` for an abnormal close doesn't make every frame bill at the `ERROR` weight. (This matches the Python SDK; Go and Java take a status per call.)
112
+
113
+ WebSocket frames carry no success or failure signal, so the SDK never sets `executionStatus` on its own — not even for abnormal closes or socket errors. Pass it yourself, as a string or as a synchronous function called with the closing event:
114
+
115
+ ```typescript
116
+ billing.wrapServer(wss, {
117
+ customerResolver: (req) => sessions.customerIdFor(req),
118
+ // (event, req) => status | undefined
119
+ executionStatus: (event, req) =>
120
+ event.wsFrameType === 'CLOSE' && event.wsCloseReason !== 'NORMAL_CLOSURE' ? 'ERROR' : undefined,
121
+ });
122
+
123
+ billing.trackConnection(socket, { customerId: 'cust_42', executionStatus: 'SUCCESS' });
124
+ ```
125
+
126
+ ## Dropped events
127
+
128
+ The SDK does not throw into your socket handlers for event content. An event that cannot be delivered is counted in `billing.droppedCount`, logged with `console.warn`, and passed to the optional `onDrop(events, reason)` hook. Events handed to the hook keep their `idempotencyKey`, so storing them and re-submitting later is dedup-safe. Exceptions thrown by the hook are swallowed.
129
+
130
+ | Reason | When |
131
+ |---|---|
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. |
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. |
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. |
86
138
 
87
- Exported symbols: `AforoWsBilling` (with `wrapServer` / `trackConnection` / `shutdown`), the `WS_CLOSE_REASONS` map, and the `AforoWsConfig` / `WrapServerOptions` types.
139
+ A connection with no resolvable customer is not metered and is counted as one `invalid` drop.
140
+
141
+ ```ts
142
+ const billing = new AforoWsBilling({
143
+ // …
144
+ onDrop: (events, reason) => deadLetterQueue.push({ reason, events }),
145
+ });
146
+ ```
88
147
 
89
148
  ## Walk me through it
90
149
 
@@ -93,6 +152,6 @@ Step-by-step from install to a verified event in Aforo: [USER_GUIDE.md](USER_GUI
93
152
  ## What this doesn't cover
94
153
 
95
154
  - **`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.
155
+ - **No persistent buffer.** Events are in memory until sent; a hard crash loses what is queued. `shutdown()` covers graceful exit only.
97
156
  - **Connection identity is per-process.** `wsConnectionId` is a fresh UUID per `trackConnection` call; it does not survive reconnects or correlate across processes.
98
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,62 +46,205 @@ 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;
77
+ /**
78
+ * Opt-in hook receiving events that were permanently dropped (see
79
+ * DropReason). Events keep their idempotency keys,
80
+ * so persisting and re-submitting them after recovery is dedup-safe.
81
+ * Exceptions thrown by the hook are swallowed. Default: none (drops are
82
+ * still counted in droppedCount and WARN-logged).
83
+ */
84
+ onDrop?: (events: WsUsageEvent[], reason: DropReason) => void;
48
85
  }
86
+ /**
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.
94
+ */
95
+ export type DropReason = 'overflow' | 'retry_exhausted' | 'rejected' | 'invalid' | 'shutdown' | 'closed';
49
96
  export interface WrapServerOptions {
50
- /** Extract Aforo customer ID from the upgrade request. */
51
- 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;
52
112
  /** Optional per-connection metadata (product-defined tags). */
53
113
  extractMetadata?: (req: any) => Record<string, unknown> | undefined;
114
+ /**
115
+ * Optional outcome of the request (used by OUTCOME_BASED pricing, which bills
116
+ * each event at the weight set for its status). Events without a status bill
117
+ * at full price. Trimmed and upper-cased by the SDK; blank is treated as absent.
118
+ * Accepted values: SUCCESS, PARTIAL, TIMEOUT, ERROR, VALIDATION_FAILED, FAILED,
119
+ * FAILURE, CANCELLED, PENDING, BLOCKED, HITL_REQUIRED (max 20 chars). Any other
120
+ * value — or a Promise from an async resolver — is reported through `onError`
121
+ * and left off the event (the event is still sent), because the server would
122
+ * reject the event.
123
+ *
124
+ * This is the outcome of the CONNECTION: it is set on the closing event only
125
+ * (CONNECTION_CLOSED, and the synthetic close emitted on a socket error).
126
+ * CONNECTION_OPENED and per-frame MESSAGE events never carry it, so a status
127
+ * meant for the connection (e.g. ERROR on an abnormal close) can't zero out
128
+ * every frame under OUTCOME_BASED pricing. WebSocket frames carry no
129
+ * success/failure signal, so the SDK never derives one: pass a fixed string,
130
+ * or a synchronous function called with the closing event (and the upgrade
131
+ * request) that returns the status or undefined.
132
+ */
133
+ executionStatus?: string | ((event: WsUsageEvent, req: any) => string | undefined);
54
134
  /** Product type for connections accepted by this server. Default: the client-level `productType`. */
55
135
  productType?: string;
56
136
  }
137
+ /** Options for trackConnection(). */
138
+ export interface TrackConnectionOptions {
139
+ customerId: string;
140
+ metadata?: Record<string, unknown>;
141
+ /**
142
+ * Optional outcome of the request (used by OUTCOME_BASED pricing, which bills
143
+ * each event at the weight set for its status). Events without a status bill
144
+ * at full price. Trimmed and upper-cased by the SDK; blank is treated as absent.
145
+ * Accepted values: SUCCESS, PARTIAL, TIMEOUT, ERROR, VALIDATION_FAILED, FAILED,
146
+ * FAILURE, CANCELLED, PENDING, BLOCKED, HITL_REQUIRED (max 20 chars). Any other
147
+ * value — or a Promise from an async resolver — is reported through `onError`
148
+ * and left off the event (the event is still sent), because the server would
149
+ * reject the event.
150
+ *
151
+ * The outcome of the connection, set on its closing event only
152
+ * (CONNECTION_CLOSED, or the synthetic close on a socket error) — never on
153
+ * CONNECTION_OPENED or per-frame events. Never derived by the SDK: pass a
154
+ * fixed string, or a synchronous function called with the closing event that
155
+ * returns the status or undefined.
156
+ */
157
+ executionStatus?: string | ((event: WsUsageEvent) => string | undefined);
158
+ /** Product type for this connection's events (trimmed + uppercased). Default: the client-level `productType`. */
159
+ productType?: string;
160
+ }
57
161
  /** Minimal WebSocket surface — matches `ws` WebSocket, Fastify socket, Deno, Bun. */
58
162
  interface MinimalWs {
59
163
  on(event: 'message', fn: (data: any, isBinary?: boolean) => void): void;
60
164
  on(event: 'close', fn: (code: number, reason: Buffer | string) => void): void;
61
165
  on(event: 'error', fn: (err: Error) => void): void;
62
- 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;
63
168
  readyState?: number;
64
169
  }
65
170
  /** Minimal WebSocketServer surface — matches `ws` WebSocketServer. */
66
171
  interface MinimalWss {
67
172
  on(event: 'connection', fn: (ws: MinimalWs, req: any) => void): void;
68
173
  }
174
+ /** Default for AforoWsConfig.connectionIdleTimeoutMs: one hour. */
175
+ export declare const DEFAULT_CONNECTION_IDLE_TIMEOUT_MS: number;
69
176
  /** Default top-level `productType` for this SDK. */
70
177
  export declare const DEFAULT_PRODUCT_TYPE = "WEBSOCKET_API";
178
+ export interface WsUsageEvent {
179
+ customerId: string;
180
+ metricName: string;
181
+ quantity: number;
182
+ occurredAt: string;
183
+ idempotencyKey: string;
184
+ productType: string;
185
+ wsConnectionId: string;
186
+ wsDirection: 'CLIENT_TO_SERVER' | 'SERVER_TO_CLIENT';
187
+ wsFrameType: 'OPEN' | 'TEXT' | 'BINARY' | 'PING' | 'PONG' | 'CLOSE';
188
+ wsCloseReason?: string;
189
+ messageCount: number;
190
+ dataBytes: number;
191
+ executionDurationMs: number;
192
+ metadata?: Record<string, unknown>;
193
+ /** Normalized (trimmed, upper-cased) execution status; omitted when not set. */
194
+ executionStatus?: string;
195
+ }
71
196
  export declare class AforoWsBilling {
72
197
  private readonly config;
73
198
  private readonly productType;
74
- private readonly flushCount;
75
- private readonly flushIntervalMs;
76
199
  private readonly perFrameEvents;
77
200
  private readonly onError;
78
- private buffer;
79
- private flushTimer;
201
+ private readonly core;
202
+ private readonly idleTimeoutMs;
203
+ private readonly connections;
204
+ private readonly sweepTimer;
205
+ private shutdownPromise;
80
206
  constructor(config: AforoWsConfig);
81
- /** 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
+ */
82
219
  wrapServer(wss: MinimalWss, options: WrapServerOptions): void;
83
220
  /** 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;
221
+ trackConnection(ws: MinimalWs, opts: TrackConnectionOptions): () => void;
222
+ /** Fill in the fields every event carries. */
223
+ private buildEvent;
89
224
  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;
225
+ /** Invoke onError; a hook bug must never break metering or flushing. */
226
+ private reportError;
227
+ /** Number of events permanently dropped since this instance was created (every DropReason). */
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
+ }>;
238
+ /**
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.
246
+ */
95
247
  shutdown(): Promise<void>;
248
+ toJSON(): Record<string, unknown>;
96
249
  }
97
250
  export declare const WS_CLOSE_REASONS: Record<number, string>;
98
- export {};