652 lines
33 KiB
Markdown
652 lines
33 KiB
Markdown
# @larvit/smpp
|
||
|
||
[](https://www.npmjs.com/package/@larvit/smpp)
|
||
[](https://github.com/larvit/larvitsmpp/actions/workflows/test.yaml)
|
||
|
||
SMPP 3.4 client and server for Node.js with the session layer built in: keepalive, reconnect, send
|
||
window, long messages and delivery receipts. TypeScript, ESM, no dependencies.
|
||
|
||
- **Keepalive.** `enquire_link` every 20 s on a quiet link; a peer that stops answering is dropped.
|
||
- **Reconnect.** A dropped client link re-binds on its own, backing off from 1 s to 30 s.
|
||
- **Send window.** 10 requests in flight; further sends queue instead of overrunning the SMSC.
|
||
- **Long messages.** Split on send, reassembled on receive, in both the UDH and `sar_*` spellings.
|
||
- **Delivery receipts.** Read from TLVs or from receipt text, matched to the ids you were given.
|
||
- **Graceful shutdown.** `close()` waits for what is in flight, so neither end has to guess.
|
||
- **Never throws.** Every fallible call resolves to `{ err?, … }`.
|
||
- **Interoperable.** Tested as a client against Jasmin and SMPPSim, and as a server against Kannel,
|
||
jsmpp, Cloudhopper, python-smpplib and php-smpp: [interop-tests/](interop-tests/README.md).
|
||
|
||
[Install](#install) · [Send an SMS](#send-an-sms) · [Delivery reports](#delivery-reports) ·
|
||
[Receive SMS](#receive-sms) · [Run an SMPP server](#run-an-smpp-server) · [Errors](#errors) ·
|
||
[Client options](#client-options) · [Server options](#server-options) ·
|
||
[Send options](#send-options) · [Session](#session) · [Receiving in depth](#receiving-in-depth) ·
|
||
[Server in depth](#server-in-depth) · [Logging](#logging) ·
|
||
[PDUs and the low-level API](#pdus-and-the-low-level-api) ·
|
||
[Migrating from 0.4.0](#migrating-from-larvitsmpp-040) · [Development](#development)
|
||
|
||
## Install
|
||
|
||
```bash
|
||
npm install @larvit/smpp
|
||
```
|
||
|
||
Node 18 or later. ESM only, types included.
|
||
|
||
## Send an SMS
|
||
|
||
```javascript
|
||
import { client } from '@larvit/smpp';
|
||
|
||
const { err, session } = await client();
|
||
if (err) throw err;
|
||
|
||
await session.sendSms({
|
||
from: '46701113311',
|
||
message: 'Hello world',
|
||
to: '46709771337',
|
||
});
|
||
|
||
await session.unbind();
|
||
```
|
||
|
||
Without options this binds to `localhost:2775` as a transceiver with the default credentials. A real
|
||
SMSC needs `host`, `port`, `username` and `password`: [Client options](#client-options). A message
|
||
longer than one SMS is split and sent as one concatenated message: [Send options](#send-options).
|
||
|
||
## Delivery reports
|
||
|
||
Connection parameters, a receipt per segment, and logging:
|
||
|
||
```javascript
|
||
import { Log } from '@larvit/log';
|
||
import { client } from '@larvit/smpp';
|
||
|
||
const log = new Log('debug');
|
||
|
||
const { err, session } = await client({
|
||
host: 'smpp.somewhere.com',
|
||
log,
|
||
password: 'bar',
|
||
port: 2775,
|
||
username: 'foo',
|
||
});
|
||
if (err) throw err;
|
||
|
||
session.on('dlr', dlr => {
|
||
// dlr.smsId, dlr.statusMsg, dlr.statusId
|
||
});
|
||
|
||
const { err: sendErr, smsIds } = await session.sendSms({
|
||
dlr: true,
|
||
from: '46701113311',
|
||
message: '«baff»',
|
||
to: '46709771337',
|
||
});
|
||
```
|
||
|
||
`dlr: true` asks the SMSC to report on each segment. Match `dlr.smsId` against the `smsIds` the send
|
||
returned. `statusMsg` is `DELIVERED`, `UNDELIVERABLE`, `EXPIRED` and so on; `intermediate` is true
|
||
for a report that is not final. The full shape, the `messageDlr` event that merges a long message's
|
||
receipts into one, and SMSCs that write ids in two notations: [Delivery receipts](#delivery-receipts).
|
||
|
||
## Receive SMS
|
||
|
||
A `receiver` or `transceiver` client gets mobile-originated messages as `sms` events:
|
||
|
||
```javascript
|
||
session.on('sms', async sms => {
|
||
// sms.from, sms.to, sms.message
|
||
await sms.sendResp();
|
||
});
|
||
```
|
||
|
||
Call `sendResp()` for every message; it is part of the protocol. Delivery receipts reach you as
|
||
`dlr` events, not here. A multipart message arrives reassembled and already answered segment by
|
||
segment, so `sendResp()` there only says you are done with it: [Receiving in depth](#receiving-in-depth).
|
||
|
||
## Run an SMPP server
|
||
|
||
```javascript
|
||
import { server } from '@larvit/smpp';
|
||
|
||
const { err, server: smpp } = await server();
|
||
if (err) throw err;
|
||
|
||
smpp.on('session', session => {
|
||
session.on('sms', async sms => {
|
||
// sms.from, sms.to, sms.message, sms.dlr
|
||
await sms.sendResp();
|
||
});
|
||
});
|
||
```
|
||
|
||
With authentication and delivery reports:
|
||
|
||
```javascript
|
||
import { server } from '@larvit/smpp';
|
||
|
||
const { err, server: smpp } = await server({
|
||
// Replace with your own auth. Returning an object attaches it to session.userData.
|
||
authenticate: async ({ password, systemId }) => {
|
||
if (systemId !== 'foo' || password !== 'bar') return false;
|
||
|
||
return { userData: { userId: 123 } };
|
||
},
|
||
});
|
||
if (err) throw err;
|
||
|
||
smpp.on('session', session => {
|
||
session.on('sms', async sms => {
|
||
if (sms.answeredOnArrival) {
|
||
await sms.sendResp(); // multipart: already answered per segment; this only releases the shutdown drain
|
||
} else {
|
||
// no args: ESME_ROK + generated id; or sendResp({ smsId, status: 'ESME_RMSGQFUL' })
|
||
await sms.sendResp();
|
||
}
|
||
|
||
if (sms.dlr) {
|
||
await sms.sendDlr(); // same as sms.sendDlr('DELIVERED')
|
||
}
|
||
});
|
||
});
|
||
|
||
console.log(smpp.port); // the port actually bound, useful when 0 was requested
|
||
await smpp.close(); // stop listening, then drain and close every live session
|
||
```
|
||
|
||
- `sendResp()` answers `ESME_ROK` with a generated UUID v7 as the message id.
|
||
`sendResp({ smsId, status })` names the id or refuses the message.
|
||
- `sms.dlr` is true where the sender asked for a receipt. `sendDlr()` reports `DELIVERED`,
|
||
`sendDlr('UNDELIVERABLE')` any other state: [Server in depth](#server-in-depth).
|
||
- A message that arrived in several segments was answered as they arrived, so `sendResp()` there
|
||
takes no `smsId` or refusing `status`. `sms.answeredOnArrival` says which case you are in.
|
||
- `smpp.close()` stops listening, then drains and closes every live session.
|
||
|
||
## Errors
|
||
|
||
Nothing throws. Every fallible call returns a result with an optional `err`:
|
||
|
||
```javascript
|
||
const { err, session } = await client({ host: 'smpp.somewhere.com' });
|
||
if (err) return;
|
||
|
||
const { err: sendErr, smsIds } = await session.sendSms({ from, message, to });
|
||
```
|
||
|
||
Failures on a live session arrive as `sessionError` events, on a server handle as `serverError`.
|
||
Neither is named `error`, because Node throws on an unhandled `error` event.
|
||
|
||
`sessionError` carries three kinds of failure:
|
||
|
||
| Kind | Type | |
|
||
| --- | --- | --- |
|
||
| A PDU the peer sent that the codec could not read. The link stays up; only that PDU is lost. | `PduRefusedError` | Count it. |
|
||
| A concatenated message given up on before it was whole. Its segments were answered, so the peer will not resend, and no `sms` fired for it. | `Error` | Count it as lost traffic. |
|
||
| The session or socket failing, or a hook or listener that threw or rejected. | `Error` | Alert. |
|
||
|
||
The last two are told apart by message text only, so this alerts on both:
|
||
|
||
```javascript
|
||
import { PduRefusedError } from '@larvit/smpp';
|
||
|
||
session.on('sessionError', err => {
|
||
if (err instanceof PduRefusedError) {
|
||
log.warn('the peer sent a PDU that could not be read', {
|
||
cmdName: err.header.cmdName ?? err.header.cmdId,
|
||
reason: err.reason,
|
||
});
|
||
|
||
return;
|
||
}
|
||
|
||
log.error('a session failure or lost traffic', { message: err.message });
|
||
});
|
||
```
|
||
|
||
- `reason` is `command`, `body` or `tlvs`: the part the codec stopped at.
|
||
- `header` is the 16 octets that did parse: `cmdId`, `cmdLength`, `cmdName`, `cmdStatusId` and
|
||
`seqNr`. `cmdName` is undefined for a command id this library does not know. `PduHeader` is its type.
|
||
- A refused request is answered with the status SMPP names for it. A refused response is answered
|
||
with nothing, and settles the request it named as `unanswered`.
|
||
- A refused inbound `deliver_sm` is lost traffic: a message or receipt that never arrives as `sms`
|
||
or `dlr`. A refused response is reported twice, as the `err` of the `sendSms()` or `send()`
|
||
waiting on it and here.
|
||
- A `PduRefusedError` is always a PDU that arrived. What this library refuses to build or send (an
|
||
alphabet, a time, a body its `data_coding` cannot carry) is a plain `Error` in the call's result.
|
||
|
||
## Client options
|
||
|
||
All optional. Timeouts and delays are milliseconds.
|
||
|
||
| Option | Default | |
|
||
| --- | --- | --- |
|
||
| `host`, `port` | `localhost`, `2775` | Where to connect. |
|
||
| `username`, `password` | `user`, `pass` | Bind credentials: `system_id` and `password`. |
|
||
| `bindType` | `transceiver` | `transceiver`, `transmitter` or `receiver`. |
|
||
| `interfaceVersion` | `0x34` | The SMPP version declared at bind. `0x50` for an SMSC that requires SMPP 5.0. |
|
||
| `systemType`, `addressRange`, `addrTon`, `addrNpi` | `''`, `''`, `0`, `0` | The remaining bind fields, for operators that require them. |
|
||
| `tls` | `false` | `true` for defaults, or a `tls.ConnectionOptions` object for a private CA or a client certificate. |
|
||
| `enquireLinkInterval` | `20000` | Interval between `enquire_link` on a quiet link. |
|
||
| `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering, and re-bind unless `reconnect` is `false`. |
|
||
| `responseTimeout` | `30000` | How long to wait for a response, and how long a send with no link waits for the next one. `0` waits forever. |
|
||
| `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for requests already sent and messages not yet answered. `0` waits forever for the requests, which end when the peer answers or `responseTimeout` expires, so both at `0` never ends. The messages then fall back to `responseTimeout`, or to its default where that is `0` too. |
|
||
| `maxOutstanding` | `10` | Requests on the wire at once; further sends queue. |
|
||
| `smsIdFormat` | — | The notation the SMSC writes message ids in, per place: `{ receipt: 'decimal', submitResp: 'hex' }`. Only where the two disagree: [Delivery receipts](#delivery-receipts). |
|
||
| `reconnect` | on | `{ minDelay, maxDelay }` retunes the backoff; `false` turns it off, so a drop ends the session; `{ fromStart: true }` retries the first connect too. |
|
||
| `log` | silent | Any object with `debug`, `error`, `info`, `verbose` and `warn` methods: [Logging](#logging). |
|
||
| `signal` | — | An `AbortSignal` that cancels connecting and tears the session down. |
|
||
|
||
**Reconnect.** After a drop, an idle timeout, or a stream the library cannot frame, the client
|
||
reopens the socket and re-binds, doubling the delay from `minDelay` (1 s) to `maxDelay` (30 s), and
|
||
starts over at `minDelay` once a link has lasted `maxDelay`.
|
||
|
||
`reconnect: { fromStart: true }` puts the first connect and bind through the same loop, a bind the
|
||
SMSC refuses included, so a client started while its SMSC is down keeps retrying. `client()` then
|
||
resolves once bound, and only an aborted `signal` ends the wait. That signal also closes the session
|
||
once bound, so write a deadline as an `AbortController` you stop arming when `client()` returns,
|
||
not as `AbortSignal.timeout(ms)`.
|
||
|
||
## Server options
|
||
|
||
All optional. Timeouts are milliseconds.
|
||
|
||
| Option | Default | |
|
||
| --- | --- | --- |
|
||
| `host`, `port` | all interfaces, `2775` | Where to listen. `port: 0` takes any free port; `smpp.port` says which. |
|
||
| `authenticate` | accept everything | `({ password, session, systemId, systemType }) => false \| { userData }`, sync or async. |
|
||
| `onRequest` | none | `(session, pduObj) => true \| false`, sync or async. First refusal on every request a bound peer sends: [Server in depth](#server-in-depth). |
|
||
| `systemId` | `''` | The SMSC identity returned in the bind response. |
|
||
| `interfaceVersion` | `0x34` | The SMPP version advertised in the bind response. Optional parameters are sent to a peer from `0x34` up, whatever this is set to. |
|
||
| `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. A bare `true` is refused. |
|
||
| `idleTimeout` | `40000` | Drop a peer that has been silent this long. |
|
||
| `maxReassembly` | `1000` | Incomplete multipart messages held per session. |
|
||
| `maxOctets` | `67108864` | Bytes of incomplete multipart messages held per session. |
|
||
| `reassemblyTimeout` | `300000` | How long a late segment can still join an incomplete message. |
|
||
| `responseTimeout`, `shutdownTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | |
|
||
|
||
## Send options
|
||
|
||
```javascript
|
||
await session.sendSms({
|
||
dlr: true, // ask for a delivery report
|
||
destinationAddrNpi: 0, // override the numbering plan of the recipient
|
||
destinationAddrTon: 1,
|
||
encoding: 'UCS2', // override the automatic choice
|
||
flash: false,
|
||
from: 'MyBrand', // alphanumeric -> TON 5, digits -> TON 1
|
||
maxSegments: 10, // refuse a longer message instead of sending it
|
||
message: 'Hello world',
|
||
messagingMode: 'SMSC_DEFAULT', // or DATAGRAM or STORE_FORWARD
|
||
scheduleDeliveryTime: new Date(Date.now() + 3600_000),
|
||
sourceAddrNpi: 0, // override the numbering plan of the sender
|
||
sourceAddrTon: 5,
|
||
to: '46709771337',
|
||
validityPeriod: 3600, // seconds, or a Date
|
||
}, { signal }); // optional per-call AbortSignal
|
||
```
|
||
|
||
**Addresses.** `sourceAddrTon` and `destinationAddrTon` default to 5 for an alphanumeric address
|
||
and 1 for a numeric one; the NPI fields default to 0.
|
||
|
||
**Encoding.**
|
||
|
||
| `encoding` | Alphabet | Characters per SMS | Per segment of a long message |
|
||
| --- | --- | --- | --- |
|
||
| `ASCII` | GSM 03.38 7-bit | 160 | 153 |
|
||
| `LATIN1` | ISO 8859-1 | 140 | 134 |
|
||
| `UCS2` | UCS-2 | 70 | 67 |
|
||
|
||
- Omitted: `ASCII` where the message fits GSM 7-bit, otherwise `UCS2`. `LATIN1` only when named.
|
||
Any other name is refused.
|
||
- GSM extension characters (`{}[]\~^|€` and form feed) count as two, as does a character outside
|
||
the basic multilingual plane in `UCS2`.
|
||
- An alphabet you name has to carry every character, or the send is refused before anything goes
|
||
out, naming the character, its code point and its index. Detection never refuses.
|
||
- `LATIN1` carries every octet, so `buffer.toString('latin1')` reaches the SMSC byte for byte, under
|
||
`data_coding` 0x03, which declares Latin-1 text. To declare 8-bit binary, hand `session.send()` a
|
||
`Buffer` body and the `data_coding` you want: [PDUs and the low-level API](#pdus-and-the-low-level-api).
|
||
- `consts.ENCODING` is the low-level `data_coding` table, not this option's list.
|
||
|
||
**Long messages.**
|
||
|
||
```javascript
|
||
const { err, pduObjs, smsIds, unanswered } = await session.sendSms({ from, message, to });
|
||
```
|
||
|
||
- One id per segment. `smsIds` is positional with `pduObjs`, and an entry is `undefined` where the
|
||
SMSC took the segment without naming an id; some name one for the first segment only. No receipt
|
||
ever carries an empty id, so an unnamed entry matches nothing.
|
||
- `err` is set when the SMSC refuses a segment, naming the status. Every segment goes out together,
|
||
so `pduObjs` and `smsIds` then hold what was accepted: enough to reconcile a later receipt, not
|
||
enough to resend the rest. Treat a partial failure as a failed message.
|
||
- `unanswered` counts segments that went out and were never answered. The SMSC may have taken each
|
||
and lost only the response, so a message with `unanswered` above zero cannot be resent without
|
||
risking a duplicate.
|
||
- More than 255 segments is refused before anything is sent, since the concatenation header numbers
|
||
segments in one octet. `maxSegments` lowers that ceiling; most handsets and SMSCs stop well short.
|
||
|
||
**Flash.** `flash: true` asks for GSM 03.38 message class 0, shown on arrival instead of stored. It
|
||
travels in `data_coding` beside the alphabet, so a flash UCS2 message stays UCS2. `flash` with
|
||
`encoding: 'LATIN1'` is refused: no `data_coding` carries both.
|
||
|
||
**Messaging mode.** `messagingMode` names the `esm_class` mode: `SMSC_DEFAULT`, which is what an
|
||
omitted option sends, `DATAGRAM` or `STORE_FORWARD`. Every segment of a long message also carries the
|
||
user data header indicator, so `STORE_FORWARD` on one sends `esm_class` 0x43. `DATAGRAM` with
|
||
`dlr: true` is refused, since datagram mode has no delivery reports. Transaction mode
|
||
(`consts.MESSAGING_MODE.FORWARD`) exists only on `data_sm`, which is never sent, and is refused too.
|
||
|
||
**Times.** `scheduleDeliveryTime` and `validityPeriod` take a `Date`, a number of seconds, or a stamp
|
||
you formatted. Refused before anything goes out: an invalid `Date`, `NaN`, `Infinity`, a negative
|
||
count, and a count past 99 days 23:59:59, since a count in seconds is spelled in days and below.
|
||
Name a later instant as a `Date`, which goes out absolute.
|
||
|
||
**What gets checked.** The library checks what it composes: an alphabet or a time you named, a string
|
||
body under a `data_coding` you named. What you formed yourself, a `Buffer` body or a stamp you
|
||
formatted, passes through as written. The same rule holds for `session.send()`.
|
||
|
||
## Session
|
||
|
||
### Events
|
||
|
||
| Event | Fires when |
|
||
| --- | --- |
|
||
| `sms` | An SMS arrives, reassembled if it was multipart. Carries `sendResp()`, `sendDlr()` and `smsId`. |
|
||
| `dlr` | A delivery report arrives, one per segment, with its PDU as the second argument: [Delivery receipts](#delivery-receipts). |
|
||
| `messageDlr` | Every segment of a long message sent with `dlr: true` has a final report: [Delivery receipts](#delivery-receipts). |
|
||
| `close` | The session is over and nothing will bring the link back. Fires once, whether you closed it or the link failed for good. |
|
||
| `disconnected` | The link dropped and the reconnect loop will retry. Do not open a replacement client: this session comes back on its own, and `reconnected` says when. Fires again for each attempt that reconnects and then fails, so it is not one-to-one with `reconnected`. |
|
||
| `reconnected` | The client re-bound after a drop. |
|
||
| `sessionError` | Something failed on a live session, a PDU the codec refused included: [Errors](#errors). |
|
||
| `data` | Raw bytes arrived on the socket. |
|
||
| `incomingPdu` | A complete PDU arrived, as a buffer. |
|
||
| `incomingPduObj` | The same PDU, parsed into an object. |
|
||
|
||
### Methods
|
||
|
||
`sendSms()`, `send()`, `sendReturn()`, `unbind()` and `close()`.
|
||
|
||
**Shutdown.** `close()` and `unbind()` both:
|
||
|
||
1. Refuse further sends. `sendDlr()` is the one send let past, when issued straight after
|
||
`sendResp()`; await anything in between and it races the shutdown like any other send.
|
||
2. Wait up to `shutdownTimeout` for the requests already sent, and for every `sms` the application
|
||
has not answered. That wait ends when `sendResp()` puts the response on the wire (or, for a
|
||
message answered on arrival, when it is called at all), or when every listener that took the
|
||
message has failed. Answering through `sendReturn()` instead leaves the wait running.
|
||
3. Tear down what is left, resolving to an `err` that says what was lost.
|
||
|
||
At most 1000 unanswered messages are held, for five minutes each; what falls out of either bound is
|
||
dropped with a warning on the log and waited for no longer. Neither bound is an option.
|
||
`close({ signal })` cuts the wait short. `unbind()` takes no signal, and waits a further
|
||
`responseTimeout` for its own response.
|
||
|
||
**Sends and the link.**
|
||
|
||
- A send issued while the link is down waits for the reconnect and goes out once the new link is
|
||
bound, up to `responseTimeout`, after which it gives up having sent nothing.
|
||
- A request already on the wire when the link drops, when the peer fails to answer in time, or when
|
||
you abort it, fails and counts in `unanswered`: the SMSC may have taken it and lost only the
|
||
response.
|
||
- With `reconnect: false` a drop ends the session, and every send after it is refused.
|
||
- `sms.sendResp()` on a message whose link dropped writes nothing and returns `err`, since a response
|
||
carries the sequence number of the link it arrived on. `sms.sendDlr()` still goes out on the new
|
||
link.
|
||
- `responseTimeout` bounds the wait for a link and the wait for an answer separately, and the wait
|
||
for a `maxOutstanding` slot is unbounded, so it is not a deadline. For a deadline pass
|
||
`{ signal: AbortSignal.timeout(ms) }`: it cuts all three waits short, and a send it stops before
|
||
anything reached the socket adds nothing to `unanswered`. A message with more segments than slots
|
||
goes out a slot at a time, so a deadline that expires mid-message is how you get a partial failure.
|
||
- There is no throughput limit. An operator's rate limit is per account, across every process bound
|
||
to it, so enforce it outside this library. `ESME_RTHROTTLED` reaches you as a send's `err`.
|
||
|
||
**Raw commands.** `send()` reaches all 33 SMPP commands, not just the four the session handles itself:
|
||
|
||
```javascript
|
||
const { err, pduObj } = await session.send({
|
||
cmdName: 'query_sm',
|
||
params: { message_id: smsId },
|
||
});
|
||
```
|
||
|
||
**The peer.**
|
||
|
||
- `acceptsOptionalParams()`: whether the peer declared SMPP 3.4 or later, the version from which
|
||
optional parameters may be sent to it. The library's own senders check it before attaching a TLV;
|
||
a `send()` you build is passed through as written, so check it yourself.
|
||
- `peerInterfaceVersion`: the version the peer declared, `0x00` if none.
|
||
- `bindAllows(cmdName)` and `boundAs`: what the bind direction carries: [Bind direction](#bind-direction).
|
||
|
||
## Receiving in depth
|
||
|
||
- **Multipart.** Segments tied together by a user data header, or by the `sar_msg_ref_num`,
|
||
`sar_total_segments` and `sar_segment_seqnum` TLVs, reassemble into one `sms` alike. A PDU carrying
|
||
both is read from the header. The two reference numbers are separate counters: the same number in
|
||
each is two messages.
|
||
- **Answered on arrival.** Each segment was answered as it landed, before you see the message:
|
||
[Server in depth](#server-in-depth).
|
||
- **Where the body is.** A body in the `message_payload` TLV, SMPP's way of carrying up to 64 KB and
|
||
the only place a `data_sm` has, reads exactly like one in `short_message`, concatenated messages
|
||
and receipts included. A PDU filling both is read from `short_message`.
|
||
- **`data_sm`.** A client reads an inbound `data_sm` as a delivery: a message arrives as `sms`, a
|
||
receipt as `dlr`. A `server()` session reads it as a submission and always emits `sms`. Either way
|
||
it is answered `data_sm_resp`.
|
||
- **Flash.** `sms.flash` is true where `data_coding` carries GSM 03.38 message class 0, in every
|
||
coding group that carries one: `0x10`, `0x18`, `0x50` and `0xF0` alike. Classes 1 to 3 name where
|
||
the handset stores the message and are not flash.
|
||
- **Binary.** A message whose `data_coding` says 8-bit binary arrives as Latin-1:
|
||
`Buffer.from(sms.message, 'latin1')` gives the original octets.
|
||
|
||
### Delivery receipts
|
||
|
||
Receipts travel on the same command as messages but reach you as `dlr`, one per segment. What marks
|
||
one: `esm_class`; where that names no type, a `receipted_message_id` TLV; failing both, `id:` and
|
||
`stat:` in the body. The body is read as text whatever `data_coding` the receipt declares, since
|
||
SMSCs commonly copy the reported message's onto it. An intermediate delivery notification is a report
|
||
too, never an inbound message.
|
||
|
||
| `Dlr` field | |
|
||
| --- | --- |
|
||
| `smsId` | The id reported on. `undefined` where the receipt carries no readable id. |
|
||
| `statusMsg` | `DELIVERED`, `UNDELIVERABLE`, `EXPIRED`, `REJECTED`, `DELETED`, `ACCEPTED`, `UNKNOWN`, `ENROUTE`, `SCHEDULED` or `SKIPPED`. `stat:FAILED`, which several operators write and SMPP does not define, reads as `UNDELIVERABLE`. |
|
||
| `statusId` | The numeric `message_state`. Where the peer sent one this library cannot name, its raw value, with `statusMsg` from the body or `UNKNOWN`. |
|
||
| `intermediate` | The report is not final: marked an intermediate notification, or reporting `ENROUTE` or `SCHEDULED`. |
|
||
| `receipt` | The receipt text parsed: `id`, `sub`, `dlvrd`, `submitDate`, `doneDate`, `stat` as the SMSC wrote it, `err` and `text`. |
|
||
| `doneDate`, `errorCode` | The `done date:` field as a `Date`, and the `err:` field. |
|
||
|
||
**Matching a receipt to a send** means comparing `dlr.smsId` with the `smsIds` from `sendSms()`.
|
||
Some SMSCs write the two in different notations, a hex `message_id` on the `submit_sm_resp` and a
|
||
decimal `id:` in the receipt, or one of them zero-padded, and the comparison then matches nothing.
|
||
Name each notation and both are read into plain decimal:
|
||
|
||
```javascript
|
||
const { err, session } = await client({ smsIdFormat: { receipt: 'decimal', submitResp: 'hex' } });
|
||
```
|
||
|
||
`receipt` is the notation of the body's `id:`; `submitResp` that of the `message_id` in
|
||
`submit_sm_resp` and of the `receipted_message_id` TLV, which carries that same id. An id that is not
|
||
a number in the notation named is left as it arrived. The PDUs carry what the peer wrote either way:
|
||
`pduObjs` from the send, and the `dlr` event's second argument.
|
||
|
||
**`messageDlr`** fires once every segment of a long message sent with `dlr: true` has a final report,
|
||
carrying the worst status of the segments and each of them under `segments`. An `intermediate`
|
||
report never counts. Merging needs the SMSC to number its segment ids `<base>-<n>`, this library's
|
||
own server's convention; an SMSC that hands out unrelated ids per segment never fires it. A base is
|
||
merged once: a later message the SMSC gives the same ids is reported through `dlr` alone, and an
|
||
earlier one still collecting loses its merged report.
|
||
|
||
## Server in depth
|
||
|
||
**Multipart is answered on arrival.** Each segment is answered as it lands, because a relaying SMSC
|
||
will not send the next until the last is answered. The answer is `ESME_ROK`, unless the segment
|
||
numbers itself into no message this session can join, which refuses it, or the reassembly buffer is
|
||
full, which asks the SMSC to keep it and try again. `sms.answeredOnArrival` says whether the message
|
||
you hold was answered that way; a segment count cannot, since a peer may number a message one part
|
||
of one.
|
||
|
||
- The id was fixed with the first segment, so `sendResp()` there only says you are done, and
|
||
returns `err` for an `smsId` or a refusing `status`.
|
||
- `sms.smsId` is the base. `sendDlr()` names `<smsId>-1`, `<smsId>-2` and so on: the ids the
|
||
`submit_sm` responses carried.
|
||
- A `deliver_sm` is answered with no id at all, since SMPP marks that field unused, so an inbound
|
||
message's base is a handle of your own only.
|
||
|
||
**Refusing a request** for a reason in the request rather than the message (a full queue, an unknown
|
||
recipient, an unauthorised sender) has to land before a segment is answered. `onRequest` runs on
|
||
every request a bound peer sends, before reassembly and before the `sms` event:
|
||
|
||
```javascript
|
||
import { isCommand, server } from '@larvit/smpp';
|
||
|
||
const knownRecipients = new Set(['46709771337']);
|
||
|
||
const { err } = await server({
|
||
onRequest: async (session, pduObj) => {
|
||
if (!isCommand(pduObj, 'submit_sm') || knownRecipients.has(pduObj.params.destination_addr)) {
|
||
return false;
|
||
}
|
||
|
||
await session.sendReturn(pduObj, 'ESME_RINVDSTADR');
|
||
|
||
return true;
|
||
},
|
||
});
|
||
if (err) throw err;
|
||
```
|
||
|
||
- Return `true`: the hook answered the PDU and the library leaves it alone. `false`: the built-in
|
||
handling runs.
|
||
- Every segment of a long message is a request of its own, so the hook sees each one.
|
||
- No bind reaches it, nor anything a peer sends before one: `server()` answers those and runs
|
||
`authenticate` itself.
|
||
- A hook that throws or rejects reaches `sessionError`, and nothing is written for that request;
|
||
the peer's own response timeout settles it. `authenticate` fails the same way, leaving the bind
|
||
unanswered.
|
||
- `enquire_link` and `unbind` reach the hook too, and an unanswered `enquire_link` has the peer drop
|
||
the link. Guard on the command name, as above, and a failing hook costs only its own request.
|
||
- A `Session` you construct yourself takes the same hook as a session option, and that is where a
|
||
peer's bind gets accepted, since a hand-wired session has no bind handling of its own.
|
||
|
||
**`sendDlr()`** takes `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`,
|
||
`ACCEPTED`, `UNKNOWN`, `REJECTED` or `SKIPPED`. The first two go out as intermediate delivery
|
||
notifications (`esm_class` 0x20), the rest as delivery receipts (0x04).
|
||
|
||
### Bind direction
|
||
|
||
The three bind types are honoured in both directions, whichever end of the link the session is:
|
||
|
||
- `session.sendSms()` on a receiver-bound session, and `sms.sendDlr()` to a transmitter-bound peer,
|
||
return `err` before anything reaches the wire.
|
||
- A `submit_sm` arriving on a receiver-bound session, or a `deliver_sm` on a transmitter-bound one,
|
||
is answered `ESME_RINVBNDSTS`.
|
||
- `data_sm` carries a message either way, so which end the session is decides: a client refuses one
|
||
on a transmitter bind, a `server()` session on a receiver bind. `bindAllows('data_sm')` answers for
|
||
the inbound direction. A `Session` you construct yourself is the ESME end, as `client()` builds;
|
||
a hand-wired SMSC sets `session.linkEnd = 'smsc'`, as `server()` does.
|
||
- `transceiver`, the default, carries both. `session.send()` is a passthrough and is not checked.
|
||
|
||
## Logging
|
||
|
||
`log` takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods, each
|
||
`(msg: string, metadata?: Record<string, boolean | number | string>) => void`. Message strings are
|
||
static; every dynamic value is in the metadata, so entries group by message.
|
||
|
||
[`@larvit/log`](https://www.npmjs.com/package/@larvit/log) implements it as it stands:
|
||
|
||
```javascript
|
||
import { Log } from '@larvit/log';
|
||
import { client } from '@larvit/smpp';
|
||
|
||
const { err, session } = await client({ log: new Log('debug') });
|
||
```
|
||
|
||
So does an object of your own:
|
||
|
||
```javascript
|
||
const log = {
|
||
debug: () => undefined,
|
||
error: (msg, metadata) => { console.error(msg, metadata); },
|
||
info: (msg, metadata) => { console.info(msg, metadata); },
|
||
verbose: () => undefined,
|
||
warn: (msg, metadata) => { console.warn(msg, metadata); },
|
||
};
|
||
```
|
||
|
||
`SmppLog` is the type.
|
||
|
||
## PDUs and the low-level API
|
||
|
||
The codec is exported, synchronous, and never throws:
|
||
|
||
```javascript
|
||
import { isCommand, objToPdu, pduToObj } from '@larvit/smpp';
|
||
|
||
const { err, pduObj } = pduToObj(buffer);
|
||
if (err) return;
|
||
|
||
if (isCommand(pduObj, 'submit_sm')) {
|
||
pduObj.params.destination_addr; // typed as a string
|
||
}
|
||
```
|
||
|
||
**Reading.**
|
||
|
||
- `params.short_message` is decoded with the PDU's own `data_coding`; `shortMessageOctets` is that
|
||
field as it arrived. Neither holds a body carried in the `message_payload` TLV, which a `data_sm`
|
||
always uses.
|
||
- `messageOctets(pduObj)` is the one answer to which of the two the peer used, undecoded.
|
||
`decodeMessage(octets, pduObj.params.data_coding, pduObj.params.esm_class)` turns them into text
|
||
and hands back the UDH where the PDU carries one.
|
||
- `concatOf(pduObj)`: the `part`, `total` and `reference` a PDU declares and the `spelling` that
|
||
carried them, `'udh'` or `'sar'`, or `undefined` for a whole message.
|
||
- `messageClassOf(dataCoding)`: `0` for the flash class, `1`, `2` and `3` for the ME-, SIM- and
|
||
TE-specific ones, `undefined` where that `data_coding`'s coding group carries no class.
|
||
|
||
**Building.**
|
||
|
||
- A string `short_message` or `message_payload` is encoded in the alphabet the PDU's `data_coding`
|
||
names, detected from the text where you name none. One that alphabet cannot carry is refused,
|
||
naming the character, its code point and where it is.
|
||
- A `Buffer` goes out exactly as given under any `data_coding`: binary payloads, hand-built user
|
||
data headers, deliberately malformed bodies.
|
||
- `session.send()` and `session.sendReturn()` build through the same codec and refuse the same bodies.
|
||
- `unencodable(message, encoding)`: `{ char, index }` for the first character an alphabet cannot
|
||
carry, `undefined` where it carries them all. The check `sendSms()` makes before encoding.
|
||
- `dataCodingByEncoding[encoding]`: the `data_coding` this library writes each alphabet under, which
|
||
is what to put beside octets from `encodeMessage()`.
|
||
- `smppTime.encode(value)` returns `{ err, text }` for a `validity_period` or
|
||
`schedule_delivery_time`; `smppTime.decode(text)` returns `{ err, date }`.
|
||
|
||
**Everything exported.**
|
||
|
||
| | |
|
||
| --- | --- |
|
||
| Sessions | `client`, `server`, `Session`, `SmppServer` |
|
||
| Codec | `pduToObj`, `objToPdu`, `pduReturn`, `isCommand`, `isResp`, `PduFramer`, `PduRefusedError`, `maxPduLength`, `maxSeqNr` |
|
||
| Messages | `encodeMessage`, `decodeMessage`, `splitMessage`, `bitCount`, `messageOctets`, `concatOf`, `concatInfo`, `detect`, `unencodable`, `messageClassOf`, `dataCodingByEncoding`, `encodingByDataCoding` |
|
||
| Receipts | `dlrFromPdu`, `parseReceipt`, `receiptCodes` |
|
||
| Time and ids | `smppDate`, `smppTime`, `uuidv7` |
|
||
| Spec tables | `cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, the `cmdsById`, `constsById`, `errorsById` and `tlvsById` maps, and all of them grouped as `defs`. `isCommandName`, `isErrorName`, `isEncodingName`, `commandNameById` and `errorNameById` narrow a value into them. |
|
||
| Types | Every option, result, event payload and table entry has a named type: `ClientOptions`, `ServerOptions`, `SendSmsOptions`, `SendSmsResult`, `Sms`, `Dlr`, `MessageDlr`, `Receipt`, `PduObject`, `PduHeader`, `SmppLog`, `Result` and the rest in `dist/index.d.ts`. |
|
||
|
||
## Migrating from larvitsmpp 0.4.0
|
||
|
||
See [MIGRATION.md](MIGRATION.md).
|
||
|
||
## Development
|
||
|
||
Everything runs in the container; nothing is installed on the host.
|
||
|
||
```bash
|
||
docker compose run --rm node npm install
|
||
docker compose run --rm node npm test # lint, typecheck and tests
|
||
docker compose run --rm node npm run build
|
||
docker compose run --rm node npm run test:compiled # what CI runs on older Node versions
|
||
```
|
||
|
||
Tests are TypeScript and run directly under Node's type stripping, so there is no build step in the
|
||
development loop. CI compiles them and runs them on Node 18, every LTS above it, and current.
|
||
|
||
## License
|
||
|
||
MIT
|