Compare commits

..

2 Commits

Author SHA1 Message Date
lilleman 190ddfffa1 Split the GitHub mirror so Gitea creates its runs and no delete is dropped
Mirror / push (push) Successful in 3s
Test / test (22) (pull_request) Successful in 19s
Test / test (24) (pull_request) Successful in 18s
Test / lint (pull_request) Successful in 24s
Test / test (18) (pull_request) Successful in 18s
Test / test (20) (pull_request) Successful in 17s
Test / test (26) (pull_request) Successful in 25s
2026-09-14 20:38:46 +02:00
lilleman 84dcf4f3ec Mirror every branch and tag to GitHub from CI, keeping what only GitHub has
Test / test (26) (pull_request) Successful in 17s
Test / lint (pull_request) Successful in 19s
Test / test (18) (pull_request) Successful in 18s
Test / test (20) (pull_request) Successful in 17s
Test / test (22) (pull_request) Successful in 18s
Test / test (24) (pull_request) Successful in 18s
2026-09-14 19:36:44 +02:00
2 changed files with 33 additions and 213 deletions
+16 -30
View File
@@ -32,23 +32,16 @@ one wins. They do not override the hard rules below.
about a message the application sent reaches it as a report rather than as an inbound message, and about a message the application sent reaches it as a report rather than as an inbound message, and
says whether it is final, so nothing has to read the PDU to tell those apart. An option retunes a says whether it is final, so nothing has to read the PDU to tell those apart. An option retunes a
default or opts out of it; an option does not switch on the thing the caller obviously wanted. default or opts out of it; an option does not switch on the thing the caller obviously wanted.
6. **Configurable and extendable, never at the defaults' expense.** Where an application needs other 6. **A small, stable public surface over reshapeable internals.** Only what `src/index.ts` exports is
than the default and cannot build it from what is exported — a rate limit counted per PDU, an
alphabet, a receipt format — it gets an option or a hook rather than a fork. A call that passes no
options stays exactly as easy and as safe, and a hook is a seam the library calls, never a way into
its internals.
7. **A small, stable public surface over reshapeable internals.** Only what `src/index.ts` exports is
published. A new option has to beat "the application can do this itself", and has to keep a published. A new option has to beat "the application can do this itself", and has to keep a
promise this library can verify. The low-level surface is a passthrough: policy binds what the promise this library can verify. The low-level surface is a passthrough: policy binds what the
library composes, never what the caller wrote. library composes, never what the caller wrote.
8. **State wider than one session goes through one store.** A pool of sessions, a limit shared 7. **Nothing that needs state wider than one session.** No throughput throttling, no persistence
between processes, and what has to survive a restart — receipts still awaited, a message half across a restart, no coordination between processes and no seam handing the application state to
reassembled — are held through a store interface and never beside it. Without a store the persist for one of those either, which commits to the same scope through the back door and
application supplies, that state is in memory and ends with the process, and the defaults need publishes an internal shape to do it. This is the scope floor, and it is why an otherwise
none. The interface carries the library's own versioned records, never an internal shape handed reasonable feature is declined without a fresh argument each time.
to the application to persist. Coordinating processes any other way is declined without a fresh 8. **It builds, tests and runs the same everywhere.** Container-only toolchain, no runtime
argument each time.
9. **It builds, tests and runs the same everywhere.** Container-only toolchain, no runtime
dependencies, the Node 18 floor verified in CI rather than asserted, every README example executed dependencies, the Node 18 floor verified in CI rather than asserted, every README example executed
by the suite. by the suite.
@@ -301,7 +294,7 @@ Grouped by what each one constrains.
- **`PduRefusedError` is exported, and `sessionError` names it in the event's type.** Maintainer's - **`PduRefusedError` is exported, and `sessionError` names it in the event's type.** Maintainer's
call, 2026-09-05, from a product review: one event carries both a PDU the peer malformed and the call, 2026-09-05, from a product review: one event carries both a PDU the peer malformed and the
session's own failure, and `instanceof` is the only way to separate them that hard rule 4 allows — session's own failure, and `instanceof` is the only way to separate them that hard rule 4 allows —
without the class as a value an application is left string-matching `err.message`. Goal 7 is paid by without the class as a value an application is left string-matching `err.message`. Goal 6 is paid by
exporting the discriminant and the struct it carries and nothing else: `PduHeader` is named because exporting the discriminant and the struct it carries and nothing else: `PduHeader` is named because
an application that logs or forwards a header wants a name for it, `PduRefusalReason` is not an application that logs or forwards a header wants a name for it, `PduRefusalReason` is not
because `reason` is compared against string literals, and an accessor because `reason` is compared against string literals, and an accessor
@@ -328,7 +321,7 @@ Grouped by what each one constrains.
runtime: `Object.hasOwn(encodings, x)` is the only test the published surface offered and it runtime: `Object.hasOwn(encodings, x)` is the only test the published surface offered and it
narrows nothing, so `isEncodingName()` is exported beside `isCommandName()` and `isErrorName()`, narrows nothing, so `isEncodingName()` is exported beside `isCommandName()` and `isErrorName()`,
which serve their own tables that way. Rejected: a `Result` signature on all three, which costs which serve their own tables that way. Rejected: a `Result` signature on all three, which costs
every typed consumer a narrow forever — goal 7, and the tag is the last cheap chance to spend it — every typed consumer a narrow forever — goal 6, and the tag is the last cheap chance to spend it —
to guard a state the compiler refuses. Where the domain really is open the check is already there: to guard a state the compiler refuses. Where the domain really is open the check is already there:
`sendSms()` takes its options as `unknown` and refuses `encoding` by name, which is what a caller `sendSms()` takes its options as `unknown` and refuses `encoding` by name, which is what a caller
without types gets. `smppTime.encode()` is where that reasoning lands the other way and is recorded without types gets. `smppTime.encode()` is where that reasoning lands the other way and is recorded
@@ -433,13 +426,13 @@ Grouped by what each one constrains.
the suite ran took the 0x40 this library sends on a concatenated segment, but Route Mobile and the suite ran took the 0x40 this library sends on a concatenated segment, but Route Mobile and
Kaleyra both document `esm_class` 0x43 for one, and a caller facing either had to hand-build every Kaleyra both document `esm_class` 0x43 for one, and a caller facing either had to hand-build every
segment through `send()` — giving up the split, the per-segment ids, the send window and the segment through `send()` — giving up the split, the per-segment ids, the send window and the
receipt merge, which is what goal 7 means by beating "the application can do this itself". The four receipt merge, which is what goal 6 means by beating "the application can do this itself". The four
modes of SMPP 3.4 5.2.12 are a `MESSAGING_MODE` constant group and the option takes one of their modes of SMPP 3.4 5.2.12 are a `MESSAGING_MODE` constant group and the option takes one of their
names, so 0x43 is a composition this library makes rather than a value a caller states, and the UDH names, so 0x43 is a composition this library makes rather than a value a caller states, and the UDH
indicator a segment carrying a header needs cannot be cleared by anything the option can express. indicator a segment carrying a header needs cannot be cleared by anything the option can express.
It takes three of those four: 2.10.3 carries transaction mode on `data_sm` alone, and none goes out It takes three of those four: 2.10.3 carries transaction mode on `data_sm` alone, and none goes out
of here, so `FORWARD` stays in the group that mirrors the spec table and `sendSms()` refuses it by of here, so `FORWARD` stays in the group that mirrors the spec table and `sendSms()` refuses it by
that reason rather than as an unknown name — a mode this library cannot deliver is a promise goal 7 that reason rather than as an unknown name — a mode this library cannot deliver is a promise goal 6
will not let it make. `DATAGRAM` with `dlr: true` is refused on the same footing: 2.10.2 defines the will not let it make. `DATAGRAM` with `dlr: true` is refused on the same footing: 2.10.2 defines the
report away, so arming `DlrMerger` for one is goal 2's wrong answer, where the mode alone and a report away, so arming `DlrMerger` for one is goal 2's wrong answer, where the mode alone and a
report under any other mode both go out untouched. Those three names left `ESM_CLASS`, where they report under any other mode both go out untouched. Those three names left `ESM_CLASS`, where they
@@ -496,7 +489,7 @@ Grouped by what each one constrains.
`encodingByDataCoding()` reads the alphabet off that same test rather than repeating the group `encodingByDataCoding()` reads the alphabet off that same test rather than repeating the group
masks beside it. It is exported for the reason `concatOf()` is — an application that needs a class masks beside it. It is exported for the reason `concatOf()` is — an application that needs a class
other than 0 would otherwise rewrite the read this fixed. Rejected: a `messageClass` field on the other than 0 would otherwise rewrite the read this fixed. Rejected: a `messageClass` field on the
`sms` event, which pays goal 7 for three classes nothing here acts on, where the boolean the `sms` event, which pays goal 6 for three classes nothing here acts on, where the boolean the
application already had covers the one it does. Compressed text is out of scope and stays out — application already had covers the one it does. Compressed text is out of scope and stays out —
nothing here implements 3GPP TS 23.042, so a compressed body reaches the application as whatever nothing here implements 3GPP TS 23.042, so a compressed body reaches the application as whatever
its declared alphabet makes of it — but bit 5 does not move the class bits, so 0x30 is read as its declared alphabet makes of it — but bit 5 does not move the class bits, so 0x30 is read as
@@ -678,14 +671,14 @@ Grouped by what each one constrains.
returned `42 44 46``"BDF"` — reported as built, while a string `message_payload` was cut to its returned `42 44 46``"BDF"` — reported as built, while a string `message_payload` was cut to its
low octets whatever `data_coding` said. Goal 2 owns it, as it owns the `sendSms()` guard above. low octets whatever `data_coding` said. Goal 2 owns it, as it owns the `sendSms()` guard above.
The line falls at the string: a `Buffer` is octets the caller already chose and goes out as given The line falls at the string: a `Buffer` is octets the caller already chose and goes out as given
under any `data_coding`, which is what keeps goal 7's escape hatch open — the raw UDH, 8-bit binary under any `data_coding`, which is what keeps goal 6's escape hatch open — the raw UDH, 8-bit binary
and deliberately malformed bodies `interop-tests/` builds are all still buildable — and a string and deliberately malformed bodies `interop-tests/` builds are all still buildable — and a string
with no `data_coding` is untouched, detection carrying every character it was picked for. The with no `data_coding` is untouched, detection carrying every character it was picked for. The
guard is `unencodable()` again rather than a second reading, and `unencodableText()` is the guard is `unencodable()` again rather than a second reading, and `unencodableText()` is the
character, its code point and its index said once for both refusals — unexported where character, its code point and its index said once for both refusals — unexported where
`unencodable()` is published, since wording `{ char, index }` into a sentence rewrites no read a `unencodable()` is published, since wording `{ char, index }` into a sentence rewrites no read a
caller would get wrong, where asking the codec is, and publishing it would freeze this library's caller would get wrong, where asking the codec is, and publishing it would freeze this library's
error prose as API for an application whose own refusal should read like itself. Goal 7, from the error prose as API for an application whose own refusal should read like itself. Goal 6, from the
architecture review of [#99](https://github.com/larvit/larvitsmpp/pull/99), 2026-09-09. It is architecture review of [#99](https://github.com/larvit/larvitsmpp/pull/99), 2026-09-09. It is
reached through reached through
`encodeBody()` in `message.ts`, which is where the `data_coding`-to-text pair already lives: `encodeBody()` in `message.ts`, which is where the `data_coding`-to-text pair already lives:
@@ -840,7 +833,7 @@ Grouped by what each one constrains.
dropped with the link — is traffic the peer will not send again, so each one reaches `sessionError` dropped with the link — is traffic the peer will not send again, so each one reaches `sessionError`
as well as the log. Rejected there: an exported `MessageLostError` carrying the group, on the as well as the log. Rejected there: an exported `MessageLostError` carrying the group, on the
`PduRefusedError` pattern — no `sms` ever fired for that group, so there is nothing in it the `PduRefusedError` pattern — no `sms` ever fired for that group, so there is nothing in it the
application could act on, and goal 7 does not buy a second exported class to make a count application could act on, and goal 6 does not buy a second exported class to make a count
distinguishable. Accepted: a completing segment whose own answer the socket would not carry still distinguishable. Accepted: a completing segment whose own answer the socket would not carry still
reaches the application, because the message is whole and correct and the failed answer is on reaches the application, because the message is whole and correct and the failed answer is on
`sessionError` — a peer that re-sends after the drop is the smaller risk than dropping a message `sessionError` — a peer that re-sends after the drop is the smaller risk than dropping a message
@@ -852,7 +845,7 @@ Grouped by what each one constrains.
of the multipart change: `server()` filled the session's only `onRequest` slot, so the escape hatch of the multipart change: `server()` filled the session's only `onRequest` slot, so the escape hatch
the error above names was reachable only by hand-wiring a `Session` over a raw socket, giving up the error above names was reachable only by hand-wiring a `Session` over a raw socket, giving up
bind acceptance, `authenticate`, the session set and the drain `close()` runs over it — which is bind acceptance, `authenticate`, the session set and the drain `close()` runs over it — which is
what goal 7 means by beating "the application can do this itself". What the library verifies is the what goal 6 means by beating "the application can do this itself". What the library verifies is the
ordering rather than the hook's honesty about answering: the hook is consulted only for a non-bind ordering rather than the hook's honesty about answering: the hook is consulted only for a non-bind
request on a session already bound, so no bind — a second one on a live session included — and request on a session already bound, so no bind — a second one on a live session included — and
nothing a peer sends before one can be intercepted however the hook is written. One nothing a peer sends before one can be intercepted however the hook is written. One
@@ -1028,10 +1021,3 @@ Grouped by what each one constrains.
their own. The four files that are not tests are the exception the rule needs stated: their own. The four files that are not tests are the exception the rule needs stated:
`dummy-smsc.ts`, `raw-pdus.ts`, `reference-smpp.d.ts` and `teardown.ts` answer no question and are `dummy-smsc.ts`, `raw-pdus.ts`, `reference-smpp.d.ts` and `teardown.ts` answer no question and are
named for what they hold. named for what they hold.
- **CI tests on Linux only; `src/` keeps off what is known to break on macOS or Windows.** Maintainer's
call, 2026-09-14. Nothing verifies either platform, so the code avoids what is known to differ there:
shelling out, a path joined by hand, a signal Windows does not deliver, a Unix socket or a file mode.
That binds what `dist/` runs; the container tooling, `interop-tests/` and the `package.json` scripts
run on Linux by goal 9. Rejected: macOS and Windows runners, on GitHub's mirror or as Gitea
host-mode runners on a Windows VM and a Mac.
+17 -183
View File
@@ -9,8 +9,7 @@ govern it, and nothing here is a source anything else may cite.
## Status ## Status
The rewrite is **feature complete and green**: the suite, lint and typecheck are clean on Node 18 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 to 26. What is left is release work and a few things worth adding before or after 0.5.0.
adding, and the gaps a comparison with other SMPP libraries found.
## The agreed API ## 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] 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] `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`. token can publish under `@larvit`.
- [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not - [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not
something CI should do. 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. end to end. The interop suite is the natural place.
- [ ] **Move to TypeScript 7** once `typescript-eslint` supports it; `renovate.json` pins TypeScript - [ ] **Move to TypeScript 7** once `typescript-eslint` supports it; `renovate.json` pins TypeScript
below 6.1 for exactly that reason. 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, - [ ] **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 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 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 Mirror the `onRequest` seam — return a `Dlr` to own the receipt, `undefined` to fall through
to the built-in parser. 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 ## Declined
- **CommonJS.** ESM only, maintainer's call reaffirmed 2026-09-14, though `node-smpp-next` ships both. - **Merge state surviving a process restart.** Declined by AGENTS.md goal 7, maintainer's call,
`require()` of an ES module works unflagged from Node 20.19 and 22.12. 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 - **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
- [ ] **Pooling, and state that survives a restart, through an optional store.** Maintainer's call, is a session, so a bucket here cannot see a second process binding the same account and is wrong in
2026-09-14. It replaces two declines — merge state surviving a restart, and a pool of sessions — exactly the case it exists for. `sendSms()` surfaces `ESME_RTHROTTLED` to the caller instead, and
and AGENTS.md goal 8 was rewritten for it. Big: design before code. `maxOutstanding` stays — a window slot frees on the peer's next response, which is self-limiting in
- **What it holds.** Receipts still awaited and the groups `DlrMerger` collects. Segments of a a way a rate ceiling is not.
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.