# @larvit/smpp
[](https://www.npmjs.com/package/@larvit/smpp)
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/](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/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) · [Goals](#goals) ·
[Audience](#audience) · [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.
## 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. |
| `connectTimeout` | `10000` | Give up on **each connect attempt** the SMSC never completes, the TLS handshake included, and report it as an ordinary connect failure, which `reconnect` then retries. It bounds the socket and the handshake — never the `client()` call, and never the wait for the bind response, which is `responseTimeout`. `false` waits the operating system out instead, around 130 s on Linux; `0` is refused. |
| `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` | Roughly the memory incomplete multipart messages may hold per session: each held segment counts its octets plus 1000, 300 more per TLV on it, and 300 per occurrence of a repeatable one. |
| `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. An address is latin1, so `é` is one octet on
the wire and an address you received always sends back. One outside `/^[\u0001-\u00FF]*$/` is
refused, naming the character and its index — strip or transliterate it first. An SMSC may still
refuse a non-ASCII sender of its own accord, which reaches you as a refusal such as
`ESME_RINVSRCADR`.
**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, or leaves one unanswered. Every
segment goes out together, so `pduObjs` and `smsIds` then hold only the accepted segments, in send
order: enough to reconcile a later receipt, not enough to resend the rest. Treat a partial failure
as a failed message; its accepted segments report through `dlr` alone.
- `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 address you gave as `from` or `to`,
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, except that a text
field is still checked: [PDUs and the low-level API](#pdus-and-the-low-level-api). 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, and 64 MiB of them by the `maxOctets` charge, are held. Past
either bound a new message is refused with `ESME_RTHROTTLED` (`ESME_RX_T_APPN` on a `deliver_sm`),
so the peer retries it. One held five minutes is dropped with a warning on the log and waited for
no longer. None of the bounds 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 `-`, 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. A send that returned an `err` never fires `messageDlr`,
even where the SMSC took some of its segments; their receipts still arrive as `dlr`.
## 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 or
the unanswered messages are at their bound, 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 `-1`, `-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) => 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.
- `callback_num`, `callback_num_atag`, `callback_num_pres_ind`, `broadcast_area_identifier` and
`broadcast_error_status` may repeat in one PDU, so each reads as an array of every occurrence in wire
order: `number[]` for `callback_num_pres_ind` and `broadcast_error_status`, `Buffer[]` for the rest.
A `broadcast_sm_resp`'s `failed_broadcast_area_identifier` reads as `broadcast_area_identifier`.
- `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.
- Every text field is latin1: addresses, `system_id`, `message_id`, `service_type` and the C-Octet
String TLVs. A character past `U+00FF` is refused, as is a `U+0000` in a C-Octet String.
- The five repeatable TLVs take an array, written as one TLV per element; a lone value or an empty
array is refused.
- 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`. |
## What changed per release
See [CHANGELOG.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/CHANGELOG.md).
## Migrating from larvitsmpp 0.4.0
See [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md).
## Goals
In priority order, and the order is the point: where two of them pull against each other, the earlier
one wins. They do not override the [hard rules](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/AGENTS.md#hard-rules).
1. **Correct on the wire.** SMPP 3.4 as SMSCs actually run it. Every other goal yields to this one;
the [defect table](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/AGENTS.md#defects-found-in-040) is what the alternative costs.
2. **Never give the application a wrong answer about what happened.** An outcome we cannot determine
is reported as undetermined rather than guessed; a report the peer marked as not final settles
nothing, so nothing the library concludes may rest on one; a request the peer may already have
taken is never re-sent on the library's own initiative; work the peer has no reason to send again
is not dropped; a call that reports a message as sent asserts that the wire carried what the caller
wrote, so a value we cannot send as given is refused before anything goes out; each message gets
one outcome as a whole, so a send that fails is that outcome and no merged report follows it.
3. **Strict in what we send, generous in what we read.** The library's own senders follow 3.4, and
the codec parses whatever arrives. Where the letter of the spec would discard traffic a real SMSC
sends, keep the traffic.
4. **A peer an operator never has to complain about.** No bind flooding, nothing a bind direction
forbids, no optional parameters to a peer that declared none, nothing held without a bound.
5. **The session layer is in here, and its defaults are what most applications should run.**
Keepalive, reconnect, the send window, reassembly and receipt correlation. What the network says
about a message the application sent reaches it as a report rather than as an inbound message, and
says whether it is final, so nothing has to read the PDU to tell those apart. An option retunes a
default or opts out of it; an option does not switch on the thing the caller obviously wanted.
6. **Fast enough that this library is never the bottleneck.** Throughput over a few sessions rather
than many idle ones, which is why this exists on Node at all. On four cores or more, one bound
session sustains at least 5,000 `submit_sm`/s at a send window of 1, 20,000 at the default window
of 10, and 30,000 at 50 or above, and asking for delivery receipts costs nothing measurable.
Memory is bounded per session, never per process. [benchmarks/](benchmarks/README.md) is how those
floors are checked; every release re-measures them and asks what it would take to go faster.
7. **Configurable and extendable, never at the defaults' expense.** Where an application needs other
than the default and cannot build it from what is exported — a rate limit counted per PDU, an
alphabet, a receipt format — it gets an option or a hook rather than a fork. A call that passes no
options stays exactly as easy and as safe, and a hook is a seam the library calls, never a way into
its internals.
8. **A small, stable public surface over reshapeable internals.** Only what `src/index.ts` exports is
published. A new option has to beat "the application can do this itself", and has to keep a
promise this library can verify. The low-level surface is a passthrough: policy binds what the
library composes, never what the caller wrote.
9. **State wider than one session goes through one store.** A pool of sessions, a limit shared
between processes, and what has to survive a restart — receipts still awaited, a message half
reassembled — are held through a store interface and never beside it. Without a store the
application supplies, that state is in memory and ends with the process, and the defaults need
none. The interface carries the library's own versioned records, never an internal shape handed
to the application to persist. Coordinating processes any other way is declined without a fresh
argument each time.
10. **It builds, tests and runs the same everywhere.** Container-only toolchain, no runtime
dependencies, the Node 18 floor verified in CI, every README example executed
by the suite.
## Audience
Who depends on this library, and what they may rely on.
- **The public npm audience.** Only what `src/index.ts` exports is public; everything behind it is
reshaped freely.
- **Node 18 and newer, ESM only, no runtime dependencies.**
- **Real SMSCs and ESMEs as operators actually run them**, not a reference implementation. Jasmin,
SMPPSim, Kannel, jsmpp, Cloudhopper, python-smpplib and php-smpp are the interop targets, and what
they do in practice outranks what the specification says they should do.
- **The SMSC operator on the far end**, who never sees this API but carries what it does to their
link. A peer they have to complain about is a defect however well the library reads.
- **The developer building the SMPP edge of something else.** What binds to `server()` in practice is
an aggregator's customer-facing edge, a bridge putting SMPP in front of a modern transport, or a
test double standing in for an SMSC. This is not a store-and-forward SMSC and will not become one:
spooling, scheduling, retry policy and billing belong to whatever this is the edge of, and state
shared between instances goes through the store in goal 9.
- **Pre-1.0, so the minor is the breaking unit** and a patch never breaks. What a 0.4.0 consumer has
to change is in [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md);
what each later minor changes is in
[CHANGELOG.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/CHANGELOG.md).
Personas this README serves, in order:
1. The application developer sending or receiving SMS on the defaults, who should need no options.
2. The application developer who needs one thing retuned — an alphabet, a rate limit, a receipt
format — through an option or a hook rather than a fork.
3. The operator coordinating sessions across processes through a store.
4. The developer migrating from `larvitsmpp` 0.4.0.
## 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