unismsgateway 1.5.1 → 1.5.3

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
@@ -35,26 +35,30 @@ import { init, getSmsPlatform, reset, smsPlatform } from 'unismsgateway';
35
35
  ### `IgatewaySettings`
36
36
 
37
37
 
38
- | Field | Type | Description |
39
- | ------------ | --------------- | -------------------------------------- |
40
- | `platformId` | `'route' \| 'hubtel' \| 'nest'` | Which gateway to use. |
41
- | `param` | `IgatewayParam` | Provider-specific options (see below). |
38
+ | Field | Type | Description |
39
+ | ------------ | ----------------------------- | -------------------------------------- |
40
+ | `platformId` | `'route' | 'hubtel' | 'nest'` | Which gateway to use. |
41
+ | `param` | `IgatewayParam` | Provider-specific options (see below). |
42
42
 
43
43
 
44
44
  ### `IgatewayParam` (all fields optional except what your `platformId` requires)
45
45
 
46
46
 
47
- | Field | Type | Used by | Description |
48
- | -------------- | --------- | --------------- | ---------------------------------------------------------------------- |
49
- | `username` | `string` | `route` | Route Mobile account username. **Required** for `route`. |
50
- | `password` | `string` | `route` | Route Mobile account password. **Required** for `route`. |
51
- | `host` | `string` | `route`, `nest` | API host. See per-gateway defaults below. |
52
- | `port` | `number` | `route` | TCP port for Route Mobile. Default: `8080`. |
53
- | `protocol` | `'http' \| 'https'` | `route`, `nest` | HTTPS or HTTP to the provider API. |
54
- | `clientId` | `string` | `hubtel` | Hubtel client ID. **Required** for `hubtel`. |
55
- | `clientSecret` | `string` | `hubtel` | Hubtel client secret. **Required** for `hubtel`. |
56
- | `apiKey` | `string` | `nest` | SMSOnlineGH API key (`Authorization: key …`). **Required** for `nest`. |
57
- | `debug` | `boolean` | all | If `true`, the active gateway logs each request/response to the console (prefix `[unismsgateway:…]`). Off by default. |
47
+ | Field | Type | Used by | Description |
48
+ | -------------- | ------------------ | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
49
+ | `username` | `string` | `route` | Route Mobile account username. **Required** for `route`. |
50
+ | `password` | `string` | `route` | Route Mobile account password. **Required** for `route`. |
51
+ | `host` | `string` | `route`, `nest` | API host. See per-gateway defaults below. |
52
+ | `port` | `number` | `route` | TCP port for Route Mobile. Default: `8080`. |
53
+ | `protocol` | `'http' | 'https'` | `route`, `nest` | HTTPS or HTTP to the provider API. |
54
+ | `clientId` | `string` | `hubtel` | Hubtel client ID. **Required** for `hubtel`. |
55
+ | `clientSecret` | `string` | `hubtel` | Hubtel client secret. **Required** for `hubtel`. |
56
+ | `apiKey` | `string` | `nest` | SMSOnlineGH API key (`Authorization: key …`). **Required** for `nest`. |
57
+ | `debug` | `boolean` | all | If `true`, the active gateway logs each request/response to the console (prefix `[unismsgateway:…]`). Off by default. |
58
+ | `keepAlive` | `boolean` | `nest` | Enable HTTP keep-alive connection pooling. Reuses TCP/TLS sockets across calls, eliminating per-request handshake overhead. Stale-socket errors are recovered automatically via `retries`. Default: `true`. |
59
+ | `timeout` | `number` | `nest` | Request deadline in milliseconds. The request is aborted with an `ETIMEDOUT` error if the server does not respond within this window. Default: `10000`. |
60
+ | `maxSockets` | `number` | `nest` | Maximum concurrent sockets in the keep-alive pool. Default: `10`. |
61
+ | `retries` | `number` | `nest` | Automatic retry attempts on transient socket errors (`ECONNRESET`, `ECONNABORTED`, `EPIPE`, `ETIMEDOUT`). Default: `1`. |
58
62
 
59
63
 
60
64
  Validation runs in `smsPlatform` when the instance is constructed: missing required fields for the chosen `platformId` throw `Error` with a clear message.
@@ -79,11 +83,11 @@ There are **two separate contexts**. Use the section that matches what you are d
79
83
  Nothing is read from the environment unless **you** wire it. Required fields are determined only by `platformId`:
80
84
 
81
85
 
82
- | `platformId` | Required in `param` | Optional in `param` (defaults in this library) |
83
- | ------------ | -------------------------- | --------------------------------------------------------------------------------------------- |
84
- | `nest` | `apiKey` | `host` (default `api.smsonlinegh.com`), `protocol` (default `https`), `debug` |
85
- | `hubtel` | `clientId`, `clientSecret` | `debug` |
86
- | `route` | `username`, `password` | `host` (default `rslr.connectbind.com`), `protocol` (default `http`), `port` (default `8080`), `debug` |
86
+ | `platformId` | Required in `param` | Optional in `param` (defaults in this library) |
87
+ | ------------ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
88
+ | `nest` | `apiKey` | `host` (default `api.smsonlinegh.com`), `protocol` (default `https`), `debug`, `keepAlive` (default `true`), `timeout` (default `10000` ms), `maxSockets` (default `10`), `retries` (default `1`) |
89
+ | `hubtel` | `clientId`, `clientSecret` | `debug` |
90
+ | `route` | `username`, `password` | `host` (default `rslr.connectbind.com`), `protocol` (default `http`), `port` (default `8080`), `debug` |
87
91
 
88
92
 
89
93
  **Suggested env names for your app** (optional; you can rename them). Credential keys (`NEST_`*, `HUBTEL_`*, `ROUTE_*`) match [live test](#live-integration-test-environment-variables) and `.env.example`. Platform selection differs: the test script requires `GATEWAY_PLATFORM` (or `TEST_ALL`); in your app you choose any name (the example below uses `SMS_PLATFORM_ID`):
@@ -300,20 +304,42 @@ const gateway = unisms.init({
300
304
  **Optional `param`:**
301
305
 
302
306
 
303
- | Field | Default if omitted |
304
- | ---------- | --------------------- |
305
- | `host` | `api.smsonlinegh.com` |
306
- | `protocol` | `'https'` |
307
+ | Field | Default if omitted | Notes |
308
+ | ------------ | --------------------- | --------------------------------------------------------------------- |
309
+ | `host` | `api.smsonlinegh.com` | |
310
+ | `protocol` | `'https'` | |
311
+ | `keepAlive` | `true` | Set to `false` to open a fresh TCP/TLS connection per request. |
312
+ | `timeout` | `10000` | Milliseconds before the request is aborted with `ETIMEDOUT`. |
313
+ | `maxSockets` | `10` | Maximum sockets held open in the keep-alive pool. |
314
+ | `retries` | `1` | Retry count for transient errors (`ECONNRESET`, `ECONNABORTED`, etc). |
307
315
 
308
316
 
309
- Requests use `POST` to path `**/v5/<endpoint>`** (e.g. send: `message/sms/send`, balance: `account/balance`). Authorization header: `Authorization: key <apiKey>`. Each request opens a fresh connection (keep-alive pooling is disabled) to prevent stale-socket errors in long-running processes.
317
+ Requests use `POST` to path `/v5/<endpoint>` (e.g. send: `message/sms/send`, balance: `account/balance`). Authorization header: `Authorization: key <apiKey>`.
318
+
319
+ `NestSmsGateway` maintains a private keep-alive connection pool (`https.Agent`) so TCP and TLS handshakes are paid once and subsequent requests reuse warm sockets. If the server closes an idle socket between calls and the first write fails with `ECONNABORTED` or `ECONNRESET`, the library retries automatically on a fresh socket (controlled by `retries`). Set `keepAlive: false` to revert to a per-request connection if your network environment requires it.
320
+
321
+ The `destroy()` method on `NestSmsGateway` releases all pooled sockets; call it during application shutdown so Node does not hold the event loop open. Access it via `getGateway()`:
322
+
323
+ ```javascript
324
+ const gateway = unisms.init({
325
+ platformId: 'nest',
326
+ param: { apiKey: 'your-api-key' }
327
+ });
328
+
329
+ // On app shutdown:
330
+ gateway.getGateway().destroy();
331
+ ```
332
+
333
+ **Example with performance tuning:**
310
334
 
311
335
  ```javascript
312
336
  const gateway = unisms.init({
313
337
  platformId: 'nest',
314
338
  param: {
315
- apiKey: 'your-api-key'
316
- // optional: host, protocol
339
+ apiKey: 'your-api-key',
340
+ timeout: 5000, // abort after 5 s
341
+ maxSockets: 20, // higher pool ceiling for burst traffic
342
+ retries: 2 // extra resilience on flaky networks
317
343
  }
318
344
  });
319
345
  ```
@@ -337,18 +363,20 @@ console.log(balance.balance, balance.model);
337
363
 
338
364
  ### `QuickSendParams`
339
365
 
340
- | Field | Type | Required | Description |
341
- | --------- | -------- | -------- | ---------------------------------------------------------------------- |
342
- | `From` | `string` | yes | Sender ID or label. |
343
- | `To` | `string \| number` | yes | Recipient MSISDN or number. |
344
- | `Content` | `string` | yes | Message body. |
345
- | `Type` | `number` | no | Message type; **nest** maps this to request body `type` (default `0`). |
346
366
 
347
- **camelCase:** You may pass **`from`**, **`to`**, **`content`**, and **`type`** instead of the PascalCase names above. Many JavaScript projects use camelCase; if you pass only `content` and `Content` is missing, the SMSOnlineGH (`nest`) API receives no message body and may return handshake **1305** (`MV_ERR_MESSAGE` — missing or invalid message body). The library normalizes both conventions before calling the gateway.
367
+ | Field | Type | Required | Description |
368
+ | --------- | ----------------- | -------- | ---------------------------------------------------------------------- |
369
+ | `From` | `string` | yes | Sender ID or label. |
370
+ | `To` | `string | number` | yes | Recipient MSISDN or number. |
371
+ | `Content` | `string` | yes | Message body. |
372
+ | `Type` | `number` | no | Message type; **nest** maps this to request body `type` (default `0`). |
373
+
374
+
375
+ **camelCase:** You may pass `**from`**, `**to**`, `**content**`, and `**type**` instead of the PascalCase names above. Many JavaScript projects use camelCase; if you pass only `content` and `Content` is missing, the SMSOnlineGH (`nest`) API receives no message body and may return handshake **1305** (`MV_ERR_MESSAGE` — missing or invalid message body). The library normalizes both conventions before calling the gateway.
348
376
 
349
377
  ### `quickSend(params, callback?)`
350
378
 
351
- Returns `Promise<SendResult>`. Optional `callback` is invoked with the same result when the promise completes. The `params` argument accepts **`QuickSendParams`** (PascalCase) or **`QuickSendParamsCamel`** (`{ from, to, content, type? }`). See **`normalizeQuickSendParams`** in the public API if you need the same mapping outside `quickSend`.
379
+ Returns `Promise<SendResult>`. Optional `callback` is invoked with the same result when the promise completes. The `params` argument accepts `**QuickSendParams`** (PascalCase) or `**QuickSendParamsCamel**` (`{ from, to, content, type? }`). See `**normalizeQuickSendParams**` in the public API if you need the same mapping outside `quickSend`.
352
380
 
353
381
  `**SendResult`:**
354
382
 
@@ -362,7 +390,7 @@ Returns `Promise<SendResult>`. Optional `callback` is invoked with the same resu
362
390
  }
363
391
  ```
364
392
 
365
- When `success` is `false`, always read **`error`** — it contains a human-readable reason (provider status codes, API handshake labels, network errors, and so on). For **`nest`**, if the API rejects the send but returns JSON, **`data`** is the **full parsed response body** (not only `response.data`), so you can inspect `handshake` and any provider fields. For HTTP errors, `data` may be the raw response body string. **`statusCode`** is set when the adapter knows the HTTP status (for example nest).
393
+ When `success` is `false`, always read `**error**` — it contains a human-readable reason (provider status codes, API handshake labels, network errors, and so on). For `**nest**`, if the API rejects the send but returns JSON, `**data**` is the **full parsed response body** (not only `response.data`), so you can inspect `handshake` and any provider fields. For HTTP errors, `data` may be the raw response body string. `**statusCode`** is set when the adapter knows the HTTP status (for example nest).
366
394
 
367
395
  **Debugging:** Set `param.debug: true` when calling `init()` to print request URLs, bodies, and responses to the console. The live test script enables debug for the `nest` platform so you can trace `quickSend` and `getBalance` without changing application code.
368
396
 
@@ -438,35 +466,50 @@ Full variable reference (selection, per-gateway credentials, live send): [Live i
438
466
  ## API reference
439
467
 
440
468
 
441
- | Export | Description |
442
- | -------------------------- | -------------------------------------------------------------------- |
443
- | `init(settings)` | Create and register the singleton `smsPlatform`, return it. |
444
- | `getSmsPlatform()` | Current `smsPlatform` or `null` after `reset()` and before `init()`. |
445
- | `reset()` | Clear the singleton. |
446
- | `smsPlatform` | Class type for typing/advanced use. |
447
- | `QuickSendParamsInput` | Union: PascalCase `QuickSendParams` or camelCase `QuickSendParamsCamel`. |
448
- | `QuickSendParamsCamel` | `{ from, to, content, type? }` for `quickSend`. |
469
+ | Export | Description |
470
+ | -------------------------- | -------------------------------------------------------------------------- |
471
+ | `init(settings)` | Create and register the singleton `smsPlatform`, return it. |
472
+ | `getSmsPlatform()` | Current `smsPlatform` or `null` after `reset()` and before `init()`. |
473
+ | `reset()` | Clear the singleton. |
474
+ | `smsPlatform` | Class type for typing/advanced use. |
475
+ | `QuickSendParamsInput` | Union: PascalCase `QuickSendParams` or camelCase `QuickSendParamsCamel`. |
476
+ | `QuickSendParamsCamel` | `{ from, to, content, type? }` for `quickSend`. |
449
477
  | `normalizeQuickSendParams` | Maps input to canonical `QuickSendParams` (throws if body/sender missing). |
450
478
 
451
479
 
452
480
  `**smsPlatform` instance methods**
453
481
 
454
482
 
455
- | Method | Returns | Description |
456
- | ------------------------------ | --------------------- | ---------------------------------------------- |
457
- | `init()` | `ISmsGateway` | Returns `this` (facade). |
483
+ | Method | Returns | Description |
484
+ | ------------------------------ | --------------------- | -------------------------------------------------------------------------------- |
485
+ | `init()` | `ISmsGateway` | Returns `this` (facade). |
458
486
  | `quickSend(params, callback?)` | `Promise<SendResult>` | Normalizes PascalCase or camelCase params, then delegates to the active gateway. |
459
- | `getGateway()` | `ISmsGateway` | Underlying adapter (for nest: `getBalance()`). |
487
+ | `getGateway()` | `ISmsGateway` | Underlying adapter (for nest: `getBalance()`). |
460
488
 
461
489
 
462
490
  ---
463
491
 
464
492
  ## Changelog
465
493
 
494
+ ### 1.6.0
495
+
496
+ - **Performance (`nest`):** The `NestSmsGateway` now uses a persistent keep-alive connection pool (`https.Agent`) instead of opening a fresh TCP + TLS connection on every request. Subsequent sends to the same host reuse warm sockets, eliminating the per-call handshake overhead (~100–300 ms per request).
497
+ - **Reliability (`nest`):** Stale-socket errors (`ECONNRESET`, `ECONNABORTED`, `EPIPE`, `ETIMEDOUT`) that can occur when a pooled socket is reused after the server has closed it are automatically retried on a fresh connection. The default retry count is `1`; configure via `param.retries`.
498
+ - **Timeout support (`nest`):** Requests that stall mid-flight are now aborted after a configurable deadline (`param.timeout`, default `10 000 ms`) instead of hanging indefinitely.
499
+ - **New `param` fields (`nest`):** `keepAlive` (default `true`), `timeout` (default `10000`), `maxSockets` (default `10`), `retries` (default `1`). All are optional and fully backwards-compatible; existing `init()` calls require no changes.
500
+ - **Resource cleanup (`nest`):** `NestSmsGateway` exposes a `destroy()` method (accessible via `getGateway().destroy()`) that releases pooled sockets so Node does not hold the event loop open after the gateway is no longer needed.
501
+ - **Internal (`nest`):** Response chunks are now accumulated as `Buffer[]` and concatenated once at the end, avoiding repeated string re-allocation per chunk. The POST body is serialised to a `Buffer` upfront so `Content-Length` reads `Buffer.length` (O(1)) rather than rescanning the string.
502
+
503
+ ### 1.5.2
504
+
505
+ - **Build:** TypeScript `rootDir` is now `./src` with `include: ["src/**/*.ts"]` (scripts stay `ts-node`-only). Previously the compiler also picked up `scripts/test-live.ts`, inferred a project root above `src/`, and emitted library code under `dist/src/lib/…` while published entrypoints load `dist/lib/…` — leaving **stale or missing** `dist/lib/nest-gateway.js` (wrong `requestBody` shape). The published `dist/` layout now matches `package.json` `main` and always rebuilds gateway files from current sources.
506
+
466
507
  ### 1.5.1
508
+
467
509
  - **Fix (`nest` / all gateways):** `quickSend` now accepts **camelCase** (`from`, `to`, `content`, `type`) as well as PascalCase (`From`, `To`, `Content`, `Type`). Passing only camelCase previously left `Content` undefined, so the nest JSON body omitted `text` and the API returned handshake **1305** (missing or invalid message body). Validation errors throw clear messages when body or sender is empty after trim.
468
510
 
469
511
  ### 1.5.0
512
+
470
513
  - **Fix (`nest`):** `quickSend` now reliably works in long-running processes (servers, workers). Node's global HTTP agent reuses keep-alive sockets across calls; when the provider closes an idle socket server-side, the next `quickSend` that writes a request body received `write ECONNABORTED` while `getBalance` (no body) appeared to work fine. Fixed by setting `agent: false` on each request so every call opens a fresh connection rather than reusing a potentially stale one from the pool.
471
514
 
472
515
  ---
package/dist/lib/lib.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { smsPlatform, IgatewaySettings, IgatewayParam, PlatformId, QuickSendParams, SendResult, ISmsGateway } from './platform';
1
+ import { smsPlatform, IgatewaySettings, IgatewayParam, PlatformId, QuickSendParams, QuickSendParamsInput, QuickSendParamsCamel, normalizeQuickSendParams, SendResult, ISmsGateway } from './platform';
2
2
  export declare function init(settings: IgatewaySettings): smsPlatform;
3
3
  export declare function getSmsPlatform(): smsPlatform | null;
4
4
  export declare function reset(): void;
5
- export { smsPlatform, IgatewaySettings, IgatewayParam, PlatformId, QuickSendParams, SendResult, ISmsGateway };
5
+ export { smsPlatform, IgatewaySettings, IgatewayParam, PlatformId, QuickSendParams, QuickSendParamsInput, QuickSendParamsCamel, normalizeQuickSendParams, SendResult, ISmsGateway };
package/dist/lib/lib.js CHANGED
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.smsPlatform = exports.reset = exports.getSmsPlatform = exports.init = void 0;
3
+ exports.normalizeQuickSendParams = exports.smsPlatform = exports.reset = exports.getSmsPlatform = exports.init = void 0;
4
4
  const platform_1 = require("./platform");
5
5
  Object.defineProperty(exports, "smsPlatform", { enumerable: true, get: function () { return platform_1.smsPlatform; } });
6
+ Object.defineProperty(exports, "normalizeQuickSendParams", { enumerable: true, get: function () { return platform_1.normalizeQuickSendParams; } });
6
7
  let smsPlatformInstance = null;
7
8
  function init(settings) {
8
9
  smsPlatformInstance = new platform_1.smsPlatform(settings);
@@ -1,8 +1,53 @@
1
1
  import { ISmsGateway, QuickSendParams, SendResult, NestSmsConfig } from './types';
2
2
  export declare class NestSmsGateway implements ISmsGateway {
3
- private config;
3
+ private readonly _cfg;
4
+ private _agent;
4
5
  constructor(config: NestSmsConfig);
6
+ /**
7
+ * Creates a persistent keep-alive connection pool.
8
+ *
9
+ * Why keep-alive instead of `agent: false`:
10
+ * Each HTTPS request with `agent: false` pays for a full TCP + TLS
11
+ * handshake (~100–300 ms). With a pooled agent, subsequent requests to the
12
+ * same host reuse an existing socket and skip the handshake entirely.
13
+ *
14
+ * Stale-socket problem (original reason for `agent: false`):
15
+ * Servers sometimes close idle sockets, making the next reuse fail with
16
+ * ECONNABORTED/ECONNRESET. This is handled transparently by the retry
17
+ * logic in `makeRequest()` rather than paying the handshake penalty on
18
+ * every single call.
19
+ */
20
+ private _createAgent;
21
+ /**
22
+ * Destroys all sockets held by the connection pool. Call this when the
23
+ * gateway instance will no longer be used (e.g. during app shutdown) to
24
+ * prevent Node from holding the event loop open.
25
+ */
26
+ destroy(): void;
5
27
  init(): ISmsGateway;
28
+ private log;
29
+ /**
30
+ * Performs a single HTTP/S POST to the gateway. Does not retry — see
31
+ * `makeRequest()` for retry orchestration.
32
+ *
33
+ * Key implementation details:
34
+ * - Post body is serialized once to a `Buffer` so `Content-Length` is read
35
+ * directly from `Buffer.length` (O(1)) rather than re-scanning the string
36
+ * with `Buffer.byteLength()`.
37
+ * - Response chunks are accumulated in a `Buffer[]` and concatenated once
38
+ * at the end, avoiding repeated string re-allocation per chunk.
39
+ * - `req.setTimeout()` + `req.destroy()` ensure stalled connections are
40
+ * aborted within the configured timeout window.
41
+ */
42
+ private _doRequest;
43
+ /**
44
+ * Wraps `_doRequest` with automatic retry for transient socket errors.
45
+ *
46
+ * When a keep-alive socket is reused and the server has already closed it
47
+ * on its end, the write fails with ECONNABORTED or ECONNRESET. A single
48
+ * retry on a fresh socket (which the agent provisions automatically after
49
+ * destroying the bad one) is sufficient to recover in virtually all cases.
50
+ */
6
51
  private makeRequest;
7
52
  quickSend(params: QuickSendParams, callback?: Function): Promise<SendResult>;
8
53
  getBalance(): Promise<{
@@ -33,111 +33,251 @@ const https = __importStar(require("https"));
33
33
  const http = __importStar(require("http"));
34
34
  const DEFAULT_HOST = 'api.smsonlinegh.com';
35
35
  const DEFAULT_PROTOCOL = 'https';
36
+ const DEFAULT_TIMEOUT = 10000;
37
+ const DEFAULT_MAX_SOCKETS = 10;
38
+ const DEFAULT_RETRIES = 1;
39
+ /**
40
+ * Error codes that indicate a stale or broken socket rather than a genuine
41
+ * application-level failure. A single retry on a fresh socket resolves these.
42
+ */
43
+ const RETRYABLE_CODES = new Set([
44
+ 'ECONNRESET',
45
+ 'ECONNABORTED',
46
+ 'EPIPE',
47
+ 'ENOTCONN',
48
+ 'ETIMEDOUT'
49
+ ]);
36
50
  class NestSmsGateway {
37
51
  constructor(config) {
38
- this.config = {
52
+ var _a, _b, _c;
53
+ this._cfg = {
39
54
  host: config.host || DEFAULT_HOST,
40
55
  protocol: config.protocol || DEFAULT_PROTOCOL,
41
- apiKey: config.apiKey
56
+ apiKey: config.apiKey,
57
+ debug: config.debug || false,
58
+ timeout: (_a = config.timeout) !== null && _a !== void 0 ? _a : DEFAULT_TIMEOUT,
59
+ maxSockets: (_b = config.maxSockets) !== null && _b !== void 0 ? _b : DEFAULT_MAX_SOCKETS,
60
+ retries: (_c = config.retries) !== null && _c !== void 0 ? _c : DEFAULT_RETRIES,
61
+ keepAlive: config.keepAlive !== false
42
62
  };
63
+ this._agent = this._createAgent();
64
+ }
65
+ /**
66
+ * Creates a persistent keep-alive connection pool.
67
+ *
68
+ * Why keep-alive instead of `agent: false`:
69
+ * Each HTTPS request with `agent: false` pays for a full TCP + TLS
70
+ * handshake (~100–300 ms). With a pooled agent, subsequent requests to the
71
+ * same host reuse an existing socket and skip the handshake entirely.
72
+ *
73
+ * Stale-socket problem (original reason for `agent: false`):
74
+ * Servers sometimes close idle sockets, making the next reuse fail with
75
+ * ECONNABORTED/ECONNRESET. This is handled transparently by the retry
76
+ * logic in `makeRequest()` rather than paying the handshake penalty on
77
+ * every single call.
78
+ */
79
+ _createAgent() {
80
+ const { protocol, maxSockets, keepAlive } = this._cfg;
81
+ const opts = {
82
+ keepAlive,
83
+ maxSockets,
84
+ maxFreeSockets: Math.ceil(maxSockets / 2)
85
+ };
86
+ return protocol === 'https' ? new https.Agent(opts) : new http.Agent(opts);
87
+ }
88
+ /**
89
+ * Destroys all sockets held by the connection pool. Call this when the
90
+ * gateway instance will no longer be used (e.g. during app shutdown) to
91
+ * prevent Node from holding the event loop open.
92
+ */
93
+ destroy() {
94
+ this._agent.destroy();
43
95
  }
44
96
  init() {
45
97
  return this;
46
98
  }
47
- makeRequest(endpoint, data) {
48
- return __awaiter(this, void 0, void 0, function* () {
49
- return new Promise((resolve, reject) => {
50
- var _a;
51
- const postData = data ? JSON.stringify(data) : '';
52
- const protocol = this.config.protocol || DEFAULT_PROTOCOL;
53
- const httpModule = protocol === 'https' ? https : http;
54
- const defaultPort = protocol === 'https' ? 443 : 80;
55
- const options = {
56
- hostname: this.config.host || DEFAULT_HOST,
57
- port: ((_a = this.config.host) === null || _a === void 0 ? void 0 : _a.includes(':'))
58
- ? parseInt(this.config.host.split(':')[1])
59
- : defaultPort,
60
- path: `/v5/${endpoint}`,
61
- method: 'POST',
62
- headers: {
63
- 'Host': this.config.host || DEFAULT_HOST,
64
- 'Content-Type': 'application/json',
65
- 'Accept': 'application/json',
66
- 'Authorization': `key ${this.config.apiKey}`,
67
- 'Content-Length': Buffer.byteLength(postData)
68
- }
69
- };
70
- const req = httpModule.request(options, (res) => {
71
- let responseBody = '';
72
- res.on('data', (chunk) => {
73
- responseBody += chunk;
74
- });
75
- res.on('end', () => {
76
- try {
77
- if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
78
- const parsed = JSON.parse(responseBody);
79
- resolve(parsed);
80
- }
81
- else {
82
- reject(new Error(`HTTP ${res.statusCode}: ${responseBody}`));
83
- }
99
+ log(...args) {
100
+ if (this._cfg.debug) {
101
+ console.log('[unismsgateway:nest]', ...args);
102
+ }
103
+ }
104
+ /**
105
+ * Performs a single HTTP/S POST to the gateway. Does not retry — see
106
+ * `makeRequest()` for retry orchestration.
107
+ *
108
+ * Key implementation details:
109
+ * - Post body is serialized once to a `Buffer` so `Content-Length` is read
110
+ * directly from `Buffer.length` (O(1)) rather than re-scanning the string
111
+ * with `Buffer.byteLength()`.
112
+ * - Response chunks are accumulated in a `Buffer[]` and concatenated once
113
+ * at the end, avoiding repeated string re-allocation per chunk.
114
+ * - `req.setTimeout()` + `req.destroy()` ensure stalled connections are
115
+ * aborted within the configured timeout window.
116
+ */
117
+ _doRequest(endpoint, data) {
118
+ return new Promise((resolve, reject) => {
119
+ const postBuffer = data
120
+ ? Buffer.from(JSON.stringify(data), 'utf8')
121
+ : Buffer.alloc(0);
122
+ const { protocol, timeout } = this._cfg;
123
+ const httpModule = protocol === 'https' ? https : http;
124
+ const defaultPort = protocol === 'https' ? 443 : 80;
125
+ const host = this._cfg.host;
126
+ const hostname = host.includes(':') ? host.split(':')[0] : host;
127
+ const port = host.includes(':')
128
+ ? parseInt(host.split(':')[1], 10)
129
+ : defaultPort;
130
+ const options = {
131
+ hostname,
132
+ port,
133
+ path: `/v5/${endpoint}`,
134
+ method: 'POST',
135
+ agent: this._agent,
136
+ headers: {
137
+ 'Host': hostname,
138
+ 'Content-Type': 'application/json',
139
+ 'Accept': 'application/json',
140
+ 'Authorization': `key ${this._cfg.apiKey}`,
141
+ 'Content-Length': postBuffer.length
142
+ }
143
+ };
144
+ this.log(`POST /v5/${endpoint}`, data ? JSON.stringify(data) : '(no body)');
145
+ const req = httpModule.request(options, (res) => {
146
+ const chunks = [];
147
+ res.on('data', (chunk) => {
148
+ chunks.push(chunk);
149
+ });
150
+ res.on('end', () => {
151
+ var _a;
152
+ const statusCode = (_a = res.statusCode) !== null && _a !== void 0 ? _a : 0;
153
+ const responseBody = Buffer.concat(chunks).toString('utf8');
154
+ this.log(`HTTP ${statusCode} response:`, responseBody);
155
+ try {
156
+ const parsed = JSON.parse(responseBody);
157
+ if (statusCode >= 200 && statusCode < 300) {
158
+ resolve({ statusCode, body: parsed });
84
159
  }
85
- catch (error) {
86
- reject(new Error(`Failed to parse response: ${responseBody}`));
160
+ else {
161
+ const err = new Error(`HTTP ${statusCode}: ${responseBody}`);
162
+ err.statusCode = statusCode;
163
+ err.rawBody = responseBody;
164
+ reject(err);
87
165
  }
88
- });
89
- });
90
- req.on('error', (error) => {
91
- reject(error);
166
+ }
167
+ catch (_b) {
168
+ const err = new Error(`Failed to parse gateway response (HTTP ${statusCode}): ${responseBody}`);
169
+ err.statusCode = statusCode;
170
+ err.rawBody = responseBody;
171
+ reject(err);
172
+ }
92
173
  });
93
- req.write(postData);
94
- req.end();
95
174
  });
175
+ req.setTimeout(timeout, () => {
176
+ const err = new Error(`Request timed out after ${timeout}ms`);
177
+ err.code = 'ETIMEDOUT';
178
+ req.destroy(err);
179
+ });
180
+ req.on('error', (error) => {
181
+ this.log('Network error:', error.message);
182
+ reject(error);
183
+ });
184
+ if (postBuffer.length > 0) {
185
+ req.write(postBuffer);
186
+ }
187
+ req.end();
188
+ });
189
+ }
190
+ /**
191
+ * Wraps `_doRequest` with automatic retry for transient socket errors.
192
+ *
193
+ * When a keep-alive socket is reused and the server has already closed it
194
+ * on its end, the write fails with ECONNABORTED or ECONNRESET. A single
195
+ * retry on a fresh socket (which the agent provisions automatically after
196
+ * destroying the bad one) is sufficient to recover in virtually all cases.
197
+ */
198
+ makeRequest(endpoint, data, attempt = 0) {
199
+ return __awaiter(this, void 0, void 0, function* () {
200
+ try {
201
+ return yield this._doRequest(endpoint, data);
202
+ }
203
+ catch (error) {
204
+ const code = error.code;
205
+ if (code && RETRYABLE_CODES.has(code) && attempt < this._cfg.retries) {
206
+ this.log(`Retrying POST /v5/${endpoint} (attempt ${attempt + 1}/${this._cfg.retries}) after ${code}`);
207
+ return this.makeRequest(endpoint, data, attempt + 1);
208
+ }
209
+ throw error;
210
+ }
96
211
  });
97
212
  }
98
213
  quickSend(params, callback) {
99
- var _a, _b;
214
+ var _a, _b, _c, _d, _e;
100
215
  return __awaiter(this, void 0, void 0, function* () {
101
- const endpoint = 'message/sms/send';
102
216
  const requestBody = {
103
- from: params.From,
104
- to: String(params.To),
105
- content: params.Content,
106
- type: params.Type || 0
217
+ text: params.Content,
218
+ type: (_a = params.Type) !== null && _a !== void 0 ? _a : 0,
219
+ sender: params.From,
220
+ destinations: [String(params.To)]
107
221
  };
222
+ this.log('quickSend params:', JSON.stringify(params));
223
+ let result;
108
224
  try {
109
- const response = yield this.makeRequest(endpoint, requestBody);
110
- const result = {
111
- success: ((_a = response.handshake) === null || _a === void 0 ? void 0 : _a.id) === 0,
112
- data: response.data,
113
- messageId: (_b = response.data) === null || _b === void 0 ? void 0 : _b.messageId
114
- };
115
- if (callback) {
116
- callback(result);
225
+ const { statusCode, body: response } = yield this.makeRequest('message/sms/send', requestBody);
226
+ const handshakeId = (_b = response.handshake) === null || _b === void 0 ? void 0 : _b.id;
227
+ const handshakeLabel = (_c = response.handshake) === null || _c === void 0 ? void 0 : _c.label;
228
+ const handshakeOk = Number(handshakeId) === 0;
229
+ const responseData = (_d = response.data) !== null && _d !== void 0 ? _d : null;
230
+ const batchId = responseData && typeof responseData === 'object'
231
+ ? responseData.batch
232
+ : undefined;
233
+ const firstDest = responseData && typeof responseData === 'object'
234
+ ? (_e = responseData.destinations) === null || _e === void 0 ? void 0 : _e[0]
235
+ : undefined;
236
+ let errorMsg;
237
+ if (!handshakeOk) {
238
+ if (handshakeLabel) {
239
+ errorMsg = `API Error [code ${handshakeId}]: ${handshakeLabel}`;
240
+ }
241
+ else if (handshakeId !== undefined && handshakeId !== null) {
242
+ errorMsg = `API Error: handshake code=${handshakeId}`;
243
+ }
244
+ else {
245
+ errorMsg = `Unexpected API response: ${JSON.stringify(response)}`;
246
+ }
117
247
  }
118
- return result;
248
+ result = {
249
+ success: handshakeOk,
250
+ messageId: batchId || (firstDest === null || firstDest === void 0 ? void 0 : firstDest.id),
251
+ data: handshakeOk ? responseData : response,
252
+ error: errorMsg,
253
+ statusCode
254
+ };
119
255
  }
120
256
  catch (error) {
257
+ const httpErr = error;
121
258
  const errorMessage = error instanceof Error ? error.message : String(error);
122
- const result = {
259
+ result = {
123
260
  success: false,
124
- error: errorMessage
261
+ error: errorMessage,
262
+ statusCode: httpErr.statusCode,
263
+ data: httpErr.rawBody !== undefined ? httpErr.rawBody : null
125
264
  };
126
- if (callback) {
127
- callback(result);
128
- }
129
- return result;
130
265
  }
266
+ this.log('quickSend result:', JSON.stringify(result));
267
+ if (callback) {
268
+ callback(result);
269
+ }
270
+ return result;
131
271
  });
132
272
  }
133
273
  getBalance() {
134
- var _a, _b;
274
+ var _a, _b, _c, _d;
135
275
  return __awaiter(this, void 0, void 0, function* () {
136
- const endpoint = 'account/balance';
137
- const response = yield this.makeRequest(endpoint);
276
+ this.log('getBalance called');
277
+ const { body: response } = yield this.makeRequest('account/balance');
138
278
  return {
139
- balance: ((_a = response.data) === null || _a === void 0 ? void 0 : _a.balance) || 0,
140
- model: ((_b = response.data) === null || _b === void 0 ? void 0 : _b.model) || 'quantity'
279
+ balance: (_b = (_a = response.data) === null || _a === void 0 ? void 0 : _a.balance) !== null && _b !== void 0 ? _b : 0,
280
+ model: (_d = (_c = response.data) === null || _c === void 0 ? void 0 : _c.model) !== null && _d !== void 0 ? _d : 'quantity'
141
281
  };
142
282
  });
143
283
  }