Compare commits

8 Commits

Author SHA1 Message Date
lilleman 1c0b4bef34 Tick off the 0.5.0 cleanup in todo.md, and decline a token expiry check
Test / lint (pull_request) Successful in 20s
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
Test / test (26) (pull_request) Successful in 18s
Mirror / push (push) Successful in 4s
2026-09-14 21:19:17 +02:00
lilleman c5adfd8e39 Say what the mirror's concurrency group does, and name every way a stale ref survives
Test / lint (pull_request) Successful in 20s
Test / test (26) (pull_request) Successful in 17s
Mirror / push (push) Successful in 3s
Test / test (18) (pull_request) Successful in 18s
Test / test (20) (pull_request) Successful in 18s
Test / test (22) (pull_request) Successful in 18s
Test / test (24) (pull_request) Successful in 17s
2026-09-14 20:54:04 +02:00
lilleman 14b114afda Split the GitHub mirror so Gitea creates its runs and no delete is dropped
Test / test (18) (pull_request) Successful in 18s
Mirror / push (push) Successful in 5s
Test / lint (pull_request) Successful in 20s
Test / test (22) (pull_request) Successful in 22s
Test / test (24) (pull_request) Successful in 17s
Test / test (26) (pull_request) Successful in 17s
Test / test (20) (pull_request) Successful in 18s
2026-09-14 20:40:38 +02:00
lilleman 27e8f288ec Mirror every branch and tag to GitHub from CI, keeping what only GitHub has 2026-09-14 20:40:38 +02:00
lilleman e1999a7305 Let a registered encoding claim a data_coding a built-in already uses, in todo.md
Test / lint (pull_request) Successful in 20s
Test / test (18) (pull_request) Successful in 17s
Test / test (20) (pull_request) Successful in 18s
Test / test (22) (pull_request) Successful in 18s
Test / test (26) (pull_request) Successful in 19s
Test / test (24) (pull_request) Successful in 17s
2026-09-14 20:38:02 +02:00
lilleman baa0ad553a Set failover's options, and decide to test on Linux only while keeping macOS and Windows unbroken
Test / lint (pull_request) Successful in 19s
Test / test (24) (pull_request) Successful in 17s
Test / test (26) (pull_request) Successful in 17s
Test / test (18) (pull_request) Successful in 17s
Test / test (20) (pull_request) Successful in 17s
Test / test (22) (pull_request) Successful in 17s
2026-09-14 17:26:46 +02:00
lilleman e32f9ccf46 Plan failover, an optional store for pooling and restarts, shipping src, and Gitea runners for macOS and Windows
Test / lint (pull_request) Successful in 19s
Test / test (18) (pull_request) Successful in 18s
Test / test (20) (pull_request) Successful in 18s
Test / test (22) (pull_request) Successful in 19s
Test / test (24) (pull_request) Successful in 17s
Test / test (26) (pull_request) Successful in 18s
2026-09-14 17:08:54 +02:00
lilleman b3cbbba615 Record the gaps found against other SMPP libraries, and make configurable and extendable a goal
Test / lint (pull_request) Successful in 1m4s
Test / test (18) (pull_request) Successful in 17s
Test / test (20) (pull_request) Successful in 17s
Test / test (22) (pull_request) Successful in 18s
Test / test (24) (pull_request) Successful in 25s
Test / test (26) (pull_request) Successful in 56s
2026-09-14 16:32:23 +02:00
4 changed files with 245 additions and 56 deletions
-1
View File
@@ -7,7 +7,6 @@ permissions:
contents: read contents: read
jobs: jobs:
# No concurrency group: Gitea cancels a queued run when the next one in its group arrives, dropping the delete.
delete: delete:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 10 timeout-minutes: 10
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
push: push:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 10 timeout-minutes: 10
# Gitea 1.26 rolls back a run whose jobs share a group, so the delete job lives in mirror-delete.yaml. # Gitea cancels a queued job in this group when the next one arrives; only the full push may join.
concurrency: concurrency:
group: mirror group: mirror
steps: steps:
+33 -19
View File
@@ -32,16 +32,23 @@ 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. **A small, stable public surface over reshapeable internals.** Only what `src/index.ts` exports is 6. **Configurable and extendable, never at the defaults' expense.** Where an application needs other
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.
7. **Nothing that needs state wider than one session.** No throughput throttling, no persistence 8. **State wider than one session goes through one store.** A pool of sessions, a limit shared
across a restart, no coordination between processes and no seam handing the application state to between processes, and what has to survive a restart — receipts still awaited, a message half
persist for one of those either, which commits to the same scope through the back door and reassembled — are held through a store interface and never beside it. Without a store the
publishes an internal shape to do it. This is the scope floor, and it is why an otherwise application supplies, that state is in memory and ends with the process, and the defaults need
reasonable feature is declined without a fresh argument each time. none. The interface carries the library's own versioned records, never an internal shape handed
8. **It builds, tests and runs the same everywhere.** Container-only toolchain, no runtime to the application to persist. Coordinating processes any other way is declined without a fresh
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.
@@ -141,9 +148,9 @@ docker compose run --rm node npm run build
TypeScript 7 once that constraint lifts. TypeScript 7 once that constraint lifts.
- GitHub mirrors Gitea through `.gitea/workflows/mirror.yaml`, which never prunes, and - GitHub mirrors Gitea through `.gitea/workflows/mirror.yaml`, which never prunes, and
`mirror-delete.yaml`, one run per deleted ref. A delete run that fails or outlives Gitea's queue `mirror-delete.yaml`, one run per deleted ref. A delete run that fails or outlives Gitea's queue
timeout leaves the ref on GitHub until it is re-run. Accepted: a stale ref there is harmless, and timeout, or a push run that cloned before the delete, leaves the ref on GitHub until the delete
refs only GitHub has must survive. Maintainer's call, 2026-09-14; valid while nothing deploys from run is re-run. Accepted: a stale ref there is harmless, and refs only GitHub has must survive.
GitHub. Maintainer's call, 2026-09-14; valid while nothing deploys from GitHub.
## Defects found in 0.4.0 ## Defects found in 0.4.0
@@ -294,7 +301,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 6 is paid by without the class as a value an application is left string-matching `err.message`. Goal 7 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
@@ -321,7 +328,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 6, and the tag is the last cheap chance to spend it — every typed consumer a narrow forever — goal 7, 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
@@ -426,13 +433,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 6 means by beating "the application can do this itself". The four receipt merge, which is what goal 7 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 6 that reason rather than as an unknown name — a mode this library cannot deliver is a promise goal 7
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
@@ -489,7 +496,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 6 for three classes nothing here acts on, where the boolean the `sms` event, which pays goal 7 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
@@ -671,14 +678,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 6's escape hatch open — the raw UDH, 8-bit binary under any `data_coding`, which is what keeps goal 7'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 6, from the error prose as API for an application whose own refusal should read like itself. Goal 7, 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:
@@ -833,7 +840,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 6 does not buy a second exported class to make a count application could act on, and goal 7 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
@@ -845,7 +852,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 6 means by beating "the application can do this itself". What the library verifies is the what goal 7 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
@@ -1021,3 +1028,10 @@ 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.
+211 -35
View File
@@ -9,7 +9,8 @@ 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. What is left is release work and a few things worth adding before or after 0.5.0. 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.
## The agreed API ## The agreed API
@@ -97,9 +98,9 @@ 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.
- [ ] Tag `v0.5.0` on Gitea to publish. The first publish creates `@larvit/smpp` on npm, provided the - [x] 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 - [x] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not
something CI should do. something CI should do.
## Retire the GitHub repository ## Retire the GitHub repository
@@ -108,39 +109,44 @@ Nothing here starts before 0.5.0 is published. Maintainer's call, 2026-09-14. Th
deleting GitHub's old branches closes every pull request based on them without a reply, and GitHub deleting GitHub's old branches closes every pull request based on them without a reply, and GitHub
refuses to delete its default branch. refuses to delete its default branch.
- [ ] Close the backlog below. - [x] Close the backlog below.
- [ ] Close [#71](https://github.com/larvit/larvitsmpp/pull/71), pointing at Gitea. - [x] Close [#71](https://github.com/larvit/larvitsmpp/pull/71), pointing at Gitea.
- [ ] Rename `larvit/larvitsmpp` to `larvit/smpp-js`. GitHub redirects the old URLs, and the `bugs` - [x] Rename `larvit/larvitsmpp` to `larvit/smpp-js`. GitHub redirects the old URLs, and the `bugs`
URL in `package.json` resolves from then on. URL in `package.json` resolves from then on.
- [ ] Push `main` and make it GitHub's default branch. - [x] Push `main` and make it GitHub's default branch.
- [ ] Remove Renovate and CodeRabbit from the GitHub repository. - [x] Renovate is Silent for this repository in the Mend Developer Portal, so it opens nothing on
- [ ] Mirror to GitHub from `.gitea/workflows/mirror.yaml` and `mirror-delete.yaml`, with GitHub while the organization-wide installation stays. CodeRabbit stays installed.
Maintainer's call, 2026-09-14.
- [x] Mirror to GitHub from `.gitea/workflows/mirror.yaml` and `mirror-delete.yaml`, with
`MIRROR_GITHUB_TOKEN`. A push of a commit carrying the workflow, and the nightly run, send all of `MIRROR_GITHUB_TOKEN`. A push of a commit carrying the workflow, and the nightly run, send all of
Gitea's branches and tags, overwriting a same-named ref; a branch or tag deleted on Gitea is Gitea's branches and tags, overwriting a same-named ref; a branch or tag deleted on Gitea is
deleted there too. Refs only GitHub has stay. Maintainer's call, 2026-09-14. deleted there too. Refs only GitHub has stay. Maintainer's call, 2026-09-14.
- [x] GitHub's wiki, projects and Actions are off, the Travis app and webhook are gone, and its About
matches the package. Gitea carries the same description, website and topics, and sends issues
to GitHub as its external tracker.
## Close the GitHub backlog ## Close the GitHub backlog
**Answer and close as fixed by 0.5.0**, the reply naming what fixed it: **Answer and close as fixed by 0.5.0**, the reply naming what fixed it:
- [ ] [#2](https://github.com/larvit/larvitsmpp/issues/2) Tests for the README examples: - [x] [#2](https://github.com/larvit/larvitsmpp/issues/2) Tests for the README examples:
`test/readme.test.ts`. `test/readme.test.ts`.
- [ ] [#3](https://github.com/larvit/larvitsmpp/issues/3) Tests for flash messages: - [x] [#3](https://github.com/larvit/larvitsmpp/issues/3) Tests for flash messages:
`test/session.test.ts`. `test/session.test.ts`.
- [ ] [#4](https://github.com/larvit/larvitsmpp/issues/4) DLR errors with `message_state` missing: - [x] [#4](https://github.com/larvit/larvitsmpp/issues/4) DLR errors with `message_state` missing:
`dlrFromPdu()` parses the `stat:` receipt text when the TLVs are absent. `dlrFromPdu()` parses the `stat:` receipt text when the TLVs are absent.
- [ ] [#13](https://github.com/larvit/larvitsmpp/issues/13) Limit a long SMS to fewer segments: the - [x] [#13](https://github.com/larvit/larvitsmpp/issues/13) Limit a long SMS to fewer segments: the
`maxSegments` send option. `maxSegments` send option.
- [ ] [#16](https://github.com/larvit/larvitsmpp/issues/16) Support all three bind types: bound and - [x] [#16](https://github.com/larvit/larvitsmpp/issues/16) Support all three bind types: bound and
enforced in both directions. enforced in both directions.
- [ ] [#17](https://github.com/larvit/larvitsmpp/issues/17) `addr_ton`/`addr_npi` should be - [x] [#17](https://github.com/larvit/larvitsmpp/issues/17) `addr_ton`/`addr_npi` should be
settable: `sendSms()` takes all four, documented and tested. settable: `sendSms()` takes all four, documented and tested.
- [ ] [#20](https://github.com/larvit/larvitsmpp/issues/20) Tests fail on current dependency - [x] [#20](https://github.com/larvit/larvitsmpp/issues/20) Tests fail on current dependency
versions: the mocha suite is gone; `node:test` on Node 18 to 26. versions: the mocha suite is gone; `node:test` on Node 18 to 26.
- [ ] [#33](https://github.com/larvit/larvitsmpp/issues/33) Large inbound text arrives as raw - [x] [#33](https://github.com/larvit/larvitsmpp/issues/33) Large inbound text arrives as raw
`Buffer` segments: `IncomingRequests` reassembles a UDH-carrying `deliver_sm` into one `sms` `Buffer` segments: `IncomingRequests` reassembles a UDH-carrying `deliver_sm` into one `sms`
event. event.
- [ ] [#68](https://github.com/larvit/larvitsmpp/pull/68), a pull request: `message_id` in - [x] [#68](https://github.com/larvit/larvitsmpp/pull/68), a pull request: `message_id` in
`submit_sm_resp`, spec DLR codes. All four hold: `sendResp()` always answers a `message_id`, `submit_sm_resp`, spec DLR codes. All four hold: `sendResp()` always answers a `message_id`,
per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the fork found real per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the fork found real
defects. defects.
@@ -148,7 +154,7 @@ refuses to delete its default branch.
**Close as superseded**, all against 0.4.0 dependencies the rewrite does not have — `async`, **Close as superseded**, all against 0.4.0 dependencies the rewrite does not have — `async`,
`coveralls`, `eslint`, `iconv-lite`, `larvitutils`, `mocha`, `mocha-eslint`, `portfinder`, `uuid`: `coveralls`, `eslint`, `iconv-lite`, `larvitutils`, `mocha`, `mocha-eslint`, `portfinder`, `uuid`:
- [ ] [#40](https://github.com/larvit/larvitsmpp/pull/40), - [x] [#40](https://github.com/larvit/larvitsmpp/pull/40),
[#41](https://github.com/larvit/larvitsmpp/pull/41), [#41](https://github.com/larvit/larvitsmpp/pull/41),
[#42](https://github.com/larvit/larvitsmpp/pull/42), [#42](https://github.com/larvit/larvitsmpp/pull/42),
[#45](https://github.com/larvit/larvitsmpp/pull/45), [#45](https://github.com/larvit/larvitsmpp/pull/45),
@@ -168,7 +174,7 @@ the rewrite, for a dependency added later. Maintainer's call, 2026-09-14.
**Close as tracked here**, the reply saying it will be implemented on Gitea: **Close as tracked here**, the reply saying it will be implemented on Gitea:
- [ ] [#8](https://github.com/larvit/larvitsmpp/issues/8) The socket's remote host and port on log - [x] [#8](https://github.com/larvit/larvitsmpp/issues/8) The socket's remote host and port on log
messages: under Worth doing, not blocking. Maintainer's call, 2026-09-14. messages: under Worth doing, not blocking. Maintainer's call, 2026-09-14.
## Worth doing, not blocking ## Worth doing, not blocking
@@ -245,9 +251,6 @@ 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
@@ -255,18 +258,191 @@ 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
- **Merge state surviving a process restart.** Declined by AGENTS.md goal 7, maintainer's call, - **A check that warns before `MIRROR_GITHUB_TOKEN` expires.** Gitea mails no one about a failed
2026-09-02. A restart loses every incomplete receipt group and a peer has no reason to resend one it scheduled run, since its Actions bot triggers those, so a nightly check would fail unseen. The
already had answered, so the loss is real — but surviving it means handing the application the merge maintainer relies on GitHub's own expiry reminders, and on the mirror's first failed push run after
state to persist, which the scope floor covers as squarely as holding the state here would, and expiry, which mails whoever pushed. Maintainer's call, 2026-09-14.
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.
- **Throughput throttling — a TPS cap, and backing off on `ESME_RTHROTTLED`.** Declined by AGENTS.md - **CommonJS.** ESM only, maintainer's call reaffirmed 2026-09-14, though `node-smpp-next` ships both.
goal 7: an operator's rate limit is scoped to the account, while the widest thing this library owns `require()` of an ES module works unflagged from Node 20.19 and 22.12.
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 ## An optional store
`maxOutstanding` stays — a window slot frees on the peer's next response, which is self-limiting in
a way a rate ceiling is not. - [ ] **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.