15 Commits

Author SHA1 Message Date
lilleman 0f14ca85c2 Plan 0.5.0 in todo.md, holding the GitHub work until it is published
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 19s
Test / test (20) (pull_request) Successful in 24s
Test / test (22) (pull_request) Successful in 19s
Test / test (24) (pull_request) Successful in 18s
Test / test (26) (pull_request) Successful in 19s
Release / publish (push) Successful in 37s
2026-09-14 14:09:58 +02:00
lilleman e8558aa471 Release as 0.5.0, and file issues on the GitHub mirror 2026-09-14 14:09:58 +02:00
lilleman 773db43305 Record the move to Gitea and the GitHub retirement in todo.md
Test / lint (pull_request) Successful in 20s
Test / test (18) (pull_request) Successful in 18s
Test / test (20) (pull_request) Successful in 17s
Test / test (22) (pull_request) Successful in 17s
Test / test (24) (pull_request) Successful in 18s
Test / test (26) (pull_request) Successful in 19s
2026-09-13 17:57:10 +02:00
lilleman 793839d0e0 Point the package and README at Gitea 2026-09-13 17:57:10 +02:00
lilleman 68cd8e3f31 Run CI, releases and Renovate on Gitea 2026-09-13 17:57:10 +02:00
lilleman 8315f1882a Keep todo.md, and plan the backlog close-out and the move to Gitea in it
Test / test (18) (push) Successful in 19s
Test / test (20) (push) Successful in 19s
Test / test (22) (push) Successful in 19s
Test / test (24) (push) Successful in 18s
Test / test (26) (push) Successful in 29s
Test / lint (push) Successful in 20s
2026-09-13 16:31:07 +02:00
lilleman 311ee7c677 Name the branches as the rename leaves them 2026-09-13 16:31:07 +02:00
lilleman e3062066c1 Pin the Node version the lint and release jobs run on 2026-09-13 16:30:58 +02:00
lilleman 7310634d7d Rewrite the README task-first for 1.0.0 and move the 0.4.0 guide to MIGRATION.md (#102)
Test / lint (push) Successful in 20s
Test / test (18) (push) Successful in 18s
Test / test (20) (push) Successful in 18s
Test / test (22) (push) Successful in 18s
Test / test (24) (push) Successful in 24s
Test / test (26) (push) Successful in 18s
2026-09-13 12:05:13 +02:00
lilleman 549b3260c9 Record the smsIds decision under the surface it changes 2026-09-12 22:41:02 +02:00
lilleman 13e3ff8f1f Say undefined where the SMSC named no id for a segment it took (#101)
* Cover the ids a send gets back where the SMSC named only some of them

* Say undefined where the SMSC named no id for a segment it took

* Record why the merger takes the wider type, and de-duplicate the interop narrowing

* Guard the numbering hole the merger must refuse, and correct the phase record
2026-09-12 22:37:55 +02:00
lilleman ca1a7473ed Declare the alphabet a GSM message is actually written in (#100)
* Regression tests for the alphabet a GSM message declares

* Declare the alphabet a GSM message is actually written in

* Reflow the composing-by-hand paragraph

* Drop the ASCII alias SMPP's flat table shares with the option's own

* Bound the receipt test's wait and tighten the notes around it
2026-09-09 21:30:55 +02:00
lilleman 989eb01763 Name the refusal rule, and refuse a period the encoder cannot spell (#99)
* Refuse a relative validity period the SMPP time format cannot hold

* Export unencodableText(), so a caller can word a refusal as the library does

* Name the refusal rule in the README, and correct what it claims

* Word the period refusal by what the encoder spells, and keep unencodableText() internal

* Correct the decision bullet premise about what the relative format holds

* Close the stability nits on the time guard and its tests
2026-09-09 20:36:13 +02:00
lilleman ef13290230 Refuse a body the PDU's own data_coding cannot carry (#98)
* Regression tests for a body the PDU's own data_coding cannot carry

* Refuse a body the PDU's own data_coding cannot carry, in the codec both send paths build through

* Record the codec's body refusal, and drop two todo entries it and the interop run close

* Regression tests for the aliased body TLV and the data_coding short_message settles

* Find the body TLV by tag id, leave data_coding to the field that will be read, and correct the record

* Record the two architecture findings this change does not close

* Regression tests for a duplicated body tag and a short_message the wire never carries

* Resolve every body TLV against the field that will be read, and move TLV writing to its table

* Regression tests for a body only the TLV carries and an empty short_message

* Let only octets the command writes settle the alphabet, and stop shadowing the TLV table
2026-09-09 19:33:36 +02:00
lilleman c06cc0648b Refuse what we cannot send: an alphabet that cannot carry the message, and a time nobody can read (#97)
* Regression tests for a named alphabet and a time that cannot carry the send

* Refuse a message the named alphabet cannot carry, and a time nobody can read

* Close the architecture review: a builder that cannot drop a time, and docs that hold

* Refuse a time of no known kind, and clamp a relative period to what the format holds

* Reflow the decision record and name the clamp in its test
2026-09-09 18:16:08 +02:00
32 changed files with 2002 additions and 752 deletions
@@ -6,7 +6,6 @@ on:
permissions:
contents: read
id-token: write
jobs:
publish:
@@ -18,7 +17,7 @@ jobs:
- uses: actions/setup-node@v7.0.0
with:
cache: npm
node-version: 24
node-version: 24.18.0
registry-url: https://registry.npmjs.org
- run: npm ci
- name: The tag must match the version being published
@@ -31,6 +30,6 @@ jobs:
}
- run: npm run lint
- run: npm test
- run: npm publish --provenance --access public
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+25
View File
@@ -0,0 +1,25 @@
name: Renovate
on:
schedule:
- cron: '43 4 * * *'
workflow_dispatch:
jobs:
renovate:
runs-on: docker-host
steps:
- name: Run Renovate against this repo
env:
GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_TOKEN }}
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
run: |
docker run --rm \
-e GITHUB_COM_TOKEN \
-e LOG_LEVEL=info \
-e RENOVATE_ENDPOINT=https://gitea.larvit.se/api/v1 \
-e RENOVATE_GIT_AUTHOR="Renovate Bot <renovate@larvit.se>" \
-e RENOVATE_PLATFORM=gitea \
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
-e RENOVATE_TOKEN \
renovate/renovate:44.39.2
@@ -1,8 +1,6 @@
name: Test
on:
push:
branches-ignore: ['main']
pull_request:
permissions:
@@ -19,7 +17,7 @@ jobs:
- uses: actions/setup-node@v7.0.0
with:
cache: npm
node-version: 24
node-version: 24.18.0
- run: npm ci
- run: npm run lint
- run: npm run build
+158 -12
View File
@@ -5,9 +5,9 @@ Guidance for LLM agents working in this repository. What each file in it is for
## What this is
A ground-up TypeScript rewrite of `larvitsmpp` 0.4.0, published as `@larvit/smpp` 1.0.0. The branch
A ground-up TypeScript rewrite of `larvitsmpp` 0.4.0, published as `@larvit/smpp` 0.5.0. The branch
started from an orphan commit — no history from 0.4.0 is carried over. The 0.4.0 source is still
readable on the `master` branch of the same repository and is the reference for protocol behaviour,
readable on the `v0.4.0` branch of the same repository and is the reference for protocol behaviour,
not for structure or style.
## Goals
@@ -109,7 +109,7 @@ src/
constants.ts consts + constsById, and the SMPP version constants
encodings.ts GSM 03.38, LATIN1, UCS2, detection, data_coding resolution
errors.ts errors + errorsById (ESME_*)
tlvs.ts TLV definitions, tlvsById
tlvs.ts TLV definitions, tlvsById, the input shape, and writing a TLV stream
types.ts Wire types: int8/int16/int32/string/cstring/buffer/arrays
```
@@ -135,16 +135,16 @@ docker compose run --rm node npm run build
- `erasableSyntaxOnly` is on, so no enums, no namespaces, no parameter properties. Use `as const`
objects plus union types.
- The published floor is Node 18, but the dev container runs Node 24 (type stripping needs it). CI
compiles the tests and runs them on 18/20/22/24, so the floor is verified rather than asserted.
compiles the tests and runs them on 18, every LTS above it, and current, so the floor is
verified rather than asserted.
- `typescript` is pinned to the 6.x line because `typescript-eslint` peer-requires `<6.1.0`. Move to
TypeScript 7 once that constraint lifts.
## Defects found in 0.4.0
Every row names what 0.4.0's own code did, so it is not rebuilt here.
[README.md](README.md#behaviour-that-changed-on-the-wire) names what changed for a consumer, and is
the only place that does. Confirmed by reading the 0.4.0 source; each row has a regression test
naming the behaviour.
[MIGRATION.md](MIGRATION.md) names what changed for a consumer, and is the only place that does.
Confirmed by reading the 0.4.0 source; each row has a regression test naming the behaviour.
| Defect | 0.4.0 behaviour |
| --- | --- |
@@ -152,6 +152,7 @@ naming the behaviour.
| Short segments | `splitMsg` accumulates a full segment then pushes `msgPart.slice(0, -1)`, so every segment is one character short: 152 GSM characters instead of 153, 66 UCS2 instead of 67. Long messages are split into more segments than they need, and each extra segment is billed |
| DLR month off by one | `smppDate()` uses `getMonth()` (0-based) without `+1`, so January renders as `00` |
| Non-standard DLR status | Receipts emit `stat:UNDELIVERABLE`; the spec's field is 7 characters (`UNDELIV`) |
| GSM 03.38 declared as IA5 | `sendSms` resolves its encoding through `consts.ENCODING`, so a GSM body goes out under `data_coding` 0x01 — SMPP 3.4 5.2.19's IA5 (CCITT T.50), where `$` and `@` are STX and NUL |
| Flash destroys UCS2 | `flash: true` overwrites `data_coding` with 0x10, discarding the UCS2 alphabet, which needs 0x18 |
| Shared concat reference | The concatenation reference counter is a module-level global shared by every session in the process |
| `send()` never times out | Each call adds a listener keyed on the sequence number; a peer that never answers leaks it and the promise never settles |
@@ -210,9 +211,9 @@ decision under [The wire](#the-wire).
helpers each file carries are copies, tolerated because a wrong one fails that file's own tests and
nothing else, and a helper that only names the parameters of one `objToPdu()` call is on that same
footing — it encodes no wire fact `objToPdu()` does not already own. So is a stub standing in for a
collaborator the type system already keeps in step: `recordingDeps()` in `messaging-mode.test.ts`
and `message-class.test.ts` is one `SendSmsDeps.send` that answers nothing, and a field added to
that type fails to compile in both copies at once.
collaborator the type system already keeps in step: `recordingDeps()` in `messaging-mode.test.ts`,
`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.
- `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
peer's FIN, so `server.close()` hangs forever — that is a test bug, not a library one.
@@ -234,9 +235,11 @@ Each file answers one question, and a fact belongs to the file whose question it
- **README.md — what you can rely on.** Observable behaviour, for someone using the package. It
carries a reason only where the reason changes how you would call the thing.
- **MIGRATION.md — what a 0.4.0 consumer has to change.** Renamed and removed surface, and the
behaviour that changed on the wire.
- **AGENTS.md — what may not change, and why.** Goals, hard rules, architecture, conventions, and the
decisions the goals do not already settle. It does not restate behaviour README states.
- **todo.md** is a temporary 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
answer. The toolchain commands are the one deliberate exception — README's copy serves a contributor
@@ -316,7 +319,34 @@ Grouped by what each one constrains.
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.
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
@@ -586,6 +616,121 @@ Grouped by what each one constrains.
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,
@@ -827,6 +972,7 @@ Grouped by what each one constrains.
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
+93
View File
@@ -0,0 +1,93 @@
# Migrating from larvitsmpp 0.4.0
`@larvit/smpp` 0.5.0 succeeds [larvitsmpp](https://www.npmjs.com/package/larvitsmpp) 0.4.0. The
shape is the same, connect, send, listen for delivery reports, with callbacks replaced by promises.
## API changes
- **The package is `@larvit/smpp`** and ESM only. `require()` no longer works.
- **Callbacks are gone.** `client`, `server`, `sendSms`, `sendResp`, `sendDlr`, `unbind` and
`session.close` are promises resolving to a result with an optional `err`. Nothing rejects. Await
`close()`, or the socket outlives the call.
- **`server()` resolves once, when it is listening**, with a handle carrying `close()`, `port` and
a `session` event. It no longer calls back once per connection.
- **The id a message is answered with goes to `sendResp({ smsId })`.** `sms.smsId` is read-only: the
id the segments were answered with, the id `sendResp()` was given, or the generated UUID v7.
Assigning to it throws a `TypeError`, since modules are strict mode.
- **`smsIds` from `sendSms()` is `(string | undefined)[]`**, one entry per segment, positional with
`pduObjs`, `undefined` where the SMSC took the segment without naming an id.
- **`checkuserpass` is `authenticate`**, takes `{ password, session, systemId, systemType }` and
returns `false` or `{ userData }`.
- **Renamed options:** `enqLinkTiming` → `enquireLinkInterval`, server `timeout` → `idleTimeout`.
- **`larvitsmpp.utils` is gone.** Its contents are named exports: `bitCount`, `decodeMessage`,
`encodeMessage`, `objToPdu`, `pduReturn`, `pduToObj`, `smppDate`, `smppTime`, `splitMessage`. The
codec is synchronous and returns `{ err, pduObj }` / `{ err, buffer }`.
- **`pduObj.isResp()` is the standalone `isResp(pduObj)`.** `pduObj.cmdStatus` is `undefined` for
a status code the library does not know, with the raw number in `pduObj.cmdStatusId`.
- **`defs.filters` is gone.** It was declared on every command and TLV and never invoked. SMPP time
formatting, the one part worth keeping, is `smppTime`.
- **`DATAGRAM`, `FORWARD` and `STORE_FORWARD` moved from `consts.ESM_CLASS` to
`consts.MESSAGING_MODE`**, which also names `SMSC_DEFAULT`. They are bits 1-0 of `esm_class`, not
whole values of it. Read them from the new group, or pass `messagingMode` to `sendSms()`. A stale
`consts.ESM_CLASS.STORE_FORWARD` reads `undefined`, which OR-s into an `esm_class` carrying no mode.
- **The `error` event is `sessionError`**, and `serverError` on the server handle.
- **`log`** takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods instead of
a `larvitutils` one, and is silent by default: [README](README.md#logging).
- **`consts.ENCODING.ASCII` is gone**; the same entry is `consts.ENCODING.IA5`, the other name SMPP
3.4 5.2.19 gives 0x01. `dataCodingByEncoding` is the alphabet `sendSms()` writes, which is 0x00.
## Behaviour that changed on the wire
0.4.0 had protocol defects. Fixing them changes the bytes on the wire, so remove any workaround you
have for these:
- Every multipart segment was one character short (152 GSM characters instead of 153, 66 UCS2
instead of 67), so long messages were split into more segments than necessary, each one billed.
- LATIN1 (`data_coding` 0x03) was silently decoded as ASCII, corrupting the message.
- Delivery receipt dates were a month off, and the status field read `UNDELIVERABLE` where the spec
defines the 7-character `UNDELIV`.
- Every receipt went out as `esm_class` 0x04, the report of a message's final state. A receipt for a
transient state, `sendDlr('ENROUTE')`, is now marked 0x20, the intermediate delivery notification.
- `flash: true` discarded UCS2, mangling flash messages with non-GSM characters, and put the GSM
alphabet on a Latin-1 message that has no `data_coding` at all; that pair is refused now. Inbound,
only a `data_coding` of exactly 0x10 counted as flash, so a flash UCS2 message and the whole 0xF0
coding group arrived as ordinary messages.
- A GSM 03.38 message declared `data_coding` 0x01, which SMPP 3.4 5.2.19 defines as IA5, so `$` and
`@` reached a peer honouring the field as STX and NUL. It goes out as 0x00, the SMSC default
alphabet, and so does a receipt `sendDlr()` writes. Latin-1 and UCS2 stay at 0x03 and 0x08, and an
inbound 0x01 is still read as GSM 03.38.
- The multipart reference counter was shared by every session in the process.
- `tls: true` never performed a handshake, so the connection was not encrypted.
- Alphanumeric senders were sent with TON 1 (international) instead of TON 5.
- Delivery receipts carrying only the standard receipt text, with no TLVs, what Kannel and several
other SMSCs send, were rejected outright. They are parsed now.
- A message whose last octet was `0x00` was allocated one octet short while `sm_length` reported the
full length, so it went out corrupt. In UCS2 that is any message ending in a character like 一
(U+4E00), routine for CJK text.
- Every response carried a `message_id`, `deliver_sm_resp` included, where SMPP 3.4 4.6.2 makes that
field unused and NULL. Jasmin closes the connection on one. Answering an inbound message now puts
nothing in it, and `sms.smsId` is the local handle it always was.
- Binary TLVs (`message_payload`, `network_error_code`, `callback_num` and the rest) were parsed into
a hex string and written back as the ASCII of that string, so every round trip corrupted them.
They are `Buffer`s in both directions now; drop any hex encoding of your own.
- A body carried in the `message_payload` TLV was ignored, so the message arrived empty, and a
`data_sm` was answered `ESME_RINVCMDID`, so a receipt thrown on one was lost silently. Both reach
the application now: a receipt as `dlr`, answered for you, and a message as `sms` for you to answer.
- A long message segmented by the `sar_msg_ref_num`, `sar_total_segments` and `sar_segment_seqnum`
TLVs rather than a user data header was never reassembled, so each segment arrived as its own
message. Both spellings reassemble now.
- Short or malformed PDUs threw out of the codec instead of being reported as a parse failure.
- A PDU whose optional parameters do not end exactly on `command_length` is refused with
`ESME_RINVTLVSTREAM` and dropped, where 0.4.0 kept the TLVs it had read and ignored the octets
left over, losing the `receipted_message_id` that makes a receipt a receipt. The refusal reaches
`sessionError` as a `PduRefusedError` with `reason` `tlvs`.
- Binds declare `interface_version` 0x34. 0.4.0 declared 0x00, which tells the SMSC the ESME speaks
SMPP 3.3 or earlier, and a spec-following SMSC then withholds every optional parameter, the TLVs
delivery receipts are carried in included.
- A response reporting a failure carries no body, as the spec defines. 0.4.0 filled the body with
empty defaults, so a refused `submit_sm_resp` went out with an empty `message_id` a caller could
mistake for a real one.
- `submit_multi` was missing its `sm_length` field, so its `short_message` never round-tripped.
The corrected framing is cross-checked against [node-smpp](https://github.com/farhadi/node-smpp), an
independent implementation, in both directions and over a live session.
+444 -475
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -90,12 +90,12 @@ Then add the file to README.md's findings table.
Every defect a phase records is fixed before the next phase runs — a fix can change behaviour in
ways the next experiment must see. One fix per defect class, as its own change:
1. A worktree on a branch off `origin/typescript` (never `origin/master`, the 0.4.0 code), named
1. A worktree on a branch off `origin/main` (never `origin/v0.4.0`, the 0.4.0 code), named
for the defect.
2. Regression tests in `test/` first, naming the behaviour with the reproducer from the findings;
then the implementation; then the decision record in the root `AGENTS.md` where the fix settles
a question of the wire or the session's life.
3. `/larv-review` on the branch, with the pull request based on `typescript`. When it marks the PR
ready, squash-merge it.
4. Back in the experiments worktree: fast-forward `typescript`, rerun the experiment that found the
3. `/larv-review` on the branch, with the pull request based on `main`. When it marks the PR
ready, fast-forward it.
4. Back in the experiments worktree: fast-forward `main`, rerun the experiment that found the
defect, delete the workaround its test carried, and note the fix in the findings file.
@@ -177,9 +177,9 @@ the fixtures pin that a reader would not otherwise expect:
application wanting one report per message compares each `dlr.smsId` against the `smsIds` array
`sendSms()` returned and merges them itself.
- **Telesign answers only the first segment of a concatenated submit with a `message_id`.**
`sendSms()` returns `['<id>', '', '']` — one entry per segment, positional with `pduObjs`, empty
where the SMSC named nothing. `dlr.smsId` is never empty, so an empty entry matches no receipt,
and no merge is armed.
`sendSms()` returns `['<id>', undefined, undefined]` — one entry per segment, positional with
`pduObjs`, `undefined` where the SMSC named nothing; it returned `''` there until #101.
`dlr.smsId` is never empty, so an unnamed entry matches no receipt, and no merge is armed.
## Open questions
@@ -203,6 +203,5 @@ the fixtures pin that a reader would not otherwise expect:
`FAILED` and CM.com's `DELIVERD`. The documented-codes table in `operator-receipts.test.ts` is
the place a new one goes, and it fails loudly for anything nothing names.
- Whether `smsIds` carrying an empty entry for a segment the SMSC took but named no id for is the
right shape for a caller, or whether that case wants saying differently. It is documented in
`README.md` and pinned by the Telesign scenario; the question is a product one, not a correctness
one, and belongs to the phase 11 product-owner pass.
right shape for a caller. Settled in #101: the entry is `undefined`, and the decision with its
rejected alternatives is recorded in `AGENTS.md`.
+1
View File
@@ -476,6 +476,7 @@ describe('C3+C7 - long MT through the fake upstream, receipts and id consistency
assert.equal(sent.smsIds.length, testCase.expectedSegments);
for (const id of sent.smsIds) {
assert.ok(id, 'expected Jasmin to name a message id for every segment');
assert.match(id, uuidPattern, 'expected a UUID-shaped message id from Jasmin\'s submit_sm_resp');
// The full round trip - submit_sm to Jasmin's smpps, mtrouter, AMQP, the connector bind,
+18 -5
View File
@@ -97,6 +97,15 @@ function dlrLooksIntact(received: Received | undefined): received is Received {
&& received.pduObj.tlvs.message_state?.tagValue !== undefined;
}
/** SMPPSim names an id for every segment, so an unnamed one is the finding, not the norm. */
function namedIds(smsIds: (string | undefined)[]): string[] {
const ids = smsIds.filter(id => id !== undefined);
assert.equal(ids.length, smsIds.length, 'expected SMPPSim to name a message id for every segment');
return ids;
}
/**
* Resends a fresh message until one attempt's segments are every one matched by an intact `dlr`.
* Retrying works around the peer+library interaction above without hiding it: a scenario only
@@ -119,12 +128,14 @@ async function sendUntilAllDlrsArrive(
assert.equal(sent.err, undefined);
const ids = namedIds(sent.smsIds);
const complete = await waitFor(
() => (sent.smsIds.every(id => dlrLooksIntact(dlrs.find(r => r.dlr.smsId === id))) ? true : undefined),
() => (ids.every(id => dlrLooksIntact(dlrs.find(r => r.dlr.smsId === id))) ? true : undefined),
DLR_RETRY_BUDGET_MS,
);
if (complete) return sent.smsIds;
if (complete) return ids;
}
throw new Error(`no attempt got an intact DLR for every segment within ${String(DLR_MAX_ATTEMPTS)} tries`);
@@ -149,14 +160,16 @@ async function sendUntilComplete(
assert.equal(sent.err, undefined);
const ids = namedIds(sent.smsIds);
const complete = await waitFor(() => {
const allIntact = sent.smsIds.every(id => dlrLooksIntact(dlrs.find(r => r.dlr.smsId === id)));
const allIntact = ids.every(id => dlrLooksIntact(dlrs.find(r => r.dlr.smsId === id)));
const reassembled = sms.find(s => s.message === message);
return allIntact && reassembled ? { reassembled } : undefined;
}, DLR_RETRY_BUDGET_MS);
if (complete) return { reassembled: complete.reassembled, smsIds: sent.smsIds };
if (complete) return { reassembled: complete.reassembled, smsIds: ids };
}
throw new Error(`no attempt got both an intact DLR per segment and a loopback reassembly within ${String(DLR_MAX_ATTEMPTS)} tries`);
@@ -276,7 +289,7 @@ describe('smppsim-transition - C4 intermediate then final', () => {
const sent = await session.send({
cmdName: 'submit_sm',
params: {
data_coding: consts.ENCODING.ASCII,
data_coding: 0,
destination_addr: TO,
registered_delivery: 0x11,
short_message: Buffer.from('transition test', 'latin1'),
+6 -2
View File
@@ -38,13 +38,17 @@ async function sendAndAwaitDlrs(session: Session, dlrs: Dlr[], message: string):
assert.equal(sent.err, undefined);
const ids = sent.smsIds.filter(id => id !== undefined);
assert.equal(ids.length, sent.smsIds.length, 'expected smscsim to name a message id for every segment');
const complete = await waitFor(() => (
sent.smsIds.every(id => dlrs.some(dlr => dlr.smsId === id)) ? true : undefined
ids.every(id => dlrs.some(dlr => dlr.smsId === id)) ? true : undefined
));
assert.ok(complete, 'every segment of the first send should get a DLR');
return sent.smsIds;
return ids;
}
describe('smscsim - C1 bind, keepalive, unbind', () => {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@larvit/smpp",
"version": "1.0.0",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larvit/smpp",
"version": "1.0.0",
"version": "0.5.0",
"license": "MIT",
"devDependencies": {
"@eslint/js": "10.0.1",
+7 -8
View File
@@ -1,7 +1,7 @@
{
"name": "@larvit/smpp",
"version": "1.0.0",
"description": "Simplified SMPP implementation",
"version": "0.5.0",
"description": "SMPP 3.4 client and server for Node.js with the session layer built in: keepalive, reconnect, send window, long messages and delivery receipts",
"keywords": [
"esm",
"pdu",
@@ -9,19 +9,19 @@
"smpp",
"typescript"
],
"homepage": "https://github.com/larvit/larvitsmpp",
"homepage": "https://gitea.larvit.se/larvit/smpp-js",
"bugs": {
"url": "https://github.com/larvit/larvitsmpp/issues"
"url": "https://github.com/larvit/smpp-js/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/larvit/larvitsmpp.git"
"url": "git+https://gitea.larvit.se/larvit/smpp-js.git"
},
"license": "MIT",
"author": {
"name": "Mikael 'Lilleman' Göransson",
"email": "lilleman@larvit.se",
"url": "http://github.com/larvit/larvitsmpp"
"url": "https://gitea.larvit.se/larvit/smpp-js"
},
"type": "module",
"exports": {
@@ -37,8 +37,7 @@
"node": ">=18.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
"access": "public"
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
-1
View File
@@ -22,7 +22,6 @@ export const consts = {
YEARS: 0x0E,
},
ENCODING: {
ASCII: 0x01,
BINARY: 0x04,
CYRILLIC: 0x06,
EXTENDED_KANJI_JIS: 0x0D,
+33
View File
@@ -121,6 +121,29 @@ export function detect(value: string): EncodingName {
return 'UCS2';
}
export type Unencodable = { char: string; index: number };
/** What an alphabet could not carry, said the one way: the character, its code point and its index. */
export function unencodableText(at: Unencodable): string {
const point = (at.char.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, '0');
return `${JSON.stringify(at.char)} (U+${point}) at index ${String(at.index)}`;
}
/** The first character `encoding` cannot carry, or undefined where it carries every one of them. */
export function unencodable(message: string, encoding: EncodingName): Unencodable | undefined {
const codec = encodings[encoding];
let index = 0;
for (const char of message) {
if (codec.decode(codec.encode(char)) !== char) return { char, index };
index += char.length;
}
return undefined;
}
/**
* The GSM 03.38 section 4 message class a `data_coding` octet carries, in bits 1-0, or undefined
* where its coding group carries none. Below 0x80 bit 4 says whether one is there; 0xF0 always is.
@@ -165,3 +188,13 @@ export function encodingByDataCoding(dataCoding: number): EncodingName {
// 0x02 and 0x04 are 8-bit binary, 0x03 is Latin-1.
return dataCoding >= 0x02 && dataCoding <= 0x04 ? 'LATIN1' : 'ASCII';
}
/**
* The `data_coding` an alphabet is written under, the mirror of `encodingByDataCoding()`. GSM 03.38
* takes 0x00, the SMSC default alphabet, rather than SMPP 3.4 5.2.19's 0x01, which is IA5.
*/
export const dataCodingByEncoding: Readonly<Record<EncodingName, number>> = {
ASCII: 0x00,
LATIN1: 0x03,
UCS2: 0x08,
};
+58
View File
@@ -1,4 +1,5 @@
import type { ParamValue, WireType } from './types.ts';
import type { Result } from '../result.ts';
import { tlv } from './types.ts';
export type TlvDefinition = {
@@ -104,3 +105,60 @@ export type Tlv = {
tagName: string | undefined;
tagValue: ParamValue;
};
export type TlvInput = {
/** Resolved from the record key; pass it for a tag the TLV table does not define. */
tagId?: number | undefined;
tagValue: ParamValue;
};
export function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> {
const tagId = input.tagId ?? tlvs[name]?.id;
if (tagId === undefined) {
return { err: new Error(`TLV "${name}": unknown tag name, give it a tagId`) };
}
if (!Number.isInteger(tagId) || tagId < 0 || tagId > 0xFFFF) {
return { err: new Error(`TLV "${name}": tagId ${String(tagId)} out of range 0-65535`) };
}
return { tagId };
}
/** Each TLV as its four octet header and the value the tag's own wire type writes. */
export function writeTlvs(inputs: Record<string, TlvInput> | undefined): Result<{ chunks: Buffer[] }> {
const chunks: Buffer[] = [];
for (const [name, input] of Object.entries(inputs ?? {})) {
const tag = tagIdOf(name, input);
if (tag.err) return { err: tag.err };
const type = tlvsById[tag.tagId]?.type ?? tlvDefault;
const sized = type.size(input.tagValue);
if (sized.err) {
return { err: new Error(`TLV "${name}": ${sized.err.message}`) };
}
if (sized.size > 0xffff) {
return { err: new Error(`TLV "${name}": ${String(sized.size)} octets overflow the two octet length`) };
}
const chunk = Buffer.alloc(sized.size + 4);
chunk.writeUInt16BE(tag.tagId, 0);
chunk.writeUInt16BE(sized.size, 2);
const written = type.write(input.tagValue, chunk, 4);
if (written.err) {
return { err: new Error(`TLV "${name}": ${written.err.message}`) };
}
chunks.push(chunk);
}
return { chunks };
}
+3 -3
View File
@@ -37,12 +37,12 @@ const severity: Record<MessageState, number> = {
};
/** The base and the part numbers one send's ids carry, or nothing when they do not spell out one message. */
function idNumbering(smsIds: string[]): { base: string; parts: Set<number> } | undefined {
function idNumbering(smsIds: (string | undefined)[]): { base: string; parts: Set<number> } | undefined {
const bases = new Set<string>();
const parts = new Set<number>();
for (const smsId of smsIds) {
const numbering = parseSegmentId(smsId);
const numbering = smsId === undefined ? undefined : parseSegmentId(smsId);
if (!numbering) return undefined;
@@ -93,7 +93,7 @@ export class DlrMerger {
}
/** Registers the ids one multipart send got back, so their receipts can be merged. */
expect(smsIds: string[]): void {
expect(smsIds: (string | undefined)[]): void {
if (smsIds.length < 2) return;
const numbering = idNumbering(smsIds);
+2 -2
View File
@@ -4,7 +4,7 @@ export { Session } from './session.ts';
export { cmds, cmdsById, commandNameById, isCommandName } from './defs/commands.ts';
export { consts, constsById } from './defs/constants.ts';
export { detect, encodingByDataCoding, encodings, isEncodingName, messageClassOf } from './defs/encodings.ts';
export { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, messageClassOf, unencodable } from './defs/encodings.ts';
export { errorNameById, errors, errorsById, isErrorName } from './defs/errors.ts';
export { tlvs, tlvsById } from './defs/tlvs.ts';
export { types } from './defs/types.ts';
@@ -62,7 +62,7 @@ export type {
} from './session.ts';
export type { CommandName, PduParams, PduParamsInput } from './defs/commands.ts';
export type { ConstGroup, MessageState, SubmitMessagingMode } from './defs/constants.ts';
export type { Encoding, EncodingName } from './defs/encodings.ts';
export type { Encoding, EncodingName, Unencodable } from './defs/encodings.ts';
export type { ErrorName } from './defs/errors.ts';
export type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
export type { PduHeader } from './pdu-refusal.ts';
+53 -16
View File
@@ -1,6 +1,6 @@
import type { Result } from './result.ts';
import type { EncodingName } from './defs/encodings.ts';
import { detect, encodingByDataCoding, encodings } from './defs/encodings.ts';
import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, unencodable, unencodableText } from './defs/encodings.ts';
import { hasUdh } from './defs/constants.ts';
import { udhLength } from './udh.ts';
@@ -27,6 +27,25 @@ export function encodeMessage(
return { buffer: encodings[resolved].encode(message), encoding: resolved };
}
/** A message body as octets, under the alphabet `dataCoding` resolves to, or one detected for it. */
export function encodeBody(
text: string,
dataCoding: number | undefined,
): Result<{ buffer: Buffer; dataCoding: number }> {
if (dataCoding === undefined) {
const detected = encodeMessage(text);
return { buffer: detected.buffer, dataCoding: dataCodingByEncoding[detected.encoding] };
}
const encoding = encodingByDataCoding(dataCoding);
const lost = unencodable(text, encoding);
return lost
? { err: new Error(`data_coding ${String(dataCoding)} resolves to ${encoding}, which cannot carry ${unencodableText(lost)}; pass a Buffer of octets, or a data_coding whose alphabet carries them`) }
: { buffer: encodings[encoding].encode(text), dataCoding };
}
export function decodeMessage(
buffer: Buffer,
dataCoding: number,
@@ -113,39 +132,57 @@ export function smppDate(date: Date): string {
+ pad(date.getUTCMinutes(), 2);
}
/** A second count is spelled in days and below: no fixed number of seconds is a month or a year. */
const maxRelativeSeconds = 99 * 86400 + 86399;
const absoluteTime = /^(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d)(\d\d)([+-])$/;
const relativeTime = /^(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)000R$/;
/** The SMPP absolute and relative time format, as used by validity_period and friends. */
export const smppTime = {
/**
* A Date becomes an absolute UTC time; a number is a relative period in seconds. Relative
* periods are expressed in days and below, so anything past 99 days is clamped to that.
* A Date becomes an absolute UTC time; a number is a relative period in seconds, expressed in
* days and below; a string is a stamp the caller formatted itself and is passed through. A value
* naming no instant or period, and a second count no day field reaches, are refused.
*/
encode(value: Date | number | string): string {
if (typeof value === 'string') return value;
encode(value: Date | number | string): Result<{ text: string }> {
if (typeof value === 'string') return { text: value };
if (typeof value === 'number') {
const total = Math.max(0, Math.floor(value));
const days = Math.min(99, Math.floor(total / 86400));
const capped = days === 99 ? 99 * 86400 + 86399 : total;
if (!Number.isFinite(value)) return { err: new Error(`Not an SMPP time: ${String(value)}`) };
return '0000'
+ pad(Math.floor(capped / 86400), 2)
+ pad(Math.floor(capped / 3600) % 24, 2)
+ pad(Math.floor(capped / 60) % 60, 2)
+ pad(capped % 60, 2)
+ '000R';
const seconds = Math.floor(value);
if (seconds < 0) {
return { err: new Error(`A relative period cannot be negative, got ${String(value)}`) };
}
return pad(value.getUTCFullYear() % 100, 2)
if (seconds > maxRelativeSeconds) {
return { err: new Error(`A period in seconds is spelled in days and below, so ${String(maxRelativeSeconds)} (99d 23:59:59) is the most, got ${String(value)}; pass a Date for an instant further out`) };
}
return {
text: '0000'
+ pad(Math.floor(seconds / 86400), 2)
+ pad(Math.floor(seconds / 3600) % 24, 2)
+ pad(Math.floor(seconds / 60) % 60, 2)
+ pad(seconds % 60, 2)
+ '000R',
};
}
if (Number.isNaN(value.getTime())) return { err: new Error('Not an SMPP time: an invalid Date') };
return {
text: pad(value.getUTCFullYear() % 100, 2)
+ pad(value.getUTCMonth() + 1, 2)
+ pad(value.getUTCDate(), 2)
+ pad(value.getUTCHours(), 2)
+ pad(value.getUTCMinutes(), 2)
+ pad(value.getUTCSeconds(), 2)
+ pad(Math.floor(value.getUTCMilliseconds() / 100), 1)
+ '00+';
+ '00+',
};
},
decode(value: string): Result<{ date: Date }> {
+96 -79
View File
@@ -3,15 +3,14 @@ import type { ErrorName } from './defs/errors.ts';
import type { ParamValue } from './defs/types.ts';
import type { PduHeader } from './pdu-refusal.ts';
import type { Result, VoidResult } from './result.ts';
import type { Tlv } from './defs/tlvs.ts';
import type { Tlv, TlvInput } from './defs/tlvs.ts';
import { PduRefusedError, framingRefusal } from './pdu-refusal.ts';
import { cmds, commandNameById, respNameFor } from './defs/commands.ts';
import { consts, hasUdh } from './defs/constants.ts';
import { decodeMessage, encodeMessage } from './message.ts';
import { detect, encodingByDataCoding } from './defs/encodings.ts';
import { hasUdh } from './defs/constants.ts';
import { decodeMessage, encodeBody } from './message.ts';
import { errorNameById, errors, isErrorName } from './defs/errors.ts';
import { paramNumber } from './defs/types.ts';
import { tlvDefault, tlvs, tlvsById } from './defs/tlvs.ts';
import { tagIdOf, tlvDefault, tlvs, tlvsById, writeTlvs } from './defs/tlvs.ts';
/** The highest sequence number this library hands out; SMPP 3.4 4.7.1 reserves 0x7fffffff. */
export const maxSeqNr = 2147483646;
@@ -19,12 +18,6 @@ export const maxSeqNr = 2147483646;
/** What the field holds. Read and echoed in full, because peers do write above the spec's range. */
const maxWireSeqNr = 0xFFFFFFFF;
export type TlvInput = {
/** Resolved from the record key; pass it for a tag the TLV table does not define. */
tagId?: number | undefined;
tagValue: ParamValue;
};
export type PduObjectInput<C extends CommandName = CommandName> = {
cmdName: C;
cmdStatus?: ErrorName;
@@ -53,6 +46,8 @@ export type PduObject = {
tlvs: Record<string, Tlv>;
};
export type { TlvInput };
const respBit = 0x80000000;
export function isResp(pduObj: Pick<PduObject, 'cmdId'>): boolean {
@@ -71,44 +66,98 @@ export function isCommand<C extends CommandName>(
return pduObj.cmdName === cmdName;
}
function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> {
const tagId = input.tagId ?? tlvs[name]?.id;
type ResolvedBody = {
params: Record<string, ParamValue | undefined>;
tlvs: Record<string, TlvInput> | undefined;
};
if (tagId === undefined) {
return { err: new Error(`TLV "${name}": unknown tag name, give it a tagId`) };
}
if (!Number.isInteger(tagId) || tagId < 0 || tagId > 0xFFFF) {
return { err: new Error(`TLV "${name}": tagId ${String(tagId)} out of range 0-65535`) };
}
return { tagId };
function codingOf(params: Record<string, ParamValue | undefined>): number | undefined {
return typeof params.data_coding === 'number' ? params.data_coding : undefined;
}
/** Encoding a string short_message settles data_coding and sm_length; a buffer settles sm_length. */
function resolveShortMessage(
params: Record<string, ParamValue | undefined>,
): Record<string, ParamValue | undefined> {
const message = params.short_message;
type CarriedBody = { name: string; text: string; tlv: TlvInput };
if (Buffer.isBuffer(message)) {
return params.sm_length === undefined
? { ...params, sm_length: message.length }
: { ...params };
/** Every entry carrying body text, under whatever names their tagIds are keyed to. */
function carriedBodies(input: Record<string, TlvInput> | undefined): CarriedBody[] {
const carried: CarriedBody[] = [];
for (const [name, tlv] of Object.entries(input ?? {})) {
const tag = tagIdOf(name, tlv);
if (!tag.err && tag.tagId === tlvs.message_payload.id && typeof tlv.tagValue === 'string') {
carried.push({ name, text: tlv.tagValue, tlv });
}
}
if (typeof message !== 'string') return { ...params };
return carried;
}
const dataCoding = params.data_coding;
const encoding = typeof dataCoding === 'number' ? encodingByDataCoding(dataCoding) : detect(message);
const encoded = encodeMessage(message, encoding);
/** messageOctets() reads short_message wherever it holds an octet, and the TLV only where it does not. */
function carriesOctets(value: ParamValue | undefined): boolean {
return Buffer.isBuffer(value) && value.length > 0;
}
return {
...params,
data_coding: typeof dataCoding === 'number' ? dataCoding : consts.ENCODING[encoded.encoding],
short_message: encoded.buffer,
sm_length: encoded.buffer.length,
};
/** The short_message the command's own table will write, since writeParams() ignores any other. */
function writtenBody(definition: CommandDefinition, value: ParamValue | undefined): ParamValue | undefined {
return definition.params?.short_message === undefined ? undefined : value;
}
/** Encoded in place, settling data_coding where `settles` says no mandatory field will carry it. */
function resolveCarried(
resolved: ResolvedBody,
inputs: Record<string, TlvInput> | undefined,
dataCoding: number | undefined,
settles: boolean,
): VoidResult {
for (const carried of carriedBodies(inputs)) {
const encoded = encodeBody(carried.text, dataCoding);
if (encoded.err) return { err: new Error(`TLV "${carried.name}": ${encoded.err.message}`) };
if (settles) resolved.params.data_coding = encoded.dataCoding;
resolved.tlvs = { ...resolved.tlvs, [carried.name]: { ...carried.tlv, tagValue: encoded.buffer } };
}
return {};
}
/** data_coding names the alphabet of the body, and short_message settles it where it carries octets. */
function resolveBody(
params: Record<string, ParamValue | undefined>,
tlvs: Record<string, TlvInput> | undefined,
definition: CommandDefinition,
): Result<ResolvedBody> {
const message = writtenBody(definition, params.short_message);
const resolved: ResolvedBody = { params: { ...params }, tlvs };
if (Buffer.isBuffer(message) && params.sm_length === undefined) {
resolved.params.sm_length = message.length;
}
if (typeof message === 'string') {
const encoded = encodeBody(message, codingOf(params));
if (encoded.err) {
return { err: new Error(`Parameter "short_message" of "${definition.command}": ${encoded.err.message}`) };
}
if (carriesOctets(encoded.buffer)) resolved.params.data_coding = encoded.dataCoding;
resolved.params.short_message = encoded.buffer;
resolved.params.sm_length = encoded.buffer.length;
}
// Only octets the command's own table will write can settle the alphabet the PDU declares.
const settles = !carriesOctets(writtenBody(definition, resolved.params.short_message));
const carried = resolveCarried(
resolved,
tlvs,
settles ? codingOf(params) : codingOf(resolved.params),
settles,
);
return carried.err ? { err: carried.err } : resolved;
}
function writeParams(
@@ -139,42 +188,6 @@ function writeParams(
return { chunks };
}
function writeTlvs(tlvs: Record<string, TlvInput> | undefined): Result<{ chunks: Buffer[] }> {
const chunks: Buffer[] = [];
for (const [name, tlv] of Object.entries(tlvs ?? {})) {
const tag = tagIdOf(name, tlv);
if (tag.err) return { err: tag.err };
const type = tlvsById[tag.tagId]?.type ?? tlvDefault;
const sized = type.size(tlv.tagValue);
if (sized.err) {
return { err: new Error(`TLV "${name}": ${sized.err.message}`) };
}
if (sized.size > 0xffff) {
return { err: new Error(`TLV "${name}": ${String(sized.size)} octets overflow the two octet length`) };
}
const chunk = Buffer.alloc(sized.size + 4);
chunk.writeUInt16BE(tag.tagId, 0);
chunk.writeUInt16BE(sized.size, 2);
const written = type.write(tlv.tagValue, chunk, 4);
if (written.err) {
return { err: new Error(`TLV "${name}": ${written.err.message}`) };
}
chunks.push(chunk);
}
return { chunks };
}
/**
* SMPP 3.4: a response reporting a failure carries no body, so its fields are not "unused but
* present" — they are absent, and a peer that reads them anyway reads past the end of the PDU.
@@ -188,11 +201,15 @@ function buildBody(
): Result<{ body: Buffer }> {
if (errors[cmdStatus] !== 0 && definition.id >= respBit) return { body: Buffer.alloc(0) };
const written = writeParams(definition, resolveShortMessage(params), cmdName);
const body = resolveBody(params, tlvs, definition);
if (body.err) return { err: body.err };
const written = writeParams(definition, body.params, cmdName);
if (written.err) return { err: written.err };
const writtenTlvs = writeTlvs(tlvs);
const writtenTlvs = writeTlvs(body.tlvs);
if (writtenTlvs.err) return { err: writtenTlvs.err };
+75 -19
View File
@@ -1,4 +1,4 @@
import type { EncodingName } from './defs/encodings.ts';
import type { EncodingName, Unencodable } from './defs/encodings.ts';
import type { ParamValue } from './defs/types.ts';
import type { SubmitMessagingMode } from './defs/constants.ts';
import type { PduObject, PduObjectInput } from './pdu.ts';
@@ -7,7 +7,7 @@ import type { SmppLog } from './log.ts';
import type { SmsIdNotation } from './sms-id.ts';
import { UnansweredError } from './unanswered-error.ts';
import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts';
import { detect, encodingNames, isEncodingName } from './defs/encodings.ts';
import { dataCodingByEncoding, detect, encodingNames, isEncodingName, unencodable, unencodableText } from './defs/encodings.ts';
import { namedValue } from './error-from.ts';
import { normaliseSmsId } from './sms-id.ts';
import { paramText } from './defs/types.ts';
@@ -33,16 +33,20 @@ export type SendSmsOptions = {
};
/** The options as they arrive: a caller without types can put anything in the checked fields. */
export type SendSmsInput = Omit<SendSmsOptions, 'encoding' | 'messagingMode'> & {
export type SendSmsInput =
Omit<SendSmsOptions, 'encoding' | 'messagingMode' | 'scheduleDeliveryTime' | 'validityPeriod'> & {
encoding?: unknown;
messagingMode?: unknown;
};
scheduleDeliveryTime?: unknown;
validityPeriod?: unknown;
};
/** Both arrays hold what the peer accepted, so a partial failure names what is already delivered. */
export type SendSmsResult = {
err?: Error;
pduObjs: PduObject[];
smsIds: string[];
/** Positional with `pduObjs`, and undefined where the SMSC took the segment and named no id. */
smsIds: (string | undefined)[];
/** Segments that went out unanswered. The peer may have taken them, so sending again may duplicate. */
unanswered: number;
};
@@ -60,16 +64,24 @@ export type SendSmsDeps = {
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
};
/** The time fields as the wire spells them, settled once for a message rather than per segment. */
type SendTimes = {
scheduleDeliveryTime?: string;
validityPeriod?: string;
};
/** What the checks below settle, before a segment exists to carry it. */
type CheckedOptions = {
encoding: EncodingName;
messagingMode: SubmitMessagingMode;
times: SendTimes;
};
type SegmentOptions = {
encoding: EncodingName;
messagingMode?: SubmitMessagingMode;
multipart: boolean;
times?: SendTimes;
};
/** Alphanumeric senders must be TON 5. */
@@ -78,7 +90,7 @@ function addressTon(address: string): number {
}
function dataCodingFor(encoding: EncodingName, flash: boolean): number {
if (!flash) return consts.ENCODING[encoding];
if (!flash) return dataCodingByEncoding[encoding];
// Message class present (0x10) plus the alphabet bits, so flash survives UCS2.
return encoding === 'UCS2' ? 0x18 : 0x10;
@@ -92,7 +104,7 @@ function esmClassFor(mode: SubmitMessagingMode | undefined, multipart: boolean):
}
export function submitSmParams(
sms: SendSmsInput,
sms: Omit<SendSmsInput, 'scheduleDeliveryTime' | 'validityPeriod'>,
segment: Buffer,
options: SegmentOptions,
): Record<string, ParamValue> {
@@ -108,13 +120,12 @@ export function submitSmParams(
source_addr_ton: sms.sourceAddrTon ?? addressTon(sms.from),
};
const schedule = options.times?.scheduleDeliveryTime;
const validity = options.times?.validityPeriod;
if (sms.dlr === true) params.registered_delivery = consts.REGISTERED_DELIVERY.FINAL;
if (sms.scheduleDeliveryTime !== undefined) {
params.schedule_delivery_time = smppTime.encode(sms.scheduleDeliveryTime);
}
if (sms.validityPeriod !== undefined) {
params.validity_period = smppTime.encode(sms.validityPeriod);
}
if (schedule !== undefined) params.schedule_delivery_time = schedule;
if (validity !== undefined) params.validity_period = validity;
return params;
}
@@ -157,11 +168,40 @@ function refusedEncoding(encoding: unknown): Error {
return new Error(`encoding must be ${encodingNames.join(', ')}, got ${namedValue(encoding)}`);
}
function refusedText(encoding: EncodingName, at: Unencodable): Error {
return new Error(`encoding ${encoding} cannot carry ${unencodableText(at)}; name UCS2 or leave encoding out`);
}
/** An alphabet the caller named has to carry the message; the one detect() picks always does. */
function checkEncoding(encoding: unknown, message: string): Result<{ encoding: EncodingName }> {
if (encoding === undefined) return { encoding: detect(message) };
if (isEncodingName(encoding)) return { encoding };
if (!isEncodingName(encoding)) return { err: refusedEncoding(encoding) };
return { err: refusedEncoding(encoding) };
const lost = unencodable(message, encoding);
return lost ? { err: refusedText(encoding, lost) } : { encoding };
}
function checkTimes(sms: SendSmsInput): Result<{ times: SendTimes }> {
const times: SendTimes = {};
for (const option of ['scheduleDeliveryTime', 'validityPeriod'] as const) {
const value = sms[option];
if (value === undefined) continue;
if (typeof value !== 'number' && typeof value !== 'string' && !(value instanceof Date)) {
return { err: new Error(`${option} must be a Date, a number of seconds or an SMPP stamp, got ${namedValue(value)}`) };
}
const encoded = smppTime.encode(value);
if (encoded.err) return { err: new Error(`${option}: ${encoded.err.message}`) };
times[option] = encoded.text;
}
return { times };
}
/** GSM 03.38 section 4 gives the class groups GSM 7-bit, 8-bit data and UCS2, and no Latin-1 at all. */
@@ -185,7 +225,11 @@ function checkOptions(sms: SendSmsInput): Result<CheckedOptions> {
if (unspellable) return { err: unspellable };
return { encoding: chosen.encoding, messagingMode: mode.messagingMode };
const times = checkTimes(sms);
if (times.err) return { err: times.err };
return { encoding: chosen.encoding, messagingMode: mode.messagingMode, times: times.times };
}
/** Nothing goes on the wire until the whole message fits: a half-sent message bills twice. */
@@ -205,12 +249,19 @@ function checkSegments(allowed: number, segments: number): Error | undefined {
return undefined;
}
/** An absent message_id and one a peer wrote empty are the same octets, and neither names an id. */
function acceptedId(pduObj: PduObject, notation: SmsIdNotation | undefined): string | undefined {
const smsId = normaliseSmsId(paramText(pduObj.params.message_id), notation);
return smsId === '' ? undefined : smsId;
}
function collectSent(
sent: Result<{ pduObj: PduObject }>[],
notation: SmsIdNotation | undefined,
): SendSmsResult {
const pduObjs: PduObject[] = [];
const smsIds: string[] = [];
const smsIds: (string | undefined)[] = [];
let failure: Error | undefined;
let unanswered = 0;
@@ -221,7 +272,7 @@ function collectSent(
failure ??= one.err;
} else if (one.pduObj.cmdStatus === 'ESME_ROK') {
pduObjs.push(one.pduObj);
smsIds.push(normaliseSmsId(paramText(one.pduObj.params.message_id), notation));
smsIds.push(acceptedId(one.pduObj, notation));
} else {
const refusal = one.pduObj.cmdStatus ?? String(one.pduObj.cmdStatusId);
@@ -252,7 +303,12 @@ export async function submitSms(deps: SendSmsDeps, sms: SendSmsInput): Promise<S
// segment before answering — this library's own server does — would otherwise deadlock.
const sent = await Promise.all(segments.map(segment => deps.send({
cmdName: 'submit_sm',
params: submitSmParams(sms, segment, { encoding, messagingMode: options.messagingMode, multipart }),
params: submitSmParams(sms, segment, {
encoding,
messagingMode: options.messagingMode,
multipart,
times: options.times,
}),
})));
return collectSent(sent, deps.respIdNotation);
+195
View File
@@ -0,0 +1,195 @@
import assert from 'node:assert/strict';
import test, { describe } from 'node:test';
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
import { client } from '../src/client.ts';
import { closeAfter } from './teardown.ts';
import { consts } from '../src/defs/constants.ts';
import { decodeMessage } from '../src/message.ts';
import { dlrFromPdu } from '../src/dlr.ts';
import { encodingByDataCoding, encodings } from '../src/defs/encodings.ts';
import { objToPdu, pduToObj } from '../src/pdu.ts';
import { paramNumber } from '../src/defs/types.ts';
import { server } from '../src/server.ts';
import type { PduObject } from '../src/pdu.ts';
const from = '46701113311';
const to = '46709771337';
/** GSM 03.38 puts $ at 0x02 and @ at 0x00, where IA5 has STX and NUL. */
const bothTables = 'Cost 5$ @home';
/** What SMPP 3.4 5.2.19 assigns each coding, read the way a peer honouring the field reads it. */
const byTheSpecsTable: Record<number, (octets: Buffer) => string> = {
// The SMSC default alphabet, which every peer in interop-tests/ runs as GSM 03.38.
0x00: octets => encodings.ASCII.decode(octets),
// IA5 (CCITT T.50), whose whole range is what Latin-1 reads below 0x80.
[consts.ENCODING.IA5]: octets => octets.toString('latin1'),
};
/** An event that never fires would otherwise block until the CI job limit, asserting nothing. */
function once<T>(register: (resolve: (value: T) => void) => void): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('waited 5000 ms for an event that never fired'));
}, 5000);
register(value => {
clearTimeout(timer);
resolve(value);
});
});
}
function submitted(octets: Buffer[]): PduObject[] {
return octets.map(pdu => {
const { pduObj } = pduToObj(pdu);
assert.ok(pduObj);
return pduObj;
});
}
function declaredBy(pduObj: PduObject): number {
return paramNumber(pduObj.params.data_coding, 0);
}
/** A deliver_sm carrying `body` under `dataCoding`, as a peer answering our own send would write it. */
function delivered(body: Buffer | string, dataCoding: number, esmClass: number): PduObject {
const { buffer } = objToPdu({
cmdName: 'deliver_sm',
params: {
data_coding: dataCoding,
destination_addr: from,
esm_class: esmClass,
short_message: body,
source_addr: to,
},
seqNr: 1,
});
assert.ok(buffer);
const { pduObj } = pduToObj(buffer);
assert.ok(pduObj);
return pduObj;
}
describe('the alphabet a message declares is the one its octets are written in', () => {
test('sends GSM 03.38 under data_coding 0x00, the SMSC default alphabet', async t => {
const smsc = await dummySmsc(t, { messageIds: ['01a08779-de97-7caa-9d26-e6d50f5c4888'] });
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
const sent = await session.sendSms({ from, message: bothTables, to });
assert.equal(sent.err, undefined);
assert.deepEqual(submitted(smsc.octets).map(declaredBy), [0x00]);
});
test('keeps $ and @ for a peer that honours the declaration, where IA5 read STX and NUL', async t => {
const smsc = await dummySmsc(t, { messageIds: ['01a08779-de98-7d24-9542-e652e0d3761c'] });
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
assert.equal((await session.sendSms({ from, message: bothTables, to })).err, undefined);
const [pduObj] = submitted(smsc.octets);
assert.ok(pduObj);
const octets = pduObj.shortMessageOctets;
assert.ok(octets);
assert.equal(octets.toString('hex'), '436f73742035022000686f6d65');
const read = byTheSpecsTable[declaredBy(pduObj)];
assert.ok(read, 'the coding declared must be one SMPP 3.4 5.2.19 names an alphabet for');
assert.equal(read(octets), bothTables);
});
test('leaves Latin-1 at 0x03 and UCS2 at 0x08, the codings those alphabets always had', async t => {
const smsc = await dummySmsc(t, {
messageIds: ['01a08779-de99-7fa5-bcac-18feef55aeee', '01a08779-de99-72ec-8dbb-9365a00158c3'],
});
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
assert.equal((await session.sendSms({ encoding: 'LATIN1', from, message: 'Räksmörgås', to })).err, undefined);
assert.equal((await session.sendSms({ from, message: 'あいう', to })).err, undefined);
assert.deepEqual(submitted(smsc.octets).map(declaredBy), [0x03, 0x08]);
});
// sendDlr() writes its body as a string with no data_coding, so it takes the detected branch too.
test('declares 0x00 on a receipt it writes itself', async t => {
const { err, server: smpp } = await server({ port: 0 });
assert.equal(err, undefined);
assert.ok(smpp);
closeAfter(t, smpp);
smpp.on('session', peer => peer.on('sms', async sms => {
await sms.sendResp();
await sms.sendDlr('DELIVERED');
}));
const connected = await client({ port: smpp.port, reconnect: false });
assert.equal(connected.err, undefined);
assert.ok(connected.session);
closeAfter(t, connected.session);
const session = connected.session;
const reported = once<PduObject>(resolve => {
session.on('dlr', (_report, pduObj) => { resolve(pduObj); });
});
assert.equal((await session.sendSms({ dlr: true, from, message: bothTables, to })).err, undefined);
assert.equal(declaredBy(await reported), 0x00);
});
// The low-level surface settles data_coding off the same detection, so it carried the same defect.
test('settles a detected string body at 0x00 where the caller named no data_coding', () => {
const built = objToPdu({
cmdName: 'submit_sm',
params: { destination_addr: to, short_message: bothTables, source_addr: from },
});
assert.ok(built.buffer);
const { pduObj } = pduToObj(built.buffer);
assert.ok(pduObj);
assert.equal(declaredBy(pduObj), 0x00);
assert.equal(pduObj.shortMessageOctets?.toString('hex'), '436f73742035022000686f6d65');
});
});
describe('what a peer declares is read as generously as it was before', () => {
test('reads data_coding 0x01 as GSM 03.38, as 0x00 is read', () => {
assert.equal(encodingByDataCoding(0x01), 'ASCII');
const octets = Buffer.from('436f73742035022000686f6d65', 'hex');
for (const dataCoding of [0x00, 0x01]) {
assert.equal(decodeMessage(octets, dataCoding).message, bothTables, String(dataCoding));
}
});
test('leaves a receipt and an inbound message reading the same under either coding', () => {
const receiptId = '01a08779-de97-7caa-9d26-e6d50f5c4888';
const body = `id:${receiptId} sub:001 dlvrd:001 submit date:2509091430 done date:2509091431 stat:DELIVRD err:000 text:${bothTables}`;
for (const dataCoding of [0x00, 0x01]) {
const receipt = dlrFromPdu(delivered(body, dataCoding, consts.ESM_CLASS.MC_DELIVERY_RECEIPT));
assert.ok(receipt, String(dataCoding));
assert.equal(receipt.smsId, receiptId, String(dataCoding));
assert.equal(receipt.statusMsg, 'DELIVERED', String(dataCoding));
const inbound = delivered(Buffer.from('436f73742035022000686f6d65', 'hex'), dataCoding, 0);
assert.equal(dlrFromPdu(inbound), undefined, String(dataCoding));
assert.equal(inbound.params.short_message, bothTables, String(dataCoding));
}
});
});
+54 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test, { describe } from 'node:test';
import { detect, encodingByDataCoding, encodings } from '../src/defs/encodings.ts';
import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, unencodable } from '../src/defs/encodings.ts';
describe('ASCII (GSM 03.38)', () => {
const samples: [string, number[]][] = [
@@ -105,6 +105,51 @@ describe('detect()', () => {
assert.equal(detect('تست'), 'UCS2');
assert.equal(detect('۱۲۳۴۵۶۷۸۹۰'), 'UCS2');
});
// Exhaustive: sendSms() leaves the automatic path unchecked on this and nothing else.
test('never picks an alphabet that loses a character, for any code point there is', () => {
for (let code = 0; code <= 0x10FFFF; code++) {
if (code >= 0xD800 && code <= 0xDFFF) continue;
const char = String.fromCodePoint(code);
const lost = unencodable(char, detect(char));
if (lost) assert.fail(`detect() picked ${detect(char)} for U+${code.toString(16)}, which loses it`);
}
});
});
describe('unencodable()', () => {
test('names the first character an alphabet cannot carry, and nothing where it carries them all', () => {
assert.deepEqual(unencodable('あいう', 'LATIN1'), { char: 'あ', index: 0 });
assert.deepEqual(unencodable('Åsa naïve', 'ASCII'), { char: 'ï', index: 6 });
assert.equal(unencodable('€{}[]\\~^|\f', 'ASCII'), undefined);
assert.equal(unencodable('Åsa', 'ASCII'), undefined);
assert.equal(unencodable('`ÁáçÚ', 'LATIN1'), undefined);
assert.equal(unencodable('あいう😀', 'UCS2'), undefined);
});
test('counts the index in the units the message is written in, so a surrogate pair reads back whole', () => {
assert.deepEqual(unencodable('ab😀', 'LATIN1'), { char: '😀', index: 2 });
assert.deepEqual(unencodable('a😀b', 'ASCII'), { char: '😀', index: 1 });
});
test('carries every octet through Latin-1, which is what an 8-bit binary body is sent as', () => {
const every = Buffer.from(Array.from({ length: 256 }, (_, byte) => byte));
assert.equal(unencodable(every.toString('latin1'), 'LATIN1'), undefined);
assert.deepEqual(unencodable('Ā', 'LATIN1'), { char: 'Ā', index: 0 });
});
test('is not match(), which says what detect() may pick rather than what the alphabet holds', () => {
assert.equal(encodings.LATIN1.match('Hello world'), false);
assert.equal(unencodable('Hello world', 'LATIN1'), undefined);
});
test('reads a character at a time, so a bare GSM escape beside its base reads as carried', () => {
assert.equal(unencodable('\x1Be', 'ASCII'), undefined);
assert.deepEqual(encodings.ASCII.encode('\x1Be'), encodings.ASCII.encode('€'));
});
});
describe('encodingByDataCoding()', () => {
@@ -143,6 +188,14 @@ describe('encodingByDataCoding()', () => {
}
});
test('reads every coding dataCodingByEncoding writes back as the alphabet that wrote it', () => {
for (const name of Object.keys(dataCodingByEncoding)) {
if (!isEncodingName(name)) return assert.fail(`${name} names no alphabet`);
assert.equal(encodingByDataCoding(dataCodingByEncoding[name]), name);
}
});
test('falls back to ASCII for alphabets it has no codec for', () => {
assert.equal(encodingByDataCoding(0x05), 'ASCII');
assert.equal(encodingByDataCoding(0x0E), 'ASCII');
+1 -1
View File
@@ -171,7 +171,7 @@ describe('sendSms() flash', () => {
}
// 0.4.0 forced 0x10 whatever the alphabet was, which mangled every non-GSM flash message.
assert.deepEqual(dataCodingsOf(smsc.octets), [0x01, 0x10, 0x08, 0x18, 0x03]);
assert.deepEqual(dataCodingsOf(smsc.octets), [0x00, 0x10, 0x08, 0x18, 0x03]);
});
test('refuses a flash Latin-1 message, which no coding group carrying a class can spell', async () => {
+61 -4
View File
@@ -234,25 +234,82 @@ describe('smppDate()', () => {
describe('smppTime', () => {
test('encodes an absolute time', () => {
assert.equal(smppTime.encode(new Date(Date.UTC(2026, 7, 25, 14, 30, 0))), '260825143000000+');
assert.equal(smppTime.encode(new Date(Date.UTC(2026, 7, 25, 14, 30, 0))).text, '260825143000000+');
});
test('encodes a relative time given in seconds', () => {
assert.equal(smppTime.encode(3600), '000000010000000R');
assert.equal(smppTime.encode(0).text, '000000000000000R');
assert.equal(smppTime.encode(3600).text, '000000010000000R');
assert.equal(smppTime.encode(99 * 86400).text, '000099000000000R');
assert.equal(smppTime.encode(99 * 86400 + 86399).text, '000099235959000R');
});
test('refuses a second count past the day field rather than clamping it, naming the Date spelling', () => {
for (const seconds of [99 * 86400 + 86400, 86400 * 365]) {
const encoded = smppTime.encode(seconds);
assert.ok(encoded.err instanceof Error, String(seconds));
assert.equal(encoded.text, undefined);
assert.match(encoded.err.message, /pass a Date/);
}
});
test('refuses a negative period without sending the caller to the Date that cannot help', () => {
const encoded = smppTime.encode(-1);
assert.ok(encoded.err instanceof Error);
assert.equal(encoded.text, undefined);
assert.match(encoded.err.message, /cannot be negative/);
assert.doesNotMatch(encoded.err.message, /Date/);
});
test('decodes the years and months the relative format holds but a second count cannot name', () => {
const before = Date.now();
const year = smppTime.decode('010000000000000R');
const months = smppTime.decode('001000000000000R');
const day = 86400_000;
assert.equal(year.err, undefined);
assert.ok(year.date, 'a year is a period the format holds');
assert.ok(year.date.getTime() >= before + 365 * day, year.date.toISOString());
assert.ok(year.date.getTime() <= Date.now() + 366 * day, year.date.toISOString());
assert.equal(months.err, undefined);
assert.ok(months.date, 'ten months is a period the format holds');
assert.ok(months.date.getTime() >= before + 290 * day, months.date.toISOString());
assert.ok(months.date.getTime() <= Date.now() + 320 * day, months.date.toISOString());
});
test('passes an already-formatted string through', () => {
assert.equal(smppTime.encode('260825143000000+'), '260825143000000+');
assert.equal(smppTime.encode('260825143000000+').text, '260825143000000+');
});
test('decodes an absolute time back to the same instant', () => {
const when = new Date(Date.UTC(2026, 7, 25, 14, 30, 0));
const { err, date } = smppTime.decode(smppTime.encode(when));
const encoded = smppTime.encode(when);
assert.ok(encoded.text);
const { err, date } = smppTime.decode(encoded.text);
assert.equal(err, undefined);
assert.equal(date.toISOString(), when.toISOString());
});
test('reports a time it cannot express rather than encoding it as a string of NaNs', () => {
for (const value of [new Date('nope'), NaN, Infinity, -Infinity]) {
const encoded = smppTime.encode(value);
assert.ok(encoded.err instanceof Error, String(value));
assert.equal(encoded.text, undefined);
}
const invalidDate = smppTime.encode(new Date('nope'));
assert.ok(invalidDate.err);
assert.match(invalidDate.err.message, /invalid Date/);
});
test('reports malformed input rather than returning an invalid date', () => {
assert.ok(smppTime.decode('nonsense').err instanceof Error);
assert.ok(smppTime.decode('').err instanceof Error);
+4 -4
View File
@@ -19,13 +19,13 @@ const to = '46709771337';
const longMessage = 'Segments of a long message, counted in a user data header. '.repeat(7);
/** A freshly bound session's first submit_sm for `Hello world`: sequence number 2. */
const singleSegmentOctets = '0000004200000004000000000000000200010034363730313131333331310001003436373039373731333337000000000000000001000b48656c6c6f20776f726c64';
const singleSegmentOctets = '0000004200000004000000000000000200010034363730313131333331310001003436373039373731333337000000000000000000000b48656c6c6f20776f726c64';
/** The same for `longMessage`: concatenation reference 1, sequence numbers 2 to 4. */
const threeSegmentOctets = [
'000000d600000004000000000000000200010034363730313131333331310001003436373039373731333337004000000000000001009f0500030103015365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e746564',
'000000d600000004000000000000000300010034363730313131333331310001003436373039373731333337004000000000000001009f05000301030220696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f66',
'000000a80000000400000000000000040001003436373031313133333131000100343637303937373133333700400000000000000100710500030103032061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e20',
'000000d600000004000000000000000200010034363730313131333331310001003436373039373731333337004000000000000000009f0500030103015365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e746564',
'000000d600000004000000000000000300010034363730313131333331310001003436373039373731333337004000000000000000009f05000301030220696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f66',
'000000a80000000400000000000000040001003436373031313133333131000100343637303937373133333700400000000000000000710500030103032061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e20',
];
type BoundPeer = {
+33 -1
View File
@@ -489,6 +489,38 @@ describe('an SMSC that reports each segment under an id of its own', () => {
assert.equal(sent.err, undefined);
assert.equal(smsc.octets.length, 3, 'every segment goes out whatever the peer answers');
assert.deepEqual(sent.smsIds, [id, '', '']);
assert.deepEqual(sent.smsIds, [id, undefined, undefined]);
assert.equal(sent.smsIds.length, sent.pduObjs.length, 'an entry per accepted segment, positional with pduObjs');
});
test('reports the segment that was named and merges nothing where the others were not', async t => {
const base = '01a09739-0a98-7ac0-8a9a-fcd3a7648e62';
const smsc = await dummySmsc(t, { messageIds: [`${base}-1`] });
const session = await bindToSmsc(t, smsc.port);
const merged: MessageDlr[] = [];
const reported = collect<Dlr>(1, push => { session.on('dlr', push); });
session.on('messageDlr', report => merged.push(report));
const sent = await session.sendSms({ dlr: true, message: 'x'.repeat(400), ...message });
assert.deepEqual(sent.smsIds, [`${base}-1`, undefined, undefined]);
smsc.deliver(receiptBody(`${base}-1`));
const [dlr] = await reported;
assert.equal(dlr?.smsId, `${base}-1`);
assert.deepEqual(merged, [], 'a send with an unnamed segment spells out no message to merge');
});
});
describe('an SMSC that answers a submit with no message id at all', () => {
test('reads an empty message_id as no id, on a single-segment send as much as a split one', async t => {
const smsc = await dummySmsc(t, { messageIds: [] });
const session = await bindToSmsc(t, smsc.port);
const sent = await session.sendSms({ dlr: true, message: 'one segment', ...message });
assert.equal(sent.err, undefined);
assert.deepEqual(sent.smsIds, [undefined]);
});
});
+3 -2
View File
@@ -130,7 +130,8 @@ describe('parsing real PDUs', () => {
});
describe('encoding submit_sm', () => {
test('produces the same bytes as 0.4.0 for a GSM message', () => {
// 0.4.0 declared IA5 for the GSM octets it wrote; that one field is the whole difference.
test('produces the 0.4.0 bytes for a GSM message, under the alphabet those octets are in', () => {
const pdu = encode({
cmdName: 'submit_sm',
cmdStatus: 'ESME_ROK',
@@ -144,7 +145,7 @@ describe('encoding submit_sm', () => {
assert.equal(
pdu.toString('hex'),
'0000004200000004000000000000000c00000034363730313131333331310000003436373039373731333337000000000000000001000b48656c6c6f20776f726c64',
'0000004200000004000000000000000c00000034363730313131333331310000003436373039373731333337000000000000000000000b48656c6c6f20776f726c64',
);
});
+1
View File
@@ -2872,6 +2872,7 @@ describe('message id notation', () => {
assert.deepEqual(sent.smsIds, [1, 2].map(part => `${sms.smsId}-${String(part)}`));
for (const smsId of sent.smsIds) {
assert.ok(smsId);
await sendReceipt(peerOf(smpp), smsId);
}
+8 -1
View File
@@ -2498,11 +2498,18 @@ describe('merged delivery report bounds', () => {
test('arms nothing for a send whose ids do not number one message', () => {
const dlrMerger = merger();
dlrMerger.expect(['5cb0ea53b5d61093529174ca44e23871', '', '']);
dlrMerger.expect(['5cb0ea53b5d61093529174ca44e23871', undefined, undefined]);
assert.equal(dlrMerger.size, 0, 'an id the peer never named numbers nothing');
dlrMerger.expect(['bf53ad8b-1', '40ccdce2-2']);
assert.equal(dlrMerger.size, 0, 'nor do ids numbered off a base each');
const holed = '01a09739-0a9a-767d-ad1d-1c27667e1ca4';
// Dropping the unnamed entry rather than passing it through leaves 1 and 3 spelling out a
// whole 2-part message, and a send of 3 reports as delivered on two receipts.
dlrMerger.expect([`${holed}-1`, undefined, `${holed}-3`]);
assert.equal(dlrMerger.size, 0, 'nor does a hole between two parts of one base');
});
// A receipt for whole-3 would otherwise fill the slot whole-2 was registered for, truncating the report.
+432
View File
@@ -0,0 +1,432 @@
import assert from 'node:assert/strict';
import test, { describe } from 'node:test';
import type { PduObjectInput } from '../src/pdu.ts';
import type { SendSmsDeps, SendSmsInput } from '../src/send-sms.ts';
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
import { decodeMessage } from '../src/message.ts';
import { messageOctets } from '../src/message-body.ts';
import { objToPdu, pduToObj } from '../src/pdu.ts';
import { paramNumber, paramText } from '../src/defs/types.ts';
import { silentLog } from '../src/log.ts';
import { submitSms } from '../src/send-sms.ts';
const from = '46701113311';
const to = '46709771337';
/** A send that records what it is handed, so a refusal shows up as an empty attempt log. */
function recordingDeps(attempts: PduObjectInput[]): SendSmsDeps {
return {
log: silentLog,
reference: 1,
send: input => {
attempts.push(input);
return Promise.resolve({ err: new Error('the recording peer never answers') });
},
};
}
/** The alphabet each submit_sm declared, beside the text those octets read back as. */
function sentAs(octets: Buffer[]): [number, string][] {
return octets.map(pdu => {
const { pduObj } = pduToObj(pdu);
assert.ok(pduObj);
const dataCoding = paramNumber(pduObj.params.data_coding, 0);
return [dataCoding, decodeMessage(pduObj.shortMessageOctets ?? Buffer.alloc(0), dataCoding).message];
});
}
describe('an alphabet the caller named that cannot carry the message', () => {
// Latin-1 takes the low octet of every code point, so あ (U+3042) went out as 0x42 — the letter B.
test('refuses a Latin-1 send of a character above 0xFF, naming the character', async () => {
const attempts: PduObjectInput[] = [];
const sent = await submitSms(recordingDeps(attempts), { encoding: 'LATIN1', from, message: 'あいう', to });
assert.ok(sent.err instanceof Error);
assert.match(sent.err.message, /LATIN1/);
assert.match(sent.err.message, /"あ"/);
assert.match(sent.err.message, /U\+3042/);
assert.deepEqual(sent.pduObjs, []);
assert.deepEqual(sent.smsIds, []);
assert.equal(sent.unanswered, 0);
assert.equal(attempts.length, 0, 'a message the named alphabet cannot carry puts nothing on the wire');
});
// Å is in the GSM table at 0x0E; ï is the one the encoder flattened to a space.
test('refuses an ASCII send of a character GSM 03.38 has no code for, naming that one', async () => {
const attempts: PduObjectInput[] = [];
const sent = await submitSms(recordingDeps(attempts), { encoding: 'ASCII', from, message: 'Åsa naïve', to });
assert.ok(sent.err instanceof Error);
assert.match(sent.err.message, /ASCII/);
assert.match(sent.err.message, /"ï"/);
assert.match(sent.err.message, /U\+00EF/);
assert.match(sent.err.message, /index 6/);
assert.equal(attempts.length, 0);
});
test('never refuses UCS2, which carries every character there is', async () => {
const attempts: PduObjectInput[] = [];
const deps = recordingDeps(attempts);
for (const message of ['あいう', 'Åsa naïve', '😀 beyond the basic plane', '�']) {
const sent = await submitSms(deps, { encoding: 'UCS2', from, message, to });
assert.equal(sent.err?.message, 'the recording peer never answers', message);
}
assert.equal(attempts.length, 4);
});
test('sends 8-bit binary named as Latin-1 unchanged, the case that codec is named for', async t => {
const messageId = '01a086cc-5ee9-759d-ba4c-96a9afd9aed9';
const smsc = await dummySmsc(t, { messageIds: [messageId] });
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
const payload = Buffer.from(Array.from({ length: 140 }, (_, at) => (at * 7 + 3) % 256));
const sent = await session.sendSms({ encoding: 'LATIN1', from, message: payload.toString('latin1'), to });
const first = smsc.octets[0];
assert.equal(sent.err, undefined);
assert.deepEqual(sent.smsIds, [messageId]);
assert.equal(smsc.octets.length, 1);
assert.ok(first);
const { pduObj } = pduToObj(first);
assert.ok(pduObj);
assert.equal(paramNumber(pduObj.params.data_coding, 0), 0x03);
assert.deepEqual(pduObj.shortMessageOctets, payload);
});
test('picks an alphabet that fits where the caller named none, so nothing is ever refused for one', async t => {
const smsc = await dummySmsc(t);
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
for (const message of ['Hello world', 'Åsa naïve', 'あいう']) {
assert.equal((await session.sendSms({ from, message, to })).err, undefined, message);
}
assert.deepEqual(sentAs(smsc.octets), [[0x00, 'Hello world'], [0x08, 'Åsa naïve'], [0x08, 'あいう']]);
});
});
describe('a body the PDU\'s own data_coding cannot carry', () => {
/** The octets a built PDU carries as its body, wherever the caller put them. */
function bodyOf(built: ReturnType<typeof objToPdu>): Buffer | undefined {
assert.equal(built.err, undefined);
assert.ok(built.buffer);
const { pduObj } = pduToObj(built.buffer);
assert.ok(pduObj);
return messageOctets(pduObj);
}
// Latin-1 takes the low octet of every code point, so あ (U+3042) went out as 0x42 — the letter B.
test('refuses a string short_message the named alphabet cannot carry, naming the character', () => {
const built = objToPdu({
cmdName: 'submit_sm',
params: { data_coding: 0x03, destination_addr: to, short_message: 'あいう', source_addr: from },
});
assert.ok(built.err instanceof Error);
assert.equal(built.buffer, undefined);
assert.match(built.err.message, /short_message/);
assert.match(built.err.message, /LATIN1/);
assert.match(built.err.message, /"あ"/);
assert.match(built.err.message, /U\+3042/);
assert.match(built.err.message, /index 0/);
});
// Å is in the GSM table at 0x0E; ï is the one the encoder flattened to a space.
test('refuses a string short_message GSM 03.38 has no code for, naming that one', () => {
for (const dataCoding of [0x00, 0x01]) {
const built = objToPdu({
cmdName: 'submit_sm',
params: { data_coding: dataCoding, destination_addr: to, short_message: 'Åsa naïve', source_addr: from },
});
assert.ok(built.err instanceof Error, String(dataCoding));
assert.match(built.err.message, /ASCII/);
assert.match(built.err.message, /"ï"/);
assert.match(built.err.message, /U\+00EF/);
assert.match(built.err.message, /index 6/);
}
});
test('refuses a string message_payload on the same terms as short_message', () => {
const built = objToPdu({
cmdName: 'data_sm',
params: { data_coding: 0x03, destination_addr: to, source_addr: from },
tlvs: { message_payload: { tagValue: 'あいう' } },
});
assert.ok(built.err instanceof Error);
assert.equal(built.buffer, undefined);
assert.match(built.err.message, /message_payload/);
assert.match(built.err.message, /LATIN1/);
assert.match(built.err.message, /"あ"/);
assert.match(built.err.message, /U\+3042/);
});
test('refuses the body TLV under whatever name the caller keyed its tagId to', () => {
const built = objToPdu({
cmdName: 'data_sm',
params: { data_coding: 0x03, destination_addr: to, source_addr: from },
tlvs: { body: { tagId: 0x0424, tagValue: 'あいう' } },
});
assert.ok(built.err instanceof Error);
assert.equal(built.buffer, undefined);
assert.match(built.err.message, /"body"/);
assert.match(built.err.message, /LATIN1/);
assert.match(built.err.message, /U\+3042/);
});
test('leaves data_coding to short_message wherever it carries octets, as messageOctets() reads it', () => {
const short = Buffer.from([0xDE, 0xAD]);
const built = objToPdu({
cmdName: 'deliver_sm',
params: { destination_addr: to, short_message: short, source_addr: from },
tlvs: { message_payload: { tagValue: 'あいう' } },
});
assert.equal(built.err, undefined);
assert.ok(built.buffer);
const { pduObj } = pduToObj(built.buffer);
assert.ok(pduObj);
assert.equal(pduObj.params.data_coding, 0, 'the TLV must not name an alphabet for octets nothing reads it as');
assert.deepEqual(messageOctets(pduObj), short);
});
test('encodes every entry carrying the body tag, so a second one cannot go out truncated', () => {
const built = objToPdu({
cmdName: 'data_sm',
params: { data_coding: 0x08, destination_addr: to, source_addr: from },
tlvs: { alias: { tagId: 0x0424, tagValue: 'あいう' }, message_payload: { tagValue: 'あいう' } },
});
assert.equal(built.err, undefined);
assert.ok(built.buffer);
const hex = built.buffer.toString('hex');
assert.equal(hex.split('304230443046').length - 1, 2, 'both entries carry the UCS2 octets');
assert.ok(!hex.includes('424446'), 'no entry goes out as the low octets of its code points');
});
test('leaves the alphabet to the body TLV wherever short_message carries no octets', () => {
for (const short of [undefined, '', Buffer.alloc(0)]) {
const built = objToPdu({
cmdName: 'deliver_sm',
params: {
destination_addr: to,
source_addr: from,
...(short === undefined ? {} : { short_message: short }),
},
tlvs: { message_payload: { tagValue: 'あいう' } },
});
const name = short === undefined ? 'absent' : JSON.stringify(short);
assert.equal(built.err, undefined, name);
assert.ok(built.buffer);
const { pduObj } = pduToObj(built.buffer);
assert.ok(pduObj);
assert.equal(pduObj.params.data_coding, 0x08, name);
assert.equal(messageOctets(pduObj)?.toString('hex'), '304230443046', name);
}
});
test('ignores a short_message on a command that declares none, which the wire never carries', () => {
for (const short of ['x', Buffer.from([0xDE, 0xAD])]) {
const input: PduObjectInput = {
cmdName: 'data_sm',
params: { destination_addr: to, short_message: short, source_addr: from },
tlvs: { message_payload: { tagValue: 'あいう' } },
};
const built = objToPdu(input);
assert.equal(built.err, undefined);
assert.ok(built.buffer);
const { pduObj } = pduToObj(built.buffer);
assert.ok(pduObj);
assert.equal(pduObj.params.data_coding, 0x08, JSON.stringify(short));
assert.equal(messageOctets(pduObj)?.toString('hex'), '304230443046');
}
});
test('leaves data_coding at its default where an empty short_message is the whole body', () => {
for (const short of ['', Buffer.alloc(0)]) {
const built = objToPdu({
cmdName: 'submit_sm',
params: { destination_addr: to, short_message: short, source_addr: from },
});
assert.equal(built.err, undefined);
assert.ok(built.buffer);
const { pduObj } = pduToObj(built.buffer);
assert.ok(pduObj);
assert.equal(pduObj.params.data_coding, 0, JSON.stringify(short));
}
});
test('never refuses a Buffer body, whatever the coding, because that is how binary is sent', () => {
const payload = Buffer.from([0x30, 0x42, 0xC3, 0x28, 0xFF, 0x00]);
for (const dataCoding of [0x00, 0x01, 0x03, 0x04, 0x08, 0xF0]) {
const params = { data_coding: dataCoding, destination_addr: to, source_addr: from };
const short = objToPdu({ cmdName: 'submit_sm', params: { ...params, short_message: payload } });
const carried = objToPdu({
cmdName: 'data_sm',
params,
tlvs: { message_payload: { tagValue: payload } },
});
assert.deepEqual(bodyOf(short), payload, `short_message under data_coding ${String(dataCoding)}`);
assert.deepEqual(bodyOf(carried), payload, `message_payload under data_coding ${String(dataCoding)}`);
}
});
test('never refuses a string the caller named no data_coding for, since detection always fits', () => {
for (const message of ['Hello world', 'Åsa naïve', 'あいう', '😀 beyond the basic plane']) {
const built = objToPdu({
cmdName: 'submit_sm',
params: { destination_addr: to, short_message: message, source_addr: from },
});
assert.equal(built.err, undefined, message);
}
});
test('writes a body the coding does carry as the octets that alphabet spells it in', () => {
const bodies: [number, string, string][] = [
[0x00, 'Hello world', '48656c6c6f20776f726c64'],
[0x03, 'Räksmörgås', '52e46b736df67267e573'],
[0x08, 'あいう', '304230443046'],
];
for (const [dataCoding, message, hex] of bodies) {
const params = { data_coding: dataCoding, destination_addr: to, source_addr: from };
const short = objToPdu({ cmdName: 'submit_sm', params: { ...params, short_message: message } });
const carried = objToPdu({
cmdName: 'data_sm',
params,
tlvs: { message_payload: { tagValue: message } },
});
assert.equal(bodyOf(short)?.toString('hex'), hex, `short_message: ${message}`);
assert.equal(bodyOf(carried)?.toString('hex'), hex, `message_payload: ${message}`);
}
});
test('refuses the same body through session.send(), with nothing reaching the socket', async t => {
const smsc = await dummySmsc(t);
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
const sent = await session.send({
cmdName: 'submit_sm',
params: { data_coding: 0x03, destination_addr: to, short_message: 'あいう', source_addr: from },
});
assert.ok(sent.err instanceof Error);
assert.match(sent.err.message, /LATIN1/);
assert.deepEqual(smsc.octets, [], 'a body the named alphabet cannot carry never reaches the socket');
});
});
describe('a time no peer can read', () => {
const invalid = new Date('nope');
test('refuses an invalid Date rather than writing NaN into the PDU, naming the option', async () => {
const attempts: PduObjectInput[] = [];
const deps = recordingDeps(attempts);
const sends: [string, SendSmsInput][] = [
['scheduleDeliveryTime', { from, message: 'Hello world', scheduleDeliveryTime: invalid, to }],
['validityPeriod', { from, message: 'Hello world', to, validityPeriod: invalid }],
];
for (const [option, send] of sends) {
const sent = await submitSms(deps, send);
assert.ok(sent.err instanceof Error, option);
assert.match(sent.err.message, new RegExp(option));
assert.match(sent.err.message, /invalid Date/);
assert.deepEqual(sent.smsIds, []);
}
assert.equal(attempts.length, 0, 'a time nobody can read puts nothing on the wire');
});
test('refuses a relative period naming no number of seconds', async () => {
const attempts: PduObjectInput[] = [];
const deps = recordingDeps(attempts);
for (const validityPeriod of [NaN, Infinity, -Infinity]) {
const sent = await submitSms(deps, { from, message: 'Hello world', to, validityPeriod });
assert.ok(sent.err instanceof Error, String(validityPeriod));
assert.match(sent.err.message, /validityPeriod/);
}
assert.equal(attempts.length, 0);
});
test('refuses a year-long relative period rather than sending 99 days and reporting a year', async () => {
const attempts: PduObjectInput[] = [];
const sent = await submitSms(recordingDeps(attempts), { from, message: 'Hello world', to, validityPeriod: 86400 * 365 });
assert.ok(sent.err instanceof Error);
assert.match(sent.err.message, /validityPeriod/);
assert.match(sent.err.message, /Date/);
assert.deepEqual(sent.smsIds, []);
assert.equal(attempts.length, 0, 'a period the relative format cannot hold puts nothing on the wire');
});
test('refuses a time that is no kind of time rather than throwing out of the send', async () => {
const attempts: PduObjectInput[] = [];
const deps = recordingDeps(attempts);
for (const validityPeriod of [null, {}, true, []]) {
const sent = await submitSms(deps, { from, message: 'Hello world', to, validityPeriod });
assert.ok(sent.err instanceof Error, JSON.stringify(validityPeriod));
assert.match(sent.err.message, /validityPeriod must be a Date/);
}
assert.equal(attempts.length, 0);
});
test('writes the times it can express into every segment it built them for', async t => {
const smsc = await dummySmsc(t);
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
const sent = await session.sendSms({
from,
message: 'A message long enough to need a header numbering its segments. '.repeat(4),
scheduleDeliveryTime: new Date(Date.UTC(2026, 8, 9, 14, 30, 0)),
to,
validityPeriod: 3600,
});
assert.equal(sent.err, undefined);
assert.equal(smsc.octets.length, 2, 'the fixture must need more than one segment');
for (const octets of smsc.octets) {
const { pduObj } = pduToObj(octets);
assert.ok(pduObj);
assert.equal(paramText(pduObj.params.schedule_delivery_time), '260909143000000+');
assert.equal(paramText(pduObj.params.validity_period), '000000010000000R');
}
});
});
+116 -90
View File
@@ -1,16 +1,15 @@
# todo.md
Remaining work for the `@larvit/smpp` 1.0.0 rewrite. Read [AGENTS.md](AGENTS.md) first — the goals
and hard rules there constrain every item below.
Remaining work for `@larvit/smpp`. Read [AGENTS.md](AGENTS.md) first — the goals and hard rules
there constrain every item below.
This is a temporary working file: it is deleted when 1.0.0 ships, and until then it sets its own
rules. The documentation conventions in AGENTS.md do not govern it, and nothing here is a source
anything else may cite.
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.
## Status
The rewrite is **feature complete and green**: the suite, lint and typecheck are clean on Node 18,
20, 22 and 24. What is left is release work and a few things worth adding before or after 1.0.0.
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.
## The agreed API
@@ -68,112 +67,139 @@ Rules the API follows:
| A transient state sent under the marker the spec gives it, off the same list the reader uses | `test/session-extras.test.ts` |
| A listener that throws, or rejects, reaching `sessionError`/`serverError` rather than the process | `test/session.test.ts`, `test/error-from.test.ts` |
| Cross-checked against node-smpp both ways and over a live session | `test/interop.test.ts` |
| CI on Node 18/20/22/24, Renovate, tag-triggered publish | `.github/workflows/` |
| CI on Node 18 to 26, Renovate, tag-triggered publish | `.gitea/workflows/` |
Every defect listed in the AGENTS.md table has a regression test naming the behaviour.
## The GitHub backlog, once this branch is `main`
## Move the repository to Gitea
Nothing below is closed while the default branch is still 0.4.0 — declining a security bump on a live
default branch is worse than leaving it open. Work through this immediately after the rename.
`gitea.larvit.se/larvit/smpp-js` is the repository. `github.com/larvit/smpp-js` becomes a push mirror
of it and the place issues are filed. Maintainer's calls, 2026-09-13 and 2026-09-14.
**Close as fixed by 1.0.0**, naming the replacement in the comment:
- [x] `larvit/smpp-js` holds `main`, from `typescript`, and `v0.4.0`, from `master`. `rewrite-base`
and the `renovate/*` branches stayed behind.
- [x] Fast-forward is the only merge style. `main` takes no pushes, requires `Test / lint
(pull_request)` and `Test / test (*) (pull_request)`, blocks an outdated branch, and gives
admins no override. Every other branch takes force pushes.
- [x] The workflows are in `.gitea/workflows/`. Tests run on pull requests only, the event the gate
reads; Renovate runs as a scheduled workflow, as on adf-codec.
- [x] The release publishes without provenance, which npm generates only on GitHub Actions and
GitLab CI/CD.
- [x] `package.json` names Gitea, and the GitHub mirror's issues as `bugs`. The README links
absolutely: npmjs.com resolves a relative link against itself when the `repository` is not on
GitHub. Its test badge is gone, since Gitea reports a workflow's status per branch and no
workflow runs on `main`.
- `RENOVATE_GITHUB_TOKEN` exists nowhere, so Renovate queries github.com unauthenticated, as
adf-codec's nightly run already does without a warning. `RENOVATE_TOKEN` is the Gitea token and
cannot stand in for it. Add a github.com token only if lookups hit the rate limit.
| | Fixed by |
| --- | --- |
| [#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 |
| [#33](https://github.com/larvit/larvitsmpp/issues/33) Large inbound text arrives as raw `Buffer` segments | `IncomingRequests` reassembles a UDH-carrying `deliver_sm` into one `sms` event |
| [#3](https://github.com/larvit/larvitsmpp/issues/3) Tests for flash messages | `test/session.test.ts` |
| [#20](https://github.com/larvit/larvitsmpp/issues/20) Tests fail on current dependency versions | The mocha suite is gone; `node:test` on Node 18/20/22/24 |
| [#2](https://github.com/larvit/larvitsmpp/issues/2) Tests for the README examples | `test/readme.test.ts` |
| [#17](https://github.com/larvit/larvitsmpp/issues/17) `addr_ton`/`addr_npi` should be settable | `sendSms()` takes all four, documented and tested |
| [#16](https://github.com/larvit/larvitsmpp/issues/16) Support all three bind types | Bound and enforced in both directions |
| [#13](https://github.com/larvit/larvitsmpp/issues/13) Limit a long SMS to fewer segments | The `maxSegments` send option |
| [#68](https://github.com/larvit/larvitsmpp/pull/68) `message_id` in `submit_sm_resp`, spec DLR codes | All four hold: `sendResp()` always answers a `message_id`, per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the fork found real defects. |
## Before publishing 0.5.0
- [x] 0.5.0 rather than 1.0.0, while usage is this low. Maintainer's call, 2026-09-14.
- [x] `NPM_TOKEN`, which `.gitea/workflows/release.yaml` needs, is a Gitea organization secret.
- [ ] Tag `v0.5.0` on Gitea to publish. The first publish creates `@larvit/smpp` on npm, provided the
token can publish under `@larvit`.
- [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not
something CI should do.
## Retire the GitHub repository
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
`master` without a reply, and GitHub refuses to delete the default branch it syncs over.
- [ ] Close the backlog below.
- [ ] 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`
URL in `package.json` resolves from then on.
- [ ] Push `main` and make it GitHub's default branch.
- [ ] Remove Renovate and CodeRabbit from the GitHub repository.
- [ ] Add it to Gitea as a push mirror, with a GitHub credential that can write to it.
## Close the GitHub backlog
**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:
`test/readme.test.ts`.
- [ ] [#3](https://github.com/larvit/larvitsmpp/issues/3) Tests for flash messages:
`test/session.test.ts`.
- [ ] [#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.
- [ ] [#13](https://github.com/larvit/larvitsmpp/issues/13) Limit a long SMS to fewer segments: the
`maxSegments` send option.
- [ ] [#16](https://github.com/larvit/larvitsmpp/issues/16) Support all three bind types: bound and
enforced in both directions.
- [ ] [#17](https://github.com/larvit/larvitsmpp/issues/17) `addr_ton`/`addr_npi` should be
settable: `sendSms()` takes all four, documented and tested.
- [ ] [#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.
- [ ] [#33](https://github.com/larvit/larvitsmpp/issues/33) Large inbound text arrives as raw
`Buffer` segments: `IncomingRequests` reassembles a UDH-carrying `deliver_sm` into one `sms`
event.
- [ ] [#68](https://github.com/larvit/larvitsmpp/pull/68), a pull request: `message_id` in
`submit_sm_resp`, spec DLR codes. All four hold: `sendResp()` always answers a `message_id`,
per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the fork found real
defects.
**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`:
[#40](https://github.com/larvit/larvitsmpp/pull/40), [#41](https://github.com/larvit/larvitsmpp/pull/41),
[#42](https://github.com/larvit/larvitsmpp/pull/42), [#45](https://github.com/larvit/larvitsmpp/pull/45),
[#46](https://github.com/larvit/larvitsmpp/pull/46), [#47](https://github.com/larvit/larvitsmpp/pull/47),
[#59](https://github.com/larvit/larvitsmpp/pull/59), [#63](https://github.com/larvit/larvitsmpp/pull/63),
[#64](https://github.com/larvit/larvitsmpp/pull/64), [#65](https://github.com/larvit/larvitsmpp/pull/65),
[#67](https://github.com/larvit/larvitsmpp/pull/67), [#70](https://github.com/larvit/larvitsmpp/pull/70).
[#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.
[#60](https://github.com/larvit/larvitsmpp/issues/60) is Renovate's dashboard — leave it, it
re-baselines itself against the new `package.json`.
- [ ] [#40](https://github.com/larvit/larvitsmpp/pull/40),
[#41](https://github.com/larvit/larvitsmpp/pull/41),
[#42](https://github.com/larvit/larvitsmpp/pull/42),
[#45](https://github.com/larvit/larvitsmpp/pull/45),
[#46](https://github.com/larvit/larvitsmpp/pull/46),
[#47](https://github.com/larvit/larvitsmpp/pull/47),
[#59](https://github.com/larvit/larvitsmpp/pull/59),
[#63](https://github.com/larvit/larvitsmpp/pull/63),
[#64](https://github.com/larvit/larvitsmpp/pull/64),
[#67](https://github.com/larvit/larvitsmpp/pull/67),
[#70](https://github.com/larvit/larvitsmpp/pull/70),
[#77](https://github.com/larvit/larvitsmpp/pull/77).
[#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.
[#60](https://github.com/larvit/larvitsmpp/issues/60) is Renovate's dashboard — close it with
Renovate's removal from GitHub.
**Leave open:** [#8](https://github.com/larvit/larvitsmpp/issues/8), the socket's remote host and
port on log messages. Only `server - incoming connection` carries them today; putting them on every
session message is a change to every call site.
## Before publishing 1.0.0
- [ ] Create the `@larvit/smpp` package on npm and add `NPM_TOKEN` to the repository secrets, which
`.github/workflows/release.yaml` needs.
- [ ] Decide whether `smsIds` should be `(string | undefined)[]`. An SMSC that names an id for the
first segment of a concatenated message only — Telesign documents exactly that — leaves the
rest empty, so `string[]` promises what the value does not keep: taking the first id, or
building a map from the array, compiles and then misbehaves, and an empty string collides with
another message's in a correlation table. The cost is that every consumer narrows, including
the majority whose SMSC always names an id. Raised by the phase 11 product review, 2026-09-08.
**This one is 1.0.0-or-never** — after release it needs a major version.
- [ ] **The interop suite still asserts the defect [#95](https://github.com/larvit/larvitsmpp/pull/95)
fixed.** `interop-tests/smppsim.test.ts:621`, `a raw submit_sm with data_coding 0xF0 is not
read as flash`, fails on the next `./interop-tests/run.py smppsim`; nothing in CI runs that
suite, so it fails silently until someone does. The test name and its assertion both need
inverting, and the C17 row in `findings/02-smppsim.md` names that sub-test, so it follows.
- [ ] Tag `v1.0.0` to publish.
- [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not
something CI should do.
- [ ] **Rename the branches, once everything above is done.** Maintainer's call, 2026-09-04:
`master` becomes `v0.4.0`, `typescript` becomes `main`, and `main` is the repository's default
branch. Renaming rather than merging is what the orphan commit leaves available — the two
histories share no ancestor, so a merge refuses them outright.
- [ ] Rename `master` to `v0.4.0`.
- [ ] Rename `typescript` to `main`, and make it the default branch.
- [ ] Retarget what still points at an old name: [#71](https://github.com/larvit/larvitsmpp/pull/71),
and the open PRs based on `master`, which a rename carries over rather than closes.
- [ ] Delete `rewrite-base` once [#71](https://github.com/larvit/larvitsmpp/pull/71) is
resolved; it exists only to give that PR a reviewable diff.
## Worth doing, not blocking
- [ ] **A named alphabet that cannot hold the message corrupts it instead of refusing it.**
`encodeMessage('あいう', 'LATIN1')` returns `42 44 46` — `"BDF"` — and `sendSms()` puts that on
the wire: `checkOptions()` refuses an unknown name, `FLASH` and flash-beside-Latin-1, but never
asks whether the alphabet the caller named can carry the text. GSM 7-bit does the same, mapping
anything outside 03.38 to a space. `Encoding.match()` cannot be the guard — `latin1.match()` is
hardcoded `false` because it doubles as the auto-selection policy `detect()` reads, so "can hold
this" and "should be picked for this" would have to be separated first. Send-side, so the tag
is not blocked on it. Raised by the architecture review of
[#96](https://github.com/larvit/larvitsmpp/pull/96), 2026-09-09.
- [ ] **A send the codec will refuse waits for a link and a window slot first.** `refuse()` in
`outgoing-requests.ts` runs `misuse()` and the abort check before the wait, precisely so a call
that can never go out does not queue for what it will never use; a body `objToPdu()` refuses on
every attempt is the same case, and #98 made it a common one. On a down link the caller waits
`responseTimeout` and is told the link failed rather than that the body could not be built —
goal 2's wrong answer about what happened. The cheap fix builds the PDU twice, so the shape is
the open half. Raised by the architecture review of
[#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09.
- [ ] **A time nobody can read goes out as `NaN` rather than being refused.**
`smppTime.encode(new Date('nope'))` returns `NaNNaNNaNNaNNaNNaNNaN00+` and
`smppTime.encode(NaN)` returns `0000NaNNaNNaNNaN000R`; `sendSms({ validityPeriod })` and
`scheduleDeliveryTime` write either straight into the PDU with no check at
`submitSmParams()`. Goal 2, since the peer reads a field that means nothing. `Date` is not the
closed domain hard rule 1's totality clause covers, so this one wants a guard at the send
boundary rather than a `Result` on `smppTime`. Raised by the stability review of
[#96](https://github.com/larvit/larvitsmpp/pull/96), 2026-09-09.
- [ ] **`message.ts` answers two questions.** Message coding and the SMPP time format (`smppDate`,
`smppTime`) share the file, which the architecture map in AGENTS.md already spells out as four
concerns. Nothing is wrong today; if the file has to move for another reason, `smpp-time.ts` is
the split. Raised by the architecture review of
[#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09.
- [ ] **A gate that refuses a floating version anywhere in the repo.** Maintainer's ask on
[#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the `release.yaml`
pinning thread, which stays open until this lands. Pinning every action and runner by hand is
what the ask followed; the gate is what keeps them pinned. It has to cover workflow `uses:`
and `runs-on:`, compose `image:`, and Dockerfile `FROM`, and the conventions differ per kind —
actions take a semver tag, images the full patch version — so one grep for `latest` is not it.
pinning thread. Pinning every action and runner by hand is what the ask followed; the gate is
what keeps them pinned. It has to cover workflow `uses:` and `runs-on:`, compose `image:`, and
Dockerfile `FROM`, and the conventions differ per kind — actions take a semver tag, images the
full patch version — so one grep for `latest` is not it.
- [ ] **A gate that fails when the test matrix misses the current Node.** Maintainer's ask on
[#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the Node 26 thread, which
stays open until this lands. Node 26 was added by hand; nothing notices when 27 ships. Needs a
source for what Current is — the Node release schedule is published as JSON — and a decision
on whether a new Current fails the build or opens a PR, which is what Renovate already does
for everything else here.
[#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the Node 26 thread. Node
26 was added by hand; nothing notices when 27 ships. Needs a source for what Current is — the
Node release schedule is published as JSON — and a decision on whether a new Current fails the
build or opens a PR, which is what Renovate already does for everything else here.
- [ ] **CodeRabbit reviews through the GitHub mirror.** CodeRabbit does not support Gitea, so mirror
each Gitea pull request to GitHub for it to review there. Maintainer's ask, 2026-09-14; not
started until asked.
- [ ] **Group the session's collaborators under `src/session/`.** `session.ts` imports
`dlr-merger`, `incoming-requests`, `link-timers`, `outgoing-requests`, `pdu-transport`,
@@ -225,7 +251,7 @@ session message is a change to every call site.
already had answered, so the loss is real — but surviving it means handing the application the merge
state to persist, which the scope floor covers as squarely as holding the state here would, and
which publishes the shape of `DlrMerger`'s groups against goal 6. Nothing is foreclosed: the seam
can still be added after 1.0.0 as a minor.
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
goal 7: an operator's rate limit is scoped to the account, while the widest thing this library owns