Compare commits
23 Commits
v0.5.0
..
4c43f6748d
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c43f6748d | |||
| 36d32591c3 | |||
| 92fbd6a9f2 | |||
| 45171c2697 | |||
| a24f350b7a | |||
| 1416615ca3 | |||
| 8f416c2bc4 | |||
| 84174b4a5b | |||
| 17447bbe3a | |||
| d874448273 | |||
| 656bbae500 | |||
| c90b855958 | |||
| 0f13951e8c | |||
| aab74e4cb3 | |||
| 1c0b4bef34 | |||
| c5adfd8e39 | |||
| 14b114afda | |||
| 27e8f288ec | |||
| e1999a7305 | |||
| baa0ad553a | |||
| e32f9ccf46 | |||
| b3cbbba615 | |||
| 95e1f910a3 |
@@ -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"
|
||||||
@@ -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/*'
|
||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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,9 @@ 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. 0.6.0 is next, and it is a quality cut rather than a feature one — the
|
||||||
|
comprehension gate and the defects under [0.6.0](#060) come first, then the gaps a comparison with
|
||||||
|
other SMPP libraries found.
|
||||||
|
|
||||||
## The agreed API
|
## The agreed API
|
||||||
|
|
||||||
@@ -73,8 +75,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 +99,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 +155,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 +170,218 @@ 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.
|
||||||
|
|
||||||
|
## 0.6.0
|
||||||
|
|
||||||
|
A nine-reader comprehension panel read the whole project on 2026-09-20 and scored it 7 overall,
|
||||||
|
mean 6.8. Navigation (7–8) capped nobody. **Locality capped every unit reader at 5–6 and Shape
|
||||||
|
capped both architects at 6**, and those two are what this release lifts. The gate is 7 on all four
|
||||||
|
dimensions, higher where it is cheap. Maintainer's call, 2026-09-20. A systems-architect review the
|
||||||
|
same day returned ALIGN with one blocking-severity finding, which is the first item under Locality
|
||||||
|
and is also what the panel ranked hardest — two methods, one answer.
|
||||||
|
|
||||||
|
### Correctness, ahead of everything below
|
||||||
|
|
||||||
|
- [ ] **Read C-Octet Strings as `latin1`, so an address survives the wire.** `defs/types.ts` reads
|
||||||
|
with `toString('ascii')` at four sites and writes with `write(text, 'ascii')` at three. Node
|
||||||
|
masks bit 7 when decoding and not when encoding, so the codec writes `0xE9` and reads back
|
||||||
|
`0x69`: an inbound `source_addr` of `Kaffeé` reaches the application as `Kaffei`, with no raw
|
||||||
|
escape hatch as `short_message` has in `shortMessageOctets`. Affects `source_addr`,
|
||||||
|
`destination_addr`, `system_id`, `message_id`, `service_type` and the cstring TLVs, and makes
|
||||||
|
`objToPdu(pduToObj(x))` non-idempotent for them. Goals 1 and 3. Verified in the container:
|
||||||
|
`Buffer.from([0xE9]).toString('ascii')` is `'i'`.
|
||||||
|
|
||||||
|
- [ ] **Answer `alert_notification` and `outbind` by not answering them.** Both are response-less in
|
||||||
|
SMPP 3.4, both fall through `route()`'s default into `unhandled()`, which calls
|
||||||
|
`sendReturn(pduObj, 'ESME_RINVCMDID')`; `pduReturn()` then finds no response command, and the
|
||||||
|
failure reaches the application as `sessionError` on every occurrence. `alert_notification`
|
||||||
|
appears nowhere in `src/` but `defs/commands.ts`. One case arm each: log and return.
|
||||||
|
|
||||||
|
- [ ] **Range-check `maxOctets` with its five siblings.** `limitsOf()` in `session-options.ts`
|
||||||
|
covers `idleTimeout`, `maxOutstanding`, `maxReassembly`, `reassemblyTimeout`, `responseTimeout`
|
||||||
|
and `shutdownTimeout`; `maxOctets` is documented, consumed by `Reassembler`, and absent from
|
||||||
|
both that list and `CheckableOptions`. `server({ maxOctets: 0 })` starts, then refuses every
|
||||||
|
multipart message and reports each as lost traffic.
|
||||||
|
|
||||||
|
- [ ] **Read `multiple` in `parseTlvs()` and `writeTlvs()`, or delete it and `tlvMap`.** Five TLVs
|
||||||
|
declare `multiple: true` (`callback_num`, `callback_num_atag`, `callback_num_pres_ind`,
|
||||||
|
`broadcast_area_identifier`, `broadcast_error_status`) and nothing reads it; `parseTlvs()` keys
|
||||||
|
by tag name, so a peer sending two `callback_num` TLVs silently keeps the last. `tlvMap` on
|
||||||
|
`broadcast_sm_resp` is declared, set once and read nowhere. This is the "Dormant filters" row
|
||||||
|
of the 0.4.0 defect table in a new spelling — metadata that reads as a guarantee.
|
||||||
|
|
||||||
|
- [ ] **Arm the merge for the segments the SMSC did take, or say why not.** `collectSent()` sets
|
||||||
|
`failure` if any segment errored, including the `UnansweredError` a mid-send drop produces, and
|
||||||
|
`session.ts` only calls `dlrMerger.expect(sent.smsIds)` when `!sent.err`. So a link drop during
|
||||||
|
a multipart send leaves per-segment `dlr` events firing while `messageDlr` never can, traced
|
||||||
|
only by one `debug` line. `session-extras.test.ts` has the adjacent case — a drop *after* the
|
||||||
|
send — and not this one. If goal 2 forbids reporting on a message we cannot fully account for,
|
||||||
|
that is the answer; it is stated in no file today either way.
|
||||||
|
|
||||||
|
### Locality — 5–6 today, and the gate is 7
|
||||||
|
|
||||||
|
- [ ] **Give `IncomingRequests` a port instead of the `Session` it drives.** It holds its owner and
|
||||||
|
calls eight members of it 18 times, including `this.session.close()` on an inbound `unbind` —
|
||||||
|
a collaborator ending its owner's life. `OutgoingRequests` is the mirror half of the same
|
||||||
|
boundary and takes no session at all. AGENTS.md's "Nothing reaches back up" is false because of
|
||||||
|
this, and `docs/decisions.md` already states the rule under The session's life: "a collaborator
|
||||||
|
that has to ask does not own its decision". It is also the missing test seam — inbound routing,
|
||||||
|
reassembly dispatch, `onRequest` ordering and bind-direction refusal have no unit test because
|
||||||
|
the class cannot be built without a live socket. Carry the eight members as `IncomingDeps`,
|
||||||
|
exactly as `sendPastDrain` is carried now. No public surface changes. **Do this before the
|
||||||
|
store (goal 8), or the back-edge is baked into the store's published interface.**
|
||||||
|
|
||||||
|
- [ ] **Route `sms.ts` through its handlers, all of it.** `createSms()` already injects
|
||||||
|
`handlers.send`, and then reaches `sms.session.sendReturn()`, `sms.session.bindAllows()` and
|
||||||
|
`sms.session.acceptsOptionalParams()` anyway — two channels to one collaborator. `Sms.session`
|
||||||
|
stays public as data the application reads. The cheaper half of the item above, and the one
|
||||||
|
that shows the shape.
|
||||||
|
|
||||||
|
- [ ] **Give the held-message protocol one name and one home.** `emitSms()` is the unit 8 of 9
|
||||||
|
readers named and 4 would least want to modify, and every one proposed the same fix. It runs
|
||||||
|
five mechanisms in one scope: a hold keyed by array identity, a `working` counter seeded from
|
||||||
|
`listenerCount('sms')`, a `WeakMap` keyed by the `Sms` object, a `setImmediate`-deferred
|
||||||
|
release, and a captured `linkGeneration` — with the counter decremented from `session.ts`'s
|
||||||
|
`captureRejectionSymbol` in another file. A `MessageHold` owning `hold/release/listenerGaveUp`
|
||||||
|
collapses three files into one readable object. Every way of getting it wrong is silent: a hung
|
||||||
|
shutdown, or a receipt refused.
|
||||||
|
|
||||||
|
- [ ] **Derive `Reassembler`'s octet total instead of maintaining it at five sites.** `this.octets`
|
||||||
|
and each `group.octets` must agree, adjusted in `collect`, `trim`, `takeOldest`, `sweep` and
|
||||||
|
`clear`, and `collect()` discovers its own eviction by re-reading the map by identity. Push the
|
||||||
|
budget into `ExpiringGroups` as a weighed capacity, and have `trim()` report whether the
|
||||||
|
current group survived. Named by 6 of 9 readers.
|
||||||
|
|
||||||
|
- [ ] **Split the two questions `OutgoingRequests.linkDown()` answers.** `Session.drain()` calls it
|
||||||
|
twice for opposite conclusions — "nothing to drain, success" and "the link died under us,
|
||||||
|
failure" — and `outgoing-requests.ts` reads it a third way. Two named predicates. Named by 7
|
||||||
|
of 9 readers, who each reconstructed the ordering by hand.
|
||||||
|
|
||||||
|
- [ ] **Name `pastDrain()`'s retry condition and what makes the loop end.** The exit is a
|
||||||
|
three-term disjunction over two collaborators, whose comment covers the first term only, and
|
||||||
|
the method is named for what it bypasses. Do not change what it asks: `gate.isUp()` rather than
|
||||||
|
`linkDown()` is deliberate and recorded.
|
||||||
|
|
||||||
|
- [ ] **Replace `resolveBody`'s `settles` boolean with the decision it stands for.** One boolean
|
||||||
|
chooses both whether to overwrite `data_coding` and which params to read it from, across four
|
||||||
|
helpers all named some abstraction of "body". Return a named source — `'short_message' |
|
||||||
|
'payload' | 'caller'` — and branch once. Ranked hardest by three readers and picked by one as
|
||||||
|
the unit they would least want to touch, because a mistake here does not throw, does not fail
|
||||||
|
the types, and reaches the peer as somebody's message rendered wrong.
|
||||||
|
|
||||||
|
### Shape — 6 today, and the gate is 7
|
||||||
|
|
||||||
|
- [ ] **Group `src/` into a second level, and retire whichever record loses.** 34 files on one
|
||||||
|
plane, where `src/defs/` at 7 proves the shape is known one level down. `docs/decisions.md`
|
||||||
|
says "`src/` stays flat until a module has to move for another reason. Valid while that map is
|
||||||
|
what a reader navigates by" — and both architects reported that the map is now AGENTS.md rather
|
||||||
|
than the tree, which is that premise failing. `todo.md` already carries the opposite
|
||||||
|
instruction under Worth doing. Two records, opposite answers; one has to go. Do it in the same
|
||||||
|
change as the `IncomingRequests` port or the imports are rewritten twice.
|
||||||
|
|
||||||
|
- [ ] **Split `test/session-extras.test.ts` by the question each block answers.** 3,010 lines, 19
|
||||||
|
unrelated `describe` blocks whose names are already the file names they should be. With
|
||||||
|
`session.test.ts` it is 54% of all test code and 84% the size of `src/`. "extras" names neither
|
||||||
|
a question nor a module — it names the rest — and AGENTS.md's own convention forbids exactly
|
||||||
|
that. `max-lines` covers `src/**` only, so nothing has stopped it growing.
|
||||||
|
|
||||||
|
- [ ] **Collapse the three objects named `defaults`.** `client.ts`, `server.ts` and
|
||||||
|
`session-options.ts` each export or hold one; `port: 2775` is written twice and the idle
|
||||||
|
timeout is derived two ways to the same 40 000. "What is the default for X" has three answers
|
||||||
|
depending on the entrypoint, and nothing fails when they drift. Named by both architects as the
|
||||||
|
most likely first bug a new contributor ships.
|
||||||
|
|
||||||
|
- [ ] **Rename `EncodingName`'s `ASCII` to `GSM7`, with `ASCII` a deprecated alias for one minor.**
|
||||||
|
It is GSM 03.38, where `$` is 0x02 and `@` is 0x00, and `segmentUnits.ASCII = 153` is a septet
|
||||||
|
budget under a name that says octets. The 2026-09-09 decision removed `consts.ENCODING.ASCII`
|
||||||
|
for exactly this reason and left the option's own vocabulary carrying it. Pre-1.0 the minor is
|
||||||
|
the breaking unit, so this is as cheap as it will ever be, and `todo.md` already requires the
|
||||||
|
`consts.ENCODING` names settled before the custom-encoding registry — this is the other half.
|
||||||
|
|
||||||
|
- [ ] **Split `session-options.ts` into the things it is.** Option types and their validator, the
|
||||||
|
`SessionEvents` map, and the bind-direction rules (`bindCommands`, `bindTypeFromCommand`,
|
||||||
|
`standsInFor`, `bindCarries`) are three questions in one file, and the `defaults` table mixes
|
||||||
|
option defaults with four hard bounds that are not options. Both architects named it as where
|
||||||
|
the codebase rots first: at 34-wide it is where anything session-shaped lands.
|
||||||
|
|
||||||
|
- [ ] **Name the base-versus-segment distinction in the message id types.** `Sms.smsId` is a base,
|
||||||
|
`sendSms().smsIds[]` are segment ids, `Dlr.smsId` is a segment id and `MessageDlr.smsId` is a
|
||||||
|
base again — four fields, one type, `string`. The whole multipart receipt mechanism turns on
|
||||||
|
telling them apart and only `parseSegmentId()` knows.
|
||||||
|
|
||||||
|
### Self-sufficiency — 6–7 today, and the gate is 7
|
||||||
|
|
||||||
|
- [ ] **Move the one-line facts out of the decision log and back to the code.** Five of nine readers
|
||||||
|
independently reported being sent to `docs/decisions.md` for a question they hit while reading,
|
||||||
|
with no link from the code; one counted roughly fifty index redirects. The four worth inlining
|
||||||
|
as one line each: that `segmentUnits`' three numbers are in two units (septets and octets),
|
||||||
|
which body settles `data_coding`, that a receipt's body is read as octets whatever its
|
||||||
|
`data_coding` says, and the `<base>-<n>` id notation. The reasoning stays in the log; the
|
||||||
|
definition belongs at the code.
|
||||||
|
|
||||||
|
- [ ] **Document the two delivery-receipt merge bounds.** `maxDlrMerges` (1000) and
|
||||||
|
`dlrMergeTimeout` (24 h) are hardcoded, are not options, and appear in no README and no test —
|
||||||
|
while README states the equivalent held-message bounds explicitly ("Neither bound is an
|
||||||
|
option"). A sender with more than 1000 concurrent multipart `dlr: true` messages silently
|
||||||
|
evicts the oldest at `warn`. The inherited architect hit this on the 3am walk.
|
||||||
|
|
||||||
|
- [ ] **Add a ten-line SMPP glossary to the README.** Both juniors and the no-domain mid reported
|
||||||
|
the same largest cost: nothing in the repo says what a PDU, `esm_class`, `data_coding`, TON/NPI
|
||||||
|
or `submit_sm`-versus-`deliver_sm` are, and the inline spec citations mark a rule without
|
||||||
|
stating it. One of them put it at a third of their reading time. Four commands, three octets,
|
||||||
|
one sentence each.
|
||||||
|
|
||||||
|
### Doc claims this review falsified
|
||||||
|
|
||||||
|
- [ ] **Make "every README example is executed by the suite" true, or stop claiming it.** Goal 9 and
|
||||||
|
the Done table both promise it; `test/readme.test.ts` transcribes the examples by hand and has
|
||||||
|
drifted — 15 fenced `javascript` blocks in the README against 10 tests, and the test named "the
|
||||||
|
documented sending options" passes none of the five options the README's example passes. Read
|
||||||
|
the fenced blocks at test time and assert each appears verbatim in the executed source, so an
|
||||||
|
edit to either fails the gate.
|
||||||
|
|
||||||
|
- [ ] **Correct AGENTS.md's "Nothing reaches back up".** False while `IncomingRequests` holds a
|
||||||
|
`Session`: either the first Locality item makes it true, or the sentence names the exception
|
||||||
|
until it does.
|
||||||
|
|
||||||
|
- [ ] **Narrow the `src/defs/*` lint exemption to the four table files.** Its stated reason — "the
|
||||||
|
spec tables are data: their length tracks the specification, not any complexity" — is false for
|
||||||
|
`defs/types.ts`, which is 595 lines of wire codec with 25 functions and is the file that parses
|
||||||
|
hostile input from the network. It carries more over-budget methods than any other file in the
|
||||||
|
repo, under a suppression written for something else.
|
||||||
|
|
||||||
|
- [ ] **Run the interop suite before cutting a minor, and date the claim.** README states
|
||||||
|
"Interoperable. Tested as a client against Jasmin and SMPPSim, and as a server against Kannel,
|
||||||
|
jsmpp, Cloudhopper, python-smpplib and php-smpp" in the present tense; `interop-tests/README.md`
|
||||||
|
is honest that the run was 2026-09-08. Nothing runs the peers on a schedule or before a tag, so
|
||||||
|
the claim rots silently. Goals 1 and 9.
|
||||||
|
|
||||||
## 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
|
||||||
@@ -201,12 +414,6 @@ session message is a change to every call site.
|
|||||||
each Gitea pull request to GitHub for it to review there. Maintainer's ask, 2026-09-14; not
|
each Gitea pull request to GitHub for it to review there. Maintainer's ask, 2026-09-14; not
|
||||||
started until asked.
|
started until asked.
|
||||||
|
|
||||||
- [ ] **Group the session's collaborators under `src/session/`.** `session.ts` imports
|
|
||||||
`dlr-merger`, `incoming-requests`, `link-timers`, `outgoing-requests`, `pdu-transport`,
|
|
||||||
`reconnect-loop` and `send-sms`, and nothing else does, so the directory would make that
|
|
||||||
boundary visible. The `OutgoingRequests` extraction this was to be done with landed on
|
|
||||||
2026-09-01, so it is the remaining half. Raised by review, 2026-09-01.
|
|
||||||
|
|
||||||
- [ ] **`leftOf()` and the link gate's own budget are one concept counted twice.**
|
- [ ] **`leftOf()` and the link gate's own budget are one concept counted twice.**
|
||||||
`idle-waiters.ts` reads what is left of a budget as `Math.max(1, deadline - now)`, because 0
|
`idle-waiters.ts` reads what is left of a budget as `Math.max(1, deadline - now)`, because 0
|
||||||
means "forever" there; `link-gate.ts` runs the same subtraction and calls `<= 0` expired.
|
means "forever" there; `link-gate.ts` runs the same subtraction and calls `<= 0` expired.
|
||||||
@@ -225,6 +432,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 +448,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 +455,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
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user