@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_linkevery 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/.
Install · Send an SMS · Delivery reports · Receive SMS · Run an SMPP server · Errors · Client options · Server options · Send options · Session · Receiving in depth · Server in depth · Logging · PDUs and the low-level API · Migrating from 0.4.0 · Development
Install
npm install @larvit/smpp
Node 18 or later. ESM only, types included.
Send an SMS
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. A message
longer than one SMS is split and sent as one concatenated message: Send options.
Delivery reports
Connection parameters, a receipt per segment, and logging:
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.
Receive SMS
A receiver or transceiver client gets mobile-originated messages as sms events:
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.
Run an SMPP server
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:
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()answersESME_ROKwith a generated UUID v7 as the message id.sendResp({ smsId, status })names the id or refuses the message.sms.dlris true where the sender asked for a receipt.sendDlr()reportsDELIVERED,sendDlr('UNDELIVERABLE')any other state: Server in depth.- A message that arrived in several segments was answered as they arrived, so
sendResp()there takes nosmsIdor refusingstatus.sms.answeredOnArrivalsays 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:
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:
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 });
});
reasoniscommand,bodyortlvs: the part the codec stopped at.headeris the 16 octets that did parse:cmdId,cmdLength,cmdName,cmdStatusIdandseqNr.cmdNameis undefined for a command id this library does not know.PduHeaderis 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_smis lost traffic: a message or receipt that never arrives assmsordlr. A refused response is reported twice, as theerrof thesendSms()orsend()waiting on it and here. - A
PduRefusedErroris always a PDU that arrived. What this library refuses to build or send (an alphabet, a time, a body itsdata_codingcannot carry) is a plainErrorin 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. |
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. |
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. |
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
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:
ASCIIwhere the message fits GSM 7-bit, otherwiseUCS2.LATIN1only 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 inUCS2. - 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.
LATIN1carries every octet, sobuffer.toString('latin1')reaches the SMSC byte for byte, underdata_coding0x03, which declares Latin-1 text. To declare 8-bit binary, handsession.send()aBufferbody and thedata_codingyou want: PDUs and the low-level API.consts.ENCODINGis the low-leveldata_codingtable, not this option's list.
Long messages.
const { err, pduObjs, smsIds, unanswered } = await session.sendSms({ from, message, to });
- One id per segment.
smsIdsis positional withpduObjs, and an entry isundefinedwhere 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. erris set when the SMSC refuses a segment, naming the status. Every segment goes out together, sopduObjsandsmsIdsthen hold what was accepted: enough to reconcile a later receipt, not enough to resend the rest. Treat a partial failure as a failed message.unansweredcounts segments that went out and were never answered. The SMSC may have taken each and lost only the response, so a message withunansweredabove 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.
maxSegmentslowers 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. |
messageDlr |
Every segment of a long message sent with dlr: true has a final report: 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. |
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:
- Refuse further sends.
sendDlr()is the one send let past, when issued straight aftersendResp(); await anything in between and it races the shutdown like any other send. - Wait up to
shutdownTimeoutfor the requests already sent, and for everysmsthe application has not answered. That wait ends whensendResp()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 throughsendReturn()instead leaves the wait running. - Tear down what is left, resolving to an
errthat 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: falsea drop ends the session, and every send after it is refused. sms.sendResp()on a message whose link dropped writes nothing and returnserr, since a response carries the sequence number of the link it arrived on.sms.sendDlr()still goes out on the new link.responseTimeoutbounds the wait for a link and the wait for an answer separately, and the wait for amaxOutstandingslot 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 tounanswered. 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_RTHROTTLEDreaches you as a send'serr.
Raw commands. send() reaches all 33 SMPP commands, not just the four the session handles itself:
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; asend()you build is passed through as written, so check it yourself.peerInterfaceVersion: the version the peer declared,0x00if none.bindAllows(cmdName)andboundAs: what the bind direction carries: Bind direction.
Receiving in depth
- Multipart. Segments tied together by a user data header, or by the
sar_msg_ref_num,sar_total_segmentsandsar_segment_seqnumTLVs, reassemble into onesmsalike. 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.
- Where the body is. A body in the
message_payloadTLV, SMPP's way of carrying up to 64 KB and the only place adata_smhas, reads exactly like one inshort_message, concatenated messages and receipts included. A PDU filling both is read fromshort_message. data_sm. A client reads an inbounddata_smas a delivery: a message arrives assms, a receipt asdlr. Aserver()session reads it as a submission and always emitssms. Either way it is answereddata_sm_resp.- Flash.
sms.flashis true wheredata_codingcarries GSM 03.38 message class 0, in every coding group that carries one:0x10,0x18,0x50and0xF0alike. Classes 1 to 3 name where the handset stores the message and are not flash. - Binary. A message whose
data_codingsays 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:
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 returnserrfor ansmsIdor a refusingstatus. sms.smsIdis the base.sendDlr()names<smsId>-1,<smsId>-2and so on: the ids thesubmit_smresponses carried.- A
deliver_smis 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:
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 runsauthenticateitself. - A hook that throws or rejects reaches
sessionError, and nothing is written for that request; the peer's own response timeout settles it.authenticatefails the same way, leaving the bind unanswered. enquire_linkandunbindreach the hook too, and an unansweredenquire_linkhas the peer drop the link. Guard on the command name, as above, and a failing hook costs only its own request.- A
Sessionyou 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, andsms.sendDlr()to a transmitter-bound peer, returnerrbefore anything reaches the wire.- A
submit_smarriving on a receiver-bound session, or adeliver_smon a transmitter-bound one, is answeredESME_RINVBNDSTS. data_smcarries a message either way, so which end the session is decides: a client refuses one on a transmitter bind, aserver()session on a receiver bind.bindAllows('data_sm')answers for the inbound direction. ASessionyou construct yourself is the ESME end, asclient()builds; a hand-wired SMSC setssession.linkEnd = 'smsc', asserver()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 implements it as it stands:
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:
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:
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_messageis decoded with the PDU's owndata_coding;shortMessageOctetsis that field as it arrived. Neither holds a body carried in themessage_payloadTLV, which adata_smalways 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): thepart,totalandreferencea PDU declares and thespellingthat carried them,'udh'or'sar', orundefinedfor a whole message.messageClassOf(dataCoding):0for the flash class,1,2and3for the ME-, SIM- and TE-specific ones,undefinedwhere thatdata_coding's coding group carries no class.
Building.
- A string
short_messageormessage_payloadis encoded in the alphabet the PDU'sdata_codingnames, 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
Buffergoes out exactly as given under anydata_coding: binary payloads, hand-built user data headers, deliberately malformed bodies. session.send()andsession.sendReturn()build through the same codec and refuse the same bodies.unencodable(message, encoding):{ char, index }for the first character an alphabet cannot carry,undefinedwhere it carries them all. The checksendSms()makes before encoding.dataCodingByEncoding[encoding]: thedata_codingthis library writes each alphabet under, which is what to put beside octets fromencodeMessage().smppTime.encode(value)returns{ err, text }for avalidity_periodorschedule_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.
Development
Everything runs in the container; nothing is installed on the host.
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