db02f0058d
* Regression tests for telling a refused PDU from a failed session * Export PduRefusedError so a refusal can be told from a session failure * Document telling a refused PDU from a session failure * Share the malformed-PDU fixtures and pin that a dead stream is no refusal * Say once what a refusal is, and why the header type is published * Fold the refused-response test into the one that already staged it * Drop the refusal-rate claim the interop finding does not support * Build a raw PDU one way, sequence number included * Follow PduRefusedError into pdu-refusal.ts
522 lines
27 KiB
Markdown
522 lines
27 KiB
Markdown
# @larvit/smpp
|
||
|
||
A simplified implementation of the SMPP protocol, in TypeScript. ESM only, types included.
|
||
|
||
## Requirements
|
||
|
||
Node 18 or later. No runtime dependencies.
|
||
|
||
## Install
|
||
|
||
```bash
|
||
npm install @larvit/smpp
|
||
```
|
||
|
||
## Client
|
||
|
||
The simplest possible client — connects to localhost:2775 with no credentials and sends a message:
|
||
|
||
```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();
|
||
```
|
||
|
||
With connection parameters, a delivery report 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',
|
||
});
|
||
```
|
||
|
||
### Client options
|
||
|
||
Every one is optional.
|
||
|
||
| 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` | How often to send `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 before giving up on it, 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 the requests this end already sent and the messages the application has not answered. `0` waits forever for the requests, which end when the peer answers or `responseTimeout` expires — so setting both to `0` never ends. The messages fall back to `responseTimeout`, or to its default where that is `0` too, since nothing but the application ends that wait. |
|
||
| `maxOutstanding` | `10` | Requests allowed on the wire at once; further sends queue. |
|
||
| `smsIdFormat` | — | The notation the SMSC writes message ids in, per place it writes them: `{ receipt: 'decimal', submitResp: 'hex' }`. Only needed where the two disagree. |
|
||
| `reconnect` | on | Re-binds after a drop, an idle timeout, or a stream the library cannot frame, backing off from `minDelay` 1 s to `maxDelay` 30 s and starting over at `minDelay` once a link has lasted `maxDelay`. `{ minDelay, maxDelay }` retunes it; `false` turns it off, so a drop ends the session; `{ fromStart: true }` retries the first connect and bind too — see below. |
|
||
| `log` | silent | Any object with `debug`, `error`, `info`, `verbose` and `warn` methods — see [Logging](#logging). |
|
||
| `signal` | — | An `AbortSignal` that cancels connecting and tears the session down. |
|
||
|
||
`reconnect: { fromStart: true }` puts the very first connect and bind through that same loop, so a
|
||
client started while its SMSC is down keeps retrying — a bind the SMSC refuses included — instead of
|
||
failing on the first attempt. `client()` then resolves once it is bound, and nothing but an aborted
|
||
`signal` ends the wait, however long the SMSC stays down. That signal also closes the session once it
|
||
is bound, so write a deadline for the wait as an `AbortController` you stop arming when `client()`
|
||
returns, rather than as `AbortSignal.timeout(ms)`, which would close the session it just bound.
|
||
|
||
### Sending
|
||
|
||
```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',
|
||
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
|
||
```
|
||
|
||
`sourceAddrTon` and `destinationAddrTon` default to 5 for an alphanumeric address and 1 for a
|
||
numeric one; the NPI fields default to 0. Set them for an operator that requires something else.
|
||
|
||
Messages too long for one SMS are split automatically and sent as a concatenated message. You get
|
||
one id per segment:
|
||
|
||
```javascript
|
||
const { err, pduObjs, smsIds, unanswered } = await session.sendSms({ from, message, to });
|
||
```
|
||
|
||
`err` is set when the SMSC refuses a segment, and it names the status it refused with. Because every
|
||
segment goes on the wire together, `pduObjs` and `smsIds` then hold what the SMSC did accept — enough
|
||
to reconcile against a later receipt, not enough to resend the rest, so treat a partial failure as a
|
||
failed message. `unanswered` counts the segments that went out and were never answered: the SMSC may
|
||
have taken each of them and lost only the response, so a message with `unanswered` above zero cannot
|
||
be sent again without risking a duplicate, however empty `smsIds` is. A message needing more than 255
|
||
segments is refused before anything is sent, since the concatenation header numbers segments in a
|
||
single octet. `maxSegments` lowers that ceiling:
|
||
most handsets and SMSCs stop well short of 255, and refusing beats a message only half delivered.
|
||
|
||
### Receiving
|
||
|
||
A `receiver` or `transceiver` client gets mobile-originated messages as `sms` events — the same
|
||
handle the server side gets, answered the same way:
|
||
|
||
```javascript
|
||
session.on('sms', async sms => {
|
||
// sms.from, sms.to, sms.message
|
||
await sms.sendResp();
|
||
});
|
||
```
|
||
|
||
Delivery receipts travel on the same SMPP command but reach you as `dlr`, so nothing you write has
|
||
to tell the two apart. `esm_class` is what tells them apart; where it names no message type a
|
||
`receipted_message_id` TLV does, and failing both the message body is read for the standard
|
||
`id:` and `stat:` receipt fields. That 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 the SMSC reporting as well, not an inbound message.
|
||
|
||
Matching a receipt to a send means comparing `dlr.smsId` against the `smsIds` that `sendSms()`
|
||
returned. 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 quietly matches nothing at all. Name each notation and both ids are read into plain decimal
|
||
before you see them:
|
||
|
||
```javascript
|
||
const { err, session } = await client({ smsIdFormat: { receipt: 'decimal', submitResp: 'hex' } });
|
||
```
|
||
|
||
`receipt` is the notation of the receipt body's `id:` field, `submitResp` that of the `message_id`
|
||
a `submit_sm_resp` carries — and of a receipt's `receipted_message_id` TLV, which is that same id.
|
||
An id that is not a number in the notation given is left exactly as it arrived, and the PDUs carry
|
||
what the peer wrote either way — `pduObjs` from the send, and the second argument of the `dlr` event.
|
||
|
||
## Server
|
||
|
||
The simplest possible server — no authentication, listening on port 2775:
|
||
|
||
```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 => {
|
||
// Responding is part of the protocol, not optional. Without arguments it answers
|
||
// ESME_ROK with a generated id; pass your own, and a status to refuse the message.
|
||
await sms.sendResp();
|
||
// await sms.sendResp({ smsId: yourOwnId, status: 'ESME_RMSGQFUL' });
|
||
|
||
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
|
||
```
|
||
|
||
`sendDlr` accepts `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`,
|
||
`ACCEPTED`, `UNKNOWN`, `REJECTED` and `SKIPPED`. `SCHEDULED` and `ENROUTE` go out as intermediate
|
||
delivery notifications (`esm_class` 0x20), the rest as delivery receipts (0x04).
|
||
|
||
A message whose `data_coding` says 8-bit binary arrives as Latin-1, so `Buffer.from(sms.message,
|
||
'latin1')` gives you back the original octets.
|
||
|
||
### Server options
|
||
|
||
| Option | Default | |
|
||
| --- | --- | --- |
|
||
| `host` / `port` | all interfaces / `2775` | Where to listen. Pass `0` for any free port. |
|
||
| `authenticate` | accept everything | `({ password, session, systemId, systemType }) => false \| { userData }`, sync or async. |
|
||
| `systemId` | `''` | The SMSC identity returned to the ESME in the bind response. |
|
||
| `interfaceVersion` | `0x34` | The SMPP version advertised in the bind response. The floor for sending a peer optional parameters stays `0x34`, whatever this is set to. |
|
||
| `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. |
|
||
| `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 | |
|
||
|
||
### Bind direction
|
||
|
||
The three bind types are honoured in both directions, not just accepted. A receiver-bound ESME
|
||
carries no `submit_sm` and a transmitter-bound one is sent no `deliver_sm`, whichever end of the
|
||
link the session is:
|
||
|
||
- `session.sendSms()` on a receiver-bound session, and `sms.sendDlr()` to a transmitter-bound peer,
|
||
fail with an `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`.
|
||
|
||
A `transceiver` bind, the default, carries both. `session.send()` stays a low-level passthrough and
|
||
is not checked, so the raw surface can still put whatever a test or a proxy needs on the wire.
|
||
|
||
## Errors
|
||
|
||
Nothing in this library throws. Every fallible call returns a result carrying an optional `err`, so
|
||
failures are handled in one place instead of two:
|
||
|
||
```javascript
|
||
const { err, session } = await client({ host: 'smpp.somewhere.com' });
|
||
if (err) return;
|
||
|
||
const { err: sendErr, smsIds } = await session.sendSms({ from, message, to });
|
||
```
|
||
|
||
Runtime failures on a live connection arrive as `sessionError` and `serverError` events. They are
|
||
deliberately not called `error`: Node turns an unhandled `error` event into a thrown exception, which
|
||
is exactly what this library promises not to do.
|
||
|
||
`sessionError` carries two kinds of failure, and `PduRefusedError` is what separates them:
|
||
|
||
- **A PDU the peer sent that the codec could not read with the stream still in sync.** The link is
|
||
healthy and only that one PDU is lost, so this is the kind to count rather than alert on.
|
||
- **Everything else**: the session or the socket failing, and a hook or listener that threw or, if it
|
||
was `async`, rejected.
|
||
|
||
```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('the session failed', { message: err.message });
|
||
});
|
||
```
|
||
|
||
`reason` is `command`, `body` or `tlvs`, naming the part the codec stopped at, and `header` is the 16
|
||
octets that did parse: `cmdId`, `cmdLength`, `cmdName`, `cmdStatusId` and `seqNr`. `cmdName` is
|
||
undefined where the command id names no command this library knows — `PduHeader` is its type, for a
|
||
TypeScript consumer passing it on.
|
||
|
||
A refused inbound `deliver_sm` is lost traffic: a message or a receipt that never arrives as `sms` or
|
||
`dlr`, and this event is where that loss shows up. A refused *response* is reported twice where a
|
||
call is still waiting on it, once as the `err` that `sendSms()` or `send()` returns and once here.
|
||
That is deliberate: the call answers what became of that one send, and the event is what shows a peer
|
||
answering unreadably at all.
|
||
|
||
## 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 and every dynamic value goes 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, forwarding wherever you want it:
|
||
|
||
```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); },
|
||
};
|
||
```
|
||
|
||
TypeScript users can import `SmppLog` to have the compiler check one.
|
||
|
||
## Sessions
|
||
|
||
### Events
|
||
|
||
| Event | Fires when |
|
||
| --- | --- |
|
||
| `sms` | An SMS arrives, reassembled if it was multipart. Carries `sendResp()`, `sendDlr()` and its `smsId`. |
|
||
| `dlr` | A delivery report arrives, one per segment. `intermediate` is true where the report is not final: the SMSC either marked it an intermediate notification, or reported `ENROUTE` or `SCHEDULED`. `smsId` is undefined when the peer marked a receipt whose body carries no readable id. `statusMsg` names `statusId` unless the peer sent a `message_state` this library cannot name — then `statusId` is that raw value and `statusMsg` is whatever the body said, or `UNKNOWN`. |
|
||
| `messageDlr` | Every segment of a multipart message sent with `dlr: true` has been reported on, carrying the worst status of the segments. A report carrying `intermediate` never counts towards it. Merging needs the SMSC to number its segment ids `<base>-<n>`, which is 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 on through `dlr` alone, and an earlier one still collecting loses its merged report as well. |
|
||
| `close` | The session is over, because 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 it. Do not open a replacement client here — the session you hold 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, including a hook or listener that threw or, if it was `async`, rejected. Fires for each PDU the codec refused as well, carrying a `PduRefusedError` while the link carries on: a refused request is answered with the status SMPP names, and a refused response is answered with nothing and settles the request it named as `unanswered`. [Errors](#errors) tells the two kinds apart. |
|
||
| `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()`. Both `close()` and `unbind()`
|
||
refuse further sends, wait out the requests this end already sent for up to `shutdownTimeout`, and
|
||
then tear down whatever is left, resolving to an `err` that says what was lost. They also wait for
|
||
every `sms` still in the application's hands, so a peer whose `submit_sm` is being handled is
|
||
answered rather than left to re-send it. That wait ends when `sendResp()` puts the response on the
|
||
wire, or when every listener that took the message has failed; answering its PDUs through
|
||
`sendReturn()` instead leaves the wait running until it gives up. `sendDlr()` is the one send the
|
||
refusal lets past, and it catches the wait when issued straight after `sendResp()`; await anything in
|
||
between and it races the shutdown like any other send. `close({ signal })` takes an `AbortSignal`
|
||
that cuts the wait short; `unbind()` takes none, and waits a further
|
||
`responseTimeout` for its own response. `send()` reaches any of the 33 SMPP commands the codec
|
||
knows, not just the four the session handles natively:
|
||
|
||
```javascript
|
||
const { err, pduObj } = await session.send({
|
||
cmdName: 'query_sm',
|
||
params: { message_id: smsId },
|
||
});
|
||
```
|
||
|
||
A send issued while the link is down waits for the reconnect instead of failing, 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 is the other case: the SMSC may have taken it and lost only the response,
|
||
so it fails, and `sendSms()` and `sms.sendDlr()` count it in `unanswered`, whether the link dropped
|
||
under it, the peer never answered in time, or you aborted it after it went out. Neither applies with `reconnect: false`,
|
||
where a drop ends the session and every send after it is refused.
|
||
|
||
An answer cannot wait for a link that way, because it carries the sequence number the message arrived
|
||
on: `sms.sendResp()` on a message whose link dropped writes nothing and returns an `err`. Where a
|
||
reconnect follows, its `sms.sendDlr()` still goes out on the new link, since a receipt is a request
|
||
of its own, correlated by the id it names.
|
||
|
||
`responseTimeout` bounds the wait for a link and the wait for an answer separately, and a send also
|
||
queues for a `maxOutstanding` slot, which nothing bounds — so it is not a deadline for the call.
|
||
Pass `{ signal: AbortSignal.timeout(ms) }` when you need one.
|
||
|
||
`acceptsOptionalParams()` answers whether the peer declared SMPP 3.4 or later, which is the version
|
||
at and above which the spec allows optional parameters to be sent to it; `peerInterfaceVersion` is
|
||
the version it declared, `0x00` if it declared none. The library's own senders consult the first before attaching a TLV — a
|
||
`send()` you build yourself is passed through as written, so consult it too when you attach TLVs.
|
||
|
||
`bindAllows(cmdName)` answers the same question for the bind direction, and `boundAs` is the role
|
||
the ESME bound with — see [Bind direction](#bind-direction).
|
||
|
||
## Beyond the PDU codec
|
||
|
||
Encoding and decoding PDUs is the easy half of SMPP. The session layer is the half usually written
|
||
by hand on top of a library; it is built in here.
|
||
|
||
| | |
|
||
| --- | --- |
|
||
| **Keepalive** | `enquire_link` every 20 s on a quiet link, and a peer that stops answering is dropped. |
|
||
| **Reconnect with backoff** | A dropped client link reopens the socket and re-binds by default, 1 s doubling to 30 s. |
|
||
| **Submit window** | `maxOutstanding` holds requests in flight at 10; further sends queue instead of overrunning the SMSC. |
|
||
| **Delivery receipts** | Correlated by `receipted_message_id`/`message_state` where the SMSC sends them, falling back to parsing the receipt text — what Kannel and several others send. |
|
||
| **Multipart** | Long messages split on send; concatenated `deliver_sm` reassembled into one `sms`. |
|
||
| **Graceful shutdown** | `close()` and `unbind()` wait out the requests this end already sent and the messages the application has not answered yet, so neither end has to guess whether a message got through. |
|
||
| **Never throws** | Everything fallible resolves to `{ err?, … }`, the codec included. |
|
||
|
||
Throughput throttling is deliberately absent: an operator's rate limit is scoped to the account, and
|
||
enforcing it needs state shared across every process bound to that account, which a library holding
|
||
everything in memory cannot provide.
|
||
|
||
## Working with PDUs directly
|
||
|
||
The codec is exported, synchronous, and never throws — handy for inspecting captured traffic:
|
||
|
||
```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
|
||
}
|
||
```
|
||
|
||
`params.short_message` is decoded with the PDU's own `data_coding`; `shortMessageOctets` is that
|
||
same field exactly as it arrived.
|
||
|
||
The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `errors`, `tlvs`,
|
||
`types`, and the matching `*ById` maps) and grouped as `defs`.
|
||
|
||
## Migrating from larvitsmpp 0.4.0
|
||
|
||
Successor to [larvitsmpp](https://www.npmjs.com/package/larvitsmpp) 0.4.0. The API is the same shape
|
||
it has always been — connect, send an SMS, listen for delivery reports — with callbacks replaced by
|
||
promises and the rough edges taken off.
|
||
|
||
- **The package is now `@larvit/smpp`** and is ESM only. `require()` no longer works.
|
||
- **Callbacks are gone.** `client`, `server`, `sendSms`, `sendResp`, `sendDlr`, `unbind` and
|
||
`session.close` are all promises resolving to a result object with an optional `err`. Nothing
|
||
rejects. Await `close()` or the socket outlives the call.
|
||
- **`server()` resolves once, when it is listening**, and gives you a handle with `close()`, `port`
|
||
and a `session` event. It no longer calls your callback once per incoming connection.
|
||
- **The id a message is answered with goes to `sendResp({ smsId })`**, and `sms.smsId` is read-only:
|
||
it reports the id `sendResp()` was given, or the UUID v7 generated instead. Delete any
|
||
`sms.smsId = …` line — assigning to it
|
||
throws a `TypeError`, since modules are always strict mode — and pass the id to `sendResp()`.
|
||
- **`checkuserpass` is now `authenticate`**, takes `{ password, session, systemId, systemType }` and
|
||
returns `false` or `{ userData }`.
|
||
- **Renamed options:** `enqLinkTiming` → `enquireLinkInterval`, server `timeout` → `idleTimeout`.
|
||
- **`larvitsmpp.utils` is gone.** Its contents are named exports: `bitCount`, `decodeMessage`,
|
||
`encodeMessage`, `objToPdu`, `pduReturn`, `pduToObj`, `smppDate`, `smppTime`, `splitMessage`. The
|
||
PDU codec is synchronous and returns `{ err, pduObj }` / `{ err, buffer }`.
|
||
- **`pduObj.isResp()` is now the standalone `isResp(pduObj)`**, and `pduObj.cmdStatus` is `undefined`
|
||
for a status code the library does not know, with the raw number in `pduObj.cmdStatusId`.
|
||
- **`defs.filters` is gone.** It was declared on every command and TLV but never invoked, so it did
|
||
nothing. SMPP time formatting, the one part worth keeping, is exported as `smppTime`.
|
||
- **The `error` event is `sessionError`** (and `serverError` on the server handle).
|
||
- **`log`** takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods instead of a
|
||
`larvitutils` one, and is silent by default. See [Logging](#logging).
|
||
|
||
### Behaviour that changed on the wire
|
||
|
||
0.4.0 had a number of protocol defects. Fixing them changes the bytes it puts on the wire, so if you
|
||
have worked around any of these, remove the workaround:
|
||
|
||
- Every multipart segment was one character short (152 GSM characters instead of 153, 66 UCS2
|
||
instead of 67), so long messages were split into more segments than necessary — and each extra
|
||
segment costs a message.
|
||
- LATIN1 (`data_coding` 0x03) was silently decoded as ASCII, corrupting the message.
|
||
- Delivery receipt dates were a month off, and the status field read `UNDELIVERABLE` where the spec
|
||
defines the 7-character `UNDELIV`.
|
||
- Every receipt went out as `esm_class` 0x04, which SMPP 3.4 defines as the report of a message's
|
||
final state. A receipt for a transient state — `sendDlr('ENROUTE')` — is now marked 0x20, the
|
||
intermediate delivery notification.
|
||
- `flash: true` discarded UCS2, mangling flash messages containing non-GSM characters.
|
||
- The multipart reference counter was shared by every session in the process.
|
||
- `tls: true` never performed a handshake, so the connection was not actually encrypted.
|
||
- Alphanumeric senders were sent with TON 1 (international) instead of TON 5.
|
||
- Delivery receipts carrying only the standard receipt text, with no TLVs — what Kannel and several
|
||
other SMSCs send — were rejected outright. They are now parsed.
|
||
- A message whose last octet was `0x00` was allocated one octet short while `sm_length` still
|
||
reported the full length, so it went out corrupt. In UCS2 that is any message ending in a
|
||
character like 一 (U+4E00), which made the bug routine for CJK text.
|
||
- Binary TLVs (`message_payload`, `network_error_code`, `callback_num` and the rest) were parsed
|
||
into a hex string and written back as the ASCII of that string, so every one that made a round
|
||
trip went out corrupt. They are `Buffer`s in both directions now, so drop any hex encoding of
|
||
your own.
|
||
- Short or malformed PDUs threw out of the codec instead of being reported as a parse failure.
|
||
- Binds now declare `interface_version` 0x34. 0.4.0 declared 0x00, which tells the SMSC the ESME
|
||
speaks SMPP 3.3 or earlier — and a spec-following SMSC then withholds every optional parameter,
|
||
including the TLVs delivery receipts are carried in.
|
||
- A response reporting a failure now carries no body, which is what the spec defines and what other
|
||
implementations send. 0.4.0 filled the body with empty defaults, so a refused `submit_sm_resp` went
|
||
out with an empty `message_id` a caller could mistake for a real one.
|
||
- `submit_multi` was missing its `sm_length` field, so its `short_message` never round-tripped.
|
||
|
||
The corrected framing is cross-checked against [node-smpp](https://github.com/farhadi/node-smpp), an
|
||
independent implementation, in both directions and over a live session.
|
||
|
||
## 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 additionally compiles and runs them on Node 18, 20, 22 and 24 to verify the
|
||
supported range.
|
||
|
||
## License
|
||
|
||
MIT
|