Compare commits

1 Commits

Author SHA1 Message Date
lilleman 9d27ca9c92 Record the TLV keying decision as the maintainer's
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 30s
Test / test (18) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 33s
Test / test (24) (pull_request) Successful in 34s
Test / test (20) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 32s
2026-09-27 23:48:05 +02:00
+183 -200
View File
@@ -78,34 +78,31 @@ 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 `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. place issues are filed. Maintainer's calls, 2026-09-13 and 2026-09-14.
- [x] **Move `main` and `v0.4.0` to `larvit/smpp-js`.** It holds `main`, from `typescript`, and - [x] `larvit/smpp-js` holds `main`, from `typescript`, and `v0.4.0`, from `master`. `rewrite-base`
`v0.4.0`, from `master`. `rewrite-base` and the `renovate/*` branches stayed behind. and the `renovate/*` branches stayed behind.
- [x] **Protect `main` with fast-forward merges and required checks.** Fast-forward is the only - [x] Fast-forward is the only merge style. `main` takes no pushes, requires `Test / lint
merge style. `main` takes no pushes, requires `Test / lint (pull_request)` and `Test / test (pull_request)` and `Test / test (*) (pull_request)`, blocks an outdated branch, and gives
(*) (pull_request)`, blocks an outdated branch, and gives admins no override. Every other admins no override. Every other branch takes force pushes.
branch takes force pushes. - [x] The workflows are in `.gitea/workflows/`. Tests run on pull requests only, the event the gate
- [x] **Run the workflows from `.gitea/workflows/`.** Tests run on pull requests only, the event the reads; Renovate runs as a scheduled workflow, as on adf-codec.
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
- [x] **Publish the release without provenance.** npm generates it only on GitHub Actions and GitLab GitLab CI/CD.
CI/CD. - [x] `package.json` names Gitea, and the GitHub mirror's issues as `bugs`. The README links
- [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 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 GitHub. Its test badge is gone, since Gitea reports a workflow's status per branch and no
workflow runs on `main`. workflow runs on `main`.
- **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
`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 adf-codec's nightly run already does without a warning. `RENOVATE_TOKEN` is the Gitea token and
cannot stand in for it. cannot stand in for it. Add a github.com token only if lookups hit the rate limit.
## Before publishing 0.5.0 ## Before publishing 0.5.0
- [x] **Publish the rewrite as 0.5.0, not 1.0.0, while usage is this low.** Maintainer's call, - [x] 0.5.0 rather than 1.0.0, while usage is this low. Maintainer's call, 2026-09-14.
2026-09-14. - [x] `NPM_TOKEN`, which `.gitea/workflows/release.yaml` needs, is a Gitea organization secret.
- [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
- [x] **Tag `v0.5.0` on Gitea to publish.** The first publish creates `@larvit/smpp` on npm, token can publish under `@larvit`.
provided the token can publish under `@larvit`. - [x] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not
- [x] **Deprecate `larvitsmpp` on npm, pointing at `@larvit/smpp`.** `npm deprecate larvitsmpp` is something CI should do.
the maintainer's call to run; not something CI should do.
## Retire the GitHub repository ## Retire the GitHub repository
@@ -113,54 +110,52 @@ 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.
- [x] **Close the backlog below.** - [x] Close the backlog below.
- [x] **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.
- [x] **Rename `larvit/larvitsmpp` to `larvit/smpp-js`.** GitHub redirects the old URLs, and the - [x] Rename `larvit/larvitsmpp` to `larvit/smpp-js`. GitHub redirects the old URLs, and the `bugs`
`bugs` URL in `package.json` resolves from then on. URL in `package.json` resolves from then on.
- [x] **Push `main` and make it GitHub's default branch.** - [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 - [x] Renovate is Silent for this repository in the Mend Developer Portal, so it opens nothing on
nothing on GitHub while the organization-wide installation stays. CodeRabbit stays installed. GitHub while the organization-wide installation stays. CodeRabbit stays installed.
Maintainer's call, 2026-09-14. Maintainer's call, 2026-09-14.
- [x] **Mirror to GitHub from `.gitea/workflows/mirror.yaml` and `mirror-delete.yaml`, with - [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 `MIRROR_GITHUB_TOKEN`. A push of a commit carrying the workflow, and the nightly run, send all of
all of Gitea's branches and tags, overwriting a same-named ref; a branch or tag deleted on Gitea's branches and tags, overwriting a same-named ref; a branch or tag deleted on Gitea is
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] **Strip GitHub's repository to a mirror, and give both forges the package's About.** GitHub's - [x] GitHub's wiki, projects and Actions are off, the Travis app and webhook are gone, and its About
wiki, projects and Actions are off, the Travis app and webhook are gone, and its About matches matches the package. Gitea carries the same description, website and topics, and sends issues
the package. Gitea carries the same description, website and topics, and sends issues to to GitHub as its external tracker.
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:
- [x] **Close [#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:
Fixed by `test/readme.test.ts`. `test/readme.test.ts`.
- [x] **Close [#3](https://github.com/larvit/larvitsmpp/issues/3), tests for flash messages.** Fixed - [x] [#3](https://github.com/larvit/larvitsmpp/issues/3) Tests for flash messages:
by `test/session.test.ts`. `test/session.test.ts`.
- [x] **Close [#4](https://github.com/larvit/larvitsmpp/issues/4), DLR errors with `message_state` - [x] [#4](https://github.com/larvit/larvitsmpp/issues/4) DLR errors with `message_state` missing:
missing.** `dlrFromPdu()` parses the `stat:` receipt text when the TLVs are absent. `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 - [x] [#13](https://github.com/larvit/larvitsmpp/issues/13) Limit a long SMS to fewer segments: the
segments.** Fixed by the `maxSegments` send option. `maxSegments` send option.
- [x] **Close [#16](https://github.com/larvit/larvitsmpp/issues/16), support all three bind types.** - [x] [#16](https://github.com/larvit/larvitsmpp/issues/16) Support all three bind types: bound and
Bound and enforced in both directions. enforced in both directions.
- [x] **Close [#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.
- [x] **Close [#20](https://github.com/larvit/larvitsmpp/issues/20), tests fail on current - [x] [#20](https://github.com/larvit/larvitsmpp/issues/20) Tests fail on current dependency
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.
- [x] **Close [#33](https://github.com/larvit/larvitsmpp/issues/33), large inbound text arrives as - [x] [#33](https://github.com/larvit/larvitsmpp/issues/33) Large inbound text arrives as raw
raw `Buffer` segments.** `IncomingRequests` reassembles a UDH-carrying `deliver_sm` into one `Buffer` segments: `IncomingRequests` reassembles a UDH-carrying `deliver_sm` into one `sms`
`sms` event. event.
- [x] **Close [#68](https://github.com/larvit/larvitsmpp/pull/68), a pull request for `message_id` - [x] [#68](https://github.com/larvit/larvitsmpp/pull/68), a pull request: `message_id` in
in `submit_sm_resp` and spec DLR codes.** All four hold: `sendResp()` always answers a `submit_sm_resp`, spec DLR codes. All four hold: `sendResp()` always answers a `message_id`,
`message_id`, per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the fork found real
fork found real defects. defects.
**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`:
- [x] **Close the twelve dependency pull requests as superseded.** - [x] [#40](https://github.com/larvit/larvitsmpp/pull/40),
[#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),
@@ -180,9 +175,8 @@ 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:
- [x] **Close [#8](https://github.com/larvit/larvitsmpp/issues/8), the socket's remote host and port - [x] [#8](https://github.com/larvit/larvitsmpp/issues/8) The socket's remote host and port on log
on log messages, as tracked here.** It sits under Worth doing, not blocking. Maintainer's messages: under Worth doing, not blocking. Maintainer's call, 2026-09-14.
call, 2026-09-14.
## 0.6.0 ## 0.6.0
@@ -452,80 +446,78 @@ 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 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. spelling to take it from. Raised by review, 2026-09-20.
- [ ] **Refuse a send the codec cannot build before it waits for a link and a window slot.** Today - [ ] **A send the codec will refuse waits for a link and a window slot first.** `refuse()` in
it waits first. `refuse()` in `outgoing-requests.ts` runs `misuse()` and the abort check `outgoing-requests.ts` runs `misuse()` and the abort check before the wait, precisely so a call
before the wait, precisely so a call that can never go out does not queue for what it will that can never go out does not queue for what it will never use; a body `objToPdu()` refuses on
never use; a body `objToPdu()` refuses on every attempt is the same case, and #98 made it a every attempt is the same case, and #98 made it a common one. On a down link the caller waits
common one. On a down link the caller waits `responseTimeout` and is told the link failed `responseTimeout` and is told the link failed rather than that the body could not be built —
rather than that the body could not be built — goal 2's wrong answer about what happened. The goal 2's wrong answer about what happened. The cheap fix builds the PDU twice, so the shape is
cheap fix builds the PDU twice, so the shape is the open half. Raised by the architecture the open half. Raised by the architecture review of
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. [#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09.
- [ ] **Add a gate that refuses a floating version anywhere in the repo.** Maintainer's ask on - [ ] **`message.ts` answers two questions.** Message coding and the SMPP time format (`smppDate`,
[#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the `release.yaml` pinning `smppTime`) share the file, which the architecture map in AGENTS.md already spells out as four
thread. Pinning every action and runner by hand is what the ask followed; the gate is what concerns. Nothing is wrong today; if the file has to move for another reason, `smpp-time.ts` is
keeps them pinned. It has to cover workflow `uses:` and `runs-on:`, compose `image:`, and 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
Dockerfile `FROM`, and the conventions differ per kind — actions take a semver tag, images the 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. full patch version — so one grep for `latest` is not it.
- [ ] **Add a gate that catches the test matrix missing the current Node.** Maintainer's ask on - [ ] **A gate that fails when the test matrix misses the current Node.** Maintainer's ask on
[#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the Node 26 thread. Node [#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 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 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. build or opens a PR, which is what Renovate already does for everything else here.
- [ ] **Have CodeRabbit review Gitea pull requests through the GitHub mirror.** CodeRabbit does not - [ ] **CodeRabbit reviews through the GitHub mirror.** CodeRabbit does not support Gitea, so mirror
support Gitea, so mirror each Gitea pull request to GitHub for it to review there. each Gitea pull request to GitHub for it to review there. Maintainer's ask, 2026-09-14; not
Maintainer's ask, 2026-09-14; not started until asked. started until asked.
- [ ] **Count what is left of a budget one way in `leftOf()` and the link gate.** Today they are one - [ ] **`leftOf()` and the link gate's own budget are one concept counted twice.**
concept counted twice. `idle-waiters.ts` reads what is left of a budget as `Math.max(1, `idle-waiters.ts` reads what is left of a budget as `Math.max(1, deadline - now)`, because 0
deadline - now)`, because 0 means "forever" there; `link-gate.ts` runs the same subtraction means "forever" there; `link-gate.ts` runs the same subtraction and calls `<= 0` expired.
and calls `<= 0` expired. Neither is reachable from the other, so nothing can disagree today, Neither is reachable from the other, so nothing can disagree today, but a reader who learns one
but a reader who learns one and applies it to the other is wrong. A budget type both take and applies it to the other is wrong. A budget type both take would close it. Raised by review,
would close it. Raised by review, 2026-09-01. 2026-09-01.
- [ ] **Write a non-zero `err:` on a receipt only for a state that failed.** `receiptText()` now - [ ] **`err:` on a receipt for a state that neither delivered nor failed.** `receiptText()` now
writes `err:000` for `DELIVERED` and for the two transient states, and `err:001` for every 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 other — so `ACCEPTED`, `SKIPPED`, `UNKNOWN` and `DELETED` still announce an error code the SMSC
SMSC never had. Which of those are failures is the open half. Raised by review, 2026-09-03; never had. Which of those are failures is the open half. Raised by review, 2026-09-03; needs a
needs a decision. decision.
- [ ] **Share one `once()` across the test files, one that gives up.** Today it is copied into four - [ ] **`once()` is copied into four test files, and two copies never give up.**
test files, and two copies never give up. `session-extras.test.ts` and `readme.test.ts` reject `session-extras.test.ts` and `readme.test.ts` reject after 5000 ms; `session.test.ts` and
after 5000 ms; `session.test.ts` and `tls.test.ts` wait forever, so an event that never fires `tls.test.ts` wait forever, so an event that never fires still hangs the run the way an
still hangs the run the way an unclosed listener used to. One shared, guarded copy closes the unclosed listener used to. One shared, guarded copy closes the rest of that class.
rest of that class.
- [ ] **Carry the peer's address and bind on every session log message.** `remoteAddress` and - [ ] **The peer's address and bind on every session log message.** `remoteAddress` and `remotePort`
`remotePort` reach only `server - incoming connection`, and `systemId` only the bind messages, reach only `server - incoming connection`, and `systemId` only the bind messages, so with
so with several peers connected one session's lines cannot be told apart, and a reconnect several peers connected one session's lines cannot be told apart, and a reconnect leaves nothing
leaves nothing stable to filter on. Carrying them in every session message's metadata is a stable to filter on. Carrying them in every session message's metadata is a change to every
change to every call site. From [#8](https://github.com/larvit/larvitsmpp/issues/8), closed call site. From [#8](https://github.com/larvit/larvitsmpp/issues/8), closed there as tracked
there as tracked here; maintainer's call, 2026-09-14. here; maintainer's call, 2026-09-14.
- [ ] **Settle how a peer whose message ids share one base logs its refused merges.** Today it logs - [ ] **A peer whose message ids share one base logs a refused merge on every send.** `smsc01-000123`
one on every send. `smsc01-000123` and `smsc01-000124` carry the same base, so `DlrMerger` and `smsc01-000124` carry the same base, so `DlrMerger` merges the first message and refuses
merges the first message and refuses every one after it, one log line per send. Left at `info` every one after it, one log line per send. Left at `info` — nothing the operator can fix is
— nothing the operator can fix is wrong — but a rate guard or silence may suit it better. wrong — but a rate guard or silence may suit it better. Raised by review, 2026-08-30.
Raised by review, 2026-08-30.
- [ ] **Exercise `submit_multi` and the broadcast commands end to end.** They encode and decode, but - [ ] **`submit_multi` and the broadcast commands** encode and decode, but nothing exercises them
nothing exercises them 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.
- [ ] **Add an `onReceipt` hook.** Receipt text is only loosely specified and operators disagree on - [ ] **An `onReceipt` hook.** Receipt text is only loosely specified and operators disagree on it,
it, but `dlrFromPdu()` is wired into `IncomingRequests` with no seam of its own: an but `dlrFromPdu()` is wired into `IncomingRequests` with no seam of its own: an application
application facing a format we do not parse has to take the whole PDU on `onRequest` and facing a format we do not parse has to take the whole PDU on `onRequest` and reimplement the
reimplement the dispatch, which owns the response as well. Mirror the `onRequest` seam — dispatch, which owns the response as well.
return a `Dlr` to own the receipt, `undefined` to fall through to the built-in parser. 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 ## Gaps against other SMPP libraries
@@ -535,40 +527,38 @@ Each lands under goal 7: an option or a hook, with the call that passes none unc
### Sending ### Sending
- [ ] **Add a limiter hook, with a messages-per-second cap built on it.** Maintainer's call, - [ ] **A limiter hook, with a messages-per-second cap built on it.** Maintainer's call, 2026-09-14,
2026-09-14, reversing the earlier decline: Kannel, Jasmin, go-smpp and `smpp-js-sdk` all limit reversing the earlier decline: Kannel, Jasmin, go-smpp and `smpp-js-sdk` all limit throughput.
throughput. Count PDUs, not `sendSms()` calls — a long message is one `submit_sm` per segment Count PDUs, not `sendSms()` calls — a long message is one `submit_sm` per segment and the
and the operator counts those, which an application wrapping `sendSms()` cannot see. The hook operator counts those, which an application wrapping `sendSms()` cannot see. The hook takes a
takes a limiter the application already runs; the built-in cap counts per session until the limiter the application already runs; the built-in cap counts per session until the store at the
store at the bottom lets it span sessions and processes. The default stays uncapped. Open: the bottom lets it span sessions and processes. The default stays uncapped. Open: the hook's shape (a
hook's shape (a wait that resolves when a PDU may go, cut short by the send's `signal`), which wait that resolves when a PDU may go, cut short by the send's `signal`), which requests it gates
requests it gates — messages, never `enquire_link`, `unbind` or a response — and whether its — messages, never `enquire_link`, `unbind` or a response — and whether its wait counts against
wait counts against `responseTimeout`. `responseTimeout`.
- [ ] **Back off and resend on `ESME_RTHROTTLED`.** The SMSC refused the PDU, so resending cannot - [ ] **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 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` 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. counts too. A throttled answer is also what a limiter hook wants to hear about.
- [ ] **Let `sendSms()` take the rest of `submit_sm`.** `service_type`, `priority_flag`, - [ ] **`sendSms()` takes the rest of `submit_sm`.** `service_type`, `priority_flag`, `protocol_id`,
`protocol_id`, `replace_if_present_flag` and TLVs on every segment, and `registered_delivery` `replace_if_present_flag` and TLVs on every segment, and `registered_delivery` beyond final
beyond final receipts: on failure only, and intermediate notifications. Today each needs receipts: on failure only, and intermediate notifications. Today each needs `send()`, which gives
`send()`, which gives up splitting, the alphabet checks and receipt merging. TLVs are the up splitting, the alphabet checks and receipt merging. TLVs are the common case: India's DLT
common case: India's DLT rules put `PE_ID` (0x1400) and `TEMPLATE_ID` (0x1401) on every rules put `PE_ID` (0x1400) and `TEMPLATE_ID` (0x1401) on every `submit_sm`, and USSD rides on
`submit_sm`, and USSD rides on `ussd_service_op`. Refuse a TLV the send composes itself `ussd_service_op`. Refuse a TLV the send composes itself (`sar_*`, `message_payload`). Open: one
(`sar_*`, `message_payload`). Open: one spelling for receipts, since `dlr: true` and a raw spelling for receipts, since `dlr: true` and a raw `registered_delivery` could disagree, and
`registered_delivery` could disagree, and whether goal 4's rule on optional parameters binds a whether goal 4's rule on optional parameters binds a TLV the caller named.
TLV the caller named.
- [ ] **Choose how a long message is spelled on the wire.** Only an 8-bit UDH reference goes out - [ ] **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 (`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 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. makes a collision rarer: the 8-bit one wraps every 255 sends on a session.
- [ ] **Fail over across SMSC hosts.** Maintainer's call, 2026-09-14. `client()` takes one `host` - [ ] **Failover across SMSC hosts.** Maintainer's call, 2026-09-14. `client()` takes one `host` and
and `port`; Kannel, Jasmin and php-smpp take several. A list the reconnect loop walks holds `port`; Kannel, Jasmin and php-smpp take several. A list the reconnect loop walks holds only
only which host the one session is on, so it needs no store. Two options, both maintainer's which host the one session is on, so it needs no store. Two options, both maintainer's calls:
calls:
- **Order**, `fixed` or round robin, default `fixed`. Fixed starts every reconnect at the first - **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. 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 - **Starting over**, a boolean, default on: once the last host has been tried, go back to the
@@ -579,29 +569,26 @@ Each lands under goal 7: an option or a hook, with the call that passes none unc
### The server ### The server
- [ ] **Send `deliver_sm` from `sendSms()` on a `server()` session.** Today it sends `submit_sm` - [ ] **`sendSms()` on a `server()` session sends `submit_sm` toward the ESME.** Kannel answers
toward the ESME. Kannel answers `ESME_RINVCMDID` (`interop-tests/kannel.test.ts`, "MO to `ESME_RINVCMDID` (`interop-tests/kannel.test.ts`, "MO to Kannel"), and goal 1 says that PDU never
Kannel"), and goal 1 says that PDU never goes out. A server has no other way to send an MO goes out. A server has no other way to send an MO message either: `sendMo()` in that test builds
message either: `sendMo()` in that test builds one from `submitSmParams()` and one from `submitSmParams()` and `ConcatReference`, neither exported. Choosing `deliver_sm` by
`ConcatReference`, neither exported. Choosing `deliver_sm` by `linkEnd` gives MO messages the `linkEnd` gives MO messages the splitting and checks, keeps one method for one goal, and refuses
splitting and checks, keeps one method for one goal, and refuses the options 3.4 has the options 3.4 has `deliver_sm` leave empty (`scheduleDeliveryTime`, `validityPeriod`).
`deliver_sm` leave empty (`scheduleDeliveryTime`, `validityPeriod`).
- [ ] **Let a response this library builds carry error TLVs.** `buildBody()` in `pdu.ts` writes no - [ ] **Error TLVs on a response this library builds.** `buildBody()` in `pdu.ts` writes no body for
body for any non-zero status, so a server cannot answer a `data_sm` with any non-zero status, so a server cannot answer a `data_sm` with `delivery_failure_reason`,
`delivery_failure_reason`, `network_error_code` or `additional_status_info_text`, and a 5.0 `network_error_code` or `additional_status_info_text`, and a 5.0 peer gets none of its error TLVs.
peer gets none of its error TLVs. 3.4 omits the body on error for `submit_sm_resp` by name; 3.4 omits the body on error for `submit_sm_resp` by name; read each response's section before
read each response's section before widening it. Reading needs nothing: an error response widening it. Reading needs nothing: an error response carrying a body already parses.
carrying a body already parses.
- [ ] **Accept the PROXY protocol on `server()`.** Behind HAProxy or an AWS NLB every session's - [ ] **PROXY protocol on `server()`.** Behind HAProxy or an AWS NLB every session's remote address is
remote address is the balancer's, so `authenticate` cannot allow-list by IP and logs name the the balancer's, so `authenticate` cannot allow-list by IP and logs name the wrong peer. v1 is
wrong peer. v1 is text; v2 is binary and the only one an NLB sends. `smpp` accepts v1 from text; v2 is binary and the only one an NLB sends. `smpp` accepts v1 from anyone; accept either
anyone; accept either only from addresses the option names. only from addresses the option names.
- [ ] **Handle `outbind`.** It is in the command table, handled nowhere: a client cannot take an - [ ] **`outbind`.** In the command table, handled nowhere: a client cannot take an SMSC's `outbind`
SMSC's `outbind` and bind back, and `server()` cannot send one. Rare; take it on with a peer and bind back, and `server()` cannot send one. Rare; take it on with a peer that uses it.
that uses it.
- [ ] **Register vendor-specific commands.** 3.4 reserves `command_id` `0x00010200`–`0x000102FF` for - [ ] **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 SMSC vendors; today one arrives as a `PduRefusedError`. `smpp` has `addCommand()`. The same
@@ -623,9 +610,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 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. `messageClassEncoding()` reads GSM 7-bit from, which `flash` writes under.
- [ ] **Carry the alphabets SMPP 3.4 names that no encoding carries.** `consts.ENCODING` lists the - [ ] **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 `data_coding` ids (5.2.19); only `ASCII`, `LATIN1` and `UCS2` can be sent. Those with a published
published definition, and what each costs: definition, and what each costs:
- 0x01 IA5 (ITU-T T.50, ASCII in practice): trivial. - 0x01 IA5 (ITU-T T.50, ASCII in practice): trivial.
- 0x06 ISO-8859-5 (Cyrillic) and 0x07 ISO-8859-8 (Hebrew): 96-entry tables. - 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. - 0x05 JIS X 0208, 0x0D JIS X 0212, 0x0A ISO-2022-JP and 0x0E KS C 5601: two-octet sets.
@@ -635,34 +622,30 @@ 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`. 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. - 0x09 pictogram has no published definition; leave it out.
- [ ] **Read and send the GSM 7-bit national language shift tables.** 3GPP TS 23.038 defines them - [ ] **GSM 7-bit national language shift tables.** 3GPP TS 23.038 defines them for Turkish, Spanish
for Turkish, Spanish (single shift only), Portuguese and ten Indian languages — Bengali, (single shift only), Portuguese and ten Indian languages — Bengali, Gujarati, Hindi, Kannada,
Gujarati, Hindi, Kannada, Malayalam, Oriya, Punjabi, Tamil, Telugu and Urdu — selected per Malayalam, Oriya, Punjabi, Tamil, Telugu and Urdu — selected per message by UDH elements 0x25
message by UDH elements 0x25 (locking) and 0x24 (single). They keep that text near GSM's (locking) and 0x24 (single). They keep that text near GSM's segment size instead of UCS2's 67
segment size instead of UCS2's 67 characters. Reading means honouring those elements in characters. Reading means honouring those elements in `decodeMessage()`; sending means `detect()`
`decodeMessage()`; sending means `detect()` picking a table, with each element's 3 octets off picking a table, with each element's 3 octets off the segment budget. `smpp` has Turkish, Spanish
the segment budget. `smpp` has Turkish, Spanish and Portuguese, used only when the caller and Portuguese, used only when the caller writes the UDH.
writes the UDH.
- [ ] **Offer packed GSM 7-bit, opt-in.** Everything goes out unpacked, SMPP's convention - [ ] **Packed GSM 7-bit, opt-in.** Everything goes out unpacked, SMPP's convention (AGENTS.md, "GSM
(AGENTS.md, "GSM 7-bit is sent unpacked"); go-smpp carries a packed codec for SMSCs that want 7-bit is sent unpacked"); go-smpp carries a packed codec for SMSCs that want septets. Find an SMSC
septets. Find an SMSC that needs it before building it. that needs it before building it.
- [ ] **Give each alphabet in `consts.ENCODING` one name.** Five are spelled twice: - [ ] **`consts.ENCODING` spells five alphabets twice.** `CYRILLIC`/`ISO_8859_5`,
`CYRILLIC`/`ISO_8859_5`, `HEBREW`/`ISO_8859_8`, `JIS`/`X_0208_1990`, `HEBREW`/`ISO_8859_8`, `JIS`/`X_0208_1990`, `EXTENDED_KANJI_JIS`/`X_0212_1990` and
`EXTENDED_KANJI_JIS`/`X_0212_1990` and `LATIN1`/`ISO_8859_1`; `FLASH` is a message class, not `LATIN1`/`ISO_8859_1`; `FLASH` is a message class, not an alphabet. One name each before
an alphabet. One name each before registration starts taking names. A breaking change to an registration starts taking names. A breaking change to an export.
export.
### Observability ### Observability
- [ ] **Add metrics: outbound PDU events and counts of requests waiting.** Inbound traffic has - [ ] **Metrics.** Inbound traffic has `data`, `incomingPdu` and `incomingPduObj`; outbound has no
`data`, `incomingPdu` and `incomingPduObj`; outbound has no event, and nothing counts requests event, and nothing counts requests in flight, queued for a window slot, waiting for a link, or
in flight, queued for a window slot, waiting for a link, or unanswered. `smpp` and unanswered. `smpp` and `@semyonf/smpp` emit `metrics`; cloudhopper keeps per-session counters. An
`@semyonf/smpp` emit `metrics`; cloudhopper keeps per-session counters. An `outgoingPdu`/`outgoingPduObj` pair mirrors the inbound events; the counters can be one read-only
`outgoingPdu`/`outgoingPduObj` pair mirrors the inbound events; the counters can be one snapshot, read from the owner of each count rather than a second tally that can drift.
read-only snapshot, read from the owner of each count rather than a second tally that can
drift.
### Packaging, tests and CI ### Packaging, tests and CI
@@ -672,21 +655,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 `src` (41 files, 209 KB) makes go to definition land in the TypeScript, and lets a debugger or
`--enable-source-maps` show it. `--enable-source-maps` show it.
- [ ] **Add a coverage report and a floor to the gate.** `node --test --experimental-test-coverage - [ ] **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, --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`, 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 `--test-coverage-branches` and `--test-coverage-functions`, which Node 22 and later take — a job
job of its own on 24, since the matrix runs compiled JavaScript — and add it to `main`'s of its own on 24, since the matrix runs compiled JavaScript — and add it to `main`'s required
required checks. Raise the floor as coverage rises; never lower it. checks. Raise the floor as coverage rises; never lower it.
- [ ] **Add mutation testing.** `@stryker-mutator/tap-runner` runs `node:test` suites and measures - [ ] **Mutation testing.** `@stryker-mutator/tap-runner` runs `node:test` suites and measures whether
whether a test notices a change, which coverage cannot; `@semyonf/smpp` runs Stryker in CI. a test notices a change, which coverage cannot; `@semyonf/smpp` runs Stryker in CI. The session
The session suites are timer-heavy, so start with the codec and the encodings. suites are timer-heavy, so start with the codec and the encodings.
- [ ] **Build a Node-RED node, as a package of its own.** `@leissner/node-red-smpp` is the only SMPP - [ ] **A Node-RED node, as a package of its own.** `@leissner/node-red-smpp` is the only SMPP node in
node in the Node-RED library, and by a read of its source it never parses a receipt and never the Node-RED library, and by a read of its source it never parses a receipt and never answers the
answers the SMSC's `enquire_link`. Its UI is a fair list of what operators set. It builds on SMSC's `enquire_link`. Its UI is a fair list of what operators set. It builds on this package,
this package, never inside it. never inside it.
## Declined ## Declined
@@ -700,9 +683,9 @@ Each lands under goal 7: an option or a hook, with the call that passes none unc
## An optional store ## An optional store
- [ ] **Add pooling, and state that survives a restart, through an optional store.** Maintainer's - [ ] **Pooling, and state that survives a restart, through an optional store.** Maintainer's call,
call, 2026-09-14. It replaces two declines — merge state surviving a restart, and a pool of 2026-09-14. It replaces two declines — merge state surviving a restart, and a pool of sessions —
sessions — and goal 9 was rewritten for it. Big: design before code. 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 - **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 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 concatenation reference, so a restart does not reuse one. For a pool, the ids every session