Compare commits

2 Commits

Author SHA1 Message Date
lilleman b90c2647be Record the TLV keying decision as the maintainer's
Test / lint (pull_request) Successful in 22s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 30s
Mirror / push (push) Successful in 5s
2026-09-27 23:59:11 +02:00
lilleman b9f278b0b0 Open every todo item with a bold one-sentence tagline
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 36s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 31s
Mirror / push (push) Successful in 5s
2026-09-27 23:37:38 +02:00
2 changed files with 202 additions and 185 deletions
+2 -2
View File
@@ -491,8 +491,8 @@ rule and an index of the titles below.
length-prefixed Octet String, whose length octet is what ends it.
- **A TLV input is keyed by its tag name, or by its decimal id where the table names none, and a
`tagId` beside the key is accepted only where it agrees.** Settled in the architecture and
product-owner reviews of [#30](https://gitea.larvit.se/larvit/smpp-js/pulls/30), 2026-09-27. The
`tagId` beside the key is accepted only where it agrees.** Maintainer's call, 2026-09-27, on the
architecture and product-owner reviews of [#30](https://gitea.larvit.se/larvit/smpp-js/pulls/30). The
key is the one spelling, because a name and a `tagId` that disagreed sent the `tagId`'s tag under
a record keyed as another. A parsed TLV carries its `tagId`, and goal 8's passthrough means a
parsed PDU's `tlvs` relay as they are, so an agreeing copy is read past rather than refused.
+200 -183
View File
@@ -78,31 +78,34 @@ Every defect listed in the AGENTS.md table has a regression test naming the beha
`gitea.larvit.se/larvit/smpp-js` is the repository. `github.com/larvit/smpp-js` mirrors it and is the
place issues are filed. Maintainer's calls, 2026-09-13 and 2026-09-14.
- [x] `larvit/smpp-js` holds `main`, from `typescript`, and `v0.4.0`, from `master`. `rewrite-base`
and the `renovate/*` branches stayed behind.
- [x] Fast-forward is the only merge style. `main` takes no pushes, requires `Test / lint
(pull_request)` and `Test / test (*) (pull_request)`, blocks an outdated branch, and gives
admins no override. Every other branch takes force pushes.
- [x] The workflows are in `.gitea/workflows/`. Tests run on pull requests only, the event the gate
reads; Renovate runs as a scheduled workflow, as on adf-codec.
- [x] The release publishes without provenance, which npm generates only on GitHub Actions and
GitLab CI/CD.
- [x] `package.json` names Gitea, and the GitHub mirror's issues as `bugs`. The README links
- [x] **Move `main` and `v0.4.0` to `larvit/smpp-js`.** It holds `main`, from `typescript`, and
`v0.4.0`, from `master`. `rewrite-base` and the `renovate/*` branches stayed behind.
- [x] **Protect `main` with fast-forward merges and required checks.** Fast-forward is the only
merge style. `main` takes no pushes, requires `Test / lint (pull_request)` and `Test / test
(*) (pull_request)`, blocks an outdated branch, and gives admins no override. Every other
branch takes force pushes.
- [x] **Run the workflows from `.gitea/workflows/`.** Tests run on pull requests only, the event the
gate reads; Renovate runs as a scheduled workflow, as on adf-codec.
- [x] **Publish the release without provenance.** npm generates it only on GitHub Actions and GitLab
CI/CD.
- [x] **Name Gitea in `package.json`, and the GitHub mirror's issues as `bugs`.** The README links
absolutely: npmjs.com resolves a relative link against itself when the `repository` is not on
GitHub. Its test badge is gone, since Gitea reports a workflow's status per branch and no
workflow runs on `main`.
- `RENOVATE_GITHUB_TOKEN` exists nowhere, so Renovate queries github.com unauthenticated, as
- **Add a github.com token for Renovate only if its lookups hit the rate limit.**
`RENOVATE_GITHUB_TOKEN` exists nowhere, so Renovate queries github.com unauthenticated, as
adf-codec's nightly run already does without a warning. `RENOVATE_TOKEN` is the Gitea token and
cannot stand in for it. Add a github.com token only if lookups hit the rate limit.
cannot stand in for it.
## Before publishing 0.5.0
- [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
token can publish under `@larvit`.
- [x] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not
something CI should do.
- [x] **Publish the rewrite as 0.5.0, not 1.0.0, while usage is this low.** Maintainer's call,
2026-09-14.
- [x] **Hold `NPM_TOKEN` as a Gitea organization secret.** `.gitea/workflows/release.yaml` needs it.
- [x] **Tag `v0.5.0` on Gitea to publish.** The first publish creates `@larvit/smpp` on npm,
provided the token can publish under `@larvit`.
- [x] **Deprecate `larvitsmpp` on npm, pointing at `@larvit/smpp`.** `npm deprecate larvitsmpp` is
the maintainer's call to run; not something CI should do.
## Retire the GitHub repository
@@ -110,52 +113,54 @@ 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
refuses to delete its default branch.
- [x] Close the backlog below.
- [x] Close [#71](https://github.com/larvit/larvitsmpp/pull/71), pointing at Gitea.
- [x] Rename `larvit/larvitsmpp` to `larvit/smpp-js`. GitHub redirects the old URLs, and the `bugs`
URL in `package.json` resolves from then on.
- [x] Push `main` and make it GitHub's default branch.
- [x] Renovate is Silent for this repository in the Mend Developer Portal, so it opens nothing on
GitHub while the organization-wide installation stays. CodeRabbit stays installed.
- [x] **Close the backlog below.**
- [x] **Close [#71](https://github.com/larvit/larvitsmpp/pull/71), pointing at Gitea.**
- [x] **Rename `larvit/larvitsmpp` to `larvit/smpp-js`.** GitHub redirects the old URLs, and the
`bugs` URL in `package.json` resolves from then on.
- [x] **Push `main` and make it GitHub's default branch.**
- [x] **Set Renovate to Silent for this repository in the Mend Developer Portal.** It then opens
nothing on 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
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.
- [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.
- [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 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.
- [x] **Strip GitHub's repository to a mirror, and give both forges the package's About.** 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
**Answer and close as fixed by 0.5.0**, the reply naming what fixed it:
- [x] [#2](https://github.com/larvit/larvitsmpp/issues/2) Tests for the README examples:
`test/readme.test.ts`.
- [x] [#3](https://github.com/larvit/larvitsmpp/issues/3) Tests for flash messages:
`test/session.test.ts`.
- [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.
- [x] [#13](https://github.com/larvit/larvitsmpp/issues/13) Limit a long SMS to fewer segments: the
`maxSegments` send option.
- [x] [#16](https://github.com/larvit/larvitsmpp/issues/16) Support all three bind types: bound and
enforced in both directions.
- [x] [#17](https://github.com/larvit/larvitsmpp/issues/17) `addr_ton`/`addr_npi` should be
settable: `sendSms()` takes all four, documented and tested.
- [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.
- [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`
event.
- [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`,
per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the fork found real
defects.
- [x] **Close [#2](https://github.com/larvit/larvitsmpp/issues/2), tests for the README examples.**
Fixed by `test/readme.test.ts`.
- [x] **Close [#3](https://github.com/larvit/larvitsmpp/issues/3), tests for flash messages.** Fixed
by `test/session.test.ts`.
- [x] **Close [#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.
- [x] **Close [#13](https://github.com/larvit/larvitsmpp/issues/13), limit a long SMS to fewer
segments.** Fixed by the `maxSegments` send option.
- [x] **Close [#16](https://github.com/larvit/larvitsmpp/issues/16), support all three bind types.**
Bound and enforced in both directions.
- [x] **Close [#17](https://github.com/larvit/larvitsmpp/issues/17), `addr_ton`/`addr_npi` should be
settable.** `sendSms()` takes all four, documented and tested.
- [x] **Close [#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.
- [x] **Close [#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` event.
- [x] **Close [#68](https://github.com/larvit/larvitsmpp/pull/68), a pull request for `message_id`
in `submit_sm_resp` and 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 defects.
**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`:
- [x] [#40](https://github.com/larvit/larvitsmpp/pull/40),
- [x] **Close the twelve dependency pull requests as superseded.**
[#40](https://github.com/larvit/larvitsmpp/pull/40),
[#41](https://github.com/larvit/larvitsmpp/pull/41),
[#42](https://github.com/larvit/larvitsmpp/pull/42),
[#45](https://github.com/larvit/larvitsmpp/pull/45),
@@ -175,8 +180,9 @@ 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:
- [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.
- [x] **Close [#8](https://github.com/larvit/larvitsmpp/issues/8), the socket's remote host and port
on log messages, as tracked here.** It sits under Worth doing, not blocking. Maintainer's
call, 2026-09-14.
## 0.6.0
@@ -446,78 +452,80 @@ next work ([decision](docs/decisions.md#internals-and-tests)).
as `true`. One fix closes all three, and `valueText()` in `defs/types.ts` is the quoted
spelling to take it from. Raised by review, 2026-09-20.
- [ ] **A send the codec will refuse waits for a link and a window slot first.** `refuse()` in
`outgoing-requests.ts` runs `misuse()` and the abort check before the wait, precisely so a call
that can never go out does not queue for what it will never use; a body `objToPdu()` refuses on
every attempt is the same case, and #98 made it a common one. On a down link the caller waits
`responseTimeout` and is told the link failed rather than that the body could not be built —
goal 2's wrong answer about what happened. The cheap fix builds the PDU twice, so the shape is
the open half. Raised by the architecture review of
- [ ] **Refuse a send the codec cannot build before it waits for a link and a window slot.** Today
it waits first. `refuse()` in `outgoing-requests.ts` runs `misuse()` and the abort check
before the wait, precisely so a call that can never go out does not queue for what it will
never use; a body `objToPdu()` refuses on every attempt is the same case, and #98 made it a
common one. On a down link the caller waits `responseTimeout` and is told the link failed
rather than that the body could not be built — goal 2's wrong answer about what happened. The
cheap fix builds the PDU twice, so the shape is the open half. Raised by the architecture
review of [#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09.
- [ ] **Split the SMPP time format out of `message.ts` once the file has to move anyway.** It
answers two questions: message coding and the SMPP time format (`smppDate`, `smppTime`) share
the file, which the architecture map in AGENTS.md already spells out as four concerns. Nothing
is wrong today; if the file has to move for another reason, `smpp-time.ts` is the split.
Raised by the architecture review of
[#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09.
- [ ] **`message.ts` answers two questions.** Message coding and the SMPP time format (`smppDate`,
`smppTime`) share the file, which the architecture map in AGENTS.md already spells out as four
concerns. Nothing is wrong today; if the file has to move for another reason, `smpp-time.ts` is
the split. Raised by the architecture review of
[#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09.
- [ ] **A gate that refuses a floating version anywhere in the repo.** Maintainer's ask on
[#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the `release.yaml`
pinning thread. Pinning every action and runner by hand is what the ask followed; the gate is
what keeps them pinned. It has to cover workflow `uses:` and `runs-on:`, compose `image:`, and
- [ ] **Add a gate that refuses a floating version anywhere in the repo.** Maintainer's ask on
[#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the `release.yaml` pinning
thread. Pinning every action and runner by hand is what the ask followed; the gate is what
keeps them pinned. It has to cover workflow `uses:` and `runs-on:`, compose `image:`, and
Dockerfile `FROM`, and the conventions differ per kind — actions take a semver tag, images the
full patch version — so one grep for `latest` is not it.
- [ ] **A gate that fails when the test matrix misses the current Node.** Maintainer's ask on
- [ ] **Add a gate that catches the test matrix missing the current Node.** Maintainer's ask on
[#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the Node 26 thread. Node
26 was added by hand; nothing notices when 27 ships. Needs a source for what Current is — the
Node release schedule is published as JSON — and a decision on whether a new Current fails the
build or opens a PR, which is what Renovate already does for everything else here.
- [ ] **CodeRabbit reviews through the GitHub mirror.** CodeRabbit does not support Gitea, so mirror
each Gitea pull request to GitHub for it to review there. Maintainer's ask, 2026-09-14; not
started until asked.
- [ ] **Have CodeRabbit review Gitea pull requests through the GitHub mirror.** CodeRabbit does not
support Gitea, so mirror each Gitea pull request to GitHub for it to review there.
Maintainer's ask, 2026-09-14; not started until asked.
- [ ] **`leftOf()` and the link gate's own budget are one concept counted twice.**
`idle-waiters.ts` reads what is left of a budget as `Math.max(1, deadline - now)`, because 0
means "forever" there; `link-gate.ts` runs the same subtraction and calls `<= 0` expired.
Neither is reachable from the other, so nothing can disagree today, but a reader who learns one
and applies it to the other is wrong. A budget type both take would close it. Raised by review,
2026-09-01.
- [ ] **Count what is left of a budget one way in `leftOf()` and the link gate.** Today they are one
concept counted twice. `idle-waiters.ts` reads what is left of a budget as `Math.max(1,
deadline - now)`, because 0 means "forever" there; `link-gate.ts` runs the same subtraction
and calls `<= 0` expired. Neither is reachable from the other, so nothing can disagree today,
but a reader who learns one and applies it to the other is wrong. A budget type both take
would close it. Raised by review, 2026-09-01.
- [ ] **`err:` on a receipt for a state that neither delivered nor failed.** `receiptText()` now
- [ ] **Write a non-zero `err:` on a receipt only for a state that failed.** `receiptText()` now
writes `err:000` for `DELIVERED` and for the two transient states, and `err:001` for every
other — so `ACCEPTED`, `SKIPPED`, `UNKNOWN` and `DELETED` still announce an error code the SMSC
never had. Which of those are failures is the open half. Raised by review, 2026-09-03; needs a
decision.
other — so `ACCEPTED`, `SKIPPED`, `UNKNOWN` and `DELETED` still announce an error code the
SMSC never had. Which of those are failures is the open half. Raised by review, 2026-09-03;
needs a decision.
- [ ] **`once()` is copied into four test files, and two copies never give up.**
`session-extras.test.ts` and `readme.test.ts` reject after 5000 ms; `session.test.ts` and
`tls.test.ts` wait forever, so an event that never fires still hangs the run the way an
unclosed listener used to. One shared, guarded copy closes the rest of that class.
- [ ] **Share one `once()` across the test files, one that gives up.** Today it is copied into four
test files, and two copies never give up. `session-extras.test.ts` and `readme.test.ts` reject
after 5000 ms; `session.test.ts` and `tls.test.ts` wait forever, so an event that never fires
still hangs the run the way an unclosed listener used to. One shared, guarded copy closes the
rest of that class.
- [ ] **The peer's address and bind on every session log message.** `remoteAddress` and `remotePort`
reach only `server - incoming connection`, and `systemId` only the bind messages, so with
several peers connected one session's lines cannot be told apart, and a reconnect leaves nothing
stable to filter on. Carrying them in every session message's metadata is a change to every
call site. From [#8](https://github.com/larvit/larvitsmpp/issues/8), closed there as tracked
here; maintainer's call, 2026-09-14.
- [ ] **Carry the peer's address and bind on every session log message.** `remoteAddress` and
`remotePort` reach only `server - incoming connection`, and `systemId` only the bind messages,
so with several peers connected one session's lines cannot be told apart, and a reconnect
leaves nothing stable to filter on. Carrying them in every session message's metadata is a
change to every call site. From [#8](https://github.com/larvit/larvitsmpp/issues/8), closed
there as tracked here; maintainer's call, 2026-09-14.
- [ ] **A peer whose message ids share one base logs a refused merge on every send.** `smsc01-000123`
and `smsc01-000124` carry the same base, so `DlrMerger` merges the first message and refuses
every one after it, one log line per send. Left at `info` — nothing the operator can fix is
wrong — but a rate guard or silence may suit it better. Raised by review, 2026-08-30.
- [ ] **Settle how a peer whose message ids share one base logs its refused merges.** Today it logs
one on every send. `smsc01-000123` and `smsc01-000124` carry the same base, so `DlrMerger`
merges the first message and refuses every one after it, one log line per send. Left at `info`
— nothing the operator can fix is wrong — but a rate guard or silence may suit it better.
Raised by review, 2026-08-30.
- [ ] **`submit_multi` and the broadcast commands** encode and decode, but nothing exercises them
end to end. The interop suite is the natural place.
- [ ] **Move to TypeScript 7** once `typescript-eslint` supports it; `renovate.json` pins TypeScript
- [ ] **Exercise `submit_multi` and the broadcast commands end to end.** They encode and decode, but
nothing exercises them 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.
- [ ] **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
dispatch, which owns the response as well.
Mirror the `onRequest` seam — return a `Dlr` to own the receipt, `undefined` to fall through
to the built-in parser.
- [ ] **Add 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 dispatch, which owns the response as well. 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
@@ -527,38 +535,40 @@ Each lands under goal 7: an option or a hook, with the call that passes none unc
### 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`.
- [ ] **Add 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.
- [ ] **Let `sendSms()` take 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:
- [ ] **Fail over 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
@@ -569,26 +579,29 @@ Each lands under goal 7: an option or a hook, with the call that passes none unc
### 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`).
- [ ] **Send `deliver_sm` from `sendSms()` on a `server()` session.** Today it 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.
- [ ] **Let a response this library builds carry error TLVs.** `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.
- [ ] **Accept the 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.
- [ ] **Handle `outbind`.** It is 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
@@ -610,9 +623,9 @@ Each lands under goal 7: an option or a hook, with the call that passes none unc
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:
- [ ] **Carry 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.
@@ -622,30 +635,34 @@ Each lands under goal 7: an option or a hook, with the call that passes none unc
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.
- [ ] **Read and send the 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.
- [ ] **Offer 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.
- [ ] **Give each alphabet in `consts.ENCODING` one name.** Five are spelled 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.
- [ ] **Add metrics: outbound PDU events and counts of requests waiting.** 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
@@ -655,21 +672,21 @@ Each lands under goal 7: an option or a hook, with the call that passes none unc
`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
- [ ] **Add a coverage report and a floor to 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.
`--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.
- [ ] **Add 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.
- [ ] **Build 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
@@ -683,9 +700,9 @@ Each lands under goal 7: an option or a hook, with the call that passes none unc
## 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 goal 9 was rewritten for it. Big: design before code.
- [ ] **Add 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 goal 9 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