@zeroxsolutions/sms 0.1.0 → 0.1.2
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/CHANGELOG.md +26 -0
- package/README.md +58 -3
- package/dist/lib/esms/sender.d.ts.map +1 -1
- package/dist/lib/esms/sender.js +2 -1
- package/dist/lib/sender/errors.d.ts +6 -2
- package/dist/lib/sender/errors.d.ts.map +1 -1
- package/dist/lib/sender/errors.js +6 -2
- package/dist/lib/sender/port.d.ts +3 -1
- package/dist/lib/sender/port.d.ts.map +1 -1
- package/dist/lib/twilio/index.d.ts +3 -0
- package/dist/lib/twilio/index.d.ts.map +1 -0
- package/dist/lib/twilio/index.js +2 -0
- package/dist/lib/twilio/sender.d.ts +25 -0
- package/dist/lib/twilio/sender.d.ts.map +1 -0
- package/dist/lib/twilio/sender.js +101 -0
- package/package.json +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
## 0.1.2 (2026-10-08)
|
|
2
|
+
|
|
3
|
+
### 🚀 Features
|
|
4
|
+
|
|
5
|
+
- **sms:** send through twilio for numbers outside vietnam ([1d94662](https://github.com/zeroxsolutions/zeroxsolutions/commit/1d94662))
|
|
6
|
+
|
|
7
|
+
### 💅 Refactors
|
|
8
|
+
|
|
9
|
+
- **sms:** name the twilio option for the origin it holds ([8c69c31](https://github.com/zeroxsolutions/zeroxsolutions/commit/8c69c31))
|
|
10
|
+
|
|
11
|
+
### ❤️ Thank You
|
|
12
|
+
|
|
13
|
+
- Claude Opus 5.5
|
|
14
|
+
- Lương Văn Tú
|
|
15
|
+
|
|
16
|
+
## 0.1.1 (2026-09-25)
|
|
17
|
+
|
|
18
|
+
### 🩹 Fixes
|
|
19
|
+
|
|
20
|
+
- **sms:** raise esms's tps refusal as a rate limit ([88b1ed1](https://github.com/zeroxsolutions/zeroxsolutions/commit/88b1ed1))
|
|
21
|
+
|
|
22
|
+
### ❤️ Thank You
|
|
23
|
+
|
|
24
|
+
- Claude Opus 5.5
|
|
25
|
+
- Lương Văn Tú
|
|
26
|
+
|
|
1
27
|
## 0.1.0 (2026-09-25)
|
|
2
28
|
|
|
3
29
|
### 🚀 Features
|
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# @zeroxsolutions/sms
|
|
2
2
|
|
|
3
|
-
The SMS seam: a composed message, the port that sends it,
|
|
3
|
+
The SMS seam: a composed message, the port that sends it, its typed refusals, and the eSMS and
|
|
4
|
+
Twilio adapters behind it.
|
|
4
5
|
|
|
5
6
|
What it does not own is the reason an SMS exists at all - templates, message keys, locales, delivery
|
|
6
7
|
records, queues and retries are product decisions and stay in the product. This package takes a
|
|
@@ -45,7 +46,8 @@ takes a name and an address pair. `to` is in E.164 and is not validated here: wh
|
|
|
45
46
|
accepts, and how it normalises one, is the product's own decision.
|
|
46
47
|
|
|
47
48
|
`reference` is optional: the caller's own id for the message. An adapter whose provider refuses a
|
|
48
|
-
repeated id sends it, so a send retried after a lost answer cannot deliver and charge twice.
|
|
49
|
+
repeated id sends it, so a send retried after a lost answer cannot deliver and charge twice. Twilio
|
|
50
|
+
takes no such id, so its adapter ignores `reference`.
|
|
49
51
|
|
|
50
52
|
## eSMS
|
|
51
53
|
|
|
@@ -81,6 +83,7 @@ not `+84` followed by 9 or 10 digits is refused before any request is made.
|
|
|
81
83
|
| `104`, `177` | `SmsSenderNotRegistered` |
|
|
82
84
|
| `146`, `201` | `SmsMessageRejected` |
|
|
83
85
|
| `103` | `SmsBalanceExhausted` |
|
|
86
|
+
| `160` | `SmsRateLimited` |
|
|
84
87
|
| not 2xx, not JSON, or no answer in time | `SmsProviderUnavailable` |
|
|
85
88
|
| `124` with a `reference` | none: answered as sent, with the `reference` as its `messageId` |
|
|
86
89
|
| any other | `SmsSendFailed` |
|
|
@@ -116,6 +119,58 @@ eSMS signs nothing. The route that receives the callback checks the secret in it
|
|
|
116
119
|
caller's address against `ESMS_CALLBACK_SOURCE_ADDRESSES`, redacts the query in its access log (it
|
|
117
120
|
carries the number), and answers quickly. A query it cannot read throws `SmsDeliveryReportInvalid`.
|
|
118
121
|
|
|
122
|
+
## Twilio
|
|
123
|
+
|
|
124
|
+
`@zeroxsolutions/sms/twilio` sends through [Twilio](https://www.twilio.com)'s Messages API, for
|
|
125
|
+
numbers outside the countries a local provider covers. It posts a form body with Basic auth over the
|
|
126
|
+
runtime's own `fetch`, so it installs nothing and runs on workerd as on Node.
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
import { TwilioSmsSender } from '@zeroxsolutions/sms/twilio';
|
|
130
|
+
|
|
131
|
+
const sender = new TwilioSmsSender({
|
|
132
|
+
accountSid: env.TWILIO_ACCOUNT_SID,
|
|
133
|
+
authToken: env.TWILIO_AUTH_TOKEN,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const { messageId } = await sender.send({
|
|
137
|
+
from: 'MG0123456789abcdef0123456789abcdef',
|
|
138
|
+
to: '+821012345678',
|
|
139
|
+
text: 'Your code is 123456.',
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`from` is a Messaging Service id (`MG` and 32 hex digits), sent as `MessagingServiceSid`, or a Twilio
|
|
144
|
+
number or alphanumeric sender id, sent as `From`. A Messaging Service picks the sender per destination
|
|
145
|
+
from a pool the account configures. A `to` that is not E.164 is refused before any request is made.
|
|
146
|
+
|
|
147
|
+
`send` resolving means Twilio queued the message, not that the handset received it.
|
|
148
|
+
|
|
149
|
+
| Twilio error `code` | Refusal |
|
|
150
|
+
| ----------------------------------- | ------------------------- |
|
|
151
|
+
| `21211`, `21614` | `SmsRecipientInvalid` |
|
|
152
|
+
| `21612`, `21610` | `SmsRecipientUnreachable` |
|
|
153
|
+
| `21212`, `21606`, `21408` | `SmsSenderNotRegistered` |
|
|
154
|
+
| `21617` | `SmsMessageRejected` |
|
|
155
|
+
| `20429`, `21611`, or HTTP 429 | `SmsRateLimited` |
|
|
156
|
+
| 5xx, not JSON, or no answer in time | `SmsProviderUnavailable` |
|
|
157
|
+
| any other | `SmsSendFailed` |
|
|
158
|
+
|
|
159
|
+
`21610` is a recipient who replied STOP. `21408` is the account's geographic permissions refusing the
|
|
160
|
+
destination country. Twilio has no code for an empty balance: a suspended account answers `20005`,
|
|
161
|
+
which is `SmsSendFailed`.
|
|
162
|
+
|
|
163
|
+
Twilio takes no idempotency key. `SmsProviderUnavailable` may follow a send Twilio already queued, and
|
|
164
|
+
retrying it can deliver and charge twice.
|
|
165
|
+
|
|
166
|
+
The account has to meet three conditions no code can check:
|
|
167
|
+
|
|
168
|
+
1. Each destination country is enabled in its geographic permissions, or Twilio answers `21408`.
|
|
169
|
+
2. The sender suits each destination. The United States accepts no alphanumeric sender id.
|
|
170
|
+
3. A trial account sends only to numbers verified in it.
|
|
171
|
+
|
|
172
|
+
Test credentials, with `+15005550006` as `from`, send nothing and charge nothing.
|
|
173
|
+
|
|
119
174
|
## Refusals
|
|
120
175
|
|
|
121
176
|
A refusal is one plain `Error` subclass per meaning a caller acts on differently - no wire status, no
|
|
@@ -125,7 +180,7 @@ code, no envelope. A caller maps them by class.
|
|
|
125
180
|
| -------------------------- | --------------------------------------------------------- |
|
|
126
181
|
| `SmsRecipientInvalid` | the number is not routable |
|
|
127
182
|
| `SmsRecipientUnreachable` | the carrier would not deliver to the subscriber |
|
|
128
|
-
| `SmsSenderNotRegistered` | the
|
|
183
|
+
| `SmsSenderNotRegistered` | the account may not send from this sender or to this destination |
|
|
129
184
|
| `SmsMessageRejected` | the text breaks a provider or carrier rule |
|
|
130
185
|
| `SmsRateLimited` | the provider is throttling this account |
|
|
131
186
|
| `SmsBalanceExhausted` | the account has no credit left |
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sender.d.ts","sourceRoot":"","sources":["../../../src/lib/esms/sender.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"sender.d.ts","sourceRoot":"","sources":["../../../src/lib/esms/sender.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,UAAU,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAiD/E,kDAAkD;AAClD,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,oFAAoF;IACpF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,6FAA6F;IAC7F,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;OAGG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,uGAAuG;IACvG,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAoBD;;;;GAIG;AACH,qBAAa,aAAc,YAAW,UAAU;IAClC,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,oBAAoB;IAEpD,IAAI,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,aAAa,CAAC;IAoCvD,qGAAqG;YACvF,IAAI;CA2BnB"}
|
package/dist/lib/esms/sender.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SmsBalanceExhausted, SmsMessageRejected, SmsProviderUnavailable, SmsRecipientInvalid, SmsRecipientUnreachable, SmsSendFailed, SmsSenderNotRegistered, } from '../sender/errors.js';
|
|
1
|
+
import { SmsBalanceExhausted, SmsMessageRejected, SmsProviderUnavailable, SmsRateLimited, SmsRecipientInvalid, SmsRecipientUnreachable, SmsSendFailed, SmsSenderNotRegistered, } from '../sender/errors.js';
|
|
2
2
|
/** eSMS's call that sends one message (developers.esms.vn, "Tin CSKH"); the default `endpoint`. */
|
|
3
3
|
const SEND_URL = 'https://rest.esms.vn/MainService.svc/json/SendMultipleMessage_V4_post_json/';
|
|
4
4
|
/** `SmsType` 2, customer care: the type a brandname sends a one-time code as. */
|
|
@@ -28,6 +28,7 @@ const REFUSALS = {
|
|
|
28
28
|
'104': SmsSenderNotRegistered,
|
|
29
29
|
'108': SmsRecipientInvalid,
|
|
30
30
|
'146': SmsMessageRejected,
|
|
31
|
+
'160': SmsRateLimited,
|
|
31
32
|
'177': SmsSenderNotRegistered,
|
|
32
33
|
'201': SmsMessageRejected,
|
|
33
34
|
};
|
|
@@ -18,7 +18,10 @@ export declare class SmsRecipientInvalid extends Error {
|
|
|
18
18
|
export declare class SmsRecipientUnreachable extends Error {
|
|
19
19
|
constructor(options?: ErrorOptions);
|
|
20
20
|
}
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* The account may not send from this sender, or to this destination. An operator fixes it in the
|
|
23
|
+
* account, and every message it covers fails alike until then.
|
|
24
|
+
*/
|
|
22
25
|
export declare class SmsSenderNotRegistered extends Error {
|
|
23
26
|
constructor(options?: ErrorOptions);
|
|
24
27
|
}
|
|
@@ -37,7 +40,8 @@ export declare class SmsBalanceExhausted extends Error {
|
|
|
37
40
|
/**
|
|
38
41
|
* The provider failed on its own side; the message itself may be fine. This refusal can follow a send
|
|
39
42
|
* the provider already accepted - a timeout or a lost answer - so a retry must reuse the same
|
|
40
|
-
* `reference`.
|
|
43
|
+
* `reference`. An adapter whose provider takes no idempotency key, Twilio's, cannot make that retry
|
|
44
|
+
* safe, so a retry may deliver twice.
|
|
41
45
|
*/
|
|
42
46
|
export declare class SmsProviderUnavailable extends Error {
|
|
43
47
|
constructor(options?: ErrorOptions);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../../src/lib/sender/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,gHAAgH;AAChH,qBAAa,mBAAoB,SAAQ,KAAK;gBAChC,OAAO,CAAC,EAAE,YAAY;CAInC;AAED,oGAAoG;AACpG,qBAAa,uBAAwB,SAAQ,KAAK;gBACpC,OAAO,CAAC,EAAE,YAAY;CAInC;AAED
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../../src/lib/sender/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,gHAAgH;AAChH,qBAAa,mBAAoB,SAAQ,KAAK;gBAChC,OAAO,CAAC,EAAE,YAAY;CAInC;AAED,oGAAoG;AACpG,qBAAa,uBAAwB,SAAQ,KAAK;gBACpC,OAAO,CAAC,EAAE,YAAY;CAInC;AAED;;;GAGG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;gBACnC,OAAO,CAAC,EAAE,YAAY;CAInC;AAED,sIAAsI;AACtI,qBAAa,kBAAmB,SAAQ,KAAK;gBAC/B,OAAO,CAAC,EAAE,YAAY;CAInC;AAED,mGAAmG;AACnG,qBAAa,cAAe,SAAQ,KAAK;gBAC3B,OAAO,CAAC,EAAE,YAAY;CAInC;AAED,yFAAyF;AACzF,qBAAa,mBAAoB,SAAQ,KAAK;gBAChC,OAAO,CAAC,EAAE,YAAY;CAInC;AAED;;;;;GAKG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;gBACnC,OAAO,CAAC,EAAE,YAAY;CAInC;AAED,sGAAsG;AACtG,qBAAa,aAAc,SAAQ,KAAK;gBAC1B,OAAO,CAAC,EAAE,YAAY;CAInC;AAED,2FAA2F;AAC3F,qBAAa,wBAAyB,SAAQ,KAAK;gBACrC,OAAO,CAAC,EAAE,YAAY;CAInC"}
|
|
@@ -24,7 +24,10 @@ export class SmsRecipientUnreachable extends Error {
|
|
|
24
24
|
this.name = 'SmsRecipientUnreachable';
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* The account may not send from this sender, or to this destination. An operator fixes it in the
|
|
29
|
+
* account, and every message it covers fails alike until then.
|
|
30
|
+
*/
|
|
28
31
|
export class SmsSenderNotRegistered extends Error {
|
|
29
32
|
constructor(options) {
|
|
30
33
|
super('The sender is not registered with the provider', options);
|
|
@@ -55,7 +58,8 @@ export class SmsBalanceExhausted extends Error {
|
|
|
55
58
|
/**
|
|
56
59
|
* The provider failed on its own side; the message itself may be fine. This refusal can follow a send
|
|
57
60
|
* the provider already accepted - a timeout or a lost answer - so a retry must reuse the same
|
|
58
|
-
* `reference`.
|
|
61
|
+
* `reference`. An adapter whose provider takes no idempotency key, Twilio's, cannot make that retry
|
|
62
|
+
* safe, so a retry may deliver twice.
|
|
59
63
|
*/
|
|
60
64
|
export class SmsProviderUnavailable extends Error {
|
|
61
65
|
constructor(options) {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/** The port a product sends one composed message through; a runtime supplies the implementation. */
|
|
2
2
|
/**
|
|
3
3
|
* One message, already composed. `from` is a single string where a mailbox takes a name and an
|
|
4
|
-
* address pair
|
|
4
|
+
* address pair. Its form depends on the provider: a brandname or short code registered with the
|
|
5
|
+
* carriers, or through Twilio an E.164 number, an alphanumeric sender id or an `MG...` Messaging
|
|
6
|
+
* Service id.
|
|
5
7
|
*
|
|
6
8
|
* `to` is in E.164 and is not branded: which numbers a product accepts, and how it normalises one,
|
|
7
9
|
* is the product's decision and not something this package can hold for it.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"port.d.ts","sourceRoot":"","sources":["../../../src/lib/sender/port.ts"],"names":[],"mappings":"AAAA,oGAAoG;AAEpG
|
|
1
|
+
{"version":3,"file":"port.d.ts","sourceRoot":"","sources":["../../../src/lib/sender/port.ts"],"names":[],"mappings":"AAAA,oGAAoG;AAEpG;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC,yCAAyC;IACzC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,OAAO,EAAE,WAAW,GAAG,QAAQ,GAAG,SAAS,CAAC;IACrD,uEAAuE;IACvE,QAAQ,CAAC,IAAI,CAAC,EAAE;QAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;CACxE;AAED,sFAAsF;AACtF,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;CACnD"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/twilio/index.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,OAAO,EAAE,eAAe,EAAE,KAAK,sBAAsB,EAAE,MAAM,aAAa,CAAC"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ISmsSender, SendSmsResult, SmsMessage } from '../sender/port.js';
|
|
2
|
+
/** How a `TwilioSmsSender` reaches the account. */
|
|
3
|
+
export interface TwilioSmsSenderOptions {
|
|
4
|
+
/** The account's `AC...` id: the path segment and the Basic-auth user. */
|
|
5
|
+
readonly accountSid: string;
|
|
6
|
+
/** The account's auth token: the Basic-auth password. */
|
|
7
|
+
readonly authToken: string;
|
|
8
|
+
/** Scheme and host the API path is appended to, Twilio's own by default. */
|
|
9
|
+
readonly origin?: string;
|
|
10
|
+
/** How long a send waits for Twilio before refusing with `SmsProviderUnavailable`, 10 000 by default. */
|
|
11
|
+
readonly timeoutMs?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Backs the SMS port with Twilio's Messages API over the runtime's own `fetch`. `send` resolving
|
|
15
|
+
* means Twilio queued the message, not that the handset received it. Twilio takes no idempotency key,
|
|
16
|
+
* so `reference` is not sent and a send retried after a lost answer can deliver twice.
|
|
17
|
+
*/
|
|
18
|
+
export declare class TwilioSmsSender implements ISmsSender {
|
|
19
|
+
private readonly options;
|
|
20
|
+
constructor(options: TwilioSmsSenderOptions);
|
|
21
|
+
send(message: SmsMessage): Promise<SendSmsResult>;
|
|
22
|
+
/** Sends `form` to the account's Messages resource; a rejected or timed-out call is the provider failing. */
|
|
23
|
+
private post;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=sender.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sender.d.ts","sourceRoot":"","sources":["../../../src/lib/twilio/sender.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,UAAU,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAwC/E,mDAAmD;AACnD,MAAM,WAAW,sBAAsB;IACrC,0EAA0E;IAC1E,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,yDAAyD;IACzD,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,yGAAyG;IACzG,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAaD;;;;GAIG;AACH,qBAAa,eAAgB,YAAW,UAAU;IACpC,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,sBAAsB;IAEtD,IAAI,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,aAAa,CAAC;IAgCvD,6GAA6G;YAC/F,IAAI;CAgBnB"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { SmsMessageRejected, SmsProviderUnavailable, SmsRateLimited, SmsRecipientInvalid, SmsRecipientUnreachable, SmsSendFailed, SmsSenderNotRegistered, } from '../sender/errors.js';
|
|
2
|
+
/** Twilio's REST host; the default `origin`. */
|
|
3
|
+
const TWILIO_ORIGIN = 'https://api.twilio.com';
|
|
4
|
+
/** A Messaging Service id, which Twilio takes as `MessagingServiceSid` rather than `From`. */
|
|
5
|
+
const MESSAGING_SERVICE_SID = /^MG[0-9a-fA-F]{32}$/;
|
|
6
|
+
/** E.164: a plus, then 8 to 15 digits, the first not 0. Anything else never reaches the form body. */
|
|
7
|
+
const E164 = /^\+[1-9]\d{7,14}$/;
|
|
8
|
+
/** HTTP 429: Twilio throttling the account, whatever the body says. */
|
|
9
|
+
const TOO_MANY_REQUESTS = 429;
|
|
10
|
+
/**
|
|
11
|
+
* How long a send waits for Twilio before giving up, unless `timeoutMs` says otherwise. A Worker waits
|
|
12
|
+
* on a stalled subrequest as long as its client stays connected, so a send with no bound would hang
|
|
13
|
+
* the caller rather than fail fast.
|
|
14
|
+
*/
|
|
15
|
+
const TWILIO_SEND_TIMEOUT_MS = 10_000;
|
|
16
|
+
/**
|
|
17
|
+
* Every Twilio error `code` a caller acts on differently, and the class it raises (Twilio's error
|
|
18
|
+
* dictionary). `21408` is the account's geographic permissions refusing the country, fixed in the
|
|
19
|
+
* account as a sender is registered. Any other code, `20003` and `20005` among them, raises
|
|
20
|
+
* `SmsSendFailed`.
|
|
21
|
+
*/
|
|
22
|
+
const REFUSALS = {
|
|
23
|
+
'20429': SmsRateLimited,
|
|
24
|
+
'21211': SmsRecipientInvalid,
|
|
25
|
+
'21212': SmsSenderNotRegistered,
|
|
26
|
+
'21408': SmsSenderNotRegistered,
|
|
27
|
+
'21606': SmsSenderNotRegistered,
|
|
28
|
+
'21610': SmsRecipientUnreachable,
|
|
29
|
+
'21611': SmsRateLimited,
|
|
30
|
+
'21612': SmsRecipientUnreachable,
|
|
31
|
+
'21614': SmsRecipientInvalid,
|
|
32
|
+
'21617': SmsMessageRejected,
|
|
33
|
+
};
|
|
34
|
+
/** The class a Twilio error `code` raises - `SmsSendFailed` wherever it is not one of the table's own keys. */
|
|
35
|
+
function refusalClassOf(code) {
|
|
36
|
+
const key = typeof code === 'number' || typeof code === 'string' ? String(code) : undefined;
|
|
37
|
+
return key !== undefined && Object.hasOwn(REFUSALS, key) ? REFUSALS[key] : SmsSendFailed;
|
|
38
|
+
}
|
|
39
|
+
/** Whether `value` is a JSON object rather than an array, a primitive or null. */
|
|
40
|
+
function isObject(value) {
|
|
41
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Backs the SMS port with Twilio's Messages API over the runtime's own `fetch`. `send` resolving
|
|
45
|
+
* means Twilio queued the message, not that the handset received it. Twilio takes no idempotency key,
|
|
46
|
+
* so `reference` is not sent and a send retried after a lost answer can deliver twice.
|
|
47
|
+
*/
|
|
48
|
+
export class TwilioSmsSender {
|
|
49
|
+
options;
|
|
50
|
+
constructor(options) {
|
|
51
|
+
this.options = options;
|
|
52
|
+
}
|
|
53
|
+
async send(message) {
|
|
54
|
+
if (!E164.test(message.to))
|
|
55
|
+
throw new SmsRecipientInvalid();
|
|
56
|
+
const form = new URLSearchParams({ To: message.to });
|
|
57
|
+
form.set(MESSAGING_SERVICE_SID.test(message.from) ? 'MessagingServiceSid' : 'From', message.from);
|
|
58
|
+
form.set('Body', message.text);
|
|
59
|
+
const response = await this.post(form);
|
|
60
|
+
if (response.status === TOO_MANY_REQUESTS) {
|
|
61
|
+
const body = await response.text().catch(() => undefined);
|
|
62
|
+
throw new SmsRateLimited({ cause: { status: response.status, body } });
|
|
63
|
+
}
|
|
64
|
+
if (response.status >= 500) {
|
|
65
|
+
const body = await response.text().catch(() => undefined);
|
|
66
|
+
throw new SmsProviderUnavailable({ cause: { status: response.status, body } });
|
|
67
|
+
}
|
|
68
|
+
let answer;
|
|
69
|
+
try {
|
|
70
|
+
answer = await response.json();
|
|
71
|
+
}
|
|
72
|
+
catch (cause) {
|
|
73
|
+
throw new SmsProviderUnavailable({ cause });
|
|
74
|
+
}
|
|
75
|
+
if (response.ok) {
|
|
76
|
+
if (isObject(answer) && typeof answer['sid'] === 'string')
|
|
77
|
+
return { messageId: answer['sid'] };
|
|
78
|
+
throw new SmsSendFailed({ cause: answer });
|
|
79
|
+
}
|
|
80
|
+
const Refusal = refusalClassOf(isObject(answer) ? answer['code'] : undefined);
|
|
81
|
+
throw new Refusal({ cause: answer });
|
|
82
|
+
}
|
|
83
|
+
/** Sends `form` to the account's Messages resource; a rejected or timed-out call is the provider failing. */
|
|
84
|
+
async post(form) {
|
|
85
|
+
const origin = (this.options.origin ?? TWILIO_ORIGIN).replace(/\/+$/, '');
|
|
86
|
+
try {
|
|
87
|
+
return await fetch(`${origin}/2010-04-01/Accounts/${encodeURIComponent(this.options.accountSid)}/Messages.json`, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: {
|
|
90
|
+
authorization: `Basic ${btoa(`${this.options.accountSid}:${this.options.authToken}`)}`,
|
|
91
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
92
|
+
},
|
|
93
|
+
body: form,
|
|
94
|
+
signal: AbortSignal.timeout(this.options.timeoutMs ?? TWILIO_SEND_TIMEOUT_MS),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
catch (cause) {
|
|
98
|
+
throw new SmsProviderUnavailable({ cause });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeroxsolutions/sms",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
7
|
-
"description": "The SMS seam: a composed message, the port that sends it, its typed refusals, and the eSMS
|
|
7
|
+
"description": "The SMS seam: a composed message, the port that sends it, its typed refusals, and the eSMS and Twilio adapters behind that port. Knows nothing of templates, locales or delivery records - those stay in the product.",
|
|
8
8
|
"main": "./dist/index.js",
|
|
9
9
|
"module": "./dist/index.js",
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
@@ -25,6 +25,11 @@
|
|
|
25
25
|
"types": "./dist/lib/esms/index.d.ts",
|
|
26
26
|
"import": "./dist/lib/esms/index.js",
|
|
27
27
|
"default": "./dist/lib/esms/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./twilio": {
|
|
30
|
+
"types": "./dist/lib/twilio/index.d.ts",
|
|
31
|
+
"import": "./dist/lib/twilio/index.js",
|
|
32
|
+
"default": "./dist/lib/twilio/index.js"
|
|
28
33
|
}
|
|
29
34
|
},
|
|
30
35
|
"dependencies": {
|