@@ -9,8 +9,7 @@ govern it, and nothing here is a source anything else may cite.
## Status
The rewrite is **feature complete and green ** : the suite, lint and typecheck are clean on Node 18
to 26, and 0.5.0 is on npm. What is left is housekeeping around the release, a few things worth
adding, and the gaps a comparison with other SMPP libraries found.
to 26. What is left is release work and a few things worth adding before or after 0.5.0.
## The agreed API
@@ -98,7 +97,7 @@ place issues are filed. Maintainer's calls, 2026-09-13 and 2026-09-14.
- [x] 0.5.0 rather than 1.0.0, while usage is this low. Maintainer's call, 2026-09-14.
- [x] ` NPM_TOKEN`, which ` .gitea/workflows/release.yaml` needs, is a Gitea organization secret.
- [x ] Tag ` v0.5.0` on Gitea to publish. The first publish creates ` @larvit/smpp ` on npm, provided the
- [ ] Tag ` v0.5.0` on Gitea to publish. The first publish creates ` @larvit/smpp ` on npm, provided the
token can publish under ` @larvit `.
- [ ] ` npm deprecate larvitsmpp` pointing at ` @larvit/smpp `. Maintainer's call to run it; not
something CI should do.
@@ -246,6 +245,9 @@ the rewrite, for a dependency added later. Maintainer's call, 2026-09-14.
end to end. The interop suite is the natural place.
- [ ] **Move to TypeScript 7** once ` typescript-eslint` supports it; ` renovate.json` pins TypeScript
below 6.1 for exactly that reason.
- [ ] **Coverage reporting.** ` node --test --experimental-test-coverage` works today; nothing
publishes the numbers.
- [ ] **An ` onReceipt` hook.** Receipt text is only loosely specified and operators disagree on it,
but ` dlrFromPdu()` is wired into ` IncomingRequests` with no seam of its own: an application
facing a format we do not parse has to take the whole PDU on ` onRequest` and reimplement the
@@ -253,186 +255,18 @@ the rewrite, for a dependency added later. Maintainer's call, 2026-09-14.
Mirror the ` onRequest` seam — return a ` Dlr` to own the receipt, ` undefined` to fall through
to the built-in parser.
## Gaps against other SMPP libraries
From comparing 0.5.0 with ` smpp`, ` @semyonf/smpp `, ` @leissner/node -red-smpp`, ` node-smpp-next`,
` smpp-js-sdk`, ` smppjs`, cloudhopper-smpp, jsmpp, go-smpp, Kannel, Jasmin and php-smpp, 2026-09-14.
Each lands under AGENTS.md goal 6: an option or a hook, with the call that passes none unchanged.
### Sending
- [ ] **A limiter hook, with a messages-per-second cap built on it.** Maintainer's call, 2026-09-14,
reversing the earlier decline: Kannel, Jasmin, go-smpp and ` smpp-js-sdk` all limit throughput.
Count PDUs, not ` sendSms()` calls — a long message is one ` submit_sm` per segment and the
operator counts those, which an application wrapping ` sendSms()` cannot see. The hook takes a
limiter the application already runs; the built-in cap counts per session until the store at the
bottom lets it span sessions and processes. The default stays uncapped. Open: the hook's shape (a
wait that resolves when a PDU may go, cut short by the send's ` signal`), which requests it gates
— messages, never ` enquire_link`, ` unbind` or a response — and whether its wait counts against
` responseTimeout`.
- [ ] **Back off and resend on ` ESME_RTHROTTLED`.** The SMSC refused the PDU, so resending cannot
duplicate it and goal 2 holds, and the retry needs nothing wider than the session. Needs a
decision: on by default with a bounded budget, as goal 5 suggests, and whether ` ESME_RMSGQFUL`
counts too. A throttled answer is also what a limiter hook wants to hear about.
- [ ] **` sendSms()` takes the rest of ` submit_sm`.** ` service_type`, ` priority_flag`, ` protocol_id`,
` replace_if_present_flag` and TLVs on every segment, and ` registered_delivery` beyond final
receipts: on failure only, and intermediate notifications. Today each needs ` send()`, which gives
up splitting, the alphabet checks and receipt merging. TLVs are the common case: India's DLT
rules put ` PE_ID` (0x1400) and ` TEMPLATE_ID` (0x1401) on every ` submit_sm`, and USSD rides on
` ussd_service_op`. Refuse a TLV the send composes itself (` sar_*`, ` message_payload`). Open: one
spelling for receipts, since ` dlr: true` and a raw ` registered_delivery` could disagree, and
whether goal 4's rule on optional parameters binds a TLV the caller named.
- [ ] **Choose how a long message is spelled on the wire.** Only an 8-bit UDH reference goes out
(` message.ts`), though the reader takes a 16-bit UDH, ` sar_*` and ` message_payload` alike. Some
SMSCs take only ` sar_*` or ` message_payload`; php-smpp offers all three. A 16-bit reference also
makes a collision rarer: the 8-bit one wraps every 255 sends on a session.
- [ ] **Failover across SMSC hosts.** Maintainer's call, 2026-09-14. ` client()` takes one ` host` and
` port`; Kannel, Jasmin and php-smpp take several. A list the reconnect loop walks holds only
which host the one session is on, so it needs no store. Two options, both maintainer's calls:
- **Order**, ` fixed` or round robin, default ` fixed`. Fixed starts every reconnect at the first
host; round robin at the host after the one the link was last on.
- **Starting over**, a boolean, default on: once the last host has been tried, go back to the
first. Off ends the session once the last host fails, as a drop does under ` reconnect: false`.
Open: how the list and today's ` host` and ` port` share one spelling; whether the backoff grows
per host or per pass; and whether round robin with starting over off still tries the hosts
before the one it started at.
### The server
- [ ] **` sendSms()` on a ` server()` session sends ` submit_sm` toward the ESME.** Kannel answers
` ESME_RINVCMDID` (` interop-tests/kannel.test.ts`, "MO to Kannel"), and goal 1 says that PDU never
goes out. A server has no other way to send an MO message either: ` sendMo()` in that test builds
one from ` submitSmParams()` and ` ConcatReference`, neither exported. Choosing ` deliver_sm` by
` linkEnd` gives MO messages the splitting and checks, keeps one method for one goal, and refuses
the options 3.4 has ` deliver_sm` leave empty (` scheduleDeliveryTime`, ` validityPeriod`).
- [ ] **Error TLVs on a response this library builds.** ` buildBody()` in ` pdu.ts` writes no body for
any non-zero status, so a server cannot answer a ` data_sm` with ` delivery_failure_reason`,
` network_error_code` or ` additional_status_info_text`, and a 5.0 peer gets none of its error TLVs.
3.4 omits the body on error for ` submit_sm_resp` by name; read each response's section before
widening it. Reading needs nothing: an error response carrying a body already parses.
- [ ] **PROXY protocol on ` server()`.** Behind HAProxy or an AWS NLB every session's remote address is
the balancer's, so ` authenticate` cannot allow-list by IP and logs name the wrong peer. v1 is
text; v2 is binary and the only one an NLB sends. ` smpp` accepts v1 from anyone; accept either
only from addresses the option names.
- [ ] **` outbind`.** In the command table, handled nowhere: a client cannot take an SMSC's ` outbind`
and bind back, and ` server()` cannot send one. Rare; take it on with a peer that uses it.
- [ ] **Register vendor-specific commands.** 3.4 reserves ` command_id` ` 0x00010200`– ` 0x000102FF` for
SMSC vendors; today one arrives as a ` PduRefusedError`. ` smpp` has ` addCommand()`. The same
shape question as registering an encoding.
### Encodings
- [ ] **Register a custom encoding.** Maintainer's ask, 2026-09-14. ` EncodingName` is a closed union
of three (` defs/encodings.ts`). An entry needs a name, a ` data_coding`, ` encode`, ` decode`,
` match`, whether ` detect()` may pick it, and enough for ` splitMessage()` to budget a segment
without halving a character. Take encodings as a client or server option rather than mutating a
module table as ` smpp` does, so two sessions in one process cannot disagree about a name. A taken
name is an ` err`. Settle ` consts.ENCODING`'s names first. An entry may claim a ` data_coding` a
built-in already uses, maintainer's call, 2026-09-14: an SMSC whose default alphabet, 0x00, is
Latin-1 needs Latin-1 written and read under it
([#23](https://github.com/larvit/larvitsmpp/issues/23)). The entry then owns that coding on its
session both ways: ` encodingByDataCoding()` resolves to it, ` detect()` tries it in the
built-in's place, and naming the displaced built-in is an ` err` naming the entry. Two entries
on one coding are an ` err`. Settle whether a claim on 0x00 reaches the class groups
` messageClassEncoding()` reads GSM 7-bit from, which ` flash` writes under.
- [ ] **The alphabets SMPP 3.4 names that no encoding carries.** ` consts.ENCODING` lists the
` data_coding` ids (5.2.19); only ` ASCII`, ` LATIN1` and ` UCS2` can be sent. Those with a published
definition, and what each costs:
- 0x01 IA5 (ITU-T T.50, ASCII in practice): trivial.
- 0x06 ISO-8859-5 (Cyrillic) and 0x07 ISO-8859-8 (Hebrew): 96-entry tables.
- 0x05 JIS X 0208, 0x0D JIS X 0212, 0x0A ISO-2022-JP and 0x0E KS C 5601: two-octet sets.
` TextDecoder` reads them through ICU — EUC-JP and EUC-KR once each octet's high bit is set,
` iso-2022-jp` as is; checked for JIS X 0208 and KS C 5601 on Node 24.18.0. Nothing built in
encodes them, so ship tables or build the reverse map on first use by decoding the 94× 94 grid.
A Node without full ICU throws from ` new TextDecoder()`, which hard rule 1 wraps into an ` err`.
- 0x09 pictogram has no published definition; leave it out.
- [ ] **GSM 7-bit national language shift tables.** 3GPP TS 23.038 defines them for Turkish, Spanish
(single shift only), Portuguese and ten Indian languages — Bengali, Gujarati, Hindi, Kannada,
Malayalam, Oriya, Punjabi, Tamil, Telugu and Urdu — selected per message by UDH elements 0x25
(locking) and 0x24 (single). They keep that text near GSM's segment size instead of UCS2's 67
characters. Reading means honouring those elements in ` decodeMessage()`; sending means ` detect()`
picking a table, with each element's 3 octets off the segment budget. ` smpp` has Turkish, Spanish
and Portuguese, used only when the caller writes the UDH.
- [ ] **Packed GSM 7-bit, opt-in.** Everything goes out unpacked, SMPP's convention (AGENTS.md, "GSM
7-bit is sent unpacked"); go-smpp carries a packed codec for SMSCs that want septets. Find an SMSC
that needs it before building it.
- [ ] **` consts.ENCODING` spells five alphabets twice.** ` CYRILLIC`/` ISO_8859_5`,
` HEBREW`/` ISO_8859_8`, ` JIS`/` X_0208_1990`, ` EXTENDED_KANJI_JIS`/` X_0212_1990` and
` LATIN1`/` ISO_8859_1`; ` FLASH` is a message class, not an alphabet. One name each before
registration starts taking names. A breaking change to an export.
### Observability
- [ ] **Metrics.** Inbound traffic has ` data`, ` incomingPdu` and ` incomingPduObj`; outbound has no
event, and nothing counts requests in flight, queued for a window slot, waiting for a link, or
unanswered. ` smpp` and ` @semyonf/smpp ` emit ` metrics`; cloudhopper keeps per-session counters. An
` outgoingPdu`/` outgoingPduObj` pair mirrors the inbound events; the counters can be one read-only
snapshot, read from the owner of each count rather than a second tally that can drift.
### Packaging, tests and CI
- [ ] **Ship ` src`, so the source maps lead somewhere.** Maintainer's call, 2026-09-14. ` sourceMap`
and ` declarationMap` write maps whose ` sources` are ` ../src/*.ts`, but ` files` publishes only
` dist`, so 82 of the package's 167 files, 225 KB of its 555 KB unpacked, point at nothing. Adding
` src` (41 files, 209 KB) makes go to definition land in the TypeScript, and lets a debugger or
` --enable-source-maps` show it.
- [ ] **A coverage report and a floor in the gate.** ` node --test --experimental-test-coverage
--test-coverage-include='src/**' test/*.test.ts` on Node 24.18.0, 2026-09-14: 98.77% lines,
93.85% branches, 98.28% functions. Gate at 98, 93 and 98 with ` --test-coverage-lines`,
` --test-coverage-branches` and ` --test-coverage-functions`, which Node 22 and later take — a job
of its own on 24, since the matrix runs compiled JavaScript — and add it to ` main`'s required
checks. Raise the floor as coverage rises; never lower it.
- [ ] **Mutation testing.** ` @stryker -mutator/tap-runner` runs ` node:test` suites and measures whether
a test notices a change, which coverage cannot; ` @semyonf/smpp ` runs Stryker in CI. The session
suites are timer-heavy, so start with the codec and the encodings.
- [ ] **A Node-RED node, as a package of its own.** ` @leissner/node -red-smpp` is the only SMPP node in
the Node-RED library, and by a read of its source it never parses a receipt and never answers the
SMSC's ` enquire_link`. Its UI is a fair list of what operators set. It builds on this package,
never inside it.
## Declined
- **CommonJS.** ESM only, maintainer's call reaffirmed 2026-09-14, though ` node-smpp-next ` ships both.
` require()` of an ES module works unflagged from Node 20.19 and 22.12.
- **Merge state surviving a process restart.** Declined by AGENTS.md goal 7, maintainer's call,
2026-09-02. A restart loses every incomplete receipt group and a peer has no reason to resend one it
already had answered, so the loss is real — but surviving it means handing the application the merge
state to persist, which the scope floor covers as squarely as holding the state here would, and
which publishes the shape of ` DlrMerger`'s groups against goal 6. Nothing is foreclosed: the seam
can still be added after 0.5.0 as a minor.
## An optional store
- [ ] **Pooling, and state that survives a restart, through an optional store.** Maintainer's call,
2026-09-14. It replaces two declines — merge state surviving a restart, and a pool of sessions —
and AGENTS.md goal 8 was rewritten for it. Big: design before code.
- **What it holds.** Receipts still awaited and the groups ` DlrMerger` collects. Segments of a
message already answered but not yet whole, which the peer will not send again (goal 2). The
concatenation reference, so a restart does not reuse one. For a pool, the ids every session
sent, since an SMSC may deliver a receipt on any bind of the account, and the
messages-per-second budget the sessions share.
- **What it cannot hold.** A response belongs to the link its request arrived on, so a message
left unanswered at a restart stays unanswerable; the peer's own timeout settles it.
- **Pooling.** Several sessions, in one process or many, behind one send: a message goes to a
bound session with a free window slot, all its segments on that one. In one process the
in-memory store is enough; across processes the application supplies one.
- **The interface.** Narrow, with keys and records of the library's own making, versioned, with
expiry: a record from an older version is read or refused, never misread, and ` DlrMerger`'s
group shape is never published (goal 7). What processes share needs an atomic operation —
compare-and-set or increment — since get-then-set races.
- **Adapters live elsewhere.** Redis, Postgres or SQLite stores are packages of their own; this
one ships the interface and the in-memory store, and no runtime dependency (goal 9).
- **When the store fails.** Open: a send whose awaited receipt cannot be recorded is refused, or
sent and reported as undetermined (goal 2); a pool whose store is down stops, or falls back to
memory. Either way, an application that supplied no store never waits on one.
- **One spelling.** A store-backed cap and the limiter hook both reach a limit shared between
processes; settle which owns that case before building the second.
- **Throughput throttling — a TPS cap, and backing off on ` ESME_RTHROTTLED`.** Declined by AGENTS.md
goal 7: an operator's rate limit is scoped to the account, while the widest thing this library owns
is a session, so a bucket here cannot see a second process binding the same account and is wrong in
exactly the case it exists for. ` sendSms()` surfaces ` ESME_RTHROTTLED` to the caller instead, and
` maxOutstanding` stays — a window slot frees on the peer's next response, which is self-limiting in
a way a rate ceiling is not.