Compare commits
8 Commits
190ddfffa1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c0b4bef34 | |||
| c5adfd8e39 | |||
| 14b114afda | |||
| 27e8f288ec | |||
| e1999a7305 | |||
| baa0ad553a | |||
| e32f9ccf46 | |||
| b3cbbba615 |
@@ -7,7 +7,6 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# No concurrency group: Gitea cancels a queued run when the next one in its group arrives, dropping the delete.
|
|
||||||
delete:
|
delete:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
push:
|
push:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
# Gitea 1.26 rolls back a run whose jobs share a group, so the delete job lives in mirror-delete.yaml.
|
# Gitea cancels a queued job in this group when the next one arrives; only the full push may join.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: mirror
|
group: mirror
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -32,16 +32,23 @@ one wins. They do not override the hard rules below.
|
|||||||
about a message the application sent reaches it as a report rather than as an inbound message, and
|
about a message the application sent reaches it as a report rather than as an inbound message, and
|
||||||
says whether it is final, so nothing has to read the PDU to tell those apart. An option retunes a
|
says whether it is final, so nothing has to read the PDU to tell those apart. An option retunes a
|
||||||
default or opts out of it; an option does not switch on the thing the caller obviously wanted.
|
default or opts out of it; an option does not switch on the thing the caller obviously wanted.
|
||||||
6. **A small, stable public surface over reshapeable internals.** Only what `src/index.ts` exports is
|
6. **Configurable and extendable, never at the defaults' expense.** Where an application needs other
|
||||||
|
than the default and cannot build it from what is exported — a rate limit counted per PDU, an
|
||||||
|
alphabet, a receipt format — it gets an option or a hook rather than a fork. A call that passes no
|
||||||
|
options stays exactly as easy and as safe, and a hook is a seam the library calls, never a way into
|
||||||
|
its internals.
|
||||||
|
7. **A small, stable public surface over reshapeable internals.** Only what `src/index.ts` exports is
|
||||||
published. A new option has to beat "the application can do this itself", and has to keep a
|
published. A new option has to beat "the application can do this itself", and has to keep a
|
||||||
promise this library can verify. The low-level surface is a passthrough: policy binds what the
|
promise this library can verify. The low-level surface is a passthrough: policy binds what the
|
||||||
library composes, never what the caller wrote.
|
library composes, never what the caller wrote.
|
||||||
7. **Nothing that needs state wider than one session.** No throughput throttling, no persistence
|
8. **State wider than one session goes through one store.** A pool of sessions, a limit shared
|
||||||
across a restart, no coordination between processes — and no seam handing the application state to
|
between processes, and what has to survive a restart — receipts still awaited, a message half
|
||||||
persist for one of those either, which commits to the same scope through the back door and
|
reassembled — are held through a store interface and never beside it. Without a store the
|
||||||
publishes an internal shape to do it. This is the scope floor, and it is why an otherwise
|
application supplies, that state is in memory and ends with the process, and the defaults need
|
||||||
reasonable feature is declined without a fresh argument each time.
|
none. The interface carries the library's own versioned records, never an internal shape handed
|
||||||
8. **It builds, tests and runs the same everywhere.** Container-only toolchain, no runtime
|
to the application to persist. Coordinating processes any other way is declined without a fresh
|
||||||
|
argument each time.
|
||||||
|
9. **It builds, tests and runs the same everywhere.** Container-only toolchain, no runtime
|
||||||
dependencies, the Node 18 floor verified in CI rather than asserted, every README example executed
|
dependencies, the Node 18 floor verified in CI rather than asserted, every README example executed
|
||||||
by the suite.
|
by the suite.
|
||||||
|
|
||||||
@@ -141,9 +148,9 @@ docker compose run --rm node npm run build
|
|||||||
TypeScript 7 once that constraint lifts.
|
TypeScript 7 once that constraint lifts.
|
||||||
- GitHub mirrors Gitea through `.gitea/workflows/mirror.yaml`, which never prunes, and
|
- GitHub mirrors Gitea through `.gitea/workflows/mirror.yaml`, which never prunes, and
|
||||||
`mirror-delete.yaml`, one run per deleted ref. A delete run that fails or outlives Gitea's queue
|
`mirror-delete.yaml`, one run per deleted ref. A delete run that fails or outlives Gitea's queue
|
||||||
timeout leaves the ref on GitHub until it is re-run. Accepted: a stale ref there is harmless, and
|
timeout, or a push run that cloned before the delete, leaves the ref on GitHub until the delete
|
||||||
refs only GitHub has must survive. Maintainer's call, 2026-09-14; valid while nothing deploys from
|
run is re-run. Accepted: a stale ref there is harmless, and refs only GitHub has must survive.
|
||||||
GitHub.
|
Maintainer's call, 2026-09-14; valid while nothing deploys from GitHub.
|
||||||
|
|
||||||
## Defects found in 0.4.0
|
## Defects found in 0.4.0
|
||||||
|
|
||||||
@@ -294,7 +301,7 @@ Grouped by what each one constrains.
|
|||||||
- **`PduRefusedError` is exported, and `sessionError` names it in the event's type.** Maintainer's
|
- **`PduRefusedError` is exported, and `sessionError` names it in the event's type.** Maintainer's
|
||||||
call, 2026-09-05, from a product review: one event carries both a PDU the peer malformed and the
|
call, 2026-09-05, from a product review: one event carries both a PDU the peer malformed and the
|
||||||
session's own failure, and `instanceof` is the only way to separate them that hard rule 4 allows —
|
session's own failure, and `instanceof` is the only way to separate them that hard rule 4 allows —
|
||||||
without the class as a value an application is left string-matching `err.message`. Goal 6 is paid by
|
without the class as a value an application is left string-matching `err.message`. Goal 7 is paid by
|
||||||
exporting the discriminant and the struct it carries and nothing else: `PduHeader` is named because
|
exporting the discriminant and the struct it carries and nothing else: `PduHeader` is named because
|
||||||
an application that logs or forwards a header wants a name for it, `PduRefusalReason` is not
|
an application that logs or forwards a header wants a name for it, `PduRefusalReason` is not
|
||||||
because `reason` is compared against string literals, and an accessor
|
because `reason` is compared against string literals, and an accessor
|
||||||
@@ -321,7 +328,7 @@ Grouped by what each one constrains.
|
|||||||
runtime: `Object.hasOwn(encodings, x)` is the only test the published surface offered and it
|
runtime: `Object.hasOwn(encodings, x)` is the only test the published surface offered and it
|
||||||
narrows nothing, so `isEncodingName()` is exported beside `isCommandName()` and `isErrorName()`,
|
narrows nothing, so `isEncodingName()` is exported beside `isCommandName()` and `isErrorName()`,
|
||||||
which serve their own tables that way. Rejected: a `Result` signature on all three, which costs
|
which serve their own tables that way. Rejected: a `Result` signature on all three, which costs
|
||||||
every typed consumer a narrow forever — goal 6, and the tag is the last cheap chance to spend it —
|
every typed consumer a narrow forever — goal 7, and the tag is the last cheap chance to spend it —
|
||||||
to guard a state the compiler refuses. Where the domain really is open the check is already there:
|
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
|
`sendSms()` takes its options as `unknown` and refuses `encoding` by name, which is what a caller
|
||||||
without types gets. `smppTime.encode()` is where that reasoning lands the other way and is recorded
|
without types gets. `smppTime.encode()` is where that reasoning lands the other way and is recorded
|
||||||
@@ -426,13 +433,13 @@ Grouped by what each one constrains.
|
|||||||
the suite ran took the 0x40 this library sends on a concatenated segment, but Route Mobile and
|
the suite ran took the 0x40 this library sends on a concatenated segment, but Route Mobile and
|
||||||
Kaleyra both document `esm_class` 0x43 for one, and a caller facing either had to hand-build every
|
Kaleyra both document `esm_class` 0x43 for one, and a caller facing either had to hand-build every
|
||||||
segment through `send()` — giving up the split, the per-segment ids, the send window and the
|
segment through `send()` — giving up the split, the per-segment ids, the send window and the
|
||||||
receipt merge, which is what goal 6 means by beating "the application can do this itself". The four
|
receipt merge, which is what goal 7 means by beating "the application can do this itself". The four
|
||||||
modes of SMPP 3.4 5.2.12 are a `MESSAGING_MODE` constant group and the option takes one of their
|
modes of SMPP 3.4 5.2.12 are a `MESSAGING_MODE` constant group and the option takes one of their
|
||||||
names, so 0x43 is a composition this library makes rather than a value a caller states, and the UDH
|
names, so 0x43 is a composition this library makes rather than a value a caller states, and the UDH
|
||||||
indicator a segment carrying a header needs cannot be cleared by anything the option can express.
|
indicator a segment carrying a header needs cannot be cleared by anything the option can express.
|
||||||
It takes three of those four: 2.10.3 carries transaction mode on `data_sm` alone, and none goes out
|
It takes three of those four: 2.10.3 carries transaction mode on `data_sm` alone, and none goes out
|
||||||
of here, so `FORWARD` stays in the group that mirrors the spec table and `sendSms()` refuses it by
|
of here, so `FORWARD` stays in the group that mirrors the spec table and `sendSms()` refuses it by
|
||||||
that reason rather than as an unknown name — a mode this library cannot deliver is a promise goal 6
|
that reason rather than as an unknown name — a mode this library cannot deliver is a promise goal 7
|
||||||
will not let it make. `DATAGRAM` with `dlr: true` is refused on the same footing: 2.10.2 defines the
|
will not let it make. `DATAGRAM` with `dlr: true` is refused on the same footing: 2.10.2 defines the
|
||||||
report away, so arming `DlrMerger` for one is goal 2's wrong answer, where the mode alone and a
|
report away, so arming `DlrMerger` for one is goal 2's wrong answer, where the mode alone and a
|
||||||
report under any other mode both go out untouched. Those three names left `ESM_CLASS`, where they
|
report under any other mode both go out untouched. Those three names left `ESM_CLASS`, where they
|
||||||
@@ -489,7 +496,7 @@ Grouped by what each one constrains.
|
|||||||
`encodingByDataCoding()` reads the alphabet off that same test rather than repeating the group
|
`encodingByDataCoding()` reads the alphabet off that same test rather than repeating the group
|
||||||
masks beside it. It is exported for the reason `concatOf()` is — an application that needs a class
|
masks beside it. It is exported for the reason `concatOf()` is — an application that needs a class
|
||||||
other than 0 would otherwise rewrite the read this fixed. Rejected: a `messageClass` field on the
|
other than 0 would otherwise rewrite the read this fixed. Rejected: a `messageClass` field on the
|
||||||
`sms` event, which pays goal 6 for three classes nothing here acts on, where the boolean the
|
`sms` event, which pays goal 7 for three classes nothing here acts on, where the boolean the
|
||||||
application already had covers the one it does. Compressed text is out of scope and stays out —
|
application already had covers the one it does. Compressed text is out of scope and stays out —
|
||||||
nothing here implements 3GPP TS 23.042, so a compressed body reaches the application as whatever
|
nothing here implements 3GPP TS 23.042, so a compressed body reaches the application as whatever
|
||||||
its declared alphabet makes of it — but bit 5 does not move the class bits, so 0x30 is read as
|
its declared alphabet makes of it — but bit 5 does not move the class bits, so 0x30 is read as
|
||||||
@@ -671,14 +678,14 @@ Grouped by what each one constrains.
|
|||||||
returned `42 44 46` — `"BDF"` — reported as built, while a string `message_payload` was cut to its
|
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.
|
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
|
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
|
under any `data_coding`, which is what keeps goal 7's escape hatch open — the raw UDH, 8-bit binary
|
||||||
and deliberately malformed bodies `interop-tests/` builds are all still buildable — and a string
|
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
|
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
|
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
|
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
|
`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
|
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
|
error prose as API for an application whose own refusal should read like itself. Goal 7, from the
|
||||||
architecture review of [#99](https://github.com/larvit/larvitsmpp/pull/99), 2026-09-09. It is
|
architecture review of [#99](https://github.com/larvit/larvitsmpp/pull/99), 2026-09-09. It is
|
||||||
reached through
|
reached through
|
||||||
`encodeBody()` in `message.ts`, which is where the `data_coding`-to-text pair already lives:
|
`encodeBody()` in `message.ts`, which is where the `data_coding`-to-text pair already lives:
|
||||||
@@ -833,7 +840,7 @@ Grouped by what each one constrains.
|
|||||||
dropped with the link — is traffic the peer will not send again, so each one reaches `sessionError`
|
dropped with the link — is traffic the peer will not send again, so each one reaches `sessionError`
|
||||||
as well as the log. Rejected there: an exported `MessageLostError` carrying the group, on the
|
as well as the log. Rejected there: an exported `MessageLostError` carrying the group, on the
|
||||||
`PduRefusedError` pattern — no `sms` ever fired for that group, so there is nothing in it the
|
`PduRefusedError` pattern — no `sms` ever fired for that group, so there is nothing in it the
|
||||||
application could act on, and goal 6 does not buy a second exported class to make a count
|
application could act on, and goal 7 does not buy a second exported class to make a count
|
||||||
distinguishable. Accepted: a completing segment whose own answer the socket would not carry still
|
distinguishable. Accepted: a completing segment whose own answer the socket would not carry still
|
||||||
reaches the application, because the message is whole and correct and the failed answer is on
|
reaches the application, because the message is whole and correct and the failed answer is on
|
||||||
`sessionError` — a peer that re-sends after the drop is the smaller risk than dropping a message
|
`sessionError` — a peer that re-sends after the drop is the smaller risk than dropping a message
|
||||||
@@ -845,7 +852,7 @@ Grouped by what each one constrains.
|
|||||||
of the multipart change: `server()` filled the session's only `onRequest` slot, so the escape hatch
|
of the multipart change: `server()` filled the session's only `onRequest` slot, so the escape hatch
|
||||||
the error above names was reachable only by hand-wiring a `Session` over a raw socket, giving up
|
the error above names was reachable only by hand-wiring a `Session` over a raw socket, giving up
|
||||||
bind acceptance, `authenticate`, the session set and the drain `close()` runs over it — which is
|
bind acceptance, `authenticate`, the session set and the drain `close()` runs over it — which is
|
||||||
what goal 6 means by beating "the application can do this itself". What the library verifies is the
|
what goal 7 means by beating "the application can do this itself". What the library verifies is the
|
||||||
ordering rather than the hook's honesty about answering: the hook is consulted only for a non-bind
|
ordering rather than the hook's honesty about answering: the hook is consulted only for a non-bind
|
||||||
request on a session already bound, so no bind — a second one on a live session included — and
|
request on a session already bound, so no bind — a second one on a live session included — and
|
||||||
nothing a peer sends before one can be intercepted however the hook is written. One
|
nothing a peer sends before one can be intercepted however the hook is written. One
|
||||||
@@ -1021,3 +1028,10 @@ Grouped by what each one constrains.
|
|||||||
their own. The four files that are not tests are the exception the rule needs stated:
|
their own. The four files that are not tests are the exception the rule needs stated:
|
||||||
`dummy-smsc.ts`, `raw-pdus.ts`, `reference-smpp.d.ts` and `teardown.ts` answer no question and are
|
`dummy-smsc.ts`, `raw-pdus.ts`, `reference-smpp.d.ts` and `teardown.ts` answer no question and are
|
||||||
named for what they hold.
|
named for what they hold.
|
||||||
|
|
||||||
|
- **CI tests on Linux only; `src/` keeps off what is known to break on macOS or Windows.** Maintainer's
|
||||||
|
call, 2026-09-14. Nothing verifies either platform, so the code avoids what is known to differ there:
|
||||||
|
shelling out, a path joined by hand, a signal Windows does not deliver, a Unix socket or a file mode.
|
||||||
|
That binds what `dist/` runs; the container tooling, `interop-tests/` and the `package.json` scripts
|
||||||
|
run on Linux by goal 9. Rejected: macOS and Windows runners, on GitHub's mirror or as Gitea
|
||||||
|
host-mode runners on a Windows VM and a Mac.
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ govern it, and nothing here is a source anything else may cite.
|
|||||||
## Status
|
## Status
|
||||||
|
|
||||||
The rewrite is **feature complete and green**: the suite, lint and typecheck are clean on Node 18
|
The rewrite is **feature complete and green**: the suite, lint and typecheck are clean on Node 18
|
||||||
to 26. What is left is release work and a few things worth adding before or after 0.5.0.
|
to 26, and 0.5.0 is on npm. What is left is housekeeping around the release, a few things worth
|
||||||
|
adding, and the gaps a comparison with other SMPP libraries found.
|
||||||
|
|
||||||
## The agreed API
|
## The agreed API
|
||||||
|
|
||||||
@@ -97,9 +98,9 @@ place issues are filed. Maintainer's calls, 2026-09-13 and 2026-09-14.
|
|||||||
|
|
||||||
- [x] 0.5.0 rather than 1.0.0, while usage is this low. Maintainer's call, 2026-09-14.
|
- [x] 0.5.0 rather than 1.0.0, while usage is this low. Maintainer's call, 2026-09-14.
|
||||||
- [x] `NPM_TOKEN`, which `.gitea/workflows/release.yaml` needs, is a Gitea organization secret.
|
- [x] `NPM_TOKEN`, which `.gitea/workflows/release.yaml` needs, is a Gitea organization secret.
|
||||||
- [ ] Tag `v0.5.0` on Gitea to publish. The first publish creates `@larvit/smpp` on npm, provided the
|
- [x] Tag `v0.5.0` on Gitea to publish. The first publish creates `@larvit/smpp` on npm, provided the
|
||||||
token can publish under `@larvit`.
|
token can publish under `@larvit`.
|
||||||
- [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not
|
- [x] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not
|
||||||
something CI should do.
|
something CI should do.
|
||||||
|
|
||||||
## Retire the GitHub repository
|
## Retire the GitHub repository
|
||||||
@@ -108,39 +109,44 @@ Nothing here starts before 0.5.0 is published. Maintainer's call, 2026-09-14. Th
|
|||||||
deleting GitHub's old branches closes every pull request based on them without a reply, and GitHub
|
deleting GitHub's old branches closes every pull request based on them without a reply, and GitHub
|
||||||
refuses to delete its default branch.
|
refuses to delete its default branch.
|
||||||
|
|
||||||
- [ ] Close the backlog below.
|
- [x] Close the backlog below.
|
||||||
- [ ] Close [#71](https://github.com/larvit/larvitsmpp/pull/71), pointing at Gitea.
|
- [x] Close [#71](https://github.com/larvit/larvitsmpp/pull/71), pointing at Gitea.
|
||||||
- [ ] Rename `larvit/larvitsmpp` to `larvit/smpp-js`. GitHub redirects the old URLs, and the `bugs`
|
- [x] Rename `larvit/larvitsmpp` to `larvit/smpp-js`. GitHub redirects the old URLs, and the `bugs`
|
||||||
URL in `package.json` resolves from then on.
|
URL in `package.json` resolves from then on.
|
||||||
- [ ] Push `main` and make it GitHub's default branch.
|
- [x] Push `main` and make it GitHub's default branch.
|
||||||
- [ ] Remove Renovate and CodeRabbit from the GitHub repository.
|
- [x] Renovate is Silent for this repository in the Mend Developer Portal, so it opens nothing on
|
||||||
- [ ] Mirror to GitHub from `.gitea/workflows/mirror.yaml` and `mirror-delete.yaml`, with
|
GitHub while the organization-wide installation stays. CodeRabbit stays installed.
|
||||||
|
Maintainer's call, 2026-09-14.
|
||||||
|
- [x] Mirror to GitHub from `.gitea/workflows/mirror.yaml` and `mirror-delete.yaml`, with
|
||||||
`MIRROR_GITHUB_TOKEN`. A push of a commit carrying the workflow, and the nightly run, send all of
|
`MIRROR_GITHUB_TOKEN`. A push of a commit carrying the workflow, and the nightly run, send all of
|
||||||
Gitea's branches and tags, overwriting a same-named ref; a branch or tag deleted on Gitea is
|
Gitea's branches and tags, overwriting a same-named ref; a branch or tag deleted on Gitea is
|
||||||
deleted there too. Refs only GitHub has stay. Maintainer's call, 2026-09-14.
|
deleted there too. Refs only GitHub has stay. Maintainer's call, 2026-09-14.
|
||||||
|
- [x] GitHub's wiki, projects and Actions are off, the Travis app and webhook are gone, and its About
|
||||||
|
matches the package. Gitea carries the same description, website and topics, and sends issues
|
||||||
|
to GitHub as its external tracker.
|
||||||
|
|
||||||
## Close the GitHub backlog
|
## Close the GitHub backlog
|
||||||
|
|
||||||
**Answer and close as fixed by 0.5.0**, the reply naming what fixed it:
|
**Answer and close as fixed by 0.5.0**, the reply naming what fixed it:
|
||||||
|
|
||||||
- [ ] [#2](https://github.com/larvit/larvitsmpp/issues/2) Tests for the README examples:
|
- [x] [#2](https://github.com/larvit/larvitsmpp/issues/2) Tests for the README examples:
|
||||||
`test/readme.test.ts`.
|
`test/readme.test.ts`.
|
||||||
- [ ] [#3](https://github.com/larvit/larvitsmpp/issues/3) Tests for flash messages:
|
- [x] [#3](https://github.com/larvit/larvitsmpp/issues/3) Tests for flash messages:
|
||||||
`test/session.test.ts`.
|
`test/session.test.ts`.
|
||||||
- [ ] [#4](https://github.com/larvit/larvitsmpp/issues/4) DLR errors with `message_state` missing:
|
- [x] [#4](https://github.com/larvit/larvitsmpp/issues/4) DLR errors with `message_state` missing:
|
||||||
`dlrFromPdu()` parses the `stat:` receipt text when the TLVs are absent.
|
`dlrFromPdu()` parses the `stat:` receipt text when the TLVs are absent.
|
||||||
- [ ] [#13](https://github.com/larvit/larvitsmpp/issues/13) Limit a long SMS to fewer segments: the
|
- [x] [#13](https://github.com/larvit/larvitsmpp/issues/13) Limit a long SMS to fewer segments: the
|
||||||
`maxSegments` send option.
|
`maxSegments` send option.
|
||||||
- [ ] [#16](https://github.com/larvit/larvitsmpp/issues/16) Support all three bind types: bound and
|
- [x] [#16](https://github.com/larvit/larvitsmpp/issues/16) Support all three bind types: bound and
|
||||||
enforced in both directions.
|
enforced in both directions.
|
||||||
- [ ] [#17](https://github.com/larvit/larvitsmpp/issues/17) `addr_ton`/`addr_npi` should be
|
- [x] [#17](https://github.com/larvit/larvitsmpp/issues/17) `addr_ton`/`addr_npi` should be
|
||||||
settable: `sendSms()` takes all four, documented and tested.
|
settable: `sendSms()` takes all four, documented and tested.
|
||||||
- [ ] [#20](https://github.com/larvit/larvitsmpp/issues/20) Tests fail on current dependency
|
- [x] [#20](https://github.com/larvit/larvitsmpp/issues/20) Tests fail on current dependency
|
||||||
versions: the mocha suite is gone; `node:test` on Node 18 to 26.
|
versions: the mocha suite is gone; `node:test` on Node 18 to 26.
|
||||||
- [ ] [#33](https://github.com/larvit/larvitsmpp/issues/33) Large inbound text arrives as raw
|
- [x] [#33](https://github.com/larvit/larvitsmpp/issues/33) Large inbound text arrives as raw
|
||||||
`Buffer` segments: `IncomingRequests` reassembles a UDH-carrying `deliver_sm` into one `sms`
|
`Buffer` segments: `IncomingRequests` reassembles a UDH-carrying `deliver_sm` into one `sms`
|
||||||
event.
|
event.
|
||||||
- [ ] [#68](https://github.com/larvit/larvitsmpp/pull/68), a pull request: `message_id` in
|
- [x] [#68](https://github.com/larvit/larvitsmpp/pull/68), a pull request: `message_id` in
|
||||||
`submit_sm_resp`, spec DLR codes. All four hold: `sendResp()` always answers a `message_id`,
|
`submit_sm_resp`, spec DLR codes. All four hold: `sendResp()` always answers a `message_id`,
|
||||||
per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the fork found real
|
per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the fork found real
|
||||||
defects.
|
defects.
|
||||||
@@ -148,7 +154,7 @@ refuses to delete its default branch.
|
|||||||
**Close as superseded**, all against 0.4.0 dependencies the rewrite does not have — `async`,
|
**Close as superseded**, all against 0.4.0 dependencies the rewrite does not have — `async`,
|
||||||
`coveralls`, `eslint`, `iconv-lite`, `larvitutils`, `mocha`, `mocha-eslint`, `portfinder`, `uuid`:
|
`coveralls`, `eslint`, `iconv-lite`, `larvitutils`, `mocha`, `mocha-eslint`, `portfinder`, `uuid`:
|
||||||
|
|
||||||
- [ ] [#40](https://github.com/larvit/larvitsmpp/pull/40),
|
- [x] [#40](https://github.com/larvit/larvitsmpp/pull/40),
|
||||||
[#41](https://github.com/larvit/larvitsmpp/pull/41),
|
[#41](https://github.com/larvit/larvitsmpp/pull/41),
|
||||||
[#42](https://github.com/larvit/larvitsmpp/pull/42),
|
[#42](https://github.com/larvit/larvitsmpp/pull/42),
|
||||||
[#45](https://github.com/larvit/larvitsmpp/pull/45),
|
[#45](https://github.com/larvit/larvitsmpp/pull/45),
|
||||||
@@ -168,7 +174,7 @@ the rewrite, for a dependency added later. Maintainer's call, 2026-09-14.
|
|||||||
|
|
||||||
**Close as tracked here**, the reply saying it will be implemented on Gitea:
|
**Close as tracked here**, the reply saying it will be implemented on Gitea:
|
||||||
|
|
||||||
- [ ] [#8](https://github.com/larvit/larvitsmpp/issues/8) The socket's remote host and port on log
|
- [x] [#8](https://github.com/larvit/larvitsmpp/issues/8) The socket's remote host and port on log
|
||||||
messages: under Worth doing, not blocking. Maintainer's call, 2026-09-14.
|
messages: under Worth doing, not blocking. Maintainer's call, 2026-09-14.
|
||||||
|
|
||||||
## Worth doing, not blocking
|
## Worth doing, not blocking
|
||||||
@@ -245,9 +251,6 @@ the rewrite, for a dependency added later. Maintainer's call, 2026-09-14.
|
|||||||
end to end. The interop suite is the natural place.
|
end to end. The interop suite is the natural place.
|
||||||
- [ ] **Move to TypeScript 7** once `typescript-eslint` supports it; `renovate.json` pins TypeScript
|
- [ ] **Move to TypeScript 7** once `typescript-eslint` supports it; `renovate.json` pins TypeScript
|
||||||
below 6.1 for exactly that reason.
|
below 6.1 for exactly that reason.
|
||||||
- [ ] **Coverage reporting.** `node --test --experimental-test-coverage` works today; nothing
|
|
||||||
publishes the numbers.
|
|
||||||
|
|
||||||
- [ ] **An `onReceipt` hook.** Receipt text is only loosely specified and operators disagree on it,
|
- [ ] **An `onReceipt` hook.** Receipt text is only loosely specified and operators disagree on it,
|
||||||
but `dlrFromPdu()` is wired into `IncomingRequests` with no seam of its own: an application
|
but `dlrFromPdu()` is wired into `IncomingRequests` with no seam of its own: an application
|
||||||
facing a format we do not parse has to take the whole PDU on `onRequest` and reimplement the
|
facing a format we do not parse has to take the whole PDU on `onRequest` and reimplement the
|
||||||
@@ -255,18 +258,191 @@ the rewrite, for a dependency added later. Maintainer's call, 2026-09-14.
|
|||||||
Mirror the `onRequest` seam — return a `Dlr` to own the receipt, `undefined` to fall through
|
Mirror the `onRequest` seam — return a `Dlr` to own the receipt, `undefined` to fall through
|
||||||
to the built-in parser.
|
to the built-in parser.
|
||||||
|
|
||||||
|
## Gaps against other SMPP libraries
|
||||||
|
|
||||||
|
From comparing 0.5.0 with `smpp`, `@semyonf/smpp`, `@leissner/node-red-smpp`, `node-smpp-next`,
|
||||||
|
`smpp-js-sdk`, `smppjs`, cloudhopper-smpp, jsmpp, go-smpp, Kannel, Jasmin and php-smpp, 2026-09-14.
|
||||||
|
Each lands under AGENTS.md goal 6: an option or a hook, with the call that passes none unchanged.
|
||||||
|
|
||||||
|
### Sending
|
||||||
|
|
||||||
|
- [ ] **A limiter hook, with a messages-per-second cap built on it.** Maintainer's call, 2026-09-14,
|
||||||
|
reversing the earlier decline: Kannel, Jasmin, go-smpp and `smpp-js-sdk` all limit throughput.
|
||||||
|
Count PDUs, not `sendSms()` calls — a long message is one `submit_sm` per segment and the
|
||||||
|
operator counts those, which an application wrapping `sendSms()` cannot see. The hook takes a
|
||||||
|
limiter the application already runs; the built-in cap counts per session until the store at the
|
||||||
|
bottom lets it span sessions and processes. The default stays uncapped. Open: the hook's shape (a
|
||||||
|
wait that resolves when a PDU may go, cut short by the send's `signal`), which requests it gates
|
||||||
|
— messages, never `enquire_link`, `unbind` or a response — and whether its wait counts against
|
||||||
|
`responseTimeout`.
|
||||||
|
|
||||||
|
- [ ] **Back off and resend on `ESME_RTHROTTLED`.** The SMSC refused the PDU, so resending cannot
|
||||||
|
duplicate it and goal 2 holds, and the retry needs nothing wider than the session. Needs a
|
||||||
|
decision: on by default with a bounded budget, as goal 5 suggests, and whether `ESME_RMSGQFUL`
|
||||||
|
counts too. A throttled answer is also what a limiter hook wants to hear about.
|
||||||
|
|
||||||
|
- [ ] **`sendSms()` takes the rest of `submit_sm`.** `service_type`, `priority_flag`, `protocol_id`,
|
||||||
|
`replace_if_present_flag` and TLVs on every segment, and `registered_delivery` beyond final
|
||||||
|
receipts: on failure only, and intermediate notifications. Today each needs `send()`, which gives
|
||||||
|
up splitting, the alphabet checks and receipt merging. TLVs are the common case: India's DLT
|
||||||
|
rules put `PE_ID` (0x1400) and `TEMPLATE_ID` (0x1401) on every `submit_sm`, and USSD rides on
|
||||||
|
`ussd_service_op`. Refuse a TLV the send composes itself (`sar_*`, `message_payload`). Open: one
|
||||||
|
spelling for receipts, since `dlr: true` and a raw `registered_delivery` could disagree, and
|
||||||
|
whether goal 4's rule on optional parameters binds a TLV the caller named.
|
||||||
|
|
||||||
|
- [ ] **Choose how a long message is spelled on the wire.** Only an 8-bit UDH reference goes out
|
||||||
|
(`message.ts`), though the reader takes a 16-bit UDH, `sar_*` and `message_payload` alike. Some
|
||||||
|
SMSCs take only `sar_*` or `message_payload`; php-smpp offers all three. A 16-bit reference also
|
||||||
|
makes a collision rarer: the 8-bit one wraps every 255 sends on a session.
|
||||||
|
|
||||||
|
- [ ] **Failover across SMSC hosts.** Maintainer's call, 2026-09-14. `client()` takes one `host` and
|
||||||
|
`port`; Kannel, Jasmin and php-smpp take several. A list the reconnect loop walks holds only
|
||||||
|
which host the one session is on, so it needs no store. Two options, both maintainer's calls:
|
||||||
|
- **Order**, `fixed` or round robin, default `fixed`. Fixed starts every reconnect at the first
|
||||||
|
host; round robin at the host after the one the link was last on.
|
||||||
|
- **Starting over**, a boolean, default on: once the last host has been tried, go back to the
|
||||||
|
first. Off ends the session once the last host fails, as a drop does under `reconnect: false`.
|
||||||
|
Open: how the list and today's `host` and `port` share one spelling; whether the backoff grows
|
||||||
|
per host or per pass; and whether round robin with starting over off still tries the hosts
|
||||||
|
before the one it started at.
|
||||||
|
|
||||||
|
### The server
|
||||||
|
|
||||||
|
- [ ] **`sendSms()` on a `server()` session sends `submit_sm` toward the ESME.** Kannel answers
|
||||||
|
`ESME_RINVCMDID` (`interop-tests/kannel.test.ts`, "MO to Kannel"), and goal 1 says that PDU never
|
||||||
|
goes out. A server has no other way to send an MO message either: `sendMo()` in that test builds
|
||||||
|
one from `submitSmParams()` and `ConcatReference`, neither exported. Choosing `deliver_sm` by
|
||||||
|
`linkEnd` gives MO messages the splitting and checks, keeps one method for one goal, and refuses
|
||||||
|
the options 3.4 has `deliver_sm` leave empty (`scheduleDeliveryTime`, `validityPeriod`).
|
||||||
|
|
||||||
|
- [ ] **Error TLVs on a response this library builds.** `buildBody()` in `pdu.ts` writes no body for
|
||||||
|
any non-zero status, so a server cannot answer a `data_sm` with `delivery_failure_reason`,
|
||||||
|
`network_error_code` or `additional_status_info_text`, and a 5.0 peer gets none of its error TLVs.
|
||||||
|
3.4 omits the body on error for `submit_sm_resp` by name; read each response's section before
|
||||||
|
widening it. Reading needs nothing: an error response carrying a body already parses.
|
||||||
|
|
||||||
|
- [ ] **PROXY protocol on `server()`.** Behind HAProxy or an AWS NLB every session's remote address is
|
||||||
|
the balancer's, so `authenticate` cannot allow-list by IP and logs name the wrong peer. v1 is
|
||||||
|
text; v2 is binary and the only one an NLB sends. `smpp` accepts v1 from anyone; accept either
|
||||||
|
only from addresses the option names.
|
||||||
|
|
||||||
|
- [ ] **`outbind`.** In the command table, handled nowhere: a client cannot take an SMSC's `outbind`
|
||||||
|
and bind back, and `server()` cannot send one. Rare; take it on with a peer that uses it.
|
||||||
|
|
||||||
|
- [ ] **Register vendor-specific commands.** 3.4 reserves `command_id` `0x00010200`–`0x000102FF` for
|
||||||
|
SMSC vendors; today one arrives as a `PduRefusedError`. `smpp` has `addCommand()`. The same
|
||||||
|
shape question as registering an encoding.
|
||||||
|
|
||||||
|
### Encodings
|
||||||
|
|
||||||
|
- [ ] **Register a custom encoding.** Maintainer's ask, 2026-09-14. `EncodingName` is a closed union
|
||||||
|
of three (`defs/encodings.ts`). An entry needs a name, a `data_coding`, `encode`, `decode`,
|
||||||
|
`match`, whether `detect()` may pick it, and enough for `splitMessage()` to budget a segment
|
||||||
|
without halving a character. Take encodings as a client or server option rather than mutating a
|
||||||
|
module table as `smpp` does, so two sessions in one process cannot disagree about a name. A taken
|
||||||
|
name is an `err`. Settle `consts.ENCODING`'s names first. An entry may claim a `data_coding` a
|
||||||
|
built-in already uses, maintainer's call, 2026-09-14: an SMSC whose default alphabet, 0x00, is
|
||||||
|
Latin-1 needs Latin-1 written and read under it
|
||||||
|
([#23](https://github.com/larvit/larvitsmpp/issues/23)). The entry then owns that coding on its
|
||||||
|
session both ways: `encodingByDataCoding()` resolves to it, `detect()` tries it in the
|
||||||
|
built-in's place, and naming the displaced built-in is an `err` naming the entry. Two entries
|
||||||
|
on one coding are an `err`. Settle whether a claim on 0x00 reaches the class groups
|
||||||
|
`messageClassEncoding()` reads GSM 7-bit from, which `flash` writes under.
|
||||||
|
|
||||||
|
- [ ] **The alphabets SMPP 3.4 names that no encoding carries.** `consts.ENCODING` lists the
|
||||||
|
`data_coding` ids (5.2.19); only `ASCII`, `LATIN1` and `UCS2` can be sent. Those with a published
|
||||||
|
definition, and what each costs:
|
||||||
|
- 0x01 IA5 (ITU-T T.50, ASCII in practice): trivial.
|
||||||
|
- 0x06 ISO-8859-5 (Cyrillic) and 0x07 ISO-8859-8 (Hebrew): 96-entry tables.
|
||||||
|
- 0x05 JIS X 0208, 0x0D JIS X 0212, 0x0A ISO-2022-JP and 0x0E KS C 5601: two-octet sets.
|
||||||
|
`TextDecoder` reads them through ICU — EUC-JP and EUC-KR once each octet's high bit is set,
|
||||||
|
`iso-2022-jp` as is; checked for JIS X 0208 and KS C 5601 on Node 24.18.0. Nothing built in
|
||||||
|
encodes them, so ship tables or build the reverse map on first use by decoding the 94×94 grid.
|
||||||
|
A Node without full ICU throws from `new TextDecoder()`, which hard rule 1 wraps into an `err`.
|
||||||
|
- 0x09 pictogram has no published definition; leave it out.
|
||||||
|
|
||||||
|
- [ ] **GSM 7-bit national language shift tables.** 3GPP TS 23.038 defines them for Turkish, Spanish
|
||||||
|
(single shift only), Portuguese and ten Indian languages — Bengali, Gujarati, Hindi, Kannada,
|
||||||
|
Malayalam, Oriya, Punjabi, Tamil, Telugu and Urdu — selected per message by UDH elements 0x25
|
||||||
|
(locking) and 0x24 (single). They keep that text near GSM's segment size instead of UCS2's 67
|
||||||
|
characters. Reading means honouring those elements in `decodeMessage()`; sending means `detect()`
|
||||||
|
picking a table, with each element's 3 octets off the segment budget. `smpp` has Turkish, Spanish
|
||||||
|
and Portuguese, used only when the caller writes the UDH.
|
||||||
|
|
||||||
|
- [ ] **Packed GSM 7-bit, opt-in.** Everything goes out unpacked, SMPP's convention (AGENTS.md, "GSM
|
||||||
|
7-bit is sent unpacked"); go-smpp carries a packed codec for SMSCs that want septets. Find an SMSC
|
||||||
|
that needs it before building it.
|
||||||
|
|
||||||
|
- [ ] **`consts.ENCODING` spells five alphabets twice.** `CYRILLIC`/`ISO_8859_5`,
|
||||||
|
`HEBREW`/`ISO_8859_8`, `JIS`/`X_0208_1990`, `EXTENDED_KANJI_JIS`/`X_0212_1990` and
|
||||||
|
`LATIN1`/`ISO_8859_1`; `FLASH` is a message class, not an alphabet. One name each before
|
||||||
|
registration starts taking names. A breaking change to an export.
|
||||||
|
|
||||||
|
### Observability
|
||||||
|
|
||||||
|
- [ ] **Metrics.** Inbound traffic has `data`, `incomingPdu` and `incomingPduObj`; outbound has no
|
||||||
|
event, and nothing counts requests in flight, queued for a window slot, waiting for a link, or
|
||||||
|
unanswered. `smpp` and `@semyonf/smpp` emit `metrics`; cloudhopper keeps per-session counters. An
|
||||||
|
`outgoingPdu`/`outgoingPduObj` pair mirrors the inbound events; the counters can be one read-only
|
||||||
|
snapshot, read from the owner of each count rather than a second tally that can drift.
|
||||||
|
|
||||||
|
### Packaging, tests and CI
|
||||||
|
|
||||||
|
- [ ] **Ship `src`, so the source maps lead somewhere.** Maintainer's call, 2026-09-14. `sourceMap`
|
||||||
|
and `declarationMap` write maps whose `sources` are `../src/*.ts`, but `files` publishes only
|
||||||
|
`dist`, so 82 of the package's 167 files, 225 KB of its 555 KB unpacked, point at nothing. Adding
|
||||||
|
`src` (41 files, 209 KB) makes go to definition land in the TypeScript, and lets a debugger or
|
||||||
|
`--enable-source-maps` show it.
|
||||||
|
|
||||||
|
- [ ] **A coverage report and a floor in the gate.** `node --test --experimental-test-coverage
|
||||||
|
--test-coverage-include='src/**' test/*.test.ts` on Node 24.18.0, 2026-09-14: 98.77% lines,
|
||||||
|
93.85% branches, 98.28% functions. Gate at 98, 93 and 98 with `--test-coverage-lines`,
|
||||||
|
`--test-coverage-branches` and `--test-coverage-functions`, which Node 22 and later take — a job
|
||||||
|
of its own on 24, since the matrix runs compiled JavaScript — and add it to `main`'s required
|
||||||
|
checks. Raise the floor as coverage rises; never lower it.
|
||||||
|
|
||||||
|
- [ ] **Mutation testing.** `@stryker-mutator/tap-runner` runs `node:test` suites and measures whether
|
||||||
|
a test notices a change, which coverage cannot; `@semyonf/smpp` runs Stryker in CI. The session
|
||||||
|
suites are timer-heavy, so start with the codec and the encodings.
|
||||||
|
|
||||||
|
- [ ] **A Node-RED node, as a package of its own.** `@leissner/node-red-smpp` is the only SMPP node in
|
||||||
|
the Node-RED library, and by a read of its source it never parses a receipt and never answers the
|
||||||
|
SMSC's `enquire_link`. Its UI is a fair list of what operators set. It builds on this package,
|
||||||
|
never inside it.
|
||||||
|
|
||||||
## Declined
|
## Declined
|
||||||
|
|
||||||
- **Merge state surviving a process restart.** Declined by AGENTS.md goal 7, maintainer's call,
|
- **A check that warns before `MIRROR_GITHUB_TOKEN` expires.** Gitea mails no one about a failed
|
||||||
2026-09-02. A restart loses every incomplete receipt group and a peer has no reason to resend one it
|
scheduled run, since its Actions bot triggers those, so a nightly check would fail unseen. The
|
||||||
already had answered, so the loss is real — but surviving it means handing the application the merge
|
maintainer relies on GitHub's own expiry reminders, and on the mirror's first failed push run after
|
||||||
state to persist, which the scope floor covers as squarely as holding the state here would, and
|
expiry, which mails whoever pushed. Maintainer's call, 2026-09-14.
|
||||||
which publishes the shape of `DlrMerger`'s groups against goal 6. Nothing is foreclosed: the seam
|
|
||||||
can still be added after 0.5.0 as a minor.
|
|
||||||
|
|
||||||
- **Throughput throttling — a TPS cap, and backing off on `ESME_RTHROTTLED`.** Declined by AGENTS.md
|
- **CommonJS.** ESM only, maintainer's call reaffirmed 2026-09-14, though `node-smpp-next` ships both.
|
||||||
goal 7: an operator's rate limit is scoped to the account, while the widest thing this library owns
|
`require()` of an ES module works unflagged from Node 20.19 and 22.12.
|
||||||
is a session, so a bucket here cannot see a second process binding the same account and is wrong in
|
|
||||||
exactly the case it exists for. `sendSms()` surfaces `ESME_RTHROTTLED` to the caller instead, and
|
## An optional store
|
||||||
`maxOutstanding` stays — a window slot frees on the peer's next response, which is self-limiting in
|
|
||||||
a way a rate ceiling is not.
|
- [ ] **Pooling, and state that survives a restart, through an optional store.** Maintainer's call,
|
||||||
|
2026-09-14. It replaces two declines — merge state surviving a restart, and a pool of sessions —
|
||||||
|
and AGENTS.md goal 8 was rewritten for it. Big: design before code.
|
||||||
|
- **What it holds.** Receipts still awaited and the groups `DlrMerger` collects. Segments of a
|
||||||
|
message already answered but not yet whole, which the peer will not send again (goal 2). The
|
||||||
|
concatenation reference, so a restart does not reuse one. For a pool, the ids every session
|
||||||
|
sent, since an SMSC may deliver a receipt on any bind of the account, and the
|
||||||
|
messages-per-second budget the sessions share.
|
||||||
|
- **What it cannot hold.** A response belongs to the link its request arrived on, so a message
|
||||||
|
left unanswered at a restart stays unanswerable; the peer's own timeout settles it.
|
||||||
|
- **Pooling.** Several sessions, in one process or many, behind one send: a message goes to a
|
||||||
|
bound session with a free window slot, all its segments on that one. In one process the
|
||||||
|
in-memory store is enough; across processes the application supplies one.
|
||||||
|
- **The interface.** Narrow, with keys and records of the library's own making, versioned, with
|
||||||
|
expiry: a record from an older version is read or refused, never misread, and `DlrMerger`'s
|
||||||
|
group shape is never published (goal 7). What processes share needs an atomic operation —
|
||||||
|
compare-and-set or increment — since get-then-set races.
|
||||||
|
- **Adapters live elsewhere.** Redis, Postgres or SQLite stores are packages of their own; this
|
||||||
|
one ships the interface and the in-memory store, and no runtime dependency (goal 9).
|
||||||
|
- **When the store fails.** Open: a send whose awaited receipt cannot be recorded is refused, or
|
||||||
|
sent and reported as undetermined (goal 2); a pool whose store is down stops, or falls back to
|
||||||
|
memory. Either way, an application that supplied no store never waits on one.
|
||||||
|
- **One spelling.** A store-backed cap and the limiter hook both reach a limit shared between
|
||||||
|
processes; settle which owns that case before building the second.
|
||||||
|
|||||||
Reference in New Issue
Block a user