@zeroxsolutions/sms 0.0.2 → 0.1.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/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## 0.1.0 (2026-09-25)
2
+
3
+ ### 🚀 Features
4
+
5
+ - **sms:** let an esms caller set the endpoint and the timeout ([d8cda41](https://github.com/zeroxsolutions/zeroxsolutions/commit/d8cda41))
6
+ - **sms:** send through esms and read its delivery reports ([d88829b](https://github.com/zeroxsolutions/zeroxsolutions/commit/d88829b))
7
+
8
+ ### ❤️ Thank You
9
+
10
+ - Claude Opus 5.5
11
+ - Lương Văn Tú
12
+
1
13
  ## 0.0.2 (2026-09-22)
2
14
 
3
15
  ### 🚀 Features
package/README.md CHANGED
@@ -18,8 +18,8 @@ pnpm add @zeroxsolutions/sms
18
18
 
19
19
  ## The port
20
20
 
21
- The package ships no adapter, so a consumer implements `ISmsSender` itself and calls `send` with a
22
- composed message:
21
+ A consumer implements `ISmsSender` for a provider this package has no adapter for, and calls `send`
22
+ with a composed message:
23
23
 
24
24
  ```ts
25
25
  import type { ISmsSender, SendSmsResult, SmsMessage } from '@zeroxsolutions/sms';
@@ -44,33 +44,94 @@ const { messageId } = await new ProviderSender().send({
44
44
  takes a name and an address pair. `to` is in E.164 and is not validated here: which numbers a product
45
45
  accepts, and how it normalises one, is the product's own decision.
46
46
 
47
- ## No adapter yet
47
+ `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.
48
49
 
49
- This package publishes the port and its refusals and no implementation. An adapter wraps one
50
- provider's SDK, and the table mapping that provider's codes onto the refusals below can only be
51
- written against that provider's documentation. When an account exists, the adapter arrives as its own
52
- subpath, `@zeroxsolutions/sms/<provider>`, with that SDK as an optional peer dependency, so a
53
- consumer that never imports it does not install it.
50
+ ## eSMS
54
51
 
55
- Two things will constrain that choice, and both are the consumer's: the SDK has to run on workerd,
56
- and A2P delivery in Vietnam goes through a brandname registered with the carriers, whose registration
57
- also fixes the text a message may carry.
52
+ `@zeroxsolutions/sms/esms` sends through [eSMS](https://esms.vn) as customer-care SMS under a
53
+ registered brandname, inside Vietnam. It posts to eSMS's REST API over the runtime's own `fetch`, so it
54
+ installs nothing and runs on workerd as on Node.
55
+
56
+ ```ts
57
+ import { EsmsSmsSender } from '@zeroxsolutions/sms/esms';
58
+
59
+ const sender = new EsmsSmsSender({
60
+ apiKey: env.ESMS_API_KEY,
61
+ secretKey: env.ESMS_SECRET_KEY,
62
+ sandbox: false,
63
+ callbackUrl: 'https://app.example.com/esms-callback/<a secret of yours>',
64
+ });
65
+
66
+ const { messageId } = await sender.send({
67
+ from: 'TRIPVN',
68
+ to: '+84900000000',
69
+ text: 'Ma xac thuc cua ban la 123456.',
70
+ reference: 'msg_01j9',
71
+ });
72
+ ```
73
+
74
+ `send` resolving means eSMS accepted the message, not that the handset received it. A number that is
75
+ not `+84` followed by 9 or 10 digits is refused before any request is made.
76
+
77
+ | eSMS `CodeResult` | Refusal |
78
+ | ------------------------------------------- | --------------------------------------------------------------- |
79
+ | `108` | `SmsRecipientInvalid` |
80
+ | `99` | `SmsRecipientUnreachable` |
81
+ | `104`, `177` | `SmsSenderNotRegistered` |
82
+ | `146`, `201` | `SmsMessageRejected` |
83
+ | `103` | `SmsBalanceExhausted` |
84
+ | not 2xx, not JSON, or no answer in time | `SmsProviderUnavailable` |
85
+ | `124` with a `reference` | none: answered as sent, with the `reference` as its `messageId` |
86
+ | any other | `SmsSendFailed` |
87
+
88
+ The account has to meet four conditions no code can check:
89
+
90
+ 1. The brandname is registered with the carriers.
91
+ 2. The text matches the customer-care template registered for it, or eSMS answers `146`.
92
+ 3. The account does not require an IP whitelist (`140`), or `endpoint` names a relay with a fixed
93
+ egress address: a Worker has none.
94
+ 4. `sandbox: true` is set wherever a message must not reach a handset or be charged.
95
+
96
+ `SmsProviderUnavailable` may follow a send eSMS already accepted: a timeout (`timeoutMs`, 10 s by
97
+ default), or an answer lost in transit. A caller retrying the same message reuses its `reference`; a
98
+ "send a new code" action uses a new one. Match a delivery report to its send by `reference` when one
99
+ was sent. Whether eSMS records a `RequestId` on a refused attempt, `103` among them, is open: if it
100
+ does, a retry after topping up the account answers `124` and is reported as sent. Check this in the
101
+ sandbox before relying on `124`.
102
+
103
+ ### Delivery reports
104
+
105
+ With `callbackUrl` set, eSMS calls it with `GET` and the message's final status in the query string,
106
+ retrying only on a timeout. `parseEsmsDeliveryReport` reads that query into an `SmsDeliveryReport`:
107
+
108
+ ```ts
109
+ import { ESMS_CALLBACK_SOURCE_ADDRESSES, parseEsmsDeliveryReport } from '@zeroxsolutions/sms/esms';
110
+
111
+ const report = parseEsmsDeliveryReport(new URL(request.url).searchParams);
112
+ // { messageId, reference?, outcome: 'delivered' | 'failed' | 'pending', cost?: { amount, currency: 'VND' } }
113
+ ```
114
+
115
+ eSMS signs nothing. The route that receives the callback checks the secret in its own path and the
116
+ caller's address against `ESMS_CALLBACK_SOURCE_ADDRESSES`, redacts the query in its access log (it
117
+ carries the number), and answers quickly. A query it cannot read throws `SmsDeliveryReportInvalid`.
58
118
 
59
119
  ## Refusals
60
120
 
61
121
  A refusal is one plain `Error` subclass per meaning a caller acts on differently - no wire status, no
62
122
  code, no envelope. A caller maps them by class.
63
123
 
64
- | Class | What happened |
65
- | --- | --- |
66
- | `SmsRecipientInvalid` | the number is not routable |
67
- | `SmsRecipientUnreachable` | the carrier would not deliver to the subscriber |
68
- | `SmsSenderNotRegistered` | the sender is not a brandname this account may send under |
69
- | `SmsMessageRejected` | the text breaks a provider or carrier rule |
70
- | `SmsRateLimited` | the provider is throttling this account |
71
- | `SmsBalanceExhausted` | the account has no credit left |
72
- | `SmsProviderUnavailable` | the provider failed on its own side |
73
- | `SmsSendFailed` | a refusal none of the above names |
124
+ | Class | What happened |
125
+ | -------------------------- | --------------------------------------------------------- |
126
+ | `SmsRecipientInvalid` | the number is not routable |
127
+ | `SmsRecipientUnreachable` | the carrier would not deliver to the subscriber |
128
+ | `SmsSenderNotRegistered` | the sender is not a brandname this account may send under |
129
+ | `SmsMessageRejected` | the text breaks a provider or carrier rule |
130
+ | `SmsRateLimited` | the provider is throttling this account |
131
+ | `SmsBalanceExhausted` | the account has no credit left |
132
+ | `SmsProviderUnavailable` | the provider failed on its own side |
133
+ | `SmsSendFailed` | a refusal none of the above names |
134
+ | `SmsDeliveryReportInvalid` | a provider's delivery report could not be read |
74
135
 
75
136
  Every message is a constant. The provider's own text names the number it refused, so it stays in
76
137
  `cause`: log the class, never `cause` and never `String(error)`.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  /** The SMS seam: one composed message, the port that sends it, and the refusals a provider raises through it. */
2
- export { SmsBalanceExhausted, SmsMessageRejected, SmsProviderUnavailable, SmsRateLimited, SmsRecipientInvalid, SmsRecipientUnreachable, SmsSendFailed, SmsSenderNotRegistered, } from './lib/sender/errors.js';
3
- export type { ISmsSender, SendSmsResult, SmsMessage } from './lib/sender/port.js';
2
+ export { SmsBalanceExhausted, SmsDeliveryReportInvalid, SmsMessageRejected, SmsProviderUnavailable, SmsRateLimited, SmsRecipientInvalid, SmsRecipientUnreachable, SmsSendFailed, SmsSenderNotRegistered, } from './lib/sender/errors.js';
3
+ export type { ISmsSender, SendSmsResult, SmsDeliveryReport, SmsMessage } from './lib/sender/port.js';
4
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,iHAAiH;AACjH,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,sBAAsB,EACtB,cAAc,EACd,mBAAmB,EACnB,uBAAuB,EACvB,aAAa,EACb,sBAAsB,GACvB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,UAAU,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,iHAAiH;AACjH,OAAO,EACL,mBAAmB,EACnB,wBAAwB,EACxB,kBAAkB,EAClB,sBAAsB,EACtB,cAAc,EACd,mBAAmB,EACnB,uBAAuB,EACvB,aAAa,EACb,sBAAsB,GACvB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,UAAU,EAAE,aAAa,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC"}
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** The SMS seam: one composed message, the port that sends it, and the refusals a provider raises through it. */
2
- export { SmsBalanceExhausted, SmsMessageRejected, SmsProviderUnavailable, SmsRateLimited, SmsRecipientInvalid, SmsRecipientUnreachable, SmsSendFailed, SmsSenderNotRegistered, } from './lib/sender/errors.js';
2
+ export { SmsBalanceExhausted, SmsDeliveryReportInvalid, SmsMessageRejected, SmsProviderUnavailable, SmsRateLimited, SmsRecipientInvalid, SmsRecipientUnreachable, SmsSendFailed, SmsSenderNotRegistered, } from './lib/sender/errors.js';
@@ -0,0 +1,13 @@
1
+ import type { SmsDeliveryReport } from '../sender/port.js';
2
+ /**
3
+ * The addresses eSMS documents its callbacks as coming from (developers.esms.vn, "Callback Url").
4
+ * eSMS signs nothing, so a product's callback route checks the caller's address against these and a
5
+ * secret in its own path.
6
+ */
7
+ export declare const ESMS_CALLBACK_SOURCE_ADDRESSES: readonly string[];
8
+ /**
9
+ * Reads the query eSMS calls `CallbackUrl` with. Throws `SmsDeliveryReportInvalid` for a query with no
10
+ * `SMSID`, or with a `SendStatus`, `SendSuccess`, `SendFailed` or `TotalPrice` that is not a number.
11
+ */
12
+ export declare function parseEsmsDeliveryReport(query: URLSearchParams): SmsDeliveryReport;
13
+ //# sourceMappingURL=delivery-report.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delivery-report.d.ts","sourceRoot":"","sources":["../../../src/lib/esms/delivery-report.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAE3D;;;;GAIG;AACH,eAAO,MAAM,8BAA8B,EAAE,SAAS,MAAM,EAI1D,CAAC;AAiBH;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,eAAe,GAAG,iBAAiB,CAuBjF"}
@@ -0,0 +1,50 @@
1
+ import { SmsDeliveryReportInvalid } from '../sender/errors.js';
2
+ /**
3
+ * The addresses eSMS documents its callbacks as coming from (developers.esms.vn, "Callback Url").
4
+ * eSMS signs nothing, so a product's callback route checks the caller's address against these and a
5
+ * secret in its own path.
6
+ */
7
+ export const ESMS_CALLBACK_SOURCE_ADDRESSES = Object.freeze([
8
+ '103.29.26.39',
9
+ '103.29.26.80',
10
+ '103.29.27.39',
11
+ ]);
12
+ /** eSMS's `SendStatus` for a message whose sending has finished; `SendSuccess` and `SendFailed` then say how. */
13
+ const SENDING_FINISHED = 5;
14
+ /** The currency eSMS prices in. It has no minor unit, so a price is already in minor units. */
15
+ const PRICE_CURRENCY = 'VND';
16
+ /** The number `name` carries; `undefined` where it is absent or empty. Anything else refuses the report. */
17
+ function numberIn(query, name) {
18
+ const raw = query.get(name);
19
+ if (raw === null || raw === '')
20
+ return undefined;
21
+ const value = Number(raw);
22
+ if (!Number.isFinite(value))
23
+ throw new SmsDeliveryReportInvalid();
24
+ return value;
25
+ }
26
+ /**
27
+ * Reads the query eSMS calls `CallbackUrl` with. Throws `SmsDeliveryReportInvalid` for a query with no
28
+ * `SMSID`, or with a `SendStatus`, `SendSuccess`, `SendFailed` or `TotalPrice` that is not a number.
29
+ */
30
+ export function parseEsmsDeliveryReport(query) {
31
+ const messageId = query.get('SMSID');
32
+ if (!messageId)
33
+ throw new SmsDeliveryReportInvalid();
34
+ const status = numberIn(query, 'SendStatus');
35
+ const succeeded = numberIn(query, 'SendSuccess') ?? 0;
36
+ const failed = numberIn(query, 'SendFailed') ?? 0;
37
+ const price = numberIn(query, 'TotalPrice');
38
+ const reference = query.get('RequestId');
39
+ const outcome = status === SENDING_FINISHED && succeeded >= 1
40
+ ? 'delivered'
41
+ : status === SENDING_FINISHED && failed >= 1
42
+ ? 'failed'
43
+ : 'pending';
44
+ return {
45
+ messageId,
46
+ ...(reference ? { reference } : {}),
47
+ outcome,
48
+ ...(price === undefined ? {} : { cost: { amount: Math.round(price), currency: PRICE_CURRENCY } }),
49
+ };
50
+ }
@@ -0,0 +1,4 @@
1
+ /** The eSMS half: the sender backed by eSMS's REST API, and the reader of its delivery callback. */
2
+ export { ESMS_CALLBACK_SOURCE_ADDRESSES, parseEsmsDeliveryReport } from './delivery-report.js';
3
+ export { EsmsSmsSender, type EsmsSmsSenderOptions } from './sender.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/esms/index.ts"],"names":[],"mappings":"AAAA,oGAAoG;AACpG,OAAO,EAAE,8BAA8B,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AAC/F,OAAO,EAAE,aAAa,EAAE,KAAK,oBAAoB,EAAE,MAAM,aAAa,CAAC"}
@@ -0,0 +1,3 @@
1
+ /** The eSMS half: the sender backed by eSMS's REST API, and the reader of its delivery callback. */
2
+ export { ESMS_CALLBACK_SOURCE_ADDRESSES, parseEsmsDeliveryReport } from './delivery-report.js';
3
+ export { EsmsSmsSender } from './sender.js';
@@ -0,0 +1,30 @@
1
+ import type { ISmsSender, SendSmsResult, SmsMessage } from '../sender/port.js';
2
+ /** How an `EsmsSmsSender` reaches the account. */
3
+ export interface EsmsSmsSenderOptions {
4
+ readonly apiKey: string;
5
+ readonly secretKey: string;
6
+ /** `Sandbox=1`: eSMS accepts and answers, delivers nothing, and charges nothing. */
7
+ readonly sandbox?: boolean;
8
+ /** Sent as `CallbackUrl` on every message; eSMS calls it with the message's final status. */
9
+ readonly callbackUrl?: string;
10
+ /**
11
+ * Where the send call goes, eSMS's own URL by default. A relay with a fixed egress address goes here
12
+ * when the account only accepts whitelisted addresses (code `140`), since a Worker has none.
13
+ */
14
+ readonly endpoint?: string;
15
+ /** How long a send waits for eSMS before refusing with `SmsProviderUnavailable`, 10 000 by default. */
16
+ readonly timeoutMs?: number;
17
+ }
18
+ /**
19
+ * Backs the SMS port with eSMS's REST API over the runtime's own `fetch`. Delivers inside Vietnam
20
+ * only; `send` resolving means eSMS accepted the message, and its delivery report says whether the
21
+ * handset received it.
22
+ */
23
+ export declare class EsmsSmsSender implements ISmsSender {
24
+ private readonly options;
25
+ constructor(options: EsmsSmsSenderOptions);
26
+ send(message: SmsMessage): Promise<SendSmsResult>;
27
+ /** Sends `body` and reads eSMS's answer; anything short of a JSON object is the provider failing. */
28
+ private post;
29
+ }
30
+ //# sourceMappingURL=sender.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sender.d.ts","sourceRoot":"","sources":["../../../src/lib/esms/sender.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,UAAU,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAgD/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"}
@@ -0,0 +1,122 @@
1
+ import { SmsBalanceExhausted, SmsMessageRejected, SmsProviderUnavailable, SmsRecipientInvalid, SmsRecipientUnreachable, SmsSendFailed, SmsSenderNotRegistered, } from '../sender/errors.js';
2
+ /** eSMS's call that sends one message (developers.esms.vn, "Tin CSKH"); the default `endpoint`. */
3
+ const SEND_URL = 'https://rest.esms.vn/MainService.svc/json/SendMultipleMessage_V4_post_json/';
4
+ /** `SmsType` 2, customer care: the type a brandname sends a one-time code as. */
5
+ const CUSTOMER_CARE = '2';
6
+ /** The `CodeResult` eSMS answers a request it accepted with. Accepted, not delivered. */
7
+ const ACCEPTED = '100';
8
+ /** The `CodeResult` eSMS answers a `RequestId` it saw in the last 24 hours with. */
9
+ const REPEATED_REQUEST = '124';
10
+ /** The longest `RequestId` eSMS accepts. */
11
+ const MAX_REFERENCE_LENGTH = 50;
12
+ /** Vietnam's national prefix plus a 9- or 10-digit subscriber number, the shape eSMS delivers to. */
13
+ const VIETNAM_PHONE = /^\+84\d{9,10}$/;
14
+ /**
15
+ * How long a send waits for eSMS before giving up, unless `timeoutMs` says otherwise. A Worker waits
16
+ * on a stalled subrequest as long as its client stays connected, so a send with no bound would hang
17
+ * the caller rather than fail fast.
18
+ */
19
+ const ESMS_SEND_TIMEOUT_MS = 10_000;
20
+ /**
21
+ * Every `CodeResult` eSMS documents for this call that a caller acts on differently, and the class it
22
+ * raises (developers.esms.vn, "Bang ma loi"). Any other code, `101` wrong keys and `140` IP not
23
+ * whitelisted among them, raises `SmsSendFailed`.
24
+ */
25
+ const REFUSALS = {
26
+ '99': SmsRecipientUnreachable,
27
+ '103': SmsBalanceExhausted,
28
+ '104': SmsSenderNotRegistered,
29
+ '108': SmsRecipientInvalid,
30
+ '146': SmsMessageRejected,
31
+ '177': SmsSenderNotRegistered,
32
+ '201': SmsMessageRejected,
33
+ };
34
+ /** The class a `CodeResult` raises - `SmsSendFailed` wherever it is not one of the table's own keys. */
35
+ function refusalClassOf(code) {
36
+ return code !== undefined && Object.hasOwn(REFUSALS, code) ? REFUSALS[code] : SmsSendFailed;
37
+ }
38
+ /**
39
+ * Whether every character of `text` is ASCII. A code-point test rather than a `[^\x00-\x7f]` class,
40
+ * which `no-control-regex` refuses.
41
+ */
42
+ function isAscii(text) {
43
+ return [...text].every((character) => (character.codePointAt(0) ?? 0) <= 0x7f);
44
+ }
45
+ /** `+84901234567` as eSMS spells it, `0901234567`; `undefined` for a number that is not this shape. */
46
+ function nationalNumber(e164) {
47
+ return VIETNAM_PHONE.test(e164) ? `0${e164.slice(3)}` : undefined;
48
+ }
49
+ /**
50
+ * Backs the SMS port with eSMS's REST API over the runtime's own `fetch`. Delivers inside Vietnam
51
+ * only; `send` resolving means eSMS accepted the message, and its delivery report says whether the
52
+ * handset received it.
53
+ */
54
+ export class EsmsSmsSender {
55
+ options;
56
+ constructor(options) {
57
+ this.options = options;
58
+ }
59
+ async send(message) {
60
+ const phone = nationalNumber(message.to);
61
+ if (phone === undefined)
62
+ throw new SmsRecipientInvalid();
63
+ if (message.reference !== undefined &&
64
+ (message.reference.length === 0 || message.reference.length > MAX_REFERENCE_LENGTH)) {
65
+ throw new SmsSendFailed();
66
+ }
67
+ const answer = await this.post({
68
+ ApiKey: this.options.apiKey,
69
+ SecretKey: this.options.secretKey,
70
+ Brandname: message.from,
71
+ SmsType: CUSTOMER_CARE,
72
+ Phone: phone,
73
+ Content: message.text,
74
+ IsUnicode: isAscii(message.text) ? '0' : '1',
75
+ ...(this.options.sandbox ? { Sandbox: '1' } : {}),
76
+ ...(message.reference === undefined ? {} : { RequestId: message.reference }),
77
+ ...(this.options.callbackUrl === undefined ? {} : { CallbackUrl: this.options.callbackUrl }),
78
+ });
79
+ const codeResult = answer.CodeResult === undefined ? undefined : String(answer.CodeResult);
80
+ if (codeResult === ACCEPTED) {
81
+ const messageId = answer.SMSID ?? message.reference;
82
+ if (messageId !== undefined)
83
+ return { messageId };
84
+ throw new SmsSendFailed({ cause: answer });
85
+ }
86
+ if (codeResult === REPEATED_REQUEST && message.reference !== undefined) {
87
+ return { messageId: message.reference };
88
+ }
89
+ const Refusal = refusalClassOf(codeResult);
90
+ throw new Refusal({ cause: answer });
91
+ }
92
+ /** Sends `body` and reads eSMS's answer; anything short of a JSON object is the provider failing. */
93
+ async post(body) {
94
+ let response;
95
+ try {
96
+ response = await fetch(this.options.endpoint ?? SEND_URL, {
97
+ method: 'POST',
98
+ headers: { 'content-type': 'application/json' },
99
+ body: JSON.stringify(body),
100
+ signal: AbortSignal.timeout(this.options.timeoutMs ?? ESMS_SEND_TIMEOUT_MS),
101
+ });
102
+ }
103
+ catch (cause) {
104
+ throw new SmsProviderUnavailable({ cause });
105
+ }
106
+ if (!response.ok) {
107
+ await response.body?.cancel().catch(() => undefined);
108
+ throw new SmsProviderUnavailable({ cause: new Error(`eSMS answered ${response.status}`) });
109
+ }
110
+ let answer;
111
+ try {
112
+ answer = await response.json();
113
+ }
114
+ catch (cause) {
115
+ throw new SmsProviderUnavailable({ cause });
116
+ }
117
+ if (answer === null || typeof answer !== 'object' || Array.isArray(answer)) {
118
+ throw new SmsProviderUnavailable({ cause: answer });
119
+ }
120
+ return answer;
121
+ }
122
+ }
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * What a provider's refusal means, one class per meaning a caller acts on differently. An adapter
3
3
  * translates its own provider's codes onto these, so nothing above the port names a provider.
4
+ * `SmsDeliveryReportInvalid` is the one refusal not raised by a send: it names a delivery report that
5
+ * cannot be read.
4
6
  *
5
7
  * Every message is a constant. The provider's own text - which often names the number - stays in
6
8
  * `cause`, so a caller logs the class and never `cause` or `String(error)`.
@@ -32,7 +34,11 @@ export declare class SmsRateLimited extends Error {
32
34
  export declare class SmsBalanceExhausted extends Error {
33
35
  constructor(options?: ErrorOptions);
34
36
  }
35
- /** The provider failed on its own side; the message itself may be fine. */
37
+ /**
38
+ * The provider failed on its own side; the message itself may be fine. This refusal can follow a send
39
+ * the provider already accepted - a timeout or a lost answer - so a retry must reuse the same
40
+ * `reference`.
41
+ */
36
42
  export declare class SmsProviderUnavailable extends Error {
37
43
  constructor(options?: ErrorOptions);
38
44
  }
@@ -40,4 +46,8 @@ export declare class SmsProviderUnavailable extends Error {
40
46
  export declare class SmsSendFailed extends Error {
41
47
  constructor(options?: ErrorOptions);
42
48
  }
49
+ /** A delivery report that is missing its message id or carries a number it cannot read. */
50
+ export declare class SmsDeliveryReportInvalid extends Error {
51
+ constructor(options?: ErrorOptions);
52
+ }
43
53
  //# sourceMappingURL=errors.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../../src/lib/sender/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;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,uGAAuG;AACvG,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,2EAA2E;AAC3E,qBAAa,sBAAuB,SAAQ,KAAK;gBACnC,OAAO,CAAC,EAAE,YAAY;CAInC;AAED,sGAAsG;AACtG,qBAAa,aAAc,SAAQ,KAAK;gBAC1B,OAAO,CAAC,EAAE,YAAY;CAInC"}
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,uGAAuG;AACvG,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;;;;GAIG;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"}
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * What a provider's refusal means, one class per meaning a caller acts on differently. An adapter
3
3
  * translates its own provider's codes onto these, so nothing above the port names a provider.
4
+ * `SmsDeliveryReportInvalid` is the one refusal not raised by a send: it names a delivery report that
5
+ * cannot be read.
4
6
  *
5
7
  * Every message is a constant. The provider's own text - which often names the number - stays in
6
8
  * `cause`, so a caller logs the class and never `cause` or `String(error)`.
@@ -50,7 +52,11 @@ export class SmsBalanceExhausted extends Error {
50
52
  this.name = 'SmsBalanceExhausted';
51
53
  }
52
54
  }
53
- /** The provider failed on its own side; the message itself may be fine. */
55
+ /**
56
+ * The provider failed on its own side; the message itself may be fine. This refusal can follow a send
57
+ * the provider already accepted - a timeout or a lost answer - so a retry must reuse the same
58
+ * `reference`.
59
+ */
54
60
  export class SmsProviderUnavailable extends Error {
55
61
  constructor(options) {
56
62
  super('The sms provider failed internally', options);
@@ -64,3 +70,10 @@ export class SmsSendFailed extends Error {
64
70
  this.name = 'SmsSendFailed';
65
71
  }
66
72
  }
73
+ /** A delivery report that is missing its message id or carries a number it cannot read. */
74
+ export class SmsDeliveryReportInvalid extends Error {
75
+ constructor(options) {
76
+ super('The delivery report could not be read', options);
77
+ this.name = 'SmsDeliveryReportInvalid';
78
+ }
79
+ }
@@ -5,16 +5,44 @@
5
5
  *
6
6
  * `to` is in E.164 and is not branded: which numbers a product accepts, and how it normalises one,
7
7
  * is the product's decision and not something this package can hold for it.
8
+ *
9
+ * `reference` is the caller's own id for this message. An adapter whose provider refuses a repeated
10
+ * id sends it, so a retried send cannot deliver and charge twice; a provider without that check
11
+ * ignores it.
8
12
  */
9
13
  export interface SmsMessage {
10
14
  readonly from: string;
11
15
  readonly to: string;
12
16
  readonly text: string;
17
+ readonly reference?: string;
13
18
  }
14
- /** What the provider hands back: the id its own logs and delivery reports name this message by. */
19
+ /**
20
+ * What the provider hands back. Normally the id its own logs and delivery reports name this message
21
+ * by; the caller's own `reference` instead, whenever the provider answered no id of its own.
22
+ */
15
23
  export interface SendSmsResult {
16
24
  readonly messageId: string;
17
25
  }
26
+ /**
27
+ * A message's final status as a provider reports it, in one shape whichever provider sent it. It
28
+ * carries no phone number: the caller already holds the number through `messageId` or `reference`.
29
+ */
30
+ export interface SmsDeliveryReport {
31
+ /** The provider's id for the message. */
32
+ readonly messageId: string;
33
+ /**
34
+ * The `reference` the message was sent with, when it had one. A caller that sent one matches
35
+ * reports by it, since `send` can answer with the reference itself instead of the provider's id,
36
+ * whenever the provider answered no id of its own.
37
+ */
38
+ readonly reference?: string;
39
+ readonly outcome: 'delivered' | 'failed' | 'pending';
40
+ /** What the provider charged, in integer minor units of `currency`. */
41
+ readonly cost?: {
42
+ readonly amount: number;
43
+ readonly currency: string;
44
+ };
45
+ }
18
46
  /** Sends one composed message. The only seam a product substitutes to send an SMS. */
19
47
  export interface ISmsSender {
20
48
  send(message: SmsMessage): Promise<SendSmsResult>;
@@ -1 +1 @@
1
- {"version":3,"file":"port.d.ts","sourceRoot":"","sources":["../../../src/lib/sender/port.ts"],"names":[],"mappings":"AAAA,oGAAoG;AAEpG;;;;;;GAMG;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;CACvB;AAED,mGAAmG;AACnG,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,sFAAsF;AACtF,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;CACnD"}
1
+ {"version":3,"file":"port.d.ts","sourceRoot":"","sources":["../../../src/lib/sender/port.ts"],"names":[],"mappings":"AAAA,oGAAoG;AAEpG;;;;;;;;;;GAUG;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"}
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@zeroxsolutions/sms",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
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, and its typed refusals. Knows nothing of templates, locales, delivery records or which provider carries the message - those stay in the product.",
7
+ "description": "The SMS seam: a composed message, the port that sends it, its typed refusals, and the eSMS adapter 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",
@@ -20,9 +20,17 @@
20
20
  "types": "./dist/index.d.ts",
21
21
  "import": "./dist/index.js",
22
22
  "default": "./dist/index.js"
23
+ },
24
+ "./esms": {
25
+ "types": "./dist/lib/esms/index.d.ts",
26
+ "import": "./dist/lib/esms/index.js",
27
+ "default": "./dist/lib/esms/index.js"
23
28
  }
24
29
  },
25
30
  "dependencies": {
26
31
  "tslib": "^2.3.0"
32
+ },
33
+ "devDependencies": {
34
+ "msw": "2.15.0"
27
35
  }
28
36
  }