lilleman ca1a7473ed Declare the alphabet a GSM message is actually written in (#100)
* Regression tests for the alphabet a GSM message declares

* Declare the alphabet a GSM message is actually written in

* Reflow the composing-by-hand paragraph

* Drop the ASCII alias SMPP's flat table shares with the option's own

* Bound the receipt test's wait and tighten the notes around it
2026-09-09 21:30:55 +02:00

@larvit/smpp

A simplified implementation of the SMPP protocol, in TypeScript. ESM only, types included.

Requirements

Node 18 or later. No runtime dependencies.

Install

npm install @larvit/smpp

Client

The simplest possible client — connects to localhost:2775 with no credentials and sends a message:

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:

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.
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

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

One rule decides which choices of yours this library second-guesses, here and on session.send(): it checks what it composes for you — an alphabet or a time you named, a string body under a data_coding you named — and passes on whatever you formed yourself, a Buffer body or a stamp you formatted.

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.

messagingMode names the esm_class messaging mode: SMSC_DEFAULT, which is what an omitted option sends, or DATAGRAM or STORE_FORWARD. Every segment of a long message carries the user data header indicator beside it, so an operator that requires esm_class 0x43 on a concatenated message gets exactly that from STORE_FORWARD. SMPP carries transaction mode on data_sm, which this never sends, so reach for one of the three above rather than consts.MESSAGING_MODE.FORWARD. Datagram mode defines the delivery report away, so pair it with dlr: true and the send is refused rather than leaving you waiting for a report that cannot come — as is any value naming no mode, both before a segment goes out.

encoding names the alphabet, and is ASCII (GSM 03.38's own 7-bit table), LATIN1 or UCS2; anything else is refused by name rather than guessed at. Leave it out and a message that fits GSM 7-bit goes as ASCII and everything else as UCS2; LATIN1 is only ever used when you name it. consts.ENCODING is the low-level surface's data_coding table rather than this option's list, FLASH and the alphabets no codec here implements included.

An alphabet you name has to carry the message: LATIN1 beside a character above U+00FF, or ASCII beside one GSM 03.38 has no code for, is refused before anything goes out, and the error names the character, its code point and where in the message it is. Leaving encoding out is never refused — what detection picks always fits. LATIN1 carries every octet, so an 8-bit body passed as buffer.toString('latin1') reaches the SMSC byte for byte — but under data_coding 0x03, which declares Latin-1 text rather than binary. To declare 8-bit binary, hand session.send() a Buffer body and the data_coding you want; see Working with PDUs directly.

scheduleDeliveryTime and validityPeriod take a Date, a number of seconds, or a stamp you formatted yourself. One that names no time — an invalid Date, NaN, Infinity — is refused before anything goes out, and so is a negative number of seconds or one past 99 days 23:59:59: a period counted in seconds is spelled in days and below, since no fixed number of them is a month or a year. Name an instant further out as a Date, which goes out absolute.

flash asks for GSM 03.38 message class 0, the class a handset shows on arrival instead of storing. It travels in data_coding beside the alphabet, so a flash UCS2 message stays UCS2. Pairing it with encoding: 'LATIN1' is the one combination with nowhere to go — a message class carries GSM 7-bit, 8-bit data or UCS2, and 8-bit data is not text a handset will display — so the send is refused before anything goes out.

Messages too long for one SMS are split automatically and sent as a concatenated message. One SMS holds 160 characters as ASCII, 140 as LATIN1 or 70 as UCS2, and the concatenation header a longer message needs takes room off each segment: 153, 134 and 67. GSM's extension characters ({}[]\~^|€ and form feed) count as two, as does a character outside the basic multilingual plane in UCS2. You get one id per segment:

const { err, pduObjs, smsIds, unanswered } = await session.sendSms({ from, message, to });

smsIds is positional with pduObjs, and an entry is empty where the SMSC accepted the segment without naming an id for it — some name one for the first segment only. No receipt ever matches an empty entry.

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. That includes a multipart message: it was already answered segment by segment before you see it, which changes what sendResp() does there — see Server.

session.on('sms', async sms => {
	// sms.from, sms.to, sms.message
	await sms.sendResp();
});

sms.flash is true where the message's data_coding carries GSM 03.38 message class 0, in every coding group that carries one — so 0x10, 0x18, 0x50 and 0xF0 alike. The other three classes name where the handset stores the message rather than that it displays it, so they are not flash.

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. stat:FAILED, which several operators write and SMPP does not define, reads as UNDELIVERABLE; dlr.receipt.stat carries the code the SMSC wrote.

Where the body sits, and which command carried it, changes none of that. An SMSC that leaves sm_length 0 and puts the body in the message_payload TLV — SMPP's way of carrying up to 64 KB, and the only place a data_sm has for one — reads exactly like one that fills short_message, concatenated messages and receipts included. A peer that fills both is read from short_message. data_sm itself is a peer of both deliver_sm and submit_sm, and its direction says which: a client reads one as a delivery, so a message on it arrives as sms and a receipt as dlr, while a server() session reads one as the submission it is and always hands it to you as sms. Either way it is answered data_sm_resp.

Nor does the way a peer ties a long message's segments together. A user data header at the start of the body and the sar_msg_ref_num, sar_total_segments and sar_segment_seqnum TLVs are the two spellings of the same thing, and either reassembles into one sms. A header that numbers the segment is what a PDU carrying both is read from, and the two reference numbers are counters of their own — the same number in each is two different messages.

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:

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:

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

A message that arrived in several segments was already answered when you see it: each segment is answered as it lands, because a relaying SMSC will not send the next one until the last is answered. That 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 are holding was answered that way — a segment count cannot, since a peer may number a concatenated message one part of one. The id was fixed with the first segment, so sendResp() there only says you are done with the message, and returns an err for an smsId or a refusing status; choosing the id and refusing the message belong to a message sendResp() still answers itself. sms.smsId is the base either way, and sendDlr() names <smsId>-1, <smsId>-2 and so on — the ids a submit_sm's 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.

A refusal that depends on the request rather than the reassembled message — a full queue, an unknown recipient, an unauthorised sender — needs to land before a segment is answered, which sendResp() can no longer do once it has. onRequest decides there instead, 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;

Returning true means the hook has answered the PDU and the library leaves it alone; false lets the built-in handling — reassembly, the sms event — run as usual. Every segment of a concatenated message is a request of its own, so the hook sees each one while refusing it still means something. No bind reaches it, and neither does anything a peer sends before one: server() answers those and runs authenticate itself, so a hook cannot intercept a bind however it is written.

A hook that throws or rejects reaches sessionError, and nothing else is written for that request: the library cannot tell a hook that failed before answering from one that failed after, and a second response on the peer's sequence number would be worse than none. The peer's own response timeout settles it, so a hook that must reach a decision either way makes that decision itself. authenticate fails the same way, leaving the bind itself unanswered.

enquire_link and unbind reach the hook too, where failing costs more than the one request a response timeout settles: an unanswered enquire_link has the peer drop the link at its own idle timer, and an unanswered unbind skips the close this end would have run on it. Guard on the command name, as the example above does, and a hook that fails takes nothing but its own request with it.

A Session you construct yourself (see Bind direction) takes the same hook as a session option, and handles a failing one the same way; it is also where a peer's bind gets accepted, since a hand-wired session has no bind handling of its own.

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.
onRequest none (session, pduObj) => true | false, sync or async. First refusal on every request a bound peer sends; no bind, and nothing before one, reaches it.
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 data_sm carries a message either way, so which end the session is decides what its bind forbids: a client refuses one on a transmitter bind, a server() session on a receiver bind. bindAllows('data_sm') answers for the direction that reaches this session, since the library sends none. A Session you construct yourself is the ESME end, which is what client() builds; a hand-wired SMSC sets session.linkEnd = 'smsc', as server() does.

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:

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 three kinds of failure, and PduRefusedError separates the first from the rest:

  • 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.
  • A concatenated message given up on before it was whole. Its arrived segments were answered, so the peer will not send them again. Also a counting kind: no sms event ever fired for it, so there is nothing to act on beyond knowing traffic was lost.
  • Everything else: the session or the socket failing, and a hook or listener that threw or, if it was async, rejected.

The last two are both a plain Error, told apart from each other by their message text alone, so the example below alerts on a lost concatenated message as well as on a session that failed.

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, 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. A PduRefusedError is always a PDU that arrived, so that is the only way one reaches a send's err: what this library refuses to build or send — an alphabet, a time, a body a data_coding cannot carry — is a plain Error in the result.

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 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, forwarding wherever you want it:

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. A multipart one was answered as its segments arrived — see Server.
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 names its three kinds, of which PduRefusedError separates one.
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, for a message whose segments were answered as they arrived, when it is called at all — or when every listener that took the message has failed; answering its PDUs through sendReturn() instead leaves the wait running until it gives up. A session holds at most 1000 unanswered messages, 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. 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:

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: 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 there are slots goes out a slot at a time and still completes, so a deadline tight enough to expire mid-message is how you produce the partial failure above.

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.

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:

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. Neither holds the body of a PDU that carried it in the message_payload TLV instead, which a data_sm always does — messageOctets(pduObj) is the one answer to which of the two the peer used, and gives back the octets 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) is the same for concatenation: the part, total and reference a PDU declares, and the spelling'udh' or 'sar' — that carried them, or undefined where the PDU is a whole message. messageClassOf(dataCoding) is the same for the GSM 03.38 message class: 0 for the flash class sms.flash already reports, 1, 2 and 3 for the ME-, SIM- and TE-specific ones, and undefined where that data_coding's coding group carries no class at all.

Building a PDU is the mirror: a string short_message, or a string message_payload, is encoded in the alphabet the PDU's own data_coding names, detected from the text where you name none, and a data_coding whose alphabet cannot carry one of its characters is refused, naming the character, its code point and where it is. A Buffer goes out exactly as given under any data_coding, which is how binary payloads, hand-built user data headers and deliberately malformed bodies are sent. session.send() and session.sendReturn() build through the same codec and refuse the same bodies.

Composing a message by hand takes the send-side pair: unencodable(message, encoding) gives { char, index } for the first character an alphabet cannot carry and undefined where it carries them all, which is the check sendSms() makes before it encodes anything; dataCodingByEncoding[encoding] is the data_coding this library writes each alphabet under, which is what to put beside octets encodeMessage() handed back; smppTime.encode(value) returns { err, text } for a validity_period or schedule_delivery_time, as smppTime.decode() returns { err, date } for one that 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 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 the segments were answered with, 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: enqLinkTimingenquireLinkInterval, server timeoutidleTimeout.
  • 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.
  • DATAGRAM, FORWARD and STORE_FORWARD moved from consts.ESM_CLASS to consts.MESSAGING_MODE, which also names the fourth mode, SMSC_DEFAULT. They are bits 1-0 of esm_class rather than whole values of it. Read them from the new group, or let sendSms() write one for you as messagingMode; a stale consts.ESM_CLASS.STORE_FORWARD now reads undefined, which OR-s into an esm_class that silently carries no mode at all.
  • 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.
  • consts.ENCODING.ASCII is gone; the same entry is consts.ENCODING.IA5, the other name SMPP 3.4 5.2.19 gives 0x01. Reach for dataCodingByEncoding where you meant the alphabet sendSms() writes — that is 0x00, not this.

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, and put the GSM alphabet on a Latin-1 message that has no data_coding at all — that pair is refused now. Inbound, only a data_coding of exactly 0x10 counted as flash, so a flash UCS2 message and the whole 0xF0 coding group arrived as ordinary messages.
  • A GSM 03.38 message declared data_coding 0x01, which SMPP 3.4 5.2.19 defines as IA5 rather than the alphabet those octets are in, so $ and @ reached a peer honouring the field as STX and NUL. It goes out as 0x00, the SMSC default alphabet, and so does a receipt sendDlr() writes. Latin-1 and UCS2 are unmoved at 0x03 and 0x08, and an inbound 0x01 is still read as GSM 03.38.
  • 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.
  • Every response carried a message_id, deliver_sm_resp included, where SMPP 3.4 4.6.2 makes that field unused and NULL. Jasmin closes the connection on one. Answering an inbound message now puts nothing in it, and sms.smsId is the local handle it always was.
  • 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 Buffers in both directions now, so drop any hex encoding of your own.
  • A body carried in the message_payload TLV was ignored, so the message arrived empty, and a data_sm was answered ESME_RINVCMDID, so a receipt thrown on one was lost with nothing said. Both reach the application now — a receipt as dlr, answered for you, and a message as sms for you to answer like any other.
  • A long message whose segments were tied together by the sar_msg_ref_num, sar_total_segments and sar_segment_seqnum TLVs rather than by a user data header was never reassembled, so each segment arrived as its own message. Both spellings reassemble now.
  • Short or malformed PDUs threw out of the codec instead of being reported as a parse failure.
  • A PDU whose optional parameters do not end exactly on command_length is refused with ESME_RINVTLVSTREAM and dropped, where 0.4.0 kept the TLVs it had read and ignored the octets left over — which loses the receipted_message_id that makes a receipt a receipt. The refusal reaches sessionError as a PduRefusedError with reason tlvs, which is what to match on where a peer's traffic goes missing.
  • 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, an independent implementation, in both directions and over a live session.

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 additionally compiles and runs them on Node 18, 20, 22 and 24 to verify the supported range.

License

MIT

S
Description
SMPP 3.4 client and server for Node.js with the session layer built in: keepalive, reconnect, send window, long messages and delivery receipts
https://www.npmjs.com/package/@larvit/smpp Readme MIT 1.4 MiB
Languages
TypeScript 89.4%
Java 5.1%
Python 2.8%
Dockerfile 1.2%
PHP 1.1%
Other 0.4%