22 Commits

Author SHA1 Message Date
lilleman fb139ab5ee Measure what this library sustains, and against Jasmin and SMPPSim
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 36s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 30s
Mirror / push (push) Successful in 5s
2026-09-20 22:57:16 +02:00
lilleman 92fbd6a9f2 Wait the default out for real, since node:test mock timers postdate the Node 18 floor
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 32s
Test / test (26) (pull_request) Successful in 30s
Mirror / push (push) Successful in 4s
2026-09-20 20:52:32 +02:00
lilleman 45171c2697 Bound the wait in the default test, and pin the boolean a refusal prints
Mirror / push (push) Successful in 4s
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Failing after 18s
Test / test (20) (pull_request) Successful in 22s
Test / test (22) (pull_request) Successful in 19s
Test / test (24) (pull_request) Successful in 19s
Test / test (26) (pull_request) Successful in 19s
2026-09-20 20:46:45 +02:00
lilleman a24f350b7a Prove the default bound is wired, and name the option on the failure a consumer reads
Mirror / push (push) Successful in 4s
Test / lint (pull_request) Successful in 22s
Test / test (18) (pull_request) Failing after 19s
Test / test (20) (pull_request) Successful in 19s
Test / test (22) (pull_request) Successful in 24s
Test / test (24) (pull_request) Successful in 20s
Test / test (26) (pull_request) Successful in 19s
2026-09-20 20:41:45 +02:00
lilleman 1416615ca3 Bound every connect attempt at 10 s by default, with false the way to opt out
Mirror / push (push) Successful in 5s
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 19s
Test / test (20) (pull_request) Successful in 19s
Test / test (22) (pull_request) Successful in 19s
Test / test (24) (pull_request) Successful in 20s
Test / test (26) (pull_request) Successful in 19s
2026-09-20 20:34:53 +02:00
lilleman 8f416c2bc4 Name the peer and the stalled phase on a connect timeout, and quote an untyped value
Mirror / push (push) Successful in 5s
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 19s
Test / test (20) (pull_request) Successful in 19s
Test / test (22) (pull_request) Successful in 24s
Test / test (24) (pull_request) Successful in 18s
Test / test (26) (pull_request) Successful in 19s
2026-09-20 18:17:39 +02:00
lilleman 84174b4a5b Refuse a connectTimeout Node's timers cannot hold, and pin the refusals the tests missed
Mirror / push (push) Successful in 5s
Test / lint (pull_request) Successful in 20s
Test / test (18) (pull_request) Successful in 26s
Test / test (20) (pull_request) Successful in 19s
Test / test (22) (pull_request) Successful in 20s
Test / test (24) (pull_request) Successful in 19s
Test / test (26) (pull_request) Successful in 20s
2026-09-20 18:12:39 +02:00
lilleman 17447bbe3a Give up a connect after connectTimeout instead of waiting the OS out
Mirror / push (push) Successful in 4s
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 22s
Test / test (20) (pull_request) Successful in 18s
Test / test (22) (pull_request) Successful in 18s
Test / test (24) (pull_request) Successful in 19s
Test / test (26) (pull_request) Successful in 27s
2026-09-20 00:42:09 +02:00
lilleman d874448273 Plan a connectTimeout option, found comparing 0.5.0 with the larvitsmpp forks
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 20s
Test / test (20) (pull_request) Successful in 18s
Test / test (22) (pull_request) Successful in 27s
Test / test (24) (pull_request) Successful in 19s
Test / test (26) (pull_request) Successful in 28s
Mirror / push (push) Successful in 4s
2026-09-20 00:23:06 +02:00
lilleman 656bbae500 Move the decisions to docs/decisions.md and index them in AGENTS.md
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 20s
Test / test (20) (pull_request) Successful in 21s
Test / test (22) (pull_request) Successful in 20s
Test / test (24) (pull_request) Successful in 20s
Test / test (26) (pull_request) Successful in 21s
Mirror / push (push) Successful in 6s
2026-09-19 01:04:08 +02:00
lilleman c90b855958 Drop the anti-mention from the audience
Test / lint (pull_request) Successful in 22s
Test / test (18) (pull_request) Successful in 23s
Test / test (20) (pull_request) Successful in 20s
Test / test (22) (pull_request) Successful in 20s
Test / test (24) (pull_request) Successful in 21s
Test / test (26) (pull_request) Successful in 20s
Mirror / push (push) Successful in 6s
2026-09-19 00:59:29 +02:00
lilleman 0f13951e8c Move the goals to README.md and state the audience
Mirror / push (push) Successful in 5s
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 21s
Test / test (20) (pull_request) Successful in 19s
Test / test (22) (pull_request) Successful in 20s
Test / test (24) (pull_request) Successful in 20s
Test / test (26) (pull_request) Successful in 20s
2026-09-19 00:48:21 +02:00
lilleman aab74e4cb3 Fixed CLAUDE.md
Test / lint (pull_request) Successful in 22s
Test / test (18) (pull_request) Successful in 20s
Test / test (20) (pull_request) Successful in 19s
Test / test (22) (pull_request) Successful in 20s
Test / test (24) (pull_request) Successful in 19s
Test / test (26) (pull_request) Successful in 19s
Mirror / push (push) Successful in 5s
2026-09-19 00:15:07 +02:00
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 (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
Test / test (26) (pull_request) Successful in 17s
Mirror / push (push) Successful in 3s
2026-09-14 20:54:04 +02:00
lilleman 14b114afda Split the GitHub mirror so Gitea creates its runs and no delete is dropped
Mirror / push (push) Successful in 5s
Test / lint (pull_request) Successful in 20s
Test / test (18) (pull_request) Successful in 18s
Test / test (20) (pull_request) Successful in 18s
Test / test (22) (pull_request) Successful in 22s
Test / test (24) (pull_request) Successful in 17s
Test / test (26) (pull_request) Successful in 17s
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 (24) (pull_request) Successful in 17s
Test / test (26) (pull_request) Successful in 19s
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 (18) (pull_request) Successful in 17s
Test / test (20) (pull_request) Successful in 17s
Test / test (22) (pull_request) Successful in 17s
Test / test (24) (pull_request) Successful in 17s
Test / test (26) (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
lilleman 95e1f910a3 Track the peer's address on every log message in todo.md, and keep Renovate's dashboard open
Test / lint (pull_request) Successful in 24s
Test / test (18) (pull_request) Successful in 19s
Test / test (20) (pull_request) Successful in 19s
Test / test (22) (pull_request) Successful in 19s
Test / test (24) (pull_request) Successful in 18s
Test / test (26) (pull_request) Successful in 20s
2026-09-14 15:04:12 +02:00
18 changed files with 1727 additions and 869 deletions
+40
View File
@@ -0,0 +1,40 @@
name: Mirror deletions
on:
delete:
permissions:
contents: read
jobs:
delete:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Delete the ref from GitHub once Gitea no longer has it
env:
MIRROR_GITHUB_TOKEN: ${{ secrets.MIRROR_GITHUB_TOKEN }}
MIRROR_URL: https://github.com/larvit/smpp-js.git
REF: ${{ github.event.ref }}
SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}.git
run: |
# Gitea Actions sends the full ref name here, where its webhooks send the short one.
case "$REF" in
refs/heads/*|refs/tags/*) ;;
*) echo "delete event names '$REF', not a full branch or tag ref"; exit 1 ;;
esac
# ls-remote --exit-code: 0 the ref exists, 2 it does not, anything else the remote could not be read.
on_gitea=0
git ls-remote --exit-code "$SOURCE_URL" "$REF" > /dev/null || on_gitea=$?
if [ "$on_gitea" -ne 2 ]; then exit "$on_gitea"; fi
on_github=0
git ls-remote --exit-code "$MIRROR_URL" "$REF" > /dev/null || on_github=$?
if [ "$on_github" -eq 2 ]; then exit 0; fi
if [ "$on_github" -ne 0 ]; then exit "$on_github"; fi
git init --bare --quiet mirror.git
git -C mirror.git -c credential.helper= \
-c credential.helper='!f() { echo username=x-access-token; echo "password=$MIRROR_GITHUB_TOKEN"; }; f' \
push "$MIRROR_URL" ":$REF"
+29
View File
@@ -0,0 +1,29 @@
name: Mirror
on:
push:
schedule:
- cron: '17 3 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
push:
runs-on: ubuntu-24.04
timeout-minutes: 10
# Gitea cancels a queued job in this group when the next one arrives; only the full push may join.
concurrency:
group: mirror
steps:
- name: Push every branch and tag to GitHub, overwriting a same-named ref
env:
MIRROR_GITHUB_TOKEN: ${{ secrets.MIRROR_GITHUB_TOKEN }}
MIRROR_URL: https://github.com/larvit/smpp-js.git
SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}.git
run: |
git clone --bare --quiet "$SOURCE_URL" mirror.git
git -C mirror.git -c credential.helper= \
-c credential.helper='!f() { echo username=x-access-token; echo "password=$MIRROR_GITHUB_TOKEN"; }; f' \
push "$MIRROR_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
+106 -793
View File
@@ -12,38 +12,11 @@ not for structure or style.
## Goals ## Goals
In priority order, and the order is the point: where two of them pull against each other, the earlier The nine goals, in priority order, live in
one wins. They do not override the hard rules below. [README.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/README.md#goals) — they say where this library is heading, which an outside
reader judges it by. The README states the audience alongside them. Everything below cites a goal by
number.
1. **Correct on the wire.** SMPP 3.4 as SMSCs actually run it. Every other goal yields to this one;
the defect table below is what the alternative costs.
2. **Never give the application a wrong answer about what happened.** An outcome we cannot determine
is reported as undetermined rather than guessed; a report the peer marked as not final settles
nothing, so nothing the library concludes may rest on one; a request the peer may already have
taken is never re-sent on the library's own initiative; work the peer has no reason to send again
is not dropped.
3. **Strict in what we send, generous in what we read.** The library's own senders follow 3.4, and
the codec parses whatever arrives. Where the letter of the spec would discard traffic a real SMSC
sends, keep the traffic.
4. **A peer an operator never has to complain about.** No bind flooding, nothing a bind direction
forbids, no optional parameters to a peer that declared none, nothing held without a bound.
5. **The session layer is in here, and its defaults are what most applications should run.**
Keepalive, reconnect, the send window, reassembly and receipt correlation. What the network says
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
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
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
library composes, never what the caller wrote.
7. **Nothing that needs state wider than one session.** No throughput throttling, no persistence
across a restart, no coordination between processes — and no seam handing the application state to
persist for one of those either, which commits to the same scope through the back door and
publishes an internal shape to do it. This is the scope floor, and it is why an otherwise
reasonable feature is declined without a fresh argument each time.
8. **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
by the suite.
## Hard rules ## Hard rules
@@ -139,6 +112,11 @@ docker compose run --rm node npm run build
verified rather than asserted. verified rather than asserted.
- `typescript` is pinned to the 6.x line because `typescript-eslint` peer-requires `<6.1.0`. Move to - `typescript` is pinned to the 6.x line because `typescript-eslint` peer-requires `<6.1.0`. Move to
TypeScript 7 once that constraint lifts. TypeScript 7 once that constraint lifts.
- 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
timeout, or a push run that cloned before the delete, leaves the ref on GitHub until the delete
run is re-run. Accepted: a stale ref there is harmless, and refs only GitHub has must survive.
Maintainer's call, 2026-09-14; valid while nothing deploys from GitHub.
## Defects found in 0.4.0 ## Defects found in 0.4.0
@@ -190,7 +168,7 @@ is why a concatenated GSM segment is 153 characters plus a 6-octet UDH — 159 o
size and would truncate every long GSM message by a fifth. size and would truncate every long GSM message by a fifth.
GSM 7-bit is the only alphabet it applies to. What each of the three is budgeted, and why, is a GSM 7-bit is the only alphabet it applies to. What each of the three is budgeted, and why, is a
decision under [The wire](#the-wire). decision under [The wire](docs/decisions.md#the-wire).
## Conventions ## Conventions
@@ -215,8 +193,9 @@ decision under [The wire](#the-wire).
`message-class.test.ts` and `unsendable.test.ts` is one `SendSmsDeps.send` that answers nothing, `message-class.test.ts` and `unsendable.test.ts` is one `SendSmsDeps.send` that answers nothing,
and a field added to that type fails to compile in every copy at once. and a field added to that type fails to compile in every copy at once.
- `message_id` values the library generates are UUID v7. - `message_id` values the library generates are UUID v7.
- A test that needs a dummy peer must `resume()` its sockets. An unread socket never processes the - A socket a test opens and never reads must be `resume()`d, and a `data` listener counts. An unread
peer's FIN, so `server.close()` hangs forever — that is a test bug, not a library one. socket never processes the peer's FIN, so `server.close()` hangs forever — that is a test bug, not
a library one.
- Everything a test opens gets its teardown registered as it is opened, never closed on the test's - Everything a test opens gets its teardown registered as it is opened, never closed on the test's
last line: an assertion that throws skips that line, and the listener it leaves behind keeps last line: an assertion that throws skips that line, and the listener it leaves behind keeps
`node --test` alive until CI's ten-minute cap. `test/teardown.ts` covers a session, a server and a `node --test` alive until CI's ten-minute cap. `test/teardown.ts` covers a session, a server and a
@@ -233,12 +212,17 @@ decision under [The wire](#the-wire).
Each file answers one question, and a fact belongs to the file whose question it answers: Each file answers one question, and a fact belongs to the file whose question it answers:
- **README.md — what you can rely on.** Observable behaviour, for someone using the package. It - **README.md — what you can rely on, and where this is heading.** Observable behaviour, for
carries a reason only where the reason changes how you would call the thing. someone using the package, plus the goals and the audience. It carries a reason only where the
reason changes how you would call the thing.
- **CHANGELOG.md — what changed for a consumer, per release.** Written for the public, never for the
next agent, and a line lands there as the work ships rather than at release.
- **MIGRATION.md — what a 0.4.0 consumer has to change.** Renamed and removed surface, and the - **MIGRATION.md — what a 0.4.0 consumer has to change.** Renamed and removed surface, and the
behaviour that changed on the wire. behaviour that changed on the wire.
- **AGENTS.md — what may not change, and why.** Goals, hard rules, architecture, conventions, and the - **AGENTS.md — what may not change, and why.** Hard rules, architecture, conventions, and an index
decisions the goals do not already settle. It does not restate behaviour README states. of the decisions. It does not restate behaviour or goals README states.
- **docs/decisions.md — what was settled, and against what.** The decisions the goals do not
already settle, each with the constraint that settled it and the alternative rejected.
- **todo.md** is a working file that sets its own rules; nothing here governs it. - **todo.md** is a working file that sets its own rules; nothing here governs it.
A sentence living in two of them is a defect: delete the copy in the file whose question it does not A sentence living in two of them is a defect: delete the copy in the file whose question it does not
@@ -250,769 +234,98 @@ follows from it; a decision record decides one. So reach for the goal list first
add one, or move one up the order — and write a decision only for what is left over: a choice a add one, or move one up the order — and write a decision only for what is left over: a choice a
competent change would otherwise re-open, that no goal implies. Give the claim, the constraint that competent change would otherwise re-open, that no goal implies. Give the claim, the constraint that
settled it and the alternative rejected, and nothing the code or README already says. Where a settled it and the alternative rejected, and nothing the code or README already says. Where a
compiler or a test already forbids the other way, it is not a decision, it is a test name. Delete one compiler or a test already forbids the other way, it is not a decision, it is a test name. It goes in
once it no longer constrains anything; this is not a changelog. `docs/decisions.md` with its title indexed below. Delete one once it no longer constrains anything;
this is not a changelog.
## Decisions ## Decisions
Grouped by what each one constrains. The decisions themselves live in [docs/decisions.md](docs/decisions.md). Their titles are indexed
here, so a reader sees that a decision exists without carrying its reasoning; the reasoning is in
the file.
### The public surface ### [The public surface](docs/decisions.md#the-public-surface)
- **`Session` is publicly constructible, which is what makes `SessionOptions` and `ReconnectOptions` - `Session` is publicly constructible, which is what makes `SessionOptions` and `ReconnectOptions`
public too.** Raised twice as a leak; it is not one. The collaborators `session.ts` delegates to public too.
(`Reassembler`, `PendingRequests`, `SendWindow`, `ReconnectLoop`, `LinkTimers`, `LinkGate`, - `acceptsOptionalParams()` and `bindAllows()` are predicates, not chokepoints.
`DlrMerger`, `PduTransport`, `submitSms`) stay unpublished so they can be reshaped. - `session.sock` is a getter over `PduTransport`.
- Both emitters re-declare their listener methods to accept a promise.
- `PduRefusedError` is exported, and `sessionError` names it in the event's type.
- `bitCount()`, `encodeMessage()` and `splitMessage()` keep their total signatures, because
`EncodingName` is what keeps an alphabet with no codec away from them.
- A segment the SMSC took and named no id for is `undefined` in `smsIds`, not an empty string.
- **`acceptsOptionalParams()` and `bindAllows()` are predicates, not chokepoints.** The library's own ### [The wire](docs/decisions.md#the-wire)
senders consult them; `session.send({ tlvs })` is passed through as written, because silently
stripping a caller's explicit TLVs off a deliberately public low-level surface would be worse than
sending them. Only `submit_sm`, `deliver_sm` and `data_sm` are policed by bind direction — the
three the library dispatches by it, of which it sends the first two.
- **`session.sock` is a getter over `PduTransport`.** Reading it is unchanged; assigning it no longer - The declared interface version is an option on both `client()` and `server()`, and is not the
compiles, which never rewired the handlers and so never worked. optional-parameter threshold.
- A peer that declared no version is pre-3.4, and `undefined` means no bind yet.
- `esm_class` decides what a `deliver_sm` is, and the body is read only when it names nothing.
- A body is read from `message_payload` where `short_message` carries none, and `short_message` wins
where a peer filled both.
- A segment's concatenation is read from its UDH, or from the `sar_*` TLVs where it declares none,
and each spelling groups in a reference space of its own.
- `sendSms()` takes the messaging mode by name, and it is the only part of `esm_class` a caller
writes.
- An inbound `data_sm` stands in for whichever of `submit_sm` and `deliver_sm` its direction makes
it, and none goes out.
- A receipt's body is read as octets, and its own `data_coding` never says how.
- A message class is read where GSM 03.38 puts it, `flash` is class 0 alone, and a flash message
with no alphabet to carry it is refused.
- A report is final unless its `esm_class` or its state says otherwise, and only `ENROUTE` and
`SCHEDULED` say otherwise.
- A `stat:` an operator spells outside Appendix B is read as the state it names, and the two
researched ones are `FAILED` and CM.com's `DELIVERD`.
- A transient state goes out as an intermediate delivery notification (0x20), every other state as a
delivery receipt (0x04).
- A refused PDU is answered from its header, and any 32-bit `sequence_number` is echoed as it
arrived.
- The optional parameters run to `command_length` exactly, and the only slack tolerated is one NULL
octet where a peer padded `short_message`.
- `smsIdFormat` names a notation per place, and normalisation never reaches inside a `<base>-<n>`
id.
- A concatenated segment is budgeted at 134 octets, which is 153 septets where the SMSC packs them
and 134 octets of anything it does not.
- An alphabet the caller named has to carry the message, and a time the format cannot express is
refused, both before a segment goes out.
- A string body is written in the alphabet its own `data_coding` names, and one that alphabet cannot
carry is refused by the codec — `message_payload` on the same terms as `short_message`.
- A GSM 03.38 message declares `data_coding` 0x00, and an inbound 0x01 is still read as GSM.
- **Both emitters re-declare their listener methods to accept a promise.** Maintainer's call, ### [The session's life](docs/decisions.md#the-sessions-life)
2026-08-27: `EventEmitter` types every listener as void-returning, so the
`session.on('sms', async sms => …)` README documents reads as a misused promise in any strict
consumer. `declare on: …` and its six siblings re-type the inherited methods to return `unknown`,
which emits nothing and needs no cast; overriding them as real methods cannot work, because the
`super.on()` call needs one. The cost is that a subclass can no longer reach those seven through
`super` — re-declaring them the same way is its way out. `unknown` rather than
`void | Promise<void>` because a listener may return anything: `session.on('close', () =>
set.delete(session))` returns a boolean. This also settles what the drain can wait on: a listener's
own promise would be the better completion signal, and reaching it needs `listeners()`, which
cannot be re-declared the same way — Node types it invariantly enough that widening `void` to
`unknown` is `TS2416`. Re-probed 2026-09-01; `sendResp()` stays the signal.
- **`PduRefusedError` is exported, and `sessionError` names it in the event's type.** Maintainer's - A close arriving after our own `unbind` is a clean unbind, not an error.
call, 2026-09-05, from a product review: one event carries both a PDU the peer malformed and the - `close` means the session is over, and a drop the loop will retry is `disconnected`.
session's own failure, and `instanceof` is the only way to separate them that hard rule 4 allows — - An answer belongs to the link the message arrived on; a receipt does not.
without the class as a value an application is left string-matching `err.message`. Goal 6 is paid by - `reconnect` takes `{ minDelay, maxDelay }` to retune and `false` to turn off
exporting the discriminant and the struct it carries and nothing else: `PduHeader` is named because - Coming up is not proof a link works, so only one that outlasted `maxDelay` resets the backoff.
an application that logs or forwards a header wants a name for it, `PduRefusalReason` is not - `reconnect: { fromStart: true }` puts the first connect and bind through that same loop, and
because `reason` is compared against string literals, and an accessor `client()` then resolves only once it is bound.
(`PduRefusedError['header']`) names either one where a signature wants it. The payload union - `connectTimeout` defaults to 10 s, bounds the whole connect including the TLS handshake, and
enforces nothing — a subclass narrows out of `Error` either way — and is there so the event's own `false` is the one way to turn it off.
type names what to narrow to, which is also what makes it a half-truth if a second `Error` subclass - A stream this library cannot frame is a dead link; one PDU it cannot parse is not.
ever reaches this event without joining it. Rejected: a `SessionError` alias for that union, a - A deliberate shutdown drains; an unusable link and an abort do not.
third name for a type that is structurally `Error`. Rejected: a separate `pduRefused` event, which - Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is the
splits the failure channel so an application that wants every failure listens twice and an existing application's own signal rather than the peer's answer.
listener silently stops seeing refusals. Rejected: coalescing or rate-limiting them, which re-opens - `server()` composes the application's `onRequest` after its own bind handling, and offers it every
the standing decision that `sessionError` carries every failure, never coalesced or suppressed — request that handling did not answer.
the filtering belongs where the application is, since only it knows which peer is routinely sloppy. - The drain waits on the messages the application holds, and `sendResp()` is what says it is done
Rejected: an error code on a plain `Error`, which reads back off an `unknown` property only through with one.
a cast and types nothing it carries. Accepted: a second copy of the package installed alongside - A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.
this one defeats `instanceof`, where `err.name` still reads `PduRefusedError`. - A message id base is merged at most once.
- A send that never reached the socket waits for the next link; one that did is counted, not resent.
- A send queued for a send-window slot is bounded by the caller's `signal`, and by nothing else.
- The gate decides whether a link can carry a request, and a bind is what makes it one.
- **`bitCount()`, `encodeMessage()` and `splitMessage()` keep their total signatures, because ### [Internals and tests](docs/decisions.md#internals-and-tests)
`EncodingName` is what keeps an alphabet with no codec away from them.** Maintainer's call,
2026-09-09, from the architecture review of [#95](https://github.com/larvit/larvitsmpp/pull/95):
all three index `encodings` by name and would throw on one it has no codec for, which hard rule 1
forbids. That PR left no such name to pass — `encodings` is a `Record<EncodingName, Encoding>`, so
every member of the union has a codec and one added without a codec, or without a segment budget,
fails to compile in four places. What was missing is the door for a caller holding a name at
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()`,
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 —
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
without types gets. `smppTime.encode()` is where that reasoning lands the other way and is recorded
under [The wire](#the-wire): `Date | number | string` is not a closed set, so it is a `Result`.
- **A segment the SMSC took and named no id for is `undefined` in `smsIds`, not an empty string.** - A listener that rejects is routed by Node's `captureRejections`, not by hand-dispatching.
Maintainer's call, 2026-09-12: `paramText()` resolves an absent `message_id` and one a peer wrote - The four-line abort dance is copied across `LinkGate`, `IdleWaiters`, `PendingRequests` and
empty to the same `''`, which `string[]` then presented as an id — taking `smsIds[0]`, or keying a `SendWindow` rather than extracted.
correlation table by the array, compiled and then misbehaved, and one message's empty entry - `SmppLog` is a five-method contract this library declares, not a dependency.
collides with another's. Telesign names an id for the first segment of a concatenated submit only, - The TLS tests build their own self-signed certificate in DER
so it is a documented operator's shape rather than a hypothesis. Nothing else moves: - `src/` stays flat until a module has to move for another reason.
`parseSegmentId('')` matched nothing, so `DlrMerger` already abandoned such a send and `undefined` - `test/` stays flat too, and a file there is named for the question it answers rather than for the
reaches that same refusal. `expect()` takes the wider type rather than a filtered `string[]` module it covers.
because the arity is what `idNumbering()` refuses on: filtering `['a-1', undefined, 'a-3']` leaves - CI tests on Linux only; `src/` keeps off what is known to break on macOS or Windows.
a numbering that spells out a whole message, and merges one that was never whole. `dlrFromPdu()`
reads an id through `nonEmptyText()`, so no receipt could ever have matched an empty entry — and
that reading stays separate from this one rather than sharing a helper, since it must leave a
Buffer-valued `receipted_message_id` unresolved for `messageType()` to read the PDU as unmarked.
What settles it here is the resolved text rather than the parameter, because `writeParams()`
substitutes the field's own default: a peer that omits `message_id` and one that writes it empty
build the same octets, leaving a raw-parameter test nothing to tell apart. Rejected: keeping `''`
and documenting it, which leaves the published type promising what the value does not keep — goal
2, a wrong answer about what the peer named. Rejected: dropping the unnamed entries, which breaks
the positional correspondence with `pduObjs` that README promises and loses which segment a PDU
belongs to. Rejected: `{ id?: string; pduObj: PduObject }[]`, which makes that positional promise
structural where today the compiler cannot check it; deferred to the next breaking release, the
first place two documented fields may become one. Accepted: every consumer reading `smsIds`
narrows, including the majority whose SMSC names every id; indexing narrows too, except for the
consumer who sets `noUncheckedIndexedAccess`, which typed `smsIds[0]` as `string | undefined`
already.
### The wire
- **The declared interface version is an option on both `client()` and `server()`, and is not the
optional-parameter threshold.** That threshold is fixed at 0x34 by the spec, so an implementation
that must declare 5.0 throughout can, without moving it.
- **A peer that declared no version is pre-3.4, and `undefined` means no bind yet.** `acceptBind()`
records what the ESME declared and the client's `bind()` records the `sc_interface_version` the
SMSC answered with; a peer that declared nothing is recorded as `undeclaredInterfaceVersion` (0x00)
and sent no optional parameters, which is how the spec reads an absent `sc_interface_version`.
- **`esm_class` decides what a `deliver_sm` is, and the body is read only when it names nothing.**
The two types the MC writes about a message we submitted — `MC_DELIVERY_RECEIPT` (0x04) and
`INTERMEDIATE_DELIVERY` (0x20) — are reports whatever the body parses to, so one in a format
`dlrFromPdu()` cannot read reaches `dlr` with `smsId` undefined instead of arriving as an inbound
SMS. The three the far-end SME writes (0x08, 0x10, 0x18) are messages and their bodies are not
scraped: Kannel reads 0x08 as report-bearing and this does not, because a delivery acknowledgement
is the handset's word about a message, not the network's. A message type of 0 or one of the ten
reserved keeps the scrape, and a non-empty `receipted_message_id` TLV marks a report on the same
footing. A report this library recognises never reaches the reassembler, so an SMSC that splits one
across segments gets a `dlr` per segment rather than one merged report. The `message_state` TLV is
authoritative only where it names a state in the table — SMPP reserves 0x80-0xFF for
MC-vendor-specific values, so an unnameable one keeps its raw `statusId` and leaves `statusMsg` to
the body.
- **A body is read from `message_payload` where `short_message` carries none, and `short_message`
wins where a peer filled both.** Maintainer's call, 2026-09-06, from the Jasmin interoperability
phase: SMPP 3.4 5.3.2.32 makes the TLV the alternative for a body the mandatory field cannot
carry, several SMSCs use it, and Jasmin relays one faithfully — reading `short_message` alone
handed the application an empty message
([interop-tests/findings/03-jasmin.md](interop-tests/findings/03-jasmin.md)). `messageOctets()` is
the single answer to where a body is, so the message path, the reassembler and `dlrFromPdu()`
cannot disagree about it, and `esm_class` still says whether that body starts with a UDH wherever
it was carried, which leaves concatenation reading exactly as before. Filling both contradicts the
spec's own instruction to leave `sm_length` zero, and taking the mandatory field there keeps the
rule purely additive: no PDU that parsed before reads differently now. Rejected: preferring the
TLV, which re-reads every message a peer echoes into both. Rejected: refusing a PDU carrying both,
which discards a message that is almost certainly present twice over, where goal 3 keeps the
traffic. The reassembler's octet cap already counts TLV values, so a 64 KB payload is bounded like
any other segment.
- **A segment's concatenation is read from its UDH, or from the `sar_*` TLVs where it declares none,
and each spelling groups in a reference space of its own.** Maintainer's call, 2026-09-06, from
the Jasmin and Java-client interoperability phases: SMPP 3.4 5.3.2.31-5.3.2.33 make
`sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum` the other way to say what a UDH says,
Jasmin documents it as its own segmentation and jsmpp writes it, and reading the UDH alone handed
the application one `sms` per fragment
([interop-tests/findings/05-java-clients.md](interop-tests/findings/05-java-clients.md)).
`concatOf()` is the single answer to how a PDU says it is a segment, as `messageOctets()` is to
where a body is, and both are exported for the same reason: an application on the low-level
surfaces would otherwise rewrite the read this fixed. It carries the spelling beside the
reference, so the key is two tokens the reassembler joins and interprets neither of, and so the
refusal can name the field the peer got wrong — `ESME_RINVESMCLASS` for a UDH, `ESME_RINVTLVVAL`
for the TLVs, whose segment's `esm_class` is 0x00 and correct. Keying them together instead would
assemble two of a peer's messages into one, since a UDH reference is 8 bits and `sar_msg_ref_num`
is 16 and neither counts the other's messages; the two UDH widths share a space because they are
one sender's counter in one layer, where a `sar_*` reference is another layer's. A UDH that names
the concatenation wins over the TLVs — one carrying only a port leaves them to say — which keeps
the change additive for every message that reassembled before, and leaves the library nothing to
guess where the two disagree. Rejected: preferring the TLVs, which regroups every message a
gateway derived them from. Rejected: comparing the parts and reporting a disagreement: the
references are not comparable at all, and where the parts are, the UDH is still what the message
is assembled by, so the report would name a failure the application cannot act on. Accepted: a
peer that switches spelling mid-message now has two groups that expire rather than fragments that
arrive, which goal 2 prefers to a message assembled from two counters. Receive-only: `sendSms()`
goes on writing a UDH with an 8-bit reference, where a send-side `sar_*` would be a second
spelling of one message whose only difference is which peers accept it.
- **`sendSms()` takes the messaging mode by name, and it is the only part of `esm_class` a caller
writes.** Maintainer's call, 2026-09-06, closing target 5 of the interoperability plan: every peer
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
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
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
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
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
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 under any other mode both go out untouched. Those three names left `ESM_CLASS`, where they
had `constsById.ESM_CLASS` read 0x03 as a whole `esm_class`. Rejected: a raw `esmClass` number,
which is exactly that clearable state and would need refusing bit by bit to be safe. Rejected:
taking a number beside a name, two spellings of one goal — which is why a value naming no mode is
refused, by name, before a segment goes out. Rejected: a session-level default with a per-send
override; an operator's requirement is a property of the link, but the library can verify nothing
the caller's own options object does not, and shipping both buys a precedence rule to document and
test for that. `SMSC_DEFAULT` is named so pinning the default deliberately is sayable.
- **An inbound `data_sm` stands in for whichever of `submit_sm` and `deliver_sm` its direction makes
it, and none goes out.** Maintainer's call, 2026-09-06, from the Jasmin interoperability phase:
SMPP 3.4 4.7.1 makes it a peer of both that always carries its body in `message_payload`, and
Jasmin's `[dlr-thrower] dlr_pdu = data_sm` throws real receipts on it, which `ESME_RINVCMDID`
dropped with nothing reported to the application at all. Every command but this one names its own
direction, which is why the bind gate and the dispatch never had to be told which end of the link
they are on; `linkEnd` is that fact, and it decides both. At the ESME end an inbound one is a
delivery, so `esm_class` classifies it as it classifies a `deliver_sm`; at the SMSC end it is a
submission and is read as one, because a report about a message this end never sent is goal 2's
wrong answer whatever `esm_class` a peer wrote on it. A concatenated one is answered segment by
segment either way, and 4.7.2 gives `data_sm_resp` a `message_id` where 4.6.2 leaves
`deliver_sm_resp`'s unused, so the answer carries one. `linkEnd` is a field beside `boundAs`
rather than a `SessionOptions` entry, so the code that knows which end this is writes it and
nothing else can contradict what the session then binds as. Rejected: grouping the command with
`deliver_sm` in the gate, which refuses a transmitter-bound ESME's legitimate submission, and with
`submit_sm`, which refuses the receiver-bound delivery this was fixed for. Rejected: sending one —
`send()` reaches the command raw, and an option choosing which command a message goes out on would
be a second spelling of `sendSms()` whose only difference is which peers accept it.
- **A receipt's body is read as octets, and its own `data_coding` never says how.** Maintainer's
call, 2026-09-05 via the SMPPSim interop run: SMPPSim copies the reported message's `data_coding`
onto a receipt whose body it always writes as plain text, and Melrose Labs documents the same
echo, so decoding by that field turns an Appendix B receipt into UCS-2 garbage — total loss
against the many peers that send no TLVs to fall back on. `dlrFromPdu()` reads
`PduObject.shortMessageOctets` through Latin-1, the one codec that maps every octet to a
character, so the fixed fields parse whatever the PDU claims; the codec keeps both spellings
because a message needs the text and a receipt needs the octets. Rejected: honouring `data_coding`
where the octets yield no field, which reads one body two ways for the sake of a peer writing a
UCS-2 receipt body that no researched SMSC is — that peer's receipt yields no fields at all here,
which goal 2 reports as undetermined rather than guessed. An inbound message is untouched: nothing
but `data_coding` can say how a message was written.
- **A message class is read where GSM 03.38 puts it, `flash` is class 0 alone, and a flash message
with no alphabet to carry it is refused.** Maintainer's call, 2026-09-09, closing the last target
of the interoperability plan: `sms.flash` was `(data_coding & 0xF0) === 0x10`, which called the
ME-, SIM- and TE-specific classes immediate display and missed the 0xF0 group entirely — the only
one SMPP 3.4 5.2.19 names, since it marks 0x0F to 0xBF reserved and hands 0xF0 to 0xFF to GSM
03.38, and the one SMPPSim demonstrated
([interop-tests/findings/02-smppsim.md](interop-tests/findings/02-smppsim.md), C17).
`messageClassOf()` is the single answer to whether a `data_coding` carries a class and which, as
`concatOf()` is to how a PDU says it is a segment: 03.38 section 4 puts the class in bits 1-0,
carried where bit 4 says so in every group below 0x80 and always in the 0xF0 group, and
`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
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
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
its declared alphabet makes of it — but bit 5 does not move the class bits, so 0x30 is read as
class 0 rather than special-cased into a wrong answer; 01xx is read for the same reason, 03.38
coding it exactly as 00xx. Rejected: reading only the two groups the defect named, which needs an
extra test to produce a wrong answer for a class the spec puts in plain sight. Accepted: the
alphabet is read only where a class is, so 0x58 is UCS2 while 0x48 — the same alphabet with the
class bit clear — stays ASCII, because below 0x10 SMPP's flat table contradicts 03.38 and wins
(0x03 is Latin-1 there, GSM 7-bit here) and a class is the only evidence a peer below 0x80 is
spelling 03.38 at all. Send-side: `flash`
is that class, so it goes out as 0x18 beside UCS2 and 0x10 beside GSM 7-bit, while
`encoding: 'LATIN1'` beside it is refused before a segment goes out, the way a messaging mode this
library cannot deliver is — 03.38's class groups hold GSM 7-bit, 8-bit data and UCS2, and Latin-1 is
SMPP's own flat-table alphabet, so the pair has no spelling. Rejected: 0x10 with Latin-1 octets,
which declares an alphabet the body is not in; rejected: 0x14, 8-bit data, which is not text to
the handset that would display it; rejected: promoting it to UCS2, which overrides the one option
the caller wrote in order to override a choice. Rejected with them: `encoding: 'FLASH'`, which
named `data_coding` 0x10 among the alphabets and so reached the class through the option that
chooses a charset — a second spelling of `flash: true` that also flattened every non-GSM character
to a space on the way. It leaves `EncodingName`, which is now exactly the three codecs `detect()`
and `encodingByDataCoding()` return, and `encoding` is checked by name like `messagingMode` so a
caller without types gets a refusal rather than a throw out of the codec table. `consts.ENCODING`
keeps its `FLASH` entry: the low-level surface reaches raw constants, and nothing reads that group
as an alphabet any more. Accepted: `flash` is now false for `data_coding` 0x11 to 0x13, which no
peer means as immediate display.
- **A report is final unless its `esm_class` or its state says otherwise, and only `ENROUTE` and
`SCHEDULED` say otherwise.** SMPP 3.4 Appendix B lists every other receipt state as final,
`UNKNOWN` and `ACCEPTED` included, so a peer writing `ACCEPTD` for a carrier-accepted step is taken
at its word. Rejected: reading `UNKNOWN` as non-final, which leaves a peer whose receipt body this
library cannot read with no `messageDlr` at all — goal 2 wants that reported as undetermined, not
withheld. Both spellings resolve into `Dlr.intermediate` at the boundary rather than being read a
second time in `DlrMerger`, so the library cannot answer the application one way and conclude the
other. Not every peer marks a transient report 0x20 — an ordinary receipt carrying `stat:ENROUTE`
is common — so the state test is what the marker test cannot replace. `message_state` 0 is 5.0's
`SCHEDULED` and undefined in 3.4; a peer that writes it is read as transient rather than as saying
nothing, maintainer's call, 2026-09-03, since the codec refuses a zero-length integer TLV and so an
absent one cannot land there.
- **A `stat:` an operator spells outside Appendix B is read as the state it names, and the two
researched ones are `FAILED` and CM.com's `DELIVERD`.** Maintainer's call, 2026-09-08, from the
operator-fixture phase: Kaleyra and Route Mobile both document `FAILED` in that field as a terminal
delivery failure, and the research attributes it to Vonage as well; CM.com's own code table prints
`DELIVERD` — eight characters — beside six correct ones. Both were left at `statusMsg: UNKNOWN` —
the same answer a receipt really saying `stat:UNKNOWN` gets, so an application could not tell an
operator's "it failed" from its "I do not know", nor a delivered message from one whose state
could not be read; and `DlrMerger` ranks `UNKNOWN` below `EXPIRED`, reporting a multipart send
carrying a failed segment as expired. They join `receiptStates` alone: `receiptCodes` goes on
writing the seven characters 3.4 defines, so nothing this library sends gains either spelling. Rejected: a `FAILED` member of `MESSAGE_STATE`, which
is 3.4's own numbered table — the code has no number there, so one would have to be invented, and
every consumer's switch would grow a case no `message_state` TLV can carry. Rejected: leaving it
`UNKNOWN` and sending the application to `dlr.receipt.stat` for the state, which reports a terminal
failure as undetermined and leaves the merge ranking it below `EXPIRED`. Rejected: reading the
numeric status tables Syniverse and Route Mobile publish beside it, which are vendor fields of
their own rather than the seven characters `stat:` holds. Accepted: all three of those operators
document `FAILED` and `UNDELIV` as separate codes, and both now resolve to `UNDELIVERABLE` — an
application that must tell them apart reads `dlr.receipt.stat`, which carries what the SMSC wrote.
Accepted: an unmarked `deliver_sm` whose body says one of them now reaches the application as a
report where it used to arrive as an inbound message, which is what every code already in the table
does. What decides a spelling is whether the corpus in `test/operator-receipts.test.ts` can cite the
page it is printed on and no other code could be meant, which is why `DELIVERD` is read and a
spelling nobody publishes is not: a mapping that costs nothing where an operator's own docs merely
contain a typo saves an application everything where they do not.
- **A transient state goes out as an intermediate delivery notification (0x20), every other state as
a delivery receipt (0x04).** Appendix B makes a receipt's `stat` the message's final status, so
0x04 over `ENROUTE` emits the two disagreeing spellings of finality the reading side above has to
reconcile, and goal 3 has our own senders write the marker 3.4 defines. `sendDlr()` takes the list
from `transientStates` in `dlr.ts`, the same one the reader uses, so the two cannot drift.
Rejected: 0x04 for every state, for the sake of a peer that classifies on the marker — the cost
accepted here is that such a peer stops recognising a transient report as a report at all and hands
its application receipt text as an inbound message, where under 0x04 it would have read the state
from `stat:` and been right. A transient state also carries `err:000`, since a message still on its
way has not failed.
- **A refused PDU is answered from its header, and any 32-bit `sequence_number` is echoed as it
arrived.** Maintainer's call, 2026-09-05 via the interop plan. The header of a framed PDU always
parses, so it carries the answer SMPP 3.4 4.3 asks for, with the status 3.4 names for the part
that would not parse. Rejected: nacking a refused *response*, whose sequence number is one of
ours — the `generic_nack` would land in the peer's own numbering and nack a request of the peer's
we never saw, so a refused response is written back nothing and settles the request it names
instead. An unknown command id with the response bit set takes that branch too: a peer echoing a
sequence number of ours is answering something, and settling it reaches the undetermined outcome
`responseTimeout` would have reached anyway, sooner. Rejected: clamping a sequence number outside 4.7.1's 0x00000001–0x7FFFFFFF into range
before answering, which correlates with nothing at the peer — stacks write the field as a plain
uint32 (ukarim/smscsim signs every unprompted `deliver_sm` with a raw `rand.Int()`), so goal 3
keeps that traffic and `PendingRequests.nextSeqNr()`, the only thing that invents one, is what
holds our own sends inside the spec.
- **The optional parameters run to `command_length` exactly, and the only slack tolerated is one
NULL octet where a peer padded `short_message`.** Maintainer's call, 2026-09-06, from the
Java-client interoperability phase: accepting any parse that merely did not error answered
`ESME_ROK` to a `deliver_sm` whose three trailing octets were never read, dropping the
`receipted_message_id` that makes a receipt a receipt
([interop-tests/findings/05-java-clients.md](interop-tests/findings/05-java-clients.md)). Goal 2
settles it against goal 3: octets this codec cannot name are a PDU it did not read, so a region
that does not end on `command_length` — the padded read included — is refused with the `tlvs`
reason and `ESME_RINVTLVSTREAM` a truncated TLV value already gets. What the rule costs is paid
once, in `readCstring()`: a trailing C-Octet String a peer left out entirely consumes no octet,
where reporting the terminator it never sent puts every later offset past the declared end and
refuses a bind, and every bodyless response, that used to parse. That composes, so a run of them
at the tail all read empty — `outbind` is the only command with two, and an absent field and an
empty one say the same thing, so goal 2 is not at stake even there. Rejected: keeping the tolerance
for the one to three trailing octets too few to hold a TLV header, which no researched peer sends
and which cannot be told apart from the truncated tail this fixes. Rejected: refusing it as
`body`/`ESME_RINVCMDLEN`, which names the mandatory fields — the part the peer got right.
- **`smsIdFormat` names a notation per place, and normalisation never reaches inside a `<base>-<n>`
id.** An SMSC may answer `submit_sm_resp` in hex and write the receipt's `id:` in decimal, so one
transform over both sides cannot make them equal. `submitResp` covers the `receipted_message_id`
TLV too, which SMPP 3.4 5.3.2.26 defines as the id the `submit_sm_resp` carried: naming one
notation for whichever id a receipt yields would break the peer that sends both. Omitting a place
is what leaving it alone means, so there is no `raw` notation, and a caller-supplied formatter is
refused because it would make the promise that the two ids are comparable unverifiable — `onRequest`
and the PDU on the `dlr` event are the escape hatches. A `<base>-<n>` id parses as no number and so
reaches `expect()` and `collect()` unchanged, which is what keeps `DlrMerger` working; normalising
the base instead would break that pair. The option is on `client()` only, since a `server()` session
writes both ids itself.
- **A concatenated segment is budgeted at 134 octets, which is 153 septets where the SMSC packs them
and 134 octets of anything it does not.** Maintainer's call, 2026-09-09, from the architecture
review of [#95](https://github.com/larvit/larvitsmpp/pull/95): `segmentUnits` handed 153 to
everything but UCS2, so a long `encoding: 'LATIN1'` message went out as segments of 153 octets plus
a 6-octet UDH — 159 on the air where GSM 03.40 carries 140, which no SMSC can deliver. Goal 1 owns
it. There is one budget, 140 less the UDH, and the alphabet decides only what it is counted in, so
Latin-1 and UCS2 both take those 134 octets — 134 characters and 67 — and it is GSM 7-bit's 153
that is the odd number rather than the other way round. `Record<EncodingName, number>` is what makes
a fourth alphabet state its own. Rejected: 134 for GSM 7-bit too, which is the mistake the
unpacked-alphabet section above exists to stop. Accepted: a Latin-1 message past the 140 characters
one SMS holds now costs more segments than it did, and `smsIds` is that much longer.
- **An alphabet the caller named has to carry the message, and a time the format cannot express is
refused, both before a segment goes out.** Maintainer's call, 2026-09-09, from the architecture and
stability reviews of [#96](https://github.com/larvit/larvitsmpp/pull/96): `encoding: 'LATIN1'` on
`あいう` put `42 44 46` — `"BDF"` — on the wire and returned success, `encoding: 'ASCII'`
flattened every character outside 03.38 to a space, and `validityPeriod: new Date('nope')` wrote
`NaNNaNNaNNaNNaNNaNNaN00+` into the PDU. Goal 2 owns all three: bytes that do not say what the
caller asked, reported as sent. `unencodable()` is the single answer to whether an alphabet can
carry a message, as `messageClassOf()` is to whether a `data_coding` carries a class, and it asks
the codec — `decode(encode(c)) === c` per code point — rather than restating the tables beside it,
so the guard cannot drift from what the encoder writes for any one character, and a fourth
alphabet answers by having a codec at all. It is exported for the reason `concatOf()` is: a caller
composing a `submit_sm` through `send()` and `encodeMessage()` would otherwise rewrite the read
this fixed. `match()` cannot be that answer — it doubles as the auto-selection policy `detect()`
reads, where LATIN1 is hardcoded false so nothing picks it, and using it would refuse the 8-bit
binary body Latin-1 is kept for. The guard is
on the named branch alone, so an unspecified send is untouched: every alphabet `detect()` returns
carries every character it was picked for, over the whole code point range. `smppTime.encode()`
returns a `Result`, where the three encoding helpers stayed total: that argument was that
`EncodingName` is a closed set the compiler guards, and `Date | number | string` is not — an
invalid `Date` and `NaN` inhabit it, which hard rule 1 makes a result "wherever the types admit
one", and `decode()` has been fallible for the same reason since it was written. Rejected:
transcoding to UCS2, which overrides the one option the caller wrote in order to override a choice
— the same reason a flash Latin-1 message is refused rather than promoted, and an operator that
accepts only `data_coding` 0x03 would be handed something it never agreed to take. Rejected:
guarding `sendSms()` alone and leaving `smppTime.encode()` writing `NaN`s, which leaves this
library's own published helper composing the garbage the guard exists to stop. Rejected: a
`holds()` member beside `match()` on `Encoding`, a second per-alphabet table to keep in step with
the codec. Rejected: validating the `string` spelling of a time, which is a stamp the caller
formatted for a peer whose format is theirs to name, its width included, where SMPP 3.4 gives the
field 1 or 17 octets. Accepted: a second count past 99d 23:59:59 is refused rather than clamped to
it, a negative one and `Infinity` with it — clamping `86400 * 365` reported success for a year and
put 99 days on the wire, the wrong answer about what happened that the rest of this bullet exists
to remove. The ceiling is this encoder's rather than the format's: 3.4's `YYMMDDhhmmss000R`
carries years and months, which `decode()` reads back, and no fixed number of seconds is either
one, so spelling a second count in days and below is where the guess would go — which is why the
too-long refusal names the `Date` that reaches every instant the absolute form holds, and the
negative one names nothing, there being no period to reach. Rejected: documenting the clamp, which
leaves the caller told a true thing and still sent the wrong period. Accepted: GSM's 0x1B is an
extension prefix rather than a character, so a bare ESC beside one of the ten extension bases is
the one input a per-character reading passes and the encoder then writes as the extended character
— the only composition in any of the three codecs, and not a character a message is written in.
- **A string body is written in the alphabet its own `data_coding` names, and one that alphabet
cannot carry is refused by the codec — `message_payload` on the same terms as `short_message`.**
Maintainer's call, 2026-09-09, from the architecture review of
[#97](https://github.com/larvit/larvitsmpp/pull/97): `objToPdu()` took the codec off the caller's
own `data_coding` and encoded with it whatever the text was, so `data_coding` 3 beside `あいう`
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.
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
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
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
`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
error prose as API for an application whose own refusal should read like itself. Goal 6, from the
architecture review of [#99](https://github.com/larvit/larvitsmpp/pull/99), 2026-09-09. It is
reached through
`encodeBody()` in `message.ts`, which is where the `data_coding`-to-text pair already lives:
`encodeBody(text, dataCoding)` is `decodeMessage(buffer, dataCoding)`'s mirror and resolves the
alphabet through the same `encodingByDataCoding()`. `send()` and `sendReturn()` inherit it,
since both build through `buildPdu()`; `sendSms()` does not, and keeps its own guard, because
`splitMessage()` hands the codec a Buffer with nothing left to refuse and the index a segment
could name is not the one in the message. The TLV is encoded rather than merely checked because
`data_coding` names the alphabet of the body wherever it is carried — that is how
`messageOctets()` and `decodeMessage()` read one back, and a `data_sm` has nowhere else to put one
— so refusing what Latin-1 cannot hold while still writing UCS-2 text as Latin-1 octets would
close half of it. `short_message` settles the `data_coding` wherever it carries octets at all, the
order `messageOctets()` reads the two in, so the alphabet a PDU declares is the one its body will
be read under — and a `short_message` on a command whose table declares none is ignored here as
`writeParams()` ignores it, so an empty one, an absent one and one the wire cannot carry are the
same input rather than three. A `data_coding` on a command that declares no such field is honoured
the other way round, since it is `replace_sm`'s only way to name the alphabet its octets are in.
Every entry carrying the payload tag is resolved, by tag id rather
than by record key, since `tagIdOf()` lets a caller name it anything and a spelling that escaped
the guard would be a second spelling that disagrees about correctness. Rejected: refusing a string
`message_payload` outright and demanding
octets, which contradicts `short_message` on the same PDU. Rejected: guarding every string-valued
field, which `data_coding` says nothing about — an address is a C-Octet String and ASCII by 3.4's
own definition.
- **A GSM 03.38 message declares `data_coding` 0x00, and an inbound 0x01 is still read as GSM.**
Maintainer's call, 2026-09-09: `dataCodingFor()` and `encodeBody()` both resolved an alphabet
through `consts.ENCODING`, so `encoding: 'ASCII'` went out as 0x01 — SMPP 3.4 5.2.19's *IA5 (CCITT
T.50)/ASCII* — while the codec writes GSM 03.38, where `$` is 0x02 and `@` is 0x00 against IA5's
STX and NUL. Goal 1 owns it, and this library's own reader hid it by resolving both codings to the
same codec. `dataCodingByEncoding` is the single answer to which coding an alphabet is written
under, as `unencodable()` is to whether one can carry a message: the mirror of
`encodingByDataCoding()`, and reached by both the `sendSms()` path and `encodeBody()`'s detected
one rather than each spelling the map again, which is what `sendDlr()` inherits it through. It is
exported for the reason `unencodable()` is — a caller pairing `encodeMessage()`'s octets with a
`data_coding` of its own had only `consts.ENCODING` to reach for, which is the trap. 0x00 is the
*SMSC's* default alphabet rather than 03.38 by name, so it is a convention rather than a guarantee;
it is also what every peer in `interop-tests/` submits under and what LINK Mobility, Route Mobile
and Telesign all publish 03.38 as, where 0x01 names a different alphabet from the one written and
so is wrong whatever the peer makes of it. Reading is untouched, goal 3: those same three map 0x01
to 03.38 too, and Kaleyra and Route Mobile publish that value as known to cause problems, so no
researched peer means IA5 by it. The two tables agree over most of the printable range and part at
0x00-0x09, 0x0B-0x0C, 0x0E-0x1A, 0x1C-0x1F, 0x24, 0x40, 0x5B-0x60 and 0x7B-0x7F — line feed,
carriage return and escape are common to both — which is where a peer that did mean IA5 is
misread. Accepted with it: `consts.ENCODING` loses its `ASCII` alias and keeps `IA5`, the two
names 5.2.19 gives 0x01, because that alias was the only name the two tables shared at different
values and so the only one a reader could carry from the option's vocabulary into SMPP's flat
table; `constsById.ENCODING[0x01]` already read `IA5`, so nothing moves but the forward name.
Rejected: moving `consts.ENCODING.ASCII` to 0x00, which would make that table contradict the
section it exists to spell — the group is SMPP's flat `data_coding` table, not the `encoding`
option's vocabulary, the distinction the `FLASH` removal already drew. Rejected: reading 0x01 as
Latin-1, the closest codec here to IA5, which mojibakes every peer that means GSM for one nothing
researched has found. Accepted: a message already in flight is unmoved — both codings resolve to
the same codec, `messageClassOf()` finds no class in either, and `Reassembler` groups on the
concatenation reference rather than on `data_coding` — so a receipt or a segment that crossed the
change reads exactly as it did.
### The session's life
- **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call,
2026-08-26: most SMSCs drop the socket instead of answering, so the documented shutdown would
otherwise always report a failure. It does mask a socket that died mid-unbind for an unrelated
reason, which is accepted — the peer sees the same TCP close either way.
- **`close` means the session is over, and a drop the loop will retry is `disconnected`.**
Maintainer's call, 2026-08-31: without the split, an application that opens a replacement client on
`close` ends up holding two binds on one account. `teardown()` picks the event by whether the
reconnect loop is still live, and `end()` stops that loop before tearing down, so every deliberate
shutdown emits `close`. A retry that opens a socket and then loses it clears `closed` through
`attach()`, which is why a second drop emits again.
- **An answer belongs to the link the message arrived on; a receipt does not.** Maintainer's call,
2026-09-01. Rejected: answering on the new link, which succeeds and reports `{}` for a response
that correlates with nothing — goal 2's wrong answer. Accepted: a receipt sent after a refused
response names an id the peer has no record of.
- **`reconnect` takes `{ minDelay, maxDelay }` to retune and `false` to turn off**, so absent means
on and there is one spelling for each. Only `client()` reconnects — a `server()` session is a
connection the peer opened, and nothing at this end can reopen it. The retry timer is `unref()`'d,
so a process with nothing else left to do still exits between attempts.
- **Coming up is not proof a link works, so only one that outlasted `maxDelay` resets the backoff.**
An unreadable stream is found after the bind returns, so resetting on connect gave a link that died
on arrival a fresh `minDelay` every cycle — one TCP connect and bind per second, forever. A drop
after a healthy link still retries at `minDelay`.
- **`reconnect: { fromStart: true }` puts the first connect and bind through that same loop, and
`client()` then resolves only once it is bound.** Maintainer's call, 2026-09-05: an application
started before its SMSC is up otherwise writes that retry itself, around the one this library
already owns. A field on `reconnect` rather than an option of its own, so the combination that
would contradict `false` cannot be written at all — `false` carries no fields — and a top-level
`fromStart` is refused by name rather than ignored. Nothing but the caller's `signal` ends the
wait: a bound of its own would be a second spelling of a deadline the caller already writes with
that signal, and giving up after one is what the default does. A bind the SMSC refuses is
retried like any other failure — rejected: giving up on `ESME_RINVPASWD` and `ESME_RBINDFAIL`,
which would have the initial attempts and a rebind disagree about what a refused bind means, and
gives up on the operator whose provisioning lands a minute later; the backoff is what bounds the
rate goal 4 cares about. The attempts before the first link report nothing, because the session
running one has not reached the application: `disconnected` would have no listener and `close`
would be a lie. Its wait is the one retry timer that is not `unref()`'d, for the reason
`LinkGate`'s hold is not — it is awaited with no other handle, so a process whose only work is
`client()` would exit unbound.
- **A stream this library cannot frame is a dead link; one PDU it cannot parse is not.**
Maintainer's call, 2026-08-31, narrowed 2026-09-05 via the interop plan: a `command_length` below
16 or above `maxPduLength` leaves nothing that can say where the next PDU starts, so it tears the
link down through `teardown()` and the reconnect loop retries it on a fresh socket with a fresh
framer. Every other codec failure honoured `command_length`, so the stream is still in sync and
the next PDU starts where it says — tearing the link down there cost one peer half its receipts
and its MO to a reconnect loop (`interop-tests/findings/01-smscsim.md`), and left the peer waiting
for answers it was owed. `sessionError` carries every failure of either kind, never coalesced or
suppressed, so a peer that only ever sends garbage is visible in the log rather than silent.
- **A deliberate shutdown drains; an unusable link and an abort do not.** `close()` and `unbind()`
wait on the send window rather than the pending map — the map misses a segment still queued behind
a full window, and finishing a half-sent multipart message is the point. The window counts slots,
never outcomes, and empties on a drop too, where `teardown()` settles everything the link was
carrying, which is why `drain()` reads `closed` before it reads the count. A stream the framer or
the codec cannot read takes `teardown()` instead, and `close({ signal })` on an aborted signal and
a peer's own `unbind` take `end()`: nothing on a dead link can answer, an abort means stop now, and
a peer that has declared itself finished will not answer what it still owes, so draining any of the
three would only hold a socket open for the timeout. `unbind()` sends its own PDU through
`request()` past both the window and the drain gate, because it must go out either way.
`shutdownTimeout` stays a session option rather than a `close()` argument: `server()` builds
sessions on the caller's behalf, so the option is the only composition point. `SmppServer.close()`
reports each session's unfinished drain through `serverError`, because its own result says nothing
but that the listener stopped.
- **Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is the
application's own signal rather than the peer's answer.** Maintainer's call, 2026-09-06, from the
Jasmin interoperability phase: Jasmin dispatches one `submit_sm` per connector at a time and will
not send segment 2 until segment 1 is answered, so holding a group unanswered until it was whole
deadlocked every multi-segment message against a production gateway
([interop-tests/findings/03-jasmin.md](interop-tests/findings/03-jasmin.md)). Goal 1 has the answer
a real SMSC gives — one `message_id` per `submit_sm`, immediately — so the group's id base is
generated when it opens and each segment is answered `<base>-<n>`, the notation `sms-id.ts` owns
and `DlrMerger` reads back. The id is therefore fixed by the first segment, which is why an `smsId`
or a refusing `status` passed to `sendResp()` on such a message is an error rather than a silent
no-op. `answeredOnArrival` is on `Sms` because nothing the application can compute says it, and the
discriminant a reader would reach for instead is wrong. A message `sendResp()` still answers itself is
untouched, and is where a caller-chosen id and a refusal live; `onRequest` is the escape hatch for
an application that must refuse a PDU the `sms` event could not have shown it yet. `collect()`
answers every segment it will not carry rather than leaving it unanswered, which is the same stall
in miniature: the field that numbered it where the segment belongs to no group, `ESME_RMSGQFUL`
where the segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected:
answering every segment but the one that completes the group, which leaves the peer holding some
segments accepted and one refused with nothing in SMPP to retract the rest, and still cannot honour
a caller's `smsId` on the segments already gone. Rejected: a hook that mints the id per segment,
which asks the application to name a message it cannot read yet — what it wants is `sms.smsId`
afterwards. Rejected: an option to keep the old behaviour, a second spelling whose only
distinguishing feature is that it deadlocks. Accepted: a group given up on — expired, evicted, or
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
`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
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
`sessionError` — a peer that re-sends after the drop is the smaller risk than dropping a message
in hand. The answer goes out before the `sms` event either way, so a listener's own receipt can
never precede the acceptance of the message it reports on.
- **`server()` composes the application's `onRequest` after its own bind handling, and offers it
every request that handling did not answer.** Maintainer's call, 2026-09-06, from a product review
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
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
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
nothing a peer sends before one can be intercepted however the hook is written. One
`OnRequest` type on both option bags, because a second contract under one name is two spellings of
one goal; widened to accept a plain boolean, as `authenticate` already is, so an observing hook need
not be `async`. Nothing of ours is written for a request whose hook failed, the same on both
surfaces: the library cannot tell one that failed before answering from one that failed after, so
goal 2 reports the outcome as undetermined rather than guessing, and the peer's own
`responseTimeout` is what settles it — the answer `authenticate` failing already takes. A hook that
throws or rejects reaches `sessionError` on the way; one that never settles reaches nothing at all,
and is visible only as the request that was never answered. That takes the keepalive with it, since
a hook broken across the board leaves `enquire_link` unanswered and the peer drops the link — the
back-pressure wanted, because an application that cannot serve a link should not hold one.
`sessionError` rather than `serverError` because the
failure belongs to one session's request, and that channel already carries every failure of one.
The hook is consulted before the bind-direction gate, so it sees a `submit_sm` a receiver-bound
peer may not send; first refusal means first, and one it declines still gets `ESME_RINVBNDSTS`.
Nothing is held for a request the hook answered: `HeldMessages` is opened by the `sms` event the
hook skipped, so the drain waits on none of it. `OnRequest` stays unexported where
`AuthenticateInput` is exported, because that hook's argument is a shape this library invents and
this one's are two types already published. Rejected: consulting the hook first, which puts
bind and authentication inside the application's reach for nothing. Rejected: a narrower hook
returning a status for the library to write, which makes the answer verifiable but pays a second
contract under a second name for it, and could not express what the session-level hook already
does — answer a bind, a vendor command, a `data_sm` — leaving that error naming something only
half the surface can do. Rejected: falling the request through to the built-in handling on a
failure, which reads as the answer the peer would have had with no hook — true only of a hook
that failed before answering, where one that failed after put a second response on the peer's own
sequence number, goal 1's wire violation. Rejected with it: recording what the hook wrote so the
fall-through could be gated on it, which buys a fail-open path with state and an internal contract
no other collaborator needs.
- **The drain waits on the messages the application holds, and `sendResp()` is what says it is done
with one.** Maintainer's call, 2026-09-01: waiting on the send window alone tore a server session
down while the application was still answering a `submit_sm`, so the peer timed out and re-sent —
the duplicate goal 2 forbids, in the direction the window already covers. No completion signal was
added to the `sms` event: `sendResp()` is what an application already calls when it is done with a
message, so it is the one the drain waits for. Counting every inbound request until `sendReturn()` answered it was rejected —
an `onRequest` that deliberately answers nothing would then cost a full `shutdownTimeout` on every
close — and a message no listener took is released at once, since nothing is going to answer it.
A listener that failed before answering gives it up the same way, but only once every listener has:
a throw stops `emit()` where it stands, while a rejection leaves the others running, so the release
waits for the last of them rather than answering on their behalf. What ends the wait is the response
reaching the wire, not the call — a `sendResp()` the library refused, or one the socket would not
carry, leaves the message held, so `close()` still reports the one the peer is owed. Where the
segments were answered as they arrived there is no response left to write, so the call itself ends
the wait, an argument the library refuses excepted. `teardown()`
drops what is still held for the same reason it drops inbound segments. The release is one turn
late, so a listener that sends its receipt straight after the response is still holding when the
drain looks; `sendDlr()` is the one send that goes out past the drain's refusal, and only while the
message is still held — past that it is an ordinary send, because the drain it would slip past is
no longer waiting for it. `shutdownTimeout: 0` does not carry over to this half:
waiting forever is safe for the peer, whose every request is bounded by `responseTimeout` unless the
caller set that to 0 as well, and unsafe for the application, which nothing bounds — `close()` is
what you reach for when the application is stuck, so it may not block on the application coming
unstuck. That half falls back to `responseTimeout`, the same answer the link gate's hold already
takes — and to that option's default where it is 0 as well, since neither option is an answer about
the application. What is held is capped and expiring like every other inbound store, on constants
rather than options, because a bound the application cannot raise is the point: an application that
answers nothing would otherwise grow it for the life of the link, which goal 4 forbids. A message
that falls out of the bound is one the drain stops waiting for, so `close()` can report fewer
unanswered than there were — accepted, because the alternative is holding what nothing will answer,
and both exits are logged.
- **A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.**
`onDelivery()` answers each receipt before the group it belongs to is complete, and `teardown()`
runs on every path — an idle timeout and a failed rebind, not only `close()` — so clearing the
merges there loses receipts no peer has a reason to send again. They are cleared where the session
is over instead. Inbound segments stay in `teardown()`: a concatenation reference is the
peer's own counter, so a half-arrived group kept across a drop would take a later message's
segments as readily as the rest of its own, and goal 2 will not hand the application a message
assembled that way. What goes there is traffic already answered, which is why each group reaches
`sessionError` like every other one given up on.
- **A message id base is merged at most once.** A receipt carries nothing but `<base>-<n>`, so a
straggler for a message whose group is gone cannot be told from a receipt for a later message the
peer handed the same ids — an SMSC whose id counter restarts with its process is the realistic
case. `DlrMerger` remembers the bases it has finished with, capped and expiring exactly like the
groups, and refuses to open one a second time: the later message gets no `messageDlr`, and an
earlier one whose receipts are still arriving is dropped rather than left to collect the later
one's. Every segment still reaches the application as a `dlr`. `expect()` ignores a lone id, so a
single-part message never claims a base.
- **A send that never reached the socket waits for the next link; one that did is counted, not
resent.** Maintainer's call, 2026-09-01: re-queueing everything unanswered would resend a
`submit_sm` the SMSC accepted and answered into a dead socket, which is delivered and billed twice,
while a request that never left this process can be lost for free. `attempt()` therefore wraps all
three ways a written request can fail in `UnansweredError`; counting only the dropped-link case, as
the first cut did, would have called the commonest one safe to resend. A count rather than a
boolean because `sendSms()` aggregates segments into one `err` slot, and required rather than
optional so every construction site answers. `UnansweredError` stays unexported: `unanswered` is
the one spelling on the public surface. The hold is bounded by `responseTimeout` rather than an
option of its own — that is already the answer to how long one request may wait — and its clock
starts when the send is issued rather than when it first finds the gate shut, so one budget covers
every hold a single call makes. That timer is the one here that is not `unref()`'d: a held request
is awaited with the socket already destroyed, so an unref'd one lets a process whose only remaining
work is that send exit without settling it.
- **A send queued for a send-window slot is bounded by the caller's `signal`, and by nothing else.**
Maintainer's call, 2026-09-06, from a review of PR #71: the hold above observes the signal and the
`acquire()` on the next line did not, so a caller that aborted while the window was full waited for
a slot it no longer wanted — at `responseTimeout: 0` for as long as the peer stayed quiet, which is
the deadline the README sends the caller to that signal for. Goal 4 is not re-opened by an
unbounded wait here: the queue is the application's own backlog, unbounded in depth as well as in
time because capping it would refuse a send the application asked for, and nothing in it keeps the
peer waiting — which is what separates it from the inbound stores capped on constants. Rejected:
having `release()` skip a waiter whose signal already fired, which leaves the departed waiter in
the queue where `unfinished()` still counts it and the drain waits on it; the waiter leaves as it
settles instead. Rejected: bounding this wait by `responseTimeout` as the hold is bounded — a full
window is this end's own concurrency draining as the peer answers rather than a link going nowhere,
and that bound would fail a message with more segments than `maxOutstanding` partway through
against a slow peer. The failure is a plain `Error` rather than `UnansweredError`, the same answer
an abort at the gate already gives. The drain half needs nothing: `close({ signal })` already hands the signal to
`window.idle()`, and `unbind()` taking none is the shape README states.
- **The gate decides whether a link can carry a request, and a bind is what makes it one.**
Maintainer's call, 2026-09-01: `attach()` clears `closed` the moment a socket is handed over, one
round trip before the bind is answered, so gating on `closed` let a send arriving in that window go
out unbound and come back `ESME_RINVBNDSTS`. `LinkGate` owns the answer instead — `shut(returning)`
on every teardown, `open()` only once `comeBackUp()` has a bound link — and
`OutgoingRequests.linkDown()` reads it rather than `closed`. The bind itself cannot wait for what it
creates, so `pastDrain()` lets the three bind commands past the gate and the window, the same door
`unbind()` takes through `now()`. The gate is told what happened and never reads back into the
session: a collaborator that has to ask does not own its decision, which is how the first cut ended
up answering the same question two different ways at admit and at release. For the same reason the
retry in `pastDrain()` asks `gate.isUp()` rather than `linkDown()`, which also reads the socket — a
condition that loops on something the gate does not gate on spins against a gate that admits it
straight back. `LinkGate.returning` is a copy of `retrying()` taken at teardown, and stays true
only because nothing stops the reconnect loop without `emitClose()` following it: `drain()` and
`end()` are the only callers of `stop()`. A third caller has to shut the gate itself.
### Internals and tests
- **A listener that rejects is routed by Node's `captureRejections`, not by hand-dispatching.** Both
emitters construct with `captureRejections: true` and implement
`[EventEmitter.captureRejectionSymbol]`, which lands a rejected `async` listener on `sessionError`
or `serverError` beside the synchronous guard in `emit()`. Dispatching `rawListeners()` from
`emit()` instead needs a cast to call them with the event's argument tuple, which hard rule 4
forbids. A rejection reason is `unknown` and `String()` throws on a null-prototype object, so both
handlers normalise through `errorFrom()` rather than inline — a route out of the handler would land
on a bare `process.nextTick` with nothing to catch it.
- **The four-line abort dance is copied across `LinkGate`, `IdleWaiters`, `PendingRequests` and
`SendWindow` rather than extracted.** Architecture review, 2026-09-06: pre-check `aborted`, attach
`{ once: true }`, detach on settle, leave the registry. What differs at each site is the registry
and what settling means — a FIFO handing over a slot, a set released together, a map keyed by
sequence number, a count recomputed at settle — so a shared `Waiters<T>` fits two of the four and
is a shallower module than the copies. Extract it once a fifth appears.
- **`SmppLog` is a five-method contract this library declares, not a dependency.** `debug`, `error`,
`info`, `verbose` and `warn` are what the code actually calls, so an application can satisfy it
with an object literal. `@larvit/log` implements it structurally and stays a devDependency, where
`test/tls.test.ts` passing a real `Log` as the server's logger keeps that compatibility compiled.
- **The TLS tests build their own self-signed certificate in DER** (`test/tls.test.ts`) instead of
adding a devDependency or shelling out to openssl. Maintainer's call, 2026-08-26: the dev image
`node:24.18.0-bookworm-slim` ships no openssl binary, so a shelled-out fixture would pass in CI and
fail on every developer machine, and a committed key leaks in a public repository. Valid while the
dev image has no openssl.
- **`src/` stays flat until a module has to move for another reason.** Architecture review,
2026-09-06: the grouping the file map above already implies — `wire/` for `pdu*` and `defs`,
`link/` for `link-*`, `reconnect-*`, `pdu-transport` and `send-window`, `messages/` for `sms*`,
`dlr*`, `message*`, `reassembly` and `udh` — rewrites every import for no change to
`dist/index.js`, the one published entry. Valid while that map is what a reader navigates by.
- **`test/` stays flat too, and a file there is named for the question it answers rather than for the
module it covers.** Architecture review, 2026-09-08, at 18 test files: what keeps that count honest
is the naming rule rather than a tree — `operator-receipts.test.ts` holds a corpus defined by where
it came from, cutting across four modules, where filing it by module would enter each new operator
twice. A split also has to be made twice, since `test` and `test:compiled` each carry a path of
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
named for what they hold.
+13
View File
@@ -0,0 +1,13 @@
# Changelog
## 0.6.0 (unreleased)
- `client()` now bounds each connect attempt at 10 seconds, the TLS handshake included, and reports
one that expires as an ordinary connect failure, so `reconnect` retries it on its usual backoff.
A connect previously waited the operating system out, around 130 s on Linux against a host that
drops SYNs. `connectTimeout` retunes the bound, and `connectTimeout: false` restores the old wait.
## 0.5.0
The TypeScript rewrite. What a 0.4.0 consumer has to change is in
[MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md).
+1 -1
View File
@@ -1 +1 @@
See [AGENTS.md](AGENTS.md). @AGENTS.md
+73 -1
View File
@@ -22,7 +22,8 @@ window, long messages and delivery receipts. TypeScript, ESM, no dependencies.
[Send options](#send-options) · [Session](#session) · [Receiving in depth](#receiving-in-depth) · [Send options](#send-options) · [Session](#session) · [Receiving in depth](#receiving-in-depth) ·
[Server in depth](#server-in-depth) · [Logging](#logging) · [Server in depth](#server-in-depth) · [Logging](#logging) ·
[PDUs and the low-level API](#pdus-and-the-low-level-api) · [PDUs and the low-level API](#pdus-and-the-low-level-api) ·
[Migrating from 0.4.0](#migrating-from-larvitsmpp-040) · [Development](#development) [Migrating from 0.4.0](#migrating-from-larvitsmpp-040) · [Goals](#goals) ·
[Audience](#audience) · [Development](#development)
## Install ## Install
@@ -226,6 +227,7 @@ All optional. Timeouts and delays are milliseconds.
| `interfaceVersion` | `0x34` | The SMPP version declared at bind. `0x50` for an SMSC that requires SMPP 5.0. | | `interfaceVersion` | `0x34` | The SMPP version declared at bind. `0x50` for an SMSC that requires SMPP 5.0. |
| `systemType`, `addressRange`, `addrTon`, `addrNpi` | `''`, `''`, `0`, `0` | The remaining bind fields, for operators that require them. | | `systemType`, `addressRange`, `addrTon`, `addrNpi` | `''`, `''`, `0`, `0` | The remaining bind fields, for operators that require them. |
| `tls` | `false` | `true` for defaults, or a `tls.ConnectionOptions` object for a private CA or a client certificate. | | `tls` | `false` | `true` for defaults, or a `tls.ConnectionOptions` object for a private CA or a client certificate. |
| `connectTimeout` | `10000` | Give up on **each connect attempt** the SMSC never completes, the TLS handshake included, and report it as an ordinary connect failure, which `reconnect` then retries. It bounds the socket and the handshake — never the `client()` call, and never the wait for the bind response, which is `responseTimeout`. `false` waits the operating system out instead, around 130 s on Linux; `0` is refused. |
| `enquireLinkInterval` | `20000` | Interval between `enquire_link` on a quiet link. | | `enquireLinkInterval` | `20000` | Interval between `enquire_link` on a quiet link. |
| `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering, and re-bind unless `reconnect` is `false`. | | `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering, and re-bind unless `reconnect` is `false`. |
| `responseTimeout` | `30000` | How long to wait for a response, and how long a send with no link waits for the next one. `0` waits forever. | | `responseTimeout` | `30000` | How long to wait for a response, and how long a send with no link waits for the next one. `0` waits forever. |
@@ -628,10 +630,80 @@ if (isCommand(pduObj, 'submit_sm')) {
| Spec tables | `cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, the `cmdsById`, `constsById`, `errorsById` and `tlvsById` maps, and all of them grouped as `defs`. `isCommandName`, `isErrorName`, `isEncodingName`, `commandNameById` and `errorNameById` narrow a value into them. | | Spec tables | `cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, the `cmdsById`, `constsById`, `errorsById` and `tlvsById` maps, and all of them grouped as `defs`. `isCommandName`, `isErrorName`, `isEncodingName`, `commandNameById` and `errorNameById` narrow a value into them. |
| Types | Every option, result, event payload and table entry has a named type: `ClientOptions`, `ServerOptions`, `SendSmsOptions`, `SendSmsResult`, `Sms`, `Dlr`, `MessageDlr`, `Receipt`, `PduObject`, `PduHeader`, `SmppLog`, `Result` and the rest in `dist/index.d.ts`. | | Types | Every option, result, event payload and table entry has a named type: `ClientOptions`, `ServerOptions`, `SendSmsOptions`, `SendSmsResult`, `Sms`, `Dlr`, `MessageDlr`, `Receipt`, `PduObject`, `PduHeader`, `SmppLog`, `Result` and the rest in `dist/index.d.ts`. |
## What changed per release
See [CHANGELOG.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/CHANGELOG.md).
## Migrating from larvitsmpp 0.4.0 ## Migrating from larvitsmpp 0.4.0
See [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md). See [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md).
## Goals
In priority order, and the order is the point: where two of them pull against each other, the earlier
one wins. They do not override the hard rules below.
1. **Correct on the wire.** SMPP 3.4 as SMSCs actually run it. Every other goal yields to this one;
the defect table below is what the alternative costs.
2. **Never give the application a wrong answer about what happened.** An outcome we cannot determine
is reported as undetermined rather than guessed; a report the peer marked as not final settles
nothing, so nothing the library concludes may rest on one; a request the peer may already have
taken is never re-sent on the library's own initiative; work the peer has no reason to send again
is not dropped.
3. **Strict in what we send, generous in what we read.** The library's own senders follow 3.4, and
the codec parses whatever arrives. Where the letter of the spec would discard traffic a real SMSC
sends, keep the traffic.
4. **A peer an operator never has to complain about.** No bind flooding, nothing a bind direction
forbids, no optional parameters to a peer that declared none, nothing held without a bound.
5. **The session layer is in here, and its defaults are what most applications should run.**
Keepalive, reconnect, the send window, reassembly and receipt correlation. What the network says
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
default or opts out of it; an option does not switch on the thing the caller obviously wanted.
6. **Configurable and extendable, never at the defaults' expense.** Where an application needs other
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
promise this library can verify. The low-level surface is a passthrough: policy binds what the
library composes, never what the caller wrote.
8. **State wider than one session goes through one store.** A pool of sessions, a limit shared
between processes, and what has to survive a restart — receipts still awaited, a message half
reassembled — are held through a store interface and never beside it. Without a store the
application supplies, that state is in memory and ends with the process, and the defaults need
none. The interface carries the library's own versioned records, never an internal shape handed
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
by the suite.
## Audience
Who depends on this library, and what they may rely on.
- **The public npm audience.** Only what `src/index.ts` exports is public; everything behind it is
reshaped freely.
- **Node 18 and newer, ESM only, no runtime dependencies.** The floor is verified in CI rather than
asserted, so the library drops into a service or a container without pulling a tree behind it.
- **Real SMSCs and ESMEs as operators actually run them**, not a reference implementation. Jasmin,
SMPPSim, Kannel, jsmpp, Cloudhopper, python-smpplib and php-smpp are the interop targets, and what
they do in practice outranks what the specification says they should do.
- **The SMSC operator on the far end**, who never sees this API but carries what it does to their
link. A peer they have to complain about is a defect however well the library reads.
- **Pre-1.0, so the minor is the breaking unit** and a patch never breaks. What a 0.4.0 consumer has
to change is in [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md).
Personas this README serves, in order:
1. The application developer sending or receiving SMS on the defaults, who should need no options.
2. The application developer who needs one thing retuned — an alphabet, a rate limit, a receipt
format — through an option or a hook rather than a fork.
3. The operator coordinating sessions across processes through a store.
4. The developer migrating from `larvitsmpp` 0.4.0.
## Development ## Development
Everything runs in the container; nothing is installed on the host. Everything runs in the container; nothing is installed on the host.
+56
View File
@@ -0,0 +1,56 @@
# Benchmarks
What this library sustains, and against what. Run them before setting or changing a scale goal.
```bash
docker compose run --rm node node benchmarks/run.ts # this library, both ends
COUNT=100000 docker compose run --rm node node benchmarks/run.ts # longer, steadier
```
`run.ts` spawns `smsc-sink.ts` — a server that answers every `submit_sm` `ESME_ROK` and stores
nothing — and drives it with `submit-load.ts` at four window sizes. Point `submit-load.ts` at any
SMSC to compare:
```bash
docker compose -f compose.yaml -f interop-tests/compose.jasmin.yaml run --rm node \
node benchmarks/submit-load.ts --host=jasmin --port=2775 --username=esme1 --password=esme1pw \
--count=20000 --concurrency=50
```
**A short run measures the JIT, not the library.** At 2,000 messages per point the same build
reported 16k/s where 100,000 messages reported 40k/s. Give each point several seconds.
## Results, 2026-09-20
Single host, 8 cores, Node 24.18.0 in the project container, loopback. Client and SMSC are separate
processes competing for the same CPUs, so these are a floor for split hosts.
`maxOutstanding` is the send window, and it is the setting that matters most:
| Window | msgs/s | with `dlr: true` |
| --- | --- | --- |
| 1 | 7,385 | 7,289 |
| 10 (default) | 25,358 | 24,282 |
| 50 | 37,125 | 37,502 |
| 200 | 40,046 | 39,730 |
Requesting delivery receipts costs nothing measurable at any window. A window of 1 — one request in
flight at a time — costs 5x, which is the round trip rather than the codec.
Against real peers, same driver, window 50, 20,000 messages:
| SMSC | msgs/s | failed |
| --- | --- | --- |
| this library's sink | 37,125 | 0 |
| Jasmin 0.10 | 2,207 | 0 |
| SMPPSim 3.0.0 | — | 19,000 of 20,000 |
Jasmin routes and persists where the sink does neither, so the gap is not an efficiency ratio
between two comparable things — what it establishes is that this library is not the bottleneck
against a production SMSC, by more than an order of magnitude. SMPPSim's store fills at roughly a
thousand messages and it then refuses the rest, so it cannot be loaded; that is a property of the
simulator, not a result.
`smppload`, the one purpose-built SMPP load generator among the peers, is blocked by a bind defect
of its own ([findings/07-load.md](../interop-tests/findings/07-load.md)), so no third-party load
tool drives these numbers.
+63
View File
@@ -0,0 +1,63 @@
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { createInterface } from 'node:readline';
/**
* Node rather than Python, unlike the repo's other standalone scripts: it spawns the two processes
* it measures, and they must run on the same runtime this library is measured under.
*/
const concurrencies = [1, 10, 50, 200];
const count = Number(process.env.COUNT ?? 5000);
function node(script: string, args: string[] = []) {
return spawn(process.execPath, [`${import.meta.dirname}/${script}`, ...args], {
stdio: ['ignore', 'pipe', 'inherit'],
});
}
async function firstLine(stream: NodeJS.ReadableStream): Promise<string> {
for await (const line of createInterface({ input: stream })) {
return line;
}
return '';
}
const sink = node('smsc-sink.ts');
const listening: unknown = JSON.parse(await firstLine(sink.stdout));
if (typeof listening !== 'object' || listening === null || !('port' in listening)) {
throw new Error('the sink did not report a port');
}
const port = String(listening.port);
const rows: Record<string, unknown>[] = [];
for (const dlr of [false, true]) {
for (const concurrency of concurrencies) {
const load = node('submit-load.ts', [
`--port=${port}`,
`--count=${String(count)}`,
`--concurrency=${String(concurrency)}`,
...(dlr ? ['--dlr'] : []),
]);
const reported: unknown = JSON.parse(await firstLine(load.stdout));
await once(load, 'exit');
if (typeof reported === 'object' && reported !== null) rows.push({ ...reported });
}
}
sink.kill('SIGTERM');
await once(sink, 'exit');
process.stdout.write(`\n${'dlr'.padEnd(6)}${'window'.padEnd(9)}${'msgs/s'.padEnd(10)}seconds\n`);
for (const row of rows) {
const dlr = String(row.dlr).padEnd(6);
const window = String(row.concurrency).padEnd(9);
const rate = String(row.perSecond).padEnd(10);
process.stdout.write(`${dlr}${window}${rate}${String(row.seconds)}\n`);
}
+33
View File
@@ -0,0 +1,33 @@
import { server } from '../src/server.ts';
/**
* Answers every submit_sm ESME_ROK and does nothing else, so a measurement against it reads this
* library's own ceiling rather than an SMSC's storage. Prints the bound port on stdout, then waits.
*/
const port = Number(process.env.PORT ?? 0);
const { err, server: smpp } = await server({ port });
if (err) {
process.stderr.write(`sink failed to listen: ${err.message}\n`);
process.exit(1);
}
let answered = 0;
smpp.on('session', session => {
session.on('sms', async sms => {
answered++;
await sms.sendResp();
});
});
smpp.on('serverError', reason => {
process.stderr.write(`sink serverError: ${reason.message}\n`);
});
process.stdout.write(`${JSON.stringify({ port: smpp.port })}\n`);
process.on('SIGTERM', () => {
process.stderr.write(`sink answered ${String(answered)}\n`);
void smpp.close({ signal: AbortSignal.abort() }).then(() => process.exit(0));
});
+72
View File
@@ -0,0 +1,72 @@
import { client } from '../src/client.ts';
/**
* Pushes `count` single-segment messages and reports what the wire carried per second. Keeps
* `concurrency` sends in flight so the send window, not the caller, is what bounds the rate.
*/
function arg(name: string, fallback: string): string {
const found = process.argv.find(one => one.startsWith(`--${name}=`));
return found === undefined ? fallback : found.slice(name.length + 3);
}
const host = arg('host', '127.0.0.1');
const port = Number(arg('port', '2775'));
const count = Number(arg('count', '2000'));
const concurrency = Number(arg('concurrency', '20'));
const message = arg('message', 'benchmark');
const dlr = process.argv.includes('--dlr');
const username = arg('username', 'user');
const password = arg('password', 'pass');
const { err, session } = await client({
host,
maxOutstanding: concurrency,
password,
port,
responseTimeout: 60_000,
username,
});
if (err) {
process.stdout.write(`${JSON.stringify({ error: err.message })}\n`);
process.exit(1);
}
// Narrowing from the guard above does not reach into worker(), which runs after it.
const bound = session;
let issued = 0;
let failed = 0;
let unanswered = 0;
async function worker(): Promise<void> {
while (issued < count) {
issued++;
const sent = await bound.sendSms({ dlr, from: 'BENCH', message, to: '46709771337' });
if (sent.err) failed++;
unanswered += sent.unanswered;
}
}
const started = process.hrtime.bigint();
await Promise.all(Array.from({ length: concurrency }, () => worker()));
const seconds = Number(process.hrtime.bigint() - started) / 1e9;
process.stdout.write(`${JSON.stringify({
concurrency,
count,
dlr,
failed,
perSecond: Math.round(count / seconds),
seconds: Number(seconds.toFixed(3)),
unanswered,
})}\n`);
await bound.close({ signal: AbortSignal.abort() });
process.exit(0);
+792
View File
@@ -0,0 +1,792 @@
# Decisions
The standing decisions for `@larvit/smpp`, grouped by what each one constrains. A decision is
written down only when it cannot be put better as a goal — [AGENTS.md](../AGENTS.md) carries that
rule and an index of the titles below.
## The public surface
- **`Session` is publicly constructible, which is what makes `SessionOptions` and `ReconnectOptions`
public too.** Raised twice as a leak; it is not one. The collaborators `session.ts` delegates to
(`Reassembler`, `PendingRequests`, `SendWindow`, `ReconnectLoop`, `LinkTimers`, `LinkGate`,
`DlrMerger`, `PduTransport`, `submitSms`) stay unpublished so they can be reshaped.
- **`acceptsOptionalParams()` and `bindAllows()` are predicates, not chokepoints.** The library's own
senders consult them; `session.send({ tlvs })` is passed through as written, because silently
stripping a caller's explicit TLVs off a deliberately public low-level surface would be worse than
sending them. Only `submit_sm`, `deliver_sm` and `data_sm` are policed by bind direction — the
three the library dispatches by it, of which it sends the first two.
- **`session.sock` is a getter over `PduTransport`.** Reading it is unchanged; assigning it no longer
compiles, which never rewired the handlers and so never worked.
- **Both emitters re-declare their listener methods to accept a promise.** Maintainer's call,
2026-08-27: `EventEmitter` types every listener as void-returning, so the
`session.on('sms', async sms => …)` README documents reads as a misused promise in any strict
consumer. `declare on: …` and its six siblings re-type the inherited methods to return `unknown`,
which emits nothing and needs no cast; overriding them as real methods cannot work, because the
`super.on()` call needs one. The cost is that a subclass can no longer reach those seven through
`super` — re-declaring them the same way is its way out. `unknown` rather than
`void | Promise<void>` because a listener may return anything: `session.on('close', () =>
set.delete(session))` returns a boolean. This also settles what the drain can wait on: a listener's
own promise would be the better completion signal, and reaching it needs `listeners()`, which
cannot be re-declared the same way — Node types it invariantly enough that widening `void` to
`unknown` is `TS2416`. Re-probed 2026-09-01; `sendResp()` stays the signal.
- **`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
session's own failure, and `instanceof` is the only way to separate them that hard rule 4 allows —
without the class as a value an application is left string-matching `err.message`. Goal 7 is paid by
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
because `reason` is compared against string literals, and an accessor
(`PduRefusedError['header']`) names either one where a signature wants it. The payload union
enforces nothing — a subclass narrows out of `Error` either way — and is there so the event's own
type names what to narrow to, which is also what makes it a half-truth if a second `Error` subclass
ever reaches this event without joining it. Rejected: a `SessionError` alias for that union, a
third name for a type that is structurally `Error`. Rejected: a separate `pduRefused` event, which
splits the failure channel so an application that wants every failure listens twice and an existing
listener silently stops seeing refusals. Rejected: coalescing or rate-limiting them, which re-opens
the standing decision that `sessionError` carries every failure, never coalesced or suppressed —
the filtering belongs where the application is, since only it knows which peer is routinely sloppy.
Rejected: an error code on a plain `Error`, which reads back off an `unknown` property only through
a cast and types nothing it carries. Accepted: a second copy of the package installed alongside
this one defeats `instanceof`, where `err.name` still reads `PduRefusedError`.
- **`bitCount()`, `encodeMessage()` and `splitMessage()` keep their total signatures, because
`EncodingName` is what keeps an alphabet with no codec away from them.** Maintainer's call,
2026-09-09, from the architecture review of [#95](https://github.com/larvit/larvitsmpp/pull/95):
all three index `encodings` by name and would throw on one it has no codec for, which hard rule 1
forbids. That PR left no such name to pass — `encodings` is a `Record<EncodingName, Encoding>`, so
every member of the union has a codec and one added without a codec, or without a segment budget,
fails to compile in four places. What was missing is the door for a caller holding a name at
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()`,
which serve their own tables that way. Rejected: a `Result` signature on all three, which costs
every typed consumer a narrow forever — goal 7, and the tag is the last cheap chance to spend it —
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
without types gets. `smppTime.encode()` is where that reasoning lands the other way and is recorded
under [The wire](#the-wire): `Date | number | string` is not a closed set, so it is a `Result`.
- **A segment the SMSC took and named no id for is `undefined` in `smsIds`, not an empty string.**
Maintainer's call, 2026-09-12: `paramText()` resolves an absent `message_id` and one a peer wrote
empty to the same `''`, which `string[]` then presented as an id — taking `smsIds[0]`, or keying a
correlation table by the array, compiled and then misbehaved, and one message's empty entry
collides with another's. Telesign names an id for the first segment of a concatenated submit only,
so it is a documented operator's shape rather than a hypothesis. Nothing else moves:
`parseSegmentId('')` matched nothing, so `DlrMerger` already abandoned such a send and `undefined`
reaches that same refusal. `expect()` takes the wider type rather than a filtered `string[]`
because the arity is what `idNumbering()` refuses on: filtering `['a-1', undefined, 'a-3']` leaves
a numbering that spells out a whole message, and merges one that was never whole. `dlrFromPdu()`
reads an id through `nonEmptyText()`, so no receipt could ever have matched an empty entry — and
that reading stays separate from this one rather than sharing a helper, since it must leave a
Buffer-valued `receipted_message_id` unresolved for `messageType()` to read the PDU as unmarked.
What settles it here is the resolved text rather than the parameter, because `writeParams()`
substitutes the field's own default: a peer that omits `message_id` and one that writes it empty
build the same octets, leaving a raw-parameter test nothing to tell apart. Rejected: keeping `''`
and documenting it, which leaves the published type promising what the value does not keep — goal
2, a wrong answer about what the peer named. Rejected: dropping the unnamed entries, which breaks
the positional correspondence with `pduObjs` that README promises and loses which segment a PDU
belongs to. Rejected: `{ id?: string; pduObj: PduObject }[]`, which makes that positional promise
structural where today the compiler cannot check it; deferred to the next breaking release, the
first place two documented fields may become one. Accepted: every consumer reading `smsIds`
narrows, including the majority whose SMSC names every id; indexing narrows too, except for the
consumer who sets `noUncheckedIndexedAccess`, which typed `smsIds[0]` as `string | undefined`
already.
## The wire
- **The declared interface version is an option on both `client()` and `server()`, and is not the
optional-parameter threshold.** That threshold is fixed at 0x34 by the spec, so an implementation
that must declare 5.0 throughout can, without moving it.
- **A peer that declared no version is pre-3.4, and `undefined` means no bind yet.** `acceptBind()`
records what the ESME declared and the client's `bind()` records the `sc_interface_version` the
SMSC answered with; a peer that declared nothing is recorded as `undeclaredInterfaceVersion` (0x00)
and sent no optional parameters, which is how the spec reads an absent `sc_interface_version`.
- **`esm_class` decides what a `deliver_sm` is, and the body is read only when it names nothing.**
The two types the MC writes about a message we submitted — `MC_DELIVERY_RECEIPT` (0x04) and
`INTERMEDIATE_DELIVERY` (0x20) — are reports whatever the body parses to, so one in a format
`dlrFromPdu()` cannot read reaches `dlr` with `smsId` undefined instead of arriving as an inbound
SMS. The three the far-end SME writes (0x08, 0x10, 0x18) are messages and their bodies are not
scraped: Kannel reads 0x08 as report-bearing and this does not, because a delivery acknowledgement
is the handset's word about a message, not the network's. A message type of 0 or one of the ten
reserved keeps the scrape, and a non-empty `receipted_message_id` TLV marks a report on the same
footing. A report this library recognises never reaches the reassembler, so an SMSC that splits one
across segments gets a `dlr` per segment rather than one merged report. The `message_state` TLV is
authoritative only where it names a state in the table — SMPP reserves 0x80-0xFF for
MC-vendor-specific values, so an unnameable one keeps its raw `statusId` and leaves `statusMsg` to
the body.
- **A body is read from `message_payload` where `short_message` carries none, and `short_message`
wins where a peer filled both.** Maintainer's call, 2026-09-06, from the Jasmin interoperability
phase: SMPP 3.4 5.3.2.32 makes the TLV the alternative for a body the mandatory field cannot
carry, several SMSCs use it, and Jasmin relays one faithfully — reading `short_message` alone
handed the application an empty message
([interop-tests/findings/03-jasmin.md](interop-tests/findings/03-jasmin.md)). `messageOctets()` is
the single answer to where a body is, so the message path, the reassembler and `dlrFromPdu()`
cannot disagree about it, and `esm_class` still says whether that body starts with a UDH wherever
it was carried, which leaves concatenation reading exactly as before. Filling both contradicts the
spec's own instruction to leave `sm_length` zero, and taking the mandatory field there keeps the
rule purely additive: no PDU that parsed before reads differently now. Rejected: preferring the
TLV, which re-reads every message a peer echoes into both. Rejected: refusing a PDU carrying both,
which discards a message that is almost certainly present twice over, where goal 3 keeps the
traffic. The reassembler's octet cap already counts TLV values, so a 64 KB payload is bounded like
any other segment.
- **A segment's concatenation is read from its UDH, or from the `sar_*` TLVs where it declares none,
and each spelling groups in a reference space of its own.** Maintainer's call, 2026-09-06, from
the Jasmin and Java-client interoperability phases: SMPP 3.4 5.3.2.31-5.3.2.33 make
`sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum` the other way to say what a UDH says,
Jasmin documents it as its own segmentation and jsmpp writes it, and reading the UDH alone handed
the application one `sms` per fragment
([interop-tests/findings/05-java-clients.md](interop-tests/findings/05-java-clients.md)).
`concatOf()` is the single answer to how a PDU says it is a segment, as `messageOctets()` is to
where a body is, and both are exported for the same reason: an application on the low-level
surfaces would otherwise rewrite the read this fixed. It carries the spelling beside the
reference, so the key is two tokens the reassembler joins and interprets neither of, and so the
refusal can name the field the peer got wrong — `ESME_RINVESMCLASS` for a UDH, `ESME_RINVTLVVAL`
for the TLVs, whose segment's `esm_class` is 0x00 and correct. Keying them together instead would
assemble two of a peer's messages into one, since a UDH reference is 8 bits and `sar_msg_ref_num`
is 16 and neither counts the other's messages; the two UDH widths share a space because they are
one sender's counter in one layer, where a `sar_*` reference is another layer's. A UDH that names
the concatenation wins over the TLVs — one carrying only a port leaves them to say — which keeps
the change additive for every message that reassembled before, and leaves the library nothing to
guess where the two disagree. Rejected: preferring the TLVs, which regroups every message a
gateway derived them from. Rejected: comparing the parts and reporting a disagreement: the
references are not comparable at all, and where the parts are, the UDH is still what the message
is assembled by, so the report would name a failure the application cannot act on. Accepted: a
peer that switches spelling mid-message now has two groups that expire rather than fragments that
arrive, which goal 2 prefers to a message assembled from two counters. Receive-only: `sendSms()`
goes on writing a UDH with an 8-bit reference, where a send-side `sar_*` would be a second
spelling of one message whose only difference is which peers accept it.
- **`sendSms()` takes the messaging mode by name, and it is the only part of `esm_class` a caller
writes.** Maintainer's call, 2026-09-06, closing target 5 of the interoperability plan: every peer
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
segment through `send()` — giving up the split, the per-segment ids, the send window and the
receipt merge, which is what goal 7 means by beating "the application can do this itself". The four
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
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
of here, so `FORWARD` stays in the group that mirrors the spec table and `sendSms()` refuses it by
that reason rather than as an unknown name — a mode this library cannot deliver is a promise goal 7
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 under any other mode both go out untouched. Those three names left `ESM_CLASS`, where they
had `constsById.ESM_CLASS` read 0x03 as a whole `esm_class`. Rejected: a raw `esmClass` number,
which is exactly that clearable state and would need refusing bit by bit to be safe. Rejected:
taking a number beside a name, two spellings of one goal — which is why a value naming no mode is
refused, by name, before a segment goes out. Rejected: a session-level default with a per-send
override; an operator's requirement is a property of the link, but the library can verify nothing
the caller's own options object does not, and shipping both buys a precedence rule to document and
test for that. `SMSC_DEFAULT` is named so pinning the default deliberately is sayable.
- **An inbound `data_sm` stands in for whichever of `submit_sm` and `deliver_sm` its direction makes
it, and none goes out.** Maintainer's call, 2026-09-06, from the Jasmin interoperability phase:
SMPP 3.4 4.7.1 makes it a peer of both that always carries its body in `message_payload`, and
Jasmin's `[dlr-thrower] dlr_pdu = data_sm` throws real receipts on it, which `ESME_RINVCMDID`
dropped with nothing reported to the application at all. Every command but this one names its own
direction, which is why the bind gate and the dispatch never had to be told which end of the link
they are on; `linkEnd` is that fact, and it decides both. At the ESME end an inbound one is a
delivery, so `esm_class` classifies it as it classifies a `deliver_sm`; at the SMSC end it is a
submission and is read as one, because a report about a message this end never sent is goal 2's
wrong answer whatever `esm_class` a peer wrote on it. A concatenated one is answered segment by
segment either way, and 4.7.2 gives `data_sm_resp` a `message_id` where 4.6.2 leaves
`deliver_sm_resp`'s unused, so the answer carries one. `linkEnd` is a field beside `boundAs`
rather than a `SessionOptions` entry, so the code that knows which end this is writes it and
nothing else can contradict what the session then binds as. Rejected: grouping the command with
`deliver_sm` in the gate, which refuses a transmitter-bound ESME's legitimate submission, and with
`submit_sm`, which refuses the receiver-bound delivery this was fixed for. Rejected: sending one —
`send()` reaches the command raw, and an option choosing which command a message goes out on would
be a second spelling of `sendSms()` whose only difference is which peers accept it.
- **A receipt's body is read as octets, and its own `data_coding` never says how.** Maintainer's
call, 2026-09-05 via the SMPPSim interop run: SMPPSim copies the reported message's `data_coding`
onto a receipt whose body it always writes as plain text, and Melrose Labs documents the same
echo, so decoding by that field turns an Appendix B receipt into UCS-2 garbage — total loss
against the many peers that send no TLVs to fall back on. `dlrFromPdu()` reads
`PduObject.shortMessageOctets` through Latin-1, the one codec that maps every octet to a
character, so the fixed fields parse whatever the PDU claims; the codec keeps both spellings
because a message needs the text and a receipt needs the octets. Rejected: honouring `data_coding`
where the octets yield no field, which reads one body two ways for the sake of a peer writing a
UCS-2 receipt body that no researched SMSC is — that peer's receipt yields no fields at all here,
which goal 2 reports as undetermined rather than guessed. An inbound message is untouched: nothing
but `data_coding` can say how a message was written.
- **A message class is read where GSM 03.38 puts it, `flash` is class 0 alone, and a flash message
with no alphabet to carry it is refused.** Maintainer's call, 2026-09-09, closing the last target
of the interoperability plan: `sms.flash` was `(data_coding & 0xF0) === 0x10`, which called the
ME-, SIM- and TE-specific classes immediate display and missed the 0xF0 group entirely — the only
one SMPP 3.4 5.2.19 names, since it marks 0x0F to 0xBF reserved and hands 0xF0 to 0xFF to GSM
03.38, and the one SMPPSim demonstrated
([interop-tests/findings/02-smppsim.md](interop-tests/findings/02-smppsim.md), C17).
`messageClassOf()` is the single answer to whether a `data_coding` carries a class and which, as
`concatOf()` is to how a PDU says it is a segment: 03.38 section 4 puts the class in bits 1-0,
carried where bit 4 says so in every group below 0x80 and always in the 0xF0 group, and
`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
other than 0 would otherwise rewrite the read this fixed. Rejected: a `messageClass` field on the
`sms` event, which pays goal 7 for three classes nothing here acts on, where the boolean the
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
its declared alphabet makes of it — but bit 5 does not move the class bits, so 0x30 is read as
class 0 rather than special-cased into a wrong answer; 01xx is read for the same reason, 03.38
coding it exactly as 00xx. Rejected: reading only the two groups the defect named, which needs an
extra test to produce a wrong answer for a class the spec puts in plain sight. Accepted: the
alphabet is read only where a class is, so 0x58 is UCS2 while 0x48 — the same alphabet with the
class bit clear — stays ASCII, because below 0x10 SMPP's flat table contradicts 03.38 and wins
(0x03 is Latin-1 there, GSM 7-bit here) and a class is the only evidence a peer below 0x80 is
spelling 03.38 at all. Send-side: `flash`
is that class, so it goes out as 0x18 beside UCS2 and 0x10 beside GSM 7-bit, while
`encoding: 'LATIN1'` beside it is refused before a segment goes out, the way a messaging mode this
library cannot deliver is — 03.38's class groups hold GSM 7-bit, 8-bit data and UCS2, and Latin-1 is
SMPP's own flat-table alphabet, so the pair has no spelling. Rejected: 0x10 with Latin-1 octets,
which declares an alphabet the body is not in; rejected: 0x14, 8-bit data, which is not text to
the handset that would display it; rejected: promoting it to UCS2, which overrides the one option
the caller wrote in order to override a choice. Rejected with them: `encoding: 'FLASH'`, which
named `data_coding` 0x10 among the alphabets and so reached the class through the option that
chooses a charset — a second spelling of `flash: true` that also flattened every non-GSM character
to a space on the way. It leaves `EncodingName`, which is now exactly the three codecs `detect()`
and `encodingByDataCoding()` return, and `encoding` is checked by name like `messagingMode` so a
caller without types gets a refusal rather than a throw out of the codec table. `consts.ENCODING`
keeps its `FLASH` entry: the low-level surface reaches raw constants, and nothing reads that group
as an alphabet any more. Accepted: `flash` is now false for `data_coding` 0x11 to 0x13, which no
peer means as immediate display.
- **A report is final unless its `esm_class` or its state says otherwise, and only `ENROUTE` and
`SCHEDULED` say otherwise.** SMPP 3.4 Appendix B lists every other receipt state as final,
`UNKNOWN` and `ACCEPTED` included, so a peer writing `ACCEPTD` for a carrier-accepted step is taken
at its word. Rejected: reading `UNKNOWN` as non-final, which leaves a peer whose receipt body this
library cannot read with no `messageDlr` at all — goal 2 wants that reported as undetermined, not
withheld. Both spellings resolve into `Dlr.intermediate` at the boundary rather than being read a
second time in `DlrMerger`, so the library cannot answer the application one way and conclude the
other. Not every peer marks a transient report 0x20 — an ordinary receipt carrying `stat:ENROUTE`
is common — so the state test is what the marker test cannot replace. `message_state` 0 is 5.0's
`SCHEDULED` and undefined in 3.4; a peer that writes it is read as transient rather than as saying
nothing, maintainer's call, 2026-09-03, since the codec refuses a zero-length integer TLV and so an
absent one cannot land there.
- **A `stat:` an operator spells outside Appendix B is read as the state it names, and the two
researched ones are `FAILED` and CM.com's `DELIVERD`.** Maintainer's call, 2026-09-08, from the
operator-fixture phase: Kaleyra and Route Mobile both document `FAILED` in that field as a terminal
delivery failure, and the research attributes it to Vonage as well; CM.com's own code table prints
`DELIVERD` — eight characters — beside six correct ones. Both were left at `statusMsg: UNKNOWN` —
the same answer a receipt really saying `stat:UNKNOWN` gets, so an application could not tell an
operator's "it failed" from its "I do not know", nor a delivered message from one whose state
could not be read; and `DlrMerger` ranks `UNKNOWN` below `EXPIRED`, reporting a multipart send
carrying a failed segment as expired. They join `receiptStates` alone: `receiptCodes` goes on
writing the seven characters 3.4 defines, so nothing this library sends gains either spelling. Rejected: a `FAILED` member of `MESSAGE_STATE`, which
is 3.4's own numbered table — the code has no number there, so one would have to be invented, and
every consumer's switch would grow a case no `message_state` TLV can carry. Rejected: leaving it
`UNKNOWN` and sending the application to `dlr.receipt.stat` for the state, which reports a terminal
failure as undetermined and leaves the merge ranking it below `EXPIRED`. Rejected: reading the
numeric status tables Syniverse and Route Mobile publish beside it, which are vendor fields of
their own rather than the seven characters `stat:` holds. Accepted: all three of those operators
document `FAILED` and `UNDELIV` as separate codes, and both now resolve to `UNDELIVERABLE` — an
application that must tell them apart reads `dlr.receipt.stat`, which carries what the SMSC wrote.
Accepted: an unmarked `deliver_sm` whose body says one of them now reaches the application as a
report where it used to arrive as an inbound message, which is what every code already in the table
does. What decides a spelling is whether the corpus in `test/operator-receipts.test.ts` can cite the
page it is printed on and no other code could be meant, which is why `DELIVERD` is read and a
spelling nobody publishes is not: a mapping that costs nothing where an operator's own docs merely
contain a typo saves an application everything where they do not.
- **A transient state goes out as an intermediate delivery notification (0x20), every other state as
a delivery receipt (0x04).** Appendix B makes a receipt's `stat` the message's final status, so
0x04 over `ENROUTE` emits the two disagreeing spellings of finality the reading side above has to
reconcile, and goal 3 has our own senders write the marker 3.4 defines. `sendDlr()` takes the list
from `transientStates` in `dlr.ts`, the same one the reader uses, so the two cannot drift.
Rejected: 0x04 for every state, for the sake of a peer that classifies on the marker — the cost
accepted here is that such a peer stops recognising a transient report as a report at all and hands
its application receipt text as an inbound message, where under 0x04 it would have read the state
from `stat:` and been right. A transient state also carries `err:000`, since a message still on its
way has not failed.
- **A refused PDU is answered from its header, and any 32-bit `sequence_number` is echoed as it
arrived.** Maintainer's call, 2026-09-05 via the interop plan. The header of a framed PDU always
parses, so it carries the answer SMPP 3.4 4.3 asks for, with the status 3.4 names for the part
that would not parse. Rejected: nacking a refused *response*, whose sequence number is one of
ours — the `generic_nack` would land in the peer's own numbering and nack a request of the peer's
we never saw, so a refused response is written back nothing and settles the request it names
instead. An unknown command id with the response bit set takes that branch too: a peer echoing a
sequence number of ours is answering something, and settling it reaches the undetermined outcome
`responseTimeout` would have reached anyway, sooner. Rejected: clamping a sequence number outside 4.7.1's 0x00000001–0x7FFFFFFF into range
before answering, which correlates with nothing at the peer — stacks write the field as a plain
uint32 (ukarim/smscsim signs every unprompted `deliver_sm` with a raw `rand.Int()`), so goal 3
keeps that traffic and `PendingRequests.nextSeqNr()`, the only thing that invents one, is what
holds our own sends inside the spec.
- **The optional parameters run to `command_length` exactly, and the only slack tolerated is one
NULL octet where a peer padded `short_message`.** Maintainer's call, 2026-09-06, from the
Java-client interoperability phase: accepting any parse that merely did not error answered
`ESME_ROK` to a `deliver_sm` whose three trailing octets were never read, dropping the
`receipted_message_id` that makes a receipt a receipt
([interop-tests/findings/05-java-clients.md](interop-tests/findings/05-java-clients.md)). Goal 2
settles it against goal 3: octets this codec cannot name are a PDU it did not read, so a region
that does not end on `command_length` — the padded read included — is refused with the `tlvs`
reason and `ESME_RINVTLVSTREAM` a truncated TLV value already gets. What the rule costs is paid
once, in `readCstring()`: a trailing C-Octet String a peer left out entirely consumes no octet,
where reporting the terminator it never sent puts every later offset past the declared end and
refuses a bind, and every bodyless response, that used to parse. That composes, so a run of them
at the tail all read empty — `outbind` is the only command with two, and an absent field and an
empty one say the same thing, so goal 2 is not at stake even there. Rejected: keeping the tolerance
for the one to three trailing octets too few to hold a TLV header, which no researched peer sends
and which cannot be told apart from the truncated tail this fixes. Rejected: refusing it as
`body`/`ESME_RINVCMDLEN`, which names the mandatory fields — the part the peer got right.
- **`smsIdFormat` names a notation per place, and normalisation never reaches inside a `<base>-<n>`
id.** An SMSC may answer `submit_sm_resp` in hex and write the receipt's `id:` in decimal, so one
transform over both sides cannot make them equal. `submitResp` covers the `receipted_message_id`
TLV too, which SMPP 3.4 5.3.2.26 defines as the id the `submit_sm_resp` carried: naming one
notation for whichever id a receipt yields would break the peer that sends both. Omitting a place
is what leaving it alone means, so there is no `raw` notation, and a caller-supplied formatter is
refused because it would make the promise that the two ids are comparable unverifiable — `onRequest`
and the PDU on the `dlr` event are the escape hatches. A `<base>-<n>` id parses as no number and so
reaches `expect()` and `collect()` unchanged, which is what keeps `DlrMerger` working; normalising
the base instead would break that pair. The option is on `client()` only, since a `server()` session
writes both ids itself.
- **A concatenated segment is budgeted at 134 octets, which is 153 septets where the SMSC packs them
and 134 octets of anything it does not.** Maintainer's call, 2026-09-09, from the architecture
review of [#95](https://github.com/larvit/larvitsmpp/pull/95): `segmentUnits` handed 153 to
everything but UCS2, so a long `encoding: 'LATIN1'` message went out as segments of 153 octets plus
a 6-octet UDH — 159 on the air where GSM 03.40 carries 140, which no SMSC can deliver. Goal 1 owns
it. There is one budget, 140 less the UDH, and the alphabet decides only what it is counted in, so
Latin-1 and UCS2 both take those 134 octets — 134 characters and 67 — and it is GSM 7-bit's 153
that is the odd number rather than the other way round. `Record<EncodingName, number>` is what makes
a fourth alphabet state its own. Rejected: 134 for GSM 7-bit too, which is the mistake the
unpacked-alphabet section above exists to stop. Accepted: a Latin-1 message past the 140 characters
one SMS holds now costs more segments than it did, and `smsIds` is that much longer.
- **An alphabet the caller named has to carry the message, and a time the format cannot express is
refused, both before a segment goes out.** Maintainer's call, 2026-09-09, from the architecture and
stability reviews of [#96](https://github.com/larvit/larvitsmpp/pull/96): `encoding: 'LATIN1'` on
`あいう` put `42 44 46` — `"BDF"` — on the wire and returned success, `encoding: 'ASCII'`
flattened every character outside 03.38 to a space, and `validityPeriod: new Date('nope')` wrote
`NaNNaNNaNNaNNaNNaNNaN00+` into the PDU. Goal 2 owns all three: bytes that do not say what the
caller asked, reported as sent. `unencodable()` is the single answer to whether an alphabet can
carry a message, as `messageClassOf()` is to whether a `data_coding` carries a class, and it asks
the codec — `decode(encode(c)) === c` per code point — rather than restating the tables beside it,
so the guard cannot drift from what the encoder writes for any one character, and a fourth
alphabet answers by having a codec at all. It is exported for the reason `concatOf()` is: a caller
composing a `submit_sm` through `send()` and `encodeMessage()` would otherwise rewrite the read
this fixed. `match()` cannot be that answer — it doubles as the auto-selection policy `detect()`
reads, where LATIN1 is hardcoded false so nothing picks it, and using it would refuse the 8-bit
binary body Latin-1 is kept for. The guard is
on the named branch alone, so an unspecified send is untouched: every alphabet `detect()` returns
carries every character it was picked for, over the whole code point range. `smppTime.encode()`
returns a `Result`, where the three encoding helpers stayed total: that argument was that
`EncodingName` is a closed set the compiler guards, and `Date | number | string` is not — an
invalid `Date` and `NaN` inhabit it, which hard rule 1 makes a result "wherever the types admit
one", and `decode()` has been fallible for the same reason since it was written. Rejected:
transcoding to UCS2, which overrides the one option the caller wrote in order to override a choice
— the same reason a flash Latin-1 message is refused rather than promoted, and an operator that
accepts only `data_coding` 0x03 would be handed something it never agreed to take. Rejected:
guarding `sendSms()` alone and leaving `smppTime.encode()` writing `NaN`s, which leaves this
library's own published helper composing the garbage the guard exists to stop. Rejected: a
`holds()` member beside `match()` on `Encoding`, a second per-alphabet table to keep in step with
the codec. Rejected: validating the `string` spelling of a time, which is a stamp the caller
formatted for a peer whose format is theirs to name, its width included, where SMPP 3.4 gives the
field 1 or 17 octets. Accepted: a second count past 99d 23:59:59 is refused rather than clamped to
it, a negative one and `Infinity` with it — clamping `86400 * 365` reported success for a year and
put 99 days on the wire, the wrong answer about what happened that the rest of this bullet exists
to remove. The ceiling is this encoder's rather than the format's: 3.4's `YYMMDDhhmmss000R`
carries years and months, which `decode()` reads back, and no fixed number of seconds is either
one, so spelling a second count in days and below is where the guess would go — which is why the
too-long refusal names the `Date` that reaches every instant the absolute form holds, and the
negative one names nothing, there being no period to reach. Rejected: documenting the clamp, which
leaves the caller told a true thing and still sent the wrong period. Accepted: GSM's 0x1B is an
extension prefix rather than a character, so a bare ESC beside one of the ten extension bases is
the one input a per-character reading passes and the encoder then writes as the extended character
— the only composition in any of the three codecs, and not a character a message is written in.
- **A string body is written in the alphabet its own `data_coding` names, and one that alphabet
cannot carry is refused by the codec — `message_payload` on the same terms as `short_message`.**
Maintainer's call, 2026-09-09, from the architecture review of
[#97](https://github.com/larvit/larvitsmpp/pull/97): `objToPdu()` took the codec off the caller's
own `data_coding` and encoded with it whatever the text was, so `data_coding` 3 beside `あいう`
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.
The line falls at the string: a `Buffer` is octets the caller already chose and goes out as given
under any `data_coding`, which is what keeps goal 7's escape hatch open — the raw UDH, 8-bit binary
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
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
`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
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
reached through
`encodeBody()` in `message.ts`, which is where the `data_coding`-to-text pair already lives:
`encodeBody(text, dataCoding)` is `decodeMessage(buffer, dataCoding)`'s mirror and resolves the
alphabet through the same `encodingByDataCoding()`. `send()` and `sendReturn()` inherit it,
since both build through `buildPdu()`; `sendSms()` does not, and keeps its own guard, because
`splitMessage()` hands the codec a Buffer with nothing left to refuse and the index a segment
could name is not the one in the message. The TLV is encoded rather than merely checked because
`data_coding` names the alphabet of the body wherever it is carried — that is how
`messageOctets()` and `decodeMessage()` read one back, and a `data_sm` has nowhere else to put one
— so refusing what Latin-1 cannot hold while still writing UCS-2 text as Latin-1 octets would
close half of it. `short_message` settles the `data_coding` wherever it carries octets at all, the
order `messageOctets()` reads the two in, so the alphabet a PDU declares is the one its body will
be read under — and a `short_message` on a command whose table declares none is ignored here as
`writeParams()` ignores it, so an empty one, an absent one and one the wire cannot carry are the
same input rather than three. A `data_coding` on a command that declares no such field is honoured
the other way round, since it is `replace_sm`'s only way to name the alphabet its octets are in.
Every entry carrying the payload tag is resolved, by tag id rather
than by record key, since `tagIdOf()` lets a caller name it anything and a spelling that escaped
the guard would be a second spelling that disagrees about correctness. Rejected: refusing a string
`message_payload` outright and demanding
octets, which contradicts `short_message` on the same PDU. Rejected: guarding every string-valued
field, which `data_coding` says nothing about — an address is a C-Octet String and ASCII by 3.4's
own definition.
- **A GSM 03.38 message declares `data_coding` 0x00, and an inbound 0x01 is still read as GSM.**
Maintainer's call, 2026-09-09: `dataCodingFor()` and `encodeBody()` both resolved an alphabet
through `consts.ENCODING`, so `encoding: 'ASCII'` went out as 0x01 — SMPP 3.4 5.2.19's *IA5 (CCITT
T.50)/ASCII* — while the codec writes GSM 03.38, where `$` is 0x02 and `@` is 0x00 against IA5's
STX and NUL. Goal 1 owns it, and this library's own reader hid it by resolving both codings to the
same codec. `dataCodingByEncoding` is the single answer to which coding an alphabet is written
under, as `unencodable()` is to whether one can carry a message: the mirror of
`encodingByDataCoding()`, and reached by both the `sendSms()` path and `encodeBody()`'s detected
one rather than each spelling the map again, which is what `sendDlr()` inherits it through. It is
exported for the reason `unencodable()` is — a caller pairing `encodeMessage()`'s octets with a
`data_coding` of its own had only `consts.ENCODING` to reach for, which is the trap. 0x00 is the
*SMSC's* default alphabet rather than 03.38 by name, so it is a convention rather than a guarantee;
it is also what every peer in `interop-tests/` submits under and what LINK Mobility, Route Mobile
and Telesign all publish 03.38 as, where 0x01 names a different alphabet from the one written and
so is wrong whatever the peer makes of it. Reading is untouched, goal 3: those same three map 0x01
to 03.38 too, and Kaleyra and Route Mobile publish that value as known to cause problems, so no
researched peer means IA5 by it. The two tables agree over most of the printable range and part at
0x00-0x09, 0x0B-0x0C, 0x0E-0x1A, 0x1C-0x1F, 0x24, 0x40, 0x5B-0x60 and 0x7B-0x7F — line feed,
carriage return and escape are common to both — which is where a peer that did mean IA5 is
misread. Accepted with it: `consts.ENCODING` loses its `ASCII` alias and keeps `IA5`, the two
names 5.2.19 gives 0x01, because that alias was the only name the two tables shared at different
values and so the only one a reader could carry from the option's vocabulary into SMPP's flat
table; `constsById.ENCODING[0x01]` already read `IA5`, so nothing moves but the forward name.
Rejected: moving `consts.ENCODING.ASCII` to 0x00, which would make that table contradict the
section it exists to spell — the group is SMPP's flat `data_coding` table, not the `encoding`
option's vocabulary, the distinction the `FLASH` removal already drew. Rejected: reading 0x01 as
Latin-1, the closest codec here to IA5, which mojibakes every peer that means GSM for one nothing
researched has found. Accepted: a message already in flight is unmoved — both codings resolve to
the same codec, `messageClassOf()` finds no class in either, and `Reassembler` groups on the
concatenation reference rather than on `data_coding` — so a receipt or a segment that crossed the
change reads exactly as it did.
## The session's life
- **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call,
2026-08-26: most SMSCs drop the socket instead of answering, so the documented shutdown would
otherwise always report a failure. It does mask a socket that died mid-unbind for an unrelated
reason, which is accepted — the peer sees the same TCP close either way.
- **`close` means the session is over, and a drop the loop will retry is `disconnected`.**
Maintainer's call, 2026-08-31: without the split, an application that opens a replacement client on
`close` ends up holding two binds on one account. `teardown()` picks the event by whether the
reconnect loop is still live, and `end()` stops that loop before tearing down, so every deliberate
shutdown emits `close`. A retry that opens a socket and then loses it clears `closed` through
`attach()`, which is why a second drop emits again.
- **An answer belongs to the link the message arrived on; a receipt does not.** Maintainer's call,
2026-09-01. Rejected: answering on the new link, which succeeds and reports `{}` for a response
that correlates with nothing — goal 2's wrong answer. Accepted: a receipt sent after a refused
response names an id the peer has no record of.
- **`reconnect` takes `{ minDelay, maxDelay }` to retune and `false` to turn off**, so absent means
on and there is one spelling for each. Only `client()` reconnects — a `server()` session is a
connection the peer opened, and nothing at this end can reopen it. The retry timer is `unref()`'d,
so a process with nothing else left to do still exits between attempts.
- **Coming up is not proof a link works, so only one that outlasted `maxDelay` resets the backoff.**
An unreadable stream is found after the bind returns, so resetting on connect gave a link that died
on arrival a fresh `minDelay` every cycle — one TCP connect and bind per second, forever. A drop
after a healthy link still retries at `minDelay`.
- **`reconnect: { fromStart: true }` puts the first connect and bind through that same loop, and
`client()` then resolves only once it is bound.** Maintainer's call, 2026-09-05: an application
started before its SMSC is up otherwise writes that retry itself, around the one this library
already owns. A field on `reconnect` rather than an option of its own, so the combination that
would contradict `false` cannot be written at all — `false` carries no fields — and a top-level
`fromStart` is refused by name rather than ignored. Nothing but the caller's `signal` ends the
wait: a bound of its own would be a second spelling of a deadline the caller already writes with
that signal, and giving up after one is what the default does. A bind the SMSC refuses is
retried like any other failure — rejected: giving up on `ESME_RINVPASWD` and `ESME_RBINDFAIL`,
which would have the initial attempts and a rebind disagree about what a refused bind means, and
gives up on the operator whose provisioning lands a minute later; the backoff is what bounds the
rate goal 4 cares about. The attempts before the first link report nothing, because the session
running one has not reached the application: `disconnected` would have no listener and `close`
would be a lie. Its wait is the one retry timer that is not `unref()`'d, for the reason
`LinkGate`'s hold is not — it is awaited with no other handle, so a process whose only work is
`client()` would exit unbound.
- **`connectTimeout` defaults to 10 s, bounds the whole connect including the TLS handshake, and
`false` is the one way to turn it off.** Maintainer's call, 2026-09-20, serving goal 5: a connect
that never returns is one `reconnect` cannot retry, because the operating system holds the attempt
for around 130 s at Linux's default `tcp_syn_retries` and nothing above it is counting — and no
application chooses that, so it is a default rather than an option that switches on what the caller
obviously wanted. Accepted: the far end sees roughly four times the SYNs against a dead host, one
per ~40 s rather than one per ~160 s, which goal 4 tolerates because the backoff still caps the
rate. `false` spells the operating system's wait, as it does for `reconnect`, and `0` is refused
naming it, so one spelling reaches each result. What expires is reported as the ordinary connect
failure, so the loop retries it like any other, and the message names the peer and whether the TCP
connect or the TLS handshake stalled — different faults, different answers. It settles on
`secureConnect` for a TLS socket, so a peer that accepts and then says nothing is bounded the same
way a black-holed SYN is. Rejected: shipping the option with no default, which left goal 5's "an
option does not switch on the thing the caller obviously wanted" unmet, and would have cost a
second breaking minor plus a reversal of the `0` spelling to correct later. Rejected:
`socket.setTimeout()`, an idle timeout that goes on arming once the link is up. Rejected: bounding
it with `responseTimeout`, which names the wait for an answer on a link that already exists and
would retune both at once. `server()` shares the checker and ignores the option, as it already
ignores `reconnect` — nothing at that end connects out.
- **A stream this library cannot frame is a dead link; one PDU it cannot parse is not.**
Maintainer's call, 2026-08-31, narrowed 2026-09-05 via the interop plan: a `command_length` below
16 or above `maxPduLength` leaves nothing that can say where the next PDU starts, so it tears the
link down through `teardown()` and the reconnect loop retries it on a fresh socket with a fresh
framer. Every other codec failure honoured `command_length`, so the stream is still in sync and
the next PDU starts where it says — tearing the link down there cost one peer half its receipts
and its MO to a reconnect loop (`interop-tests/findings/01-smscsim.md`), and left the peer waiting
for answers it was owed. `sessionError` carries every failure of either kind, never coalesced or
suppressed, so a peer that only ever sends garbage is visible in the log rather than silent.
- **A deliberate shutdown drains; an unusable link and an abort do not.** `close()` and `unbind()`
wait on the send window rather than the pending map — the map misses a segment still queued behind
a full window, and finishing a half-sent multipart message is the point. The window counts slots,
never outcomes, and empties on a drop too, where `teardown()` settles everything the link was
carrying, which is why `drain()` reads `closed` before it reads the count. A stream the framer or
the codec cannot read takes `teardown()` instead, and `close({ signal })` on an aborted signal and
a peer's own `unbind` take `end()`: nothing on a dead link can answer, an abort means stop now, and
a peer that has declared itself finished will not answer what it still owes, so draining any of the
three would only hold a socket open for the timeout. `unbind()` sends its own PDU through
`request()` past both the window and the drain gate, because it must go out either way.
`shutdownTimeout` stays a session option rather than a `close()` argument: `server()` builds
sessions on the caller's behalf, so the option is the only composition point. `SmppServer.close()`
reports each session's unfinished drain through `serverError`, because its own result says nothing
but that the listener stopped.
- **Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is the
application's own signal rather than the peer's answer.** Maintainer's call, 2026-09-06, from the
Jasmin interoperability phase: Jasmin dispatches one `submit_sm` per connector at a time and will
not send segment 2 until segment 1 is answered, so holding a group unanswered until it was whole
deadlocked every multi-segment message against a production gateway
([interop-tests/findings/03-jasmin.md](interop-tests/findings/03-jasmin.md)). Goal 1 has the answer
a real SMSC gives — one `message_id` per `submit_sm`, immediately — so the group's id base is
generated when it opens and each segment is answered `<base>-<n>`, the notation `sms-id.ts` owns
and `DlrMerger` reads back. The id is therefore fixed by the first segment, which is why an `smsId`
or a refusing `status` passed to `sendResp()` on such a message is an error rather than a silent
no-op. `answeredOnArrival` is on `Sms` because nothing the application can compute says it, and the
discriminant a reader would reach for instead is wrong. A message `sendResp()` still answers itself is
untouched, and is where a caller-chosen id and a refusal live; `onRequest` is the escape hatch for
an application that must refuse a PDU the `sms` event could not have shown it yet. `collect()`
answers every segment it will not carry rather than leaving it unanswered, which is the same stall
in miniature: the field that numbered it where the segment belongs to no group, `ESME_RMSGQFUL`
where the segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected:
answering every segment but the one that completes the group, which leaves the peer holding some
segments accepted and one refused with nothing in SMPP to retract the rest, and still cannot honour
a caller's `smsId` on the segments already gone. Rejected: a hook that mints the id per segment,
which asks the application to name a message it cannot read yet — what it wants is `sms.smsId`
afterwards. Rejected: an option to keep the old behaviour, a second spelling whose only
distinguishing feature is that it deadlocks. Accepted: a group given up on — expired, evicted, or
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
`PduRefusedError` pattern — no `sms` ever fired for that group, so there is nothing in it the
application could act on, and goal 7 does not buy a second exported class to make a count
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
`sessionError` — a peer that re-sends after the drop is the smaller risk than dropping a message
in hand. The answer goes out before the `sms` event either way, so a listener's own receipt can
never precede the acceptance of the message it reports on.
- **`server()` composes the application's `onRequest` after its own bind handling, and offers it
every request that handling did not answer.** Maintainer's call, 2026-09-06, from a product review
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
bind acceptance, `authenticate`, the session set and the drain `close()` runs over it — which is
what goal 7 means by beating "the application can do this itself". What the library verifies is the
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
nothing a peer sends before one can be intercepted however the hook is written. One
`OnRequest` type on both option bags, because a second contract under one name is two spellings of
one goal; widened to accept a plain boolean, as `authenticate` already is, so an observing hook need
not be `async`. Nothing of ours is written for a request whose hook failed, the same on both
surfaces: the library cannot tell one that failed before answering from one that failed after, so
goal 2 reports the outcome as undetermined rather than guessing, and the peer's own
`responseTimeout` is what settles it — the answer `authenticate` failing already takes. A hook that
throws or rejects reaches `sessionError` on the way; one that never settles reaches nothing at all,
and is visible only as the request that was never answered. That takes the keepalive with it, since
a hook broken across the board leaves `enquire_link` unanswered and the peer drops the link — the
back-pressure wanted, because an application that cannot serve a link should not hold one.
`sessionError` rather than `serverError` because the
failure belongs to one session's request, and that channel already carries every failure of one.
The hook is consulted before the bind-direction gate, so it sees a `submit_sm` a receiver-bound
peer may not send; first refusal means first, and one it declines still gets `ESME_RINVBNDSTS`.
Nothing is held for a request the hook answered: `HeldMessages` is opened by the `sms` event the
hook skipped, so the drain waits on none of it. `OnRequest` stays unexported where
`AuthenticateInput` is exported, because that hook's argument is a shape this library invents and
this one's are two types already published. Rejected: consulting the hook first, which puts
bind and authentication inside the application's reach for nothing. Rejected: a narrower hook
returning a status for the library to write, which makes the answer verifiable but pays a second
contract under a second name for it, and could not express what the session-level hook already
does — answer a bind, a vendor command, a `data_sm` — leaving that error naming something only
half the surface can do. Rejected: falling the request through to the built-in handling on a
failure, which reads as the answer the peer would have had with no hook — true only of a hook
that failed before answering, where one that failed after put a second response on the peer's own
sequence number, goal 1's wire violation. Rejected with it: recording what the hook wrote so the
fall-through could be gated on it, which buys a fail-open path with state and an internal contract
no other collaborator needs.
- **The drain waits on the messages the application holds, and `sendResp()` is what says it is done
with one.** Maintainer's call, 2026-09-01: waiting on the send window alone tore a server session
down while the application was still answering a `submit_sm`, so the peer timed out and re-sent —
the duplicate goal 2 forbids, in the direction the window already covers. No completion signal was
added to the `sms` event: `sendResp()` is what an application already calls when it is done with a
message, so it is the one the drain waits for. Counting every inbound request until `sendReturn()` answered it was rejected —
an `onRequest` that deliberately answers nothing would then cost a full `shutdownTimeout` on every
close — and a message no listener took is released at once, since nothing is going to answer it.
A listener that failed before answering gives it up the same way, but only once every listener has:
a throw stops `emit()` where it stands, while a rejection leaves the others running, so the release
waits for the last of them rather than answering on their behalf. What ends the wait is the response
reaching the wire, not the call — a `sendResp()` the library refused, or one the socket would not
carry, leaves the message held, so `close()` still reports the one the peer is owed. Where the
segments were answered as they arrived there is no response left to write, so the call itself ends
the wait, an argument the library refuses excepted. `teardown()`
drops what is still held for the same reason it drops inbound segments. The release is one turn
late, so a listener that sends its receipt straight after the response is still holding when the
drain looks; `sendDlr()` is the one send that goes out past the drain's refusal, and only while the
message is still held — past that it is an ordinary send, because the drain it would slip past is
no longer waiting for it. `shutdownTimeout: 0` does not carry over to this half:
waiting forever is safe for the peer, whose every request is bounded by `responseTimeout` unless the
caller set that to 0 as well, and unsafe for the application, which nothing bounds — `close()` is
what you reach for when the application is stuck, so it may not block on the application coming
unstuck. That half falls back to `responseTimeout`, the same answer the link gate's hold already
takes — and to that option's default where it is 0 as well, since neither option is an answer about
the application. What is held is capped and expiring like every other inbound store, on constants
rather than options, because a bound the application cannot raise is the point: an application that
answers nothing would otherwise grow it for the life of the link, which goal 4 forbids. A message
that falls out of the bound is one the drain stops waiting for, so `close()` can report fewer
unanswered than there were — accepted, because the alternative is holding what nothing will answer,
and both exits are logged.
- **A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.**
`onDelivery()` answers each receipt before the group it belongs to is complete, and `teardown()`
runs on every path — an idle timeout and a failed rebind, not only `close()` — so clearing the
merges there loses receipts no peer has a reason to send again. They are cleared where the session
is over instead. Inbound segments stay in `teardown()`: a concatenation reference is the
peer's own counter, so a half-arrived group kept across a drop would take a later message's
segments as readily as the rest of its own, and goal 2 will not hand the application a message
assembled that way. What goes there is traffic already answered, which is why each group reaches
`sessionError` like every other one given up on.
- **A message id base is merged at most once.** A receipt carries nothing but `<base>-<n>`, so a
straggler for a message whose group is gone cannot be told from a receipt for a later message the
peer handed the same ids — an SMSC whose id counter restarts with its process is the realistic
case. `DlrMerger` remembers the bases it has finished with, capped and expiring exactly like the
groups, and refuses to open one a second time: the later message gets no `messageDlr`, and an
earlier one whose receipts are still arriving is dropped rather than left to collect the later
one's. Every segment still reaches the application as a `dlr`. `expect()` ignores a lone id, so a
single-part message never claims a base.
- **A send that never reached the socket waits for the next link; one that did is counted, not
resent.** Maintainer's call, 2026-09-01: re-queueing everything unanswered would resend a
`submit_sm` the SMSC accepted and answered into a dead socket, which is delivered and billed twice,
while a request that never left this process can be lost for free. `attempt()` therefore wraps all
three ways a written request can fail in `UnansweredError`; counting only the dropped-link case, as
the first cut did, would have called the commonest one safe to resend. A count rather than a
boolean because `sendSms()` aggregates segments into one `err` slot, and required rather than
optional so every construction site answers. `UnansweredError` stays unexported: `unanswered` is
the one spelling on the public surface. The hold is bounded by `responseTimeout` rather than an
option of its own — that is already the answer to how long one request may wait — and its clock
starts when the send is issued rather than when it first finds the gate shut, so one budget covers
every hold a single call makes. That timer is the one here that is not `unref()`'d: a held request
is awaited with the socket already destroyed, so an unref'd one lets a process whose only remaining
work is that send exit without settling it.
- **A send queued for a send-window slot is bounded by the caller's `signal`, and by nothing else.**
Maintainer's call, 2026-09-06, from a review of PR #71: the hold above observes the signal and the
`acquire()` on the next line did not, so a caller that aborted while the window was full waited for
a slot it no longer wanted — at `responseTimeout: 0` for as long as the peer stayed quiet, which is
the deadline the README sends the caller to that signal for. Goal 4 is not re-opened by an
unbounded wait here: the queue is the application's own backlog, unbounded in depth as well as in
time because capping it would refuse a send the application asked for, and nothing in it keeps the
peer waiting — which is what separates it from the inbound stores capped on constants. Rejected:
having `release()` skip a waiter whose signal already fired, which leaves the departed waiter in
the queue where `unfinished()` still counts it and the drain waits on it; the waiter leaves as it
settles instead. Rejected: bounding this wait by `responseTimeout` as the hold is bounded — a full
window is this end's own concurrency draining as the peer answers rather than a link going nowhere,
and that bound would fail a message with more segments than `maxOutstanding` partway through
against a slow peer. The failure is a plain `Error` rather than `UnansweredError`, the same answer
an abort at the gate already gives. The drain half needs nothing: `close({ signal })` already hands the signal to
`window.idle()`, and `unbind()` taking none is the shape README states.
- **The gate decides whether a link can carry a request, and a bind is what makes it one.**
Maintainer's call, 2026-09-01: `attach()` clears `closed` the moment a socket is handed over, one
round trip before the bind is answered, so gating on `closed` let a send arriving in that window go
out unbound and come back `ESME_RINVBNDSTS`. `LinkGate` owns the answer instead — `shut(returning)`
on every teardown, `open()` only once `comeBackUp()` has a bound link — and
`OutgoingRequests.linkDown()` reads it rather than `closed`. The bind itself cannot wait for what it
creates, so `pastDrain()` lets the three bind commands past the gate and the window, the same door
`unbind()` takes through `now()`. The gate is told what happened and never reads back into the
session: a collaborator that has to ask does not own its decision, which is how the first cut ended
up answering the same question two different ways at admit and at release. For the same reason the
retry in `pastDrain()` asks `gate.isUp()` rather than `linkDown()`, which also reads the socket — a
condition that loops on something the gate does not gate on spins against a gate that admits it
straight back. `LinkGate.returning` is a copy of `retrying()` taken at teardown, and stays true
only because nothing stops the reconnect loop without `emitClose()` following it: `drain()` and
`end()` are the only callers of `stop()`. A third caller has to shut the gate itself.
## Internals and tests
- **A listener that rejects is routed by Node's `captureRejections`, not by hand-dispatching.** Both
emitters construct with `captureRejections: true` and implement
`[EventEmitter.captureRejectionSymbol]`, which lands a rejected `async` listener on `sessionError`
or `serverError` beside the synchronous guard in `emit()`. Dispatching `rawListeners()` from
`emit()` instead needs a cast to call them with the event's argument tuple, which hard rule 4
forbids. A rejection reason is `unknown` and `String()` throws on a null-prototype object, so both
handlers normalise through `errorFrom()` rather than inline — a route out of the handler would land
on a bare `process.nextTick` with nothing to catch it.
- **The four-line abort dance is copied across `LinkGate`, `IdleWaiters`, `PendingRequests` and
`SendWindow` rather than extracted.** Architecture review, 2026-09-06: pre-check `aborted`, attach
`{ once: true }`, detach on settle, leave the registry. What differs at each site is the registry
and what settling means — a FIFO handing over a slot, a set released together, a map keyed by
sequence number, a count recomputed at settle — so a shared `Waiters<T>` fits two of the four and
is a shallower module than the copies. Extract it once a fifth appears.
- **`SmppLog` is a five-method contract this library declares, not a dependency.** `debug`, `error`,
`info`, `verbose` and `warn` are what the code actually calls, so an application can satisfy it
with an object literal. `@larvit/log` implements it structurally and stays a devDependency, where
`test/tls.test.ts` passing a real `Log` as the server's logger keeps that compatibility compiled.
- **The TLS tests build their own self-signed certificate in DER** (`test/tls.test.ts`) instead of
adding a devDependency or shelling out to openssl. Maintainer's call, 2026-08-26: the dev image
`node:24.18.0-bookworm-slim` ships no openssl binary, so a shelled-out fixture would pass in CI and
fail on every developer machine, and a committed key leaks in a public repository. Valid while the
dev image has no openssl.
- **`src/` stays flat until a module has to move for another reason.** Architecture review,
2026-09-06: the grouping the file map above already implies — `wire/` for `pdu*` and `defs`,
`link/` for `link-*`, `reconnect-*`, `pdu-transport` and `send-window`, `messages/` for `sms*`,
`dlr*`, `message*`, `reassembly` and `udh` — rewrites every import for no change to
`dist/index.js`, the one published entry. Valid while that map is what a reader navigates by.
- **`test/` stays flat too, and a file there is named for the question it answers rather than for the
module it covers.** Architecture review, 2026-09-08, at 18 test files: what keeps that count honest
is the naming rule rather than a tree — `operator-receipts.test.ts` holds a corpus defined by where
it came from, cutting across four modules, where filing it by module would enter each new operator
twice. A split also has to be made twice, since `test` and `test:compiled` each carry a path of
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
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.
+30 -2
View File
@@ -22,6 +22,7 @@ export type ClientOptions = {
addrNpi?: number; addrNpi?: number;
addrTon?: number; addrTon?: number;
bindType?: BindType; bindType?: BindType;
connectTimeout?: number | false;
enquireLinkInterval?: number; enquireLinkInterval?: number;
host?: string; host?: string;
idleTimeout?: number; idleTimeout?: number;
@@ -42,6 +43,7 @@ export type ClientOptions = {
const defaults = { const defaults = {
bindType: 'transceiver', bindType: 'transceiver',
connectTimeout: 10_000,
enquireLinkInterval: 20_000, enquireLinkInterval: 20_000,
host: 'localhost', host: 'localhost',
/** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */ /** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */
@@ -52,7 +54,30 @@ const defaults = {
username: 'user', username: 'user',
} as const; } as const;
function armConnectTimeout(
sock: Socket,
connectTimeout: number | false,
target: { peer: string; secure: boolean },
settle: (result: Result<{ sock: Socket }>) => void,
): NodeJS.Timeout | undefined {
if (connectTimeout === false) return undefined;
let phase = `connecting to ${target.peer}`;
if (target.secure) {
sock.once('connect', () => { phase = `completing the TLS handshake with ${target.peer}`; });
}
return setTimeout(() => {
sock.destroy();
settle({
err: new Error(`Timed out ${phase} after ${String(connectTimeout)} ms; raise connectTimeout or set it to false`),
});
}, connectTimeout).unref();
}
function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> { function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
const connectTimeout = options.connectTimeout ?? defaults.connectTimeout;
const host = options.host ?? defaults.host; const host = options.host ?? defaults.host;
const port = options.port ?? defaults.port; const port = options.port ?? defaults.port;
const secure = options.tls !== undefined && options.tls !== false; const secure = options.tls !== undefined && options.tls !== false;
@@ -77,11 +102,14 @@ function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
return; return;
} }
const settle = (result: Result<{ sock: Socket }>): void => { const timer = armConnectTimeout(sock, connectTimeout, { peer: `${host}:${String(port)}`, secure }, settle);
function settle(result: Result<{ sock: Socket }>): void {
clearTimeout(timer);
sock.removeListener('error', onError); sock.removeListener('error', onError);
signal?.removeEventListener('abort', onAbort); signal?.removeEventListener('abort', onAbort);
resolve(result); resolve(result);
}; }
function onError(err: Error): void { function onError(err: Error): void {
settle({ err }); settle({ err });
+4 -2
View File
@@ -9,7 +9,9 @@ export function errorFrom(reason: unknown): Error {
} }
} }
/** String() throws on a null-prototype object or a symbol, so only a string or number is printed. */ const printable: readonly string[] = ['boolean', 'number', 'string'];
/** String() throws on a null-prototype object, so anything but these is named by its type. */
export function namedValue(value: unknown): string { export function namedValue(value: unknown): string {
return typeof value === 'string' || typeof value === 'number' ? String(value) : typeof value; return printable.includes(typeof value) ? String(value) : typeof value;
} }
+35 -8
View File
@@ -144,14 +144,11 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult {
return { err: new Error('fromStart is part of the reconnect policy, spell it reconnect: { fromStart: true }') }; return { err: new Error('fromStart is part of the reconnect policy, spell it reconnect: { fromStart: true }') };
} }
const checked = checkLimits([ const connect = checkConnectTimeout(options.connectTimeout);
['idleTimeout', options.idleTimeout ?? 0, 0],
['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1], if (connect.err) return connect;
['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1],
['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0], const checked = checkLimits(limitsOf(options));
['responseTimeout', options.responseTimeout ?? defaults.responseTimeout, 0],
['shutdownTimeout', options.shutdownTimeout ?? defaults.shutdownTimeout, 0],
]);
if (checked.err) return checked; if (checked.err) return checked;
@@ -160,6 +157,35 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult {
return backoff.err ? backoff : checkSmsIdFormat(options.smsIdFormat); return backoff.err ? backoff : checkSmsIdFormat(options.smsIdFormat);
} }
function limitsOf(options: CheckableOptions): [string, number, number][] {
return [
['idleTimeout', options.idleTimeout ?? 0, 0],
['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1],
['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1],
['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0],
['responseTimeout', options.responseTimeout ?? defaults.responseTimeout, 0],
['shutdownTimeout', options.shutdownTimeout ?? defaults.shutdownTimeout, 0],
];
}
const maxTimerDelay = 2_147_483_647;
function checkConnectTimeout(connectTimeout: unknown): VoidResult {
if (connectTimeout === undefined || connectTimeout === false) return {};
const got = typeof connectTimeout === 'string' ? `"${connectTimeout}"` : namedValue(connectTimeout);
if (typeof connectTimeout !== 'number' || !Number.isInteger(connectTimeout) || connectTimeout < 1) {
return { err: new Error(`connectTimeout must be a whole number of milliseconds, 1 or more, got ${got}; false waits the OS out instead`) };
}
if (connectTimeout > maxTimerDelay) {
return { err: new Error(`connectTimeout must be ${String(maxTimerDelay)} ms or less (about 24 days), got ${got}; false waits the OS out instead`) };
}
return {};
}
function checkLimits(limits: [string, number, number][]): VoidResult { function checkLimits(limits: [string, number, number][]): VoidResult {
for (const [name, value, min] of limits) { for (const [name, value, min] of limits) {
if (!Number.isInteger(value) || value < min) { if (!Number.isInteger(value) || value < min) {
@@ -238,6 +264,7 @@ function checkSmsIdFormat(smsIdFormat: unknown): VoidResult {
/** What the checker reads, as it arrives: a caller without types can put anything in it. */ /** What the checker reads, as it arrives: a caller without types can put anything in it. */
export type CheckableOptions = { export type CheckableOptions = {
connectTimeout?: unknown;
/** Not an option: the one spelling is inside reconnect, and this is where the other is refused. */ /** Not an option: the one spelling is inside reconnect, and this is where the other is refused. */
fromStart?: unknown; fromStart?: unknown;
idleTimeout?: number | undefined; idleTimeout?: number | undefined;
+130 -15
View File
@@ -84,6 +84,21 @@ function within<T>(ms: number, promise: Promise<T>): Promise<T | undefined> {
return Promise.race([promise, delay(ms).then((): undefined => undefined)]); return Promise.race([promise, delay(ms).then((): undefined => undefined)]);
} }
/** A client still retrying holds a socket and a timer nothing else releases. */
function abortAfter(
t: TestContext,
controller: AbortController,
connecting: ReturnType<typeof client>,
): void {
t.after(async () => {
controller.abort();
const { session } = await connecting;
await session?.close({ signal: AbortSignal.abort() });
});
}
function submitPdu(seqNr: number, cmdStatus: ErrorName = 'ESME_ROK'): PduObject { function submitPdu(seqNr: number, cmdStatus: ErrorName = 'ESME_ROK'): PduObject {
return { return {
cmdId: 0x00000004, cmdId: 0x00000004,
@@ -798,21 +813,6 @@ describe('reconnect from the first bind', () => {
return port; return port;
} }
/** A client still retrying holds a socket and a timer nothing else releases. */
function abortAfter(
t: TestContext,
controller: AbortController,
connecting: ReturnType<typeof client>,
): void {
t.after(async () => {
controller.abort();
const { session } = await connecting;
await session?.close({ signal: AbortSignal.abort() });
});
}
test('gives up on the first attempt where reconnect alone is asked for', async () => { test('gives up on the first attempt where reconnect alone is asked for', async () => {
const port = await closedPort(); const port = await closedPort();
const spy = logSpy(); const spy = logSpy();
@@ -1018,6 +1018,121 @@ describe('reconnect from the first bind', () => {
}); });
}); });
describe('connectTimeout', () => {
/** Accepts and then says nothing, so a TLS handshake started on it never completes. */
async function stalledListener(t: TestContext): Promise<{ accepted: net.Socket[]; port: number }> {
const accepted: net.Socket[] = [];
const listener = net.createServer(sock => {
accepted.push(sock);
sock.resume();
});
closeListenerAfter(t, listener, accepted);
await new Promise<void>(resolve => { listener.listen(0, '127.0.0.1', resolve); });
const address = listener.address();
return { accepted, port: typeof address === 'object' && address !== null ? address.port : 0 };
}
test('gives up on a connect the peer never completes', async t => {
const { port } = await stalledListener(t);
const settled = await within(2000, client({
connectTimeout: 150,
host: '127.0.0.1',
port,
reconnect: false,
tls: true,
}));
assert.ok(settled, 'a handshake nothing answers is what the OS wait would swallow for minutes');
assert.ok(settled.err instanceof Error);
assert.match(
settled.err.message,
new RegExp(`Timed out completing the TLS handshake with 127\\.0\\.0\\.1:${String(port)} after 150 ms; raise connectTimeout`),
'a firewall and a peer that accepts then stalls need different answers, and whoever reads this has never heard of the option',
);
assert.equal(settled.session, undefined);
});
// Waits the default out for real: node:test mock timers land in Node 20.4, and the floor is 18.
test('bounds a connect nobody asked to bound, at the default ten seconds', async t => {
const { port } = await stalledListener(t);
const connecting = client({ host: '127.0.0.1', port, reconnect: false, tls: true });
const settled = await within(13_000, connecting);
assert.ok(settled, 'no bound was armed, so nothing ever settled this connect');
assert.ok(settled.err instanceof Error);
assert.match(settled.err.message, /after 10000 ms/);
});
test('retries a connect it timed out on, like any other failed attempt', async t => {
const { accepted, port } = await stalledListener(t);
const controller = new AbortController();
const connecting = client({
connectTimeout: 60,
host: '127.0.0.1',
port,
reconnect: { fromStart: true, maxDelay: 40, minDelay: 10 },
signal: controller.signal,
tls: true,
});
abortAfter(t, controller, connecting);
await delay(400);
assert.ok(accepted.length >= 3, `the loop retried what timed out, got ${String(accepted.length)} attempts`);
});
test('disarms on the connect that completed, rather than on the socket that follows it', async t => {
const smpp = await startServer(t);
const { session } = await connect(t, smpp, { connectTimeout: 200 });
assert.ok(session);
await delay(300);
const probe = await session.send({ cmdName: 'enquire_link' });
assert.equal(probe.err, undefined);
assert.equal(session.sock.destroyed, false);
});
test('refuses a connect timeout that would turn itself off', async () => {
assert.match(
checkSessionOptions({ connectTimeout: 0 }).err?.message ?? '',
/false waits the OS out/,
'off is spelled false, so 0 may not stand in for it',
);
assert.equal(checkSessionOptions({ connectTimeout: false }).err, undefined);
assert.match(checkSessionOptions({ connectTimeout: -1 }).err?.message ?? '', /connectTimeout/);
assert.match(checkSessionOptions({ connectTimeout: 1.5 }).err?.message ?? '', /whole number/);
assert.match(
checkSessionOptions({ connectTimeout: '5000' }).err?.message ?? '',
/got "5000"/,
'an env var read without Number() is the commonest untyped value, and it is a correct number',
);
assert.match(checkSessionOptions({ connectTimeout: true }).err?.message ?? '', /got true/);
assert.match(
checkSessionOptions({ connectTimeout: 2_147_483_648 }).err?.message ?? '',
/2147483647 ms or less \(about 24 days\), got 2147483648; false waits the OS out instead/,
'a delay Node cannot hold in 32 bits fires after 1 ms, the inverse of what it asked for',
);
assert.equal(checkSessionOptions({ connectTimeout: 2_147_483_647 }).err, undefined);
assert.equal(checkSessionOptions({ connectTimeout: 1000 }).err, undefined);
const refused = await client({ connectTimeout: 0, port: 1 });
assert.ok(refused.err instanceof Error);
assert.match(refused.err.message, /false waits/, 'the socket may not be opened before the option is refused');
assert.equal(refused.session, undefined);
const off = await client({ connectTimeout: false, port: 1 });
assert.ok(off.err instanceof Error);
assert.match(off.err.message, /ECONNREFUSED/, 'false opts out of the bound without breaking the connect');
});
});
describe('sends across a reconnect', () => { describe('sends across a reconnect', () => {
/** Answers every message after the first, which is left to hold the send window open. */ /** Answers every message after the first, which is left to hold the send window open. */
function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Latch { function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Latch {
-1
View File
@@ -1493,7 +1493,6 @@ describe('robustness', () => {
// 0.4.0 registered a listener per sequence number and waited forever, leaking one per call. // 0.4.0 registered a listener per sequence number and waited forever, leaking one per call.
test('gives up on a peer that never answers', async t => { test('gives up on a peer that never answers', async t => {
const accepted: net.Socket[] = []; const accepted: net.Socket[] = [];
// resume() so the socket drains; an unread socket never notices the peer hanging up.
const silent = net.createServer(sock => { accepted.push(sock); sock.resume(); }); const silent = net.createServer(sock => { accepted.push(sock); sock.resume(); });
await new Promise<void>(resolve => silent.listen(0, resolve)); await new Promise<void>(resolve => silent.listen(0, resolve));
+249 -45
View File
@@ -1,7 +1,7 @@
# todo.md # todo.md
Remaining work for `@larvit/smpp`. Read [AGENTS.md](AGENTS.md) first — the goals and hard rules Remaining work for `@larvit/smpp`. Read [README.md](README.md)'s goals and [AGENTS.md](AGENTS.md)'s
there constrain every item below. hard rules first — they constrain every item below.
This is a working file that sets its own rules. The documentation conventions in AGENTS.md do not This is a working file that sets its own rules. The documentation conventions in AGENTS.md do not
govern it, and nothing here is a source anything else may cite. govern it, and nothing here is a source anything else may cite.
@@ -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
@@ -73,8 +74,8 @@ Every defect listed in the AGENTS.md table has a regression test naming the beha
## Move the repository to Gitea ## Move the repository to Gitea
`gitea.larvit.se/larvit/smpp-js` is the repository. `github.com/larvit/smpp-js` becomes a push mirror `gitea.larvit.se/larvit/smpp-js` is the repository. `github.com/larvit/smpp-js` mirrors it and is the
of it and 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] `larvit/smpp-js` holds `main`, from `typescript`, and `v0.4.0`, from `master`. `rewrite-base` - [x] `larvit/smpp-js` holds `main`, from `typescript`, and `v0.4.0`, from `master`. `rewrite-base`
and the `renovate/*` branches stayed behind. and the `renovate/*` branches stayed behind.
@@ -97,47 +98,55 @@ of it and the place issues are filed. Maintainer's calls, 2026-09-13 and 2026-09
- [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
Nothing here starts before 0.5.0 is published. Maintainer's call, 2026-09-14. Then in this order: Nothing here starts before 0.5.0 is published. Maintainer's call, 2026-09-14. Then in this order:
the push mirror replaces GitHub's branches with Gitea's, which closes every pull request based on deleting GitHub's old branches closes every pull request based on them without a reply, and GitHub
`master` without a reply, and GitHub refuses to delete the default branch it syncs over. 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
- [ ] Add it to Gitea as a push mirror, with a GitHub credential that can write to it. 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.
## 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.
@@ -145,7 +154,7 @@ the push mirror replaces GitHub's branches with Gitea's, which closes every pull
**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),
@@ -160,15 +169,33 @@ the push mirror replaces GitHub's branches with Gitea's, which closes every pull
[#70](https://github.com/larvit/larvitsmpp/pull/70) is the open `uuid` advisory GitHub reports [#70](https://github.com/larvit/larvitsmpp/pull/70) is the open `uuid` advisory GitHub reports
on the default branch; it disappears with the runtime dependencies rather than being fixed. on the default branch; it disappears with the runtime dependencies rather than being fixed.
[#60](https://github.com/larvit/larvitsmpp/issues/60) is Renovate's dashboard — close it with [#60](https://github.com/larvit/larvitsmpp/issues/60) is Renovate's dashboard and stays open after
Renovate's removal from GitHub. the rewrite, for a dependency added later. Maintainer's call, 2026-09-14.
**Leave open:** [#8](https://github.com/larvit/larvitsmpp/issues/8), the socket's remote host and **Close as tracked here**, the reply saying it will be implemented on Gitea:
port on log messages. Only `server - incoming connection` carries them today; putting them on every
session message is a change to every call site. - [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.
## Worth doing, not blocking ## Worth doing, not blocking
- [ ] **Cut the three teardown sentences `test/teardown.ts` already says.** Under AGENTS.md's
Conventions, "`test/teardown.ts` covers a session, a server and a listener" restates its two
exported names, "Its close aborts rather than drains" restates `closeAfter`'s own doc comment,
and the `net.Server.close()` sentence restates `closeListenerAfter`'s. Keep the registered-at-
creation rule and the FIFO one, which nothing else states, and drop "CI's ten-minute cap" —
that number lives in `.gitea/workflows/test.yaml`. Raised by the prose pass, 2026-09-20.
- [ ] **Refuse a delay Node's timers cannot hold, in `checkLimits`.** `idleTimeout`,
`reassemblyTimeout`, `responseTimeout` and `shutdownTimeout` take any integer, and `setTimeout`
fires after 1 ms for anything above 2147483647 — so a value in the wrong unit gets the inverse
of what it asked for, explained only by a warning on stderr. `connectTimeout` refuses one
already, which is the asymmetry to close. The same four print an untyped value bare, so
`idleTimeout: '5000'` is refused with `got 5000` — a value the reader reads as correct — where
`connectTimeout` quotes it. `namedValue()`'s four sites — `messagingMode`, `encoding`, the time
options and `smsIdFormat` — are the same defect once more: there `true` and `'true'` both print
as `true`. One fix closes all three. Raised by review, 2026-09-20.
- [ ] **A send the codec will refuse waits for a link and a window slot first.** `refuse()` in - [ ] **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 `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 that can never go out does not queue for what it will never use; a body `objToPdu()` refuses on
@@ -225,6 +252,13 @@ session message is a change to every call site.
`tls.test.ts` wait forever, so an event that never fires still hangs the run the way an `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. 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.
- [ ] **A peer whose message ids share one base logs a refused merge on every send.** `smsc01-000123` - [ ] **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 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 every one after it, one log line per send. Left at `info` — nothing the operator can fix is
@@ -234,9 +268,6 @@ session message is a change to every call site.
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
@@ -244,18 +275,191 @@ session message is a change to every call site.
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 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 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.
+1 -1
View File
@@ -24,5 +24,5 @@
"types": ["node"], "types": ["node"],
"verbatimModuleSyntax": true "verbatimModuleSyntax": true
}, },
"include": ["eslint.config.ts", "interop-tests", "src", "test"] "include": ["benchmarks", "eslint.config.ts", "interop-tests", "src", "test"]
} }