Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd09a27dc6 | |||
| 4094949917 | |||
| ac6206dfec | |||
| 9fb8f348c8 | |||
| 01891bc764 | |||
| 9379212c68 | |||
| cda8e9b112 | |||
| 58e622143b | |||
| 138464ad0f | |||
| c4b0437323 | |||
| 939eda7269 | |||
| 75d4794522 | |||
| 42b1239f3d | |||
| f2977638f2 | |||
| b9167073d4 | |||
| fb6b125aab | |||
| 855ccbe75f | |||
| d8232bcd67 | |||
| dba95d9524 | |||
| c3ebd6d6bc | |||
| d8d0947e1f | |||
| 72b400e661 | |||
| f1ef6b6643 | |||
| fd6ce16325 | |||
| f9c53aa576 | |||
| 92456b6f6e | |||
| 01210e734a | |||
| e83ed1451c | |||
| 11ff349333 | |||
| f2ee8aa016 | |||
| 2476684f24 | |||
| a885913ecb | |||
| befa648991 | |||
| cbfd6e0fbe | |||
| 5cdd3ccdae | |||
| f6a66336cd | |||
| 10877c9f5f | |||
| c5ef4c4703 | |||
| 80f77afa0f | |||
| 228b81aa5e | |||
| e725f62b95 | |||
| 0ee7bbbaf0 | |||
| b105752d53 | |||
| 65ac3a3fd7 | |||
| b54f5b02f3 | |||
| bdebef849c | |||
| 0620d205d6 | |||
| 3eb93303c3 | |||
| 0c4e6000b0 | |||
| bbfd1083b8 | |||
| faa5482c7a | |||
| 93d794e61b | |||
| 63e06856f2 | |||
| e7e71ca9bd | |||
| e2edd7e31e | |||
| 36a1c6ea7b | |||
| a7eb4923f5 | |||
| dc690b912f | |||
| f870b7530f | |||
| c1e0407942 | |||
| 7db242e375 | |||
| cbd1190b96 | |||
| b85d1f1dab | |||
| c5ed25f915 | |||
| 4c43f6748d | |||
| 36d32591c3 | |||
| 92fbd6a9f2 | |||
| 45171c2697 | |||
| a24f350b7a | |||
| 1416615ca3 | |||
| 8f416c2bc4 | |||
| 84174b4a5b | |||
| 17447bbe3a | |||
| d874448273 | |||
| 656bbae500 | |||
| c90b855958 | |||
| 0f13951e8c | |||
| aab74e4cb3 | |||
| 1c0b4bef34 | |||
| c5adfd8e39 | |||
| 14b114afda | |||
| 27e8f288ec | |||
| e1999a7305 | |||
| baa0ad553a | |||
| e32f9ccf46 | |||
| b3cbbba615 | |||
| 95e1f910a3 |
@@ -0,0 +1,40 @@
|
||||
name: Mirror deletions
|
||||
|
||||
on:
|
||||
delete:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
delete:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Delete the ref from GitHub once Gitea no longer has it
|
||||
env:
|
||||
MIRROR_GITHUB_TOKEN: ${{ secrets.MIRROR_GITHUB_TOKEN }}
|
||||
MIRROR_URL: https://github.com/larvit/smpp-js.git
|
||||
REF: ${{ github.event.ref }}
|
||||
SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}.git
|
||||
run: |
|
||||
# Gitea Actions sends the full ref name here, where its webhooks send the short one.
|
||||
case "$REF" in
|
||||
refs/heads/*|refs/tags/*) ;;
|
||||
*) echo "delete event names '$REF', not a full branch or tag ref"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# ls-remote --exit-code: 0 the ref exists, 2 it does not, anything else the remote could not be read.
|
||||
on_gitea=0
|
||||
git ls-remote --exit-code "$SOURCE_URL" "$REF" > /dev/null || on_gitea=$?
|
||||
if [ "$on_gitea" -ne 2 ]; then exit "$on_gitea"; fi
|
||||
|
||||
on_github=0
|
||||
git ls-remote --exit-code "$MIRROR_URL" "$REF" > /dev/null || on_github=$?
|
||||
if [ "$on_github" -eq 2 ]; then exit 0; fi
|
||||
if [ "$on_github" -ne 0 ]; then exit "$on_github"; fi
|
||||
|
||||
git init --bare --quiet mirror.git
|
||||
git -C mirror.git -c credential.helper= \
|
||||
-c credential.helper='!f() { echo username=x-access-token; echo "password=$MIRROR_GITHUB_TOKEN"; }; f' \
|
||||
push "$MIRROR_URL" ":$REF"
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Mirror
|
||||
|
||||
on:
|
||||
push:
|
||||
schedule:
|
||||
- cron: '17 3 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
push:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
# Gitea cancels a queued job in this group when the next one arrives; only the full push may join.
|
||||
concurrency:
|
||||
group: mirror
|
||||
steps:
|
||||
- name: Push every branch and tag to GitHub, overwriting a same-named ref
|
||||
env:
|
||||
MIRROR_GITHUB_TOKEN: ${{ secrets.MIRROR_GITHUB_TOKEN }}
|
||||
MIRROR_URL: https://github.com/larvit/smpp-js.git
|
||||
SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}.git
|
||||
run: |
|
||||
git clone --bare --quiet "$SOURCE_URL" mirror.git
|
||||
git -C mirror.git -c credential.helper= \
|
||||
-c credential.helper='!f() { echo username=x-access-token; echo "password=$MIRROR_GITHUB_TOKEN"; }; f' \
|
||||
push "$MIRROR_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
|
||||
@@ -0,0 +1,81 @@
|
||||
# Changelog
|
||||
|
||||
## 0.6.0 (unreleased)
|
||||
|
||||
- `client()` now bounds each connect attempt at 10 seconds, the TLS handshake included, and reports
|
||||
one that expires as an ordinary connect failure, so `reconnect` retries it on its usual backoff.
|
||||
A connect previously waited the operating system out, around 130 s on Linux against a host that
|
||||
drops SYNs. `connectTimeout` retunes the bound, and `connectTimeout: false` restores the old wait.
|
||||
- Addresses, ids and every other text field on the wire are read and written as latin1. A
|
||||
`source_addr` of `Kaffeé` previously reached the application as `Kaffei`, because the codec wrote
|
||||
the octet and then masked bit 7 reading it back; `destination_addr`, `system_id`, `message_id`,
|
||||
`password`, `service_type` and the C-Octet String TLVs were affected the same way. A character past
|
||||
`U+00FF` in one of those fields is now refused, where it used to go out as its low octet.
|
||||
|
||||
**A server comparing `systemId` or `password` could be impersonated.** Masking bit 7 folded 127 of
|
||||
the 255 non-zero octets onto a character a low octet also reaches, so the bind credentials your
|
||||
`authenticate` received were not unique to the octets the peer sent: one refused as `admin` could
|
||||
bind as `\xE1dmin` and match the same string. latin1 is one-to-one over the octets, so two
|
||||
different wire values no longer arrive as one. Read 0.5.0 bind logs for a `systemId` you did not
|
||||
issue.
|
||||
|
||||
**Check what you stored before you roll this out.** Values your application persisted under 0.5.0
|
||||
were read with bit 7 masked, so an address or a `message_id` carrying an octet above `0x7F` is
|
||||
spelled differently now: a stored id will not match the receipt it belongs to, and a stored address
|
||||
will not match the sender it came from. Ids most SMSCs issue are digits or hex and are unaffected.
|
||||
- A `U+0000` inside a C-Octet String — `source_addr`, `message_id`, `system_id` and the rest — is
|
||||
refused. An Octet String carries a NULL as before.
|
||||
- A non-finite number — `NaN`, `Infinity`, `-Infinity` — is refused where a text field on the wire
|
||||
takes one. `sendSms({ from: NaN })` put the literal sender `NaN` on the wire and resolved as a
|
||||
successful send; `message_id`, `source_addr` and the string TLVs took such a number the same way.
|
||||
The call now resolves with `err` naming the field — `from: Expected a finite number, got NaN` — so
|
||||
a caller that reads only `smsIds` meets a failure it has not met before. A whole number in an
|
||||
address or an id still spells its digits, so `message_id: 123` is unchanged. The integer fields
|
||||
name a refused `NaN` too, where the refusal used to read `null`.
|
||||
- An `alert_notification` or an `outbind` from the peer is logged and left unanswered, as SMPP 3.4
|
||||
gives neither a response. Each one used to emit `sessionError`, `"alert_notification" has no
|
||||
response command`.
|
||||
- `maxOctets` charges each held segment 1000 octets beyond its own, 300 more per TLV on it, and 300
|
||||
per occurrence of a repeatable one. Segments of empty fields or thousands of empty TLVs used to
|
||||
count as next to nothing, so a peer could hold far more than the cap. **Raise a `maxOctets` you
|
||||
tuned low**: it now holds several times fewer segments, and an incomplete message evicted over
|
||||
the cap is lost, since its segments were already answered.
|
||||
- A message arriving while the application holds 1000 unanswered, or 64 MiB of them counted the way
|
||||
`maxOctets` counts segments, is refused with `ESME_RTHROTTLED` (`ESME_RX_T_APPN` on a
|
||||
delivery), so the peer keeps it and retries. **Call `sendResp()` on every `sms`, multipart
|
||||
included**: 1000 left unanswered now stop inbound traffic for up to five minutes, where the oldest
|
||||
used to be dropped with a warning.
|
||||
- A `submit_sm` segment the reassembly buffer has no room for is refused with `ESME_RTHROTTLED`,
|
||||
where it was `ESME_RMSGQFUL`.
|
||||
- `server()` refuses a `maxOctets` below 1 or not a whole number, `Infinity` included, like its
|
||||
other limits. `server({ maxOctets: 0 })` used to start and then refuse every multipart message.
|
||||
- `callback_num`, `callback_num_atag`, `callback_num_pres_ind`, `broadcast_area_identifier` and
|
||||
`broadcast_error_status`, the TLVs SMPP allows more than once in a PDU, keep every occurrence in
|
||||
wire order. A PDU carrying two of one used to keep only the last.
|
||||
|
||||
**Reading one of these now needs an index.** `pduObj.tlvs.callback_num?.tagValue` is a `Buffer[]`
|
||||
even where one arrived (a `number[]` for `callback_num_pres_ind` and `broadcast_error_status`), so a
|
||||
`Buffer.isBuffer()` or `typeof` check written for 0.5.0 now reads it as absent. Read `tagValue[0]`
|
||||
for the first occurrence. `objToPdu()`, `session.send()` and `session.sendReturn()` take
|
||||
`{ tagValue: [value] }` for them and refuse a lone value before anything goes out.
|
||||
- `pduObj.tlvs` and every `tlvs` input are typed per tag, as `Tlvs` and `TlvInputs`:
|
||||
`receipted_message_id` reads as a `string`, `message_state` as a `number`, and a value the tag
|
||||
cannot carry fails to compile. Annotate with `Tlvs` or `TlvInputs` where you wrote
|
||||
`Record<string, Tlv>` or `Record<string, TlvInput>`; `TlvInput` is gone.
|
||||
|
||||
**A TLV input is keyed by its name, or by its decimal id where the table names none, and a `tagId`
|
||||
that disagrees with its key is refused.** Write `{ 5142: { tagValue } }` for a vendor tag, not
|
||||
`{ vendor: { tagId: 5142, … } }`; `{ message_state: { tagId: 5, … } }` used to go out as tag 5. A
|
||||
parsed PDU's `tlvs` still relay as they are. A number for an octet TLV, vendor tags included, is
|
||||
refused, where it went out as its ASCII digits.
|
||||
|
||||
A decimal key naming a tag the table knows, `{ 1063: … }`, is refused in favour of the name, and
|
||||
so are `alert_on_msg_delivery` and `failed_broadcast_area_identifier` in favour of
|
||||
`alert_on_message_delivery` and `broadcast_area_identifier`, the names they read back under. The
|
||||
two alternate names are gone from `tlvs` too, which is now typed by `TlvName`: narrow a `string` with `isTlvName()` before indexing it.
|
||||
- `cmds.broadcast_sm_resp.tlvMap` is removed; nothing read it.
|
||||
|
||||
## 0.5.0
|
||||
|
||||
The TypeScript rewrite. What a 0.4.0 consumer has to change is in
|
||||
[MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md).
|
||||
@@ -30,6 +30,12 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re
|
||||
`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.
|
||||
- **A TLV is keyed by its name, or by its decimal id where the table names none**, and a `tagId`
|
||||
disagreeing with its key is refused: `{ 5142: { tagValue } }`, not
|
||||
`{ vendor: { tagId: 5142, tagValue } }`. A number for an octet TLV is refused; give a Buffer or a string.
|
||||
Write `alert_on_message_delivery` and `broadcast_area_identifier`, the names they read back
|
||||
under, for `alert_on_msg_delivery` and `failed_broadcast_area_identifier`, which are gone from
|
||||
`tlvs` too.
|
||||
- **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).
|
||||
@@ -70,6 +76,9 @@ have for these:
|
||||
- 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.
|
||||
- `callback_num`, `callback_num_atag`, `callback_num_pres_ind`, `broadcast_area_identifier` and
|
||||
`broadcast_error_status` may repeat within a PDU, and each is an array of every occurrence in both
|
||||
directions. 0.4.0 kept only the last one it read.
|
||||
- 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.
|
||||
|
||||
@@ -22,7 +22,8 @@ window, long messages and delivery receipts. TypeScript, ESM, no dependencies.
|
||||
[Send options](#send-options) · [Session](#session) · [Receiving in depth](#receiving-in-depth) ·
|
||||
[Server in depth](#server-in-depth) · [Logging](#logging) ·
|
||||
[PDUs and the low-level API](#pdus-and-the-low-level-api) ·
|
||||
[Migrating from 0.4.0](#migrating-from-larvitsmpp-040) · [Development](#development)
|
||||
[Migrating from 0.4.0](#migrating-from-larvitsmpp-040) · [Goals](#goals) ·
|
||||
[Audience](#audience) · [Development](#development)
|
||||
|
||||
## Install
|
||||
|
||||
@@ -100,9 +101,10 @@ session.on('sms', async sms => {
|
||||
});
|
||||
```
|
||||
|
||||
Call `sendResp()` for every message; it is part of the protocol. Delivery receipts reach you as
|
||||
`dlr` events, not here. A multipart message arrives reassembled and already answered segment by
|
||||
segment, so `sendResp()` there only says you are done with it: [Receiving in depth](#receiving-in-depth).
|
||||
Call `sendResp()` for every message, multipart included: until you do, it counts toward the bound
|
||||
past which the peer's messages are refused. Delivery receipts reach you as `dlr` events, not here. A
|
||||
multipart message arrives reassembled and already answered segment by segment, so `sendResp()` there
|
||||
puts nothing on the wire and releases it: [Receiving in depth](#receiving-in-depth).
|
||||
|
||||
## Run an SMPP server
|
||||
|
||||
@@ -160,7 +162,6 @@ await smpp.close(); // stop listening, then drain and close every live sess
|
||||
`sendDlr('UNDELIVERABLE')` any other state: [Server in depth](#server-in-depth).
|
||||
- A message that arrived in several segments was answered as they arrived, so `sendResp()` there
|
||||
takes no `smsId` or refusing `status`. `sms.answeredOnArrival` says which case you are in.
|
||||
- `smpp.close()` stops listening, then drains and closes every live session.
|
||||
|
||||
## Errors
|
||||
|
||||
@@ -226,6 +227,7 @@ All optional. Timeouts and delays are milliseconds.
|
||||
| `interfaceVersion` | `0x34` | The SMPP version declared at bind. `0x50` for an SMSC that requires SMPP 5.0. |
|
||||
| `systemType`, `addressRange`, `addrTon`, `addrNpi` | `''`, `''`, `0`, `0` | The remaining bind fields, for operators that require them. |
|
||||
| `tls` | `false` | `true` for defaults, or a `tls.ConnectionOptions` object for a private CA or a client certificate. |
|
||||
| `connectTimeout` | `10000` | Give up on **each connect attempt** the SMSC never completes, the TLS handshake included, and report it as an ordinary connect failure, which `reconnect` then retries. It bounds the socket and the handshake — never the `client()` call, and never the wait for the bind response, which is `responseTimeout`. `false` waits the operating system out instead, around 130 s on Linux; `0` is refused. |
|
||||
| `enquireLinkInterval` | `20000` | Interval between `enquire_link` on a quiet link. |
|
||||
| `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering, and re-bind unless `reconnect` is `false`. |
|
||||
| `responseTimeout` | `30000` | How long to wait for a response, and how long a send with no link waits for the next one. `0` waits forever. |
|
||||
@@ -260,7 +262,7 @@ All optional. Timeouts are milliseconds.
|
||||
| `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. A bare `true` is refused. |
|
||||
| `idleTimeout` | `40000` | Drop a peer that has been silent this long. |
|
||||
| `maxReassembly` | `1000` | Incomplete multipart messages held per session. |
|
||||
| `maxOctets` | `67108864` | Bytes of incomplete multipart messages held per session. |
|
||||
| `maxOctets` | `67108864` | Roughly the memory incomplete multipart messages may hold per session: each held segment counts its octets plus 1000, 300 more per TLV on it, and 300 per occurrence of a repeatable one. |
|
||||
| `reassemblyTimeout` | `300000` | How long a late segment can still join an incomplete message. |
|
||||
| `responseTimeout`, `shutdownTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | |
|
||||
|
||||
@@ -286,7 +288,11 @@ await session.sendSms({
|
||||
```
|
||||
|
||||
**Addresses.** `sourceAddrTon` and `destinationAddrTon` default to 5 for an alphanumeric address
|
||||
and 1 for a numeric one; the NPI fields default to 0.
|
||||
and 1 for a numeric one; the NPI fields default to 0. An address is latin1, so `é` is one octet on
|
||||
the wire and an address you received always sends back. One outside `/^[\u0001-\u00FF]*$/` is
|
||||
refused, naming the character and its index — strip or transliterate it first. An SMSC may still
|
||||
refuse a non-ASCII sender of its own accord, which reaches you as a refusal such as
|
||||
`ESME_RINVSRCADR`.
|
||||
|
||||
**Encoding.**
|
||||
|
||||
@@ -316,9 +322,10 @@ const { err, pduObjs, smsIds, unanswered } = await session.sendSms({ from, messa
|
||||
- One id per segment. `smsIds` is positional with `pduObjs`, and an entry is `undefined` where the
|
||||
SMSC took the segment without naming an id; some name one for the first segment only. No receipt
|
||||
ever carries an empty id, so an unnamed entry matches nothing.
|
||||
- `err` is set when the SMSC refuses a segment, naming the status. Every segment goes out together,
|
||||
so `pduObjs` and `smsIds` then hold what was accepted: enough to reconcile a later receipt, not
|
||||
enough to resend the rest. Treat a partial failure as a failed message.
|
||||
- `err` is set when the SMSC refuses a segment, naming the status, or leaves one unanswered. Every
|
||||
segment goes out together, so `pduObjs` and `smsIds` then hold only the accepted segments, in send
|
||||
order: enough to reconcile a later receipt, not enough to resend the rest. Treat a partial failure
|
||||
as a failed message; its accepted segments report through `dlr` alone.
|
||||
- `unanswered` counts segments that went out and were never answered. The SMSC may have taken each
|
||||
and lost only the response, so a message with `unanswered` above zero cannot be resent without
|
||||
risking a duplicate.
|
||||
@@ -340,9 +347,11 @@ you formatted. Refused before anything goes out: an invalid `Date`, `NaN`, `Infi
|
||||
count, and a count past 99 days 23:59:59, since a count in seconds is spelled in days and below.
|
||||
Name a later instant as a `Date`, which goes out absolute.
|
||||
|
||||
**What gets checked.** The library checks what it composes: an alphabet or a time you named, a string
|
||||
body under a `data_coding` you named. What you formed yourself, a `Buffer` body or a stamp you
|
||||
formatted, passes through as written. The same rule holds for `session.send()`.
|
||||
**What gets checked.** The library checks what it composes: an address you gave as `from` or `to`,
|
||||
an alphabet or a time you named, a string body under a `data_coding` you named. What you formed
|
||||
yourself, a `Buffer` body or a stamp you formatted, passes through as written, except that a text
|
||||
field is still checked: [PDUs and the low-level API](#pdus-and-the-low-level-api). The same rule
|
||||
holds for `session.send()`.
|
||||
|
||||
## Session
|
||||
|
||||
@@ -375,8 +384,7 @@ formatted, passes through as written. The same rule holds for `session.send()`.
|
||||
message has failed. Answering through `sendReturn()` instead leaves the wait running.
|
||||
3. Tear down what is left, resolving to an `err` that says what was lost.
|
||||
|
||||
At most 1000 unanswered messages are held, for five minutes each; what falls out of either bound is
|
||||
dropped with a warning on the log and waited for no longer. Neither bound is an option.
|
||||
A message left unanswered for five minutes is no longer waited for.
|
||||
`close({ signal })` cuts the wait short. `unbind()` takes no signal, and waits a further
|
||||
`responseTimeout` for its own response.
|
||||
|
||||
@@ -424,6 +432,12 @@ const { err, pduObj } = await session.send({
|
||||
each is two messages.
|
||||
- **Answered on arrival.** Each segment was answered as it landed, before you see the message:
|
||||
[Server in depth](#server-in-depth).
|
||||
- **Unanswered messages.** While a session holds 1000 messages you have not called `sendResp()` on,
|
||||
or 64 MiB of them counted the way `maxOctets` counts segments, every new message and segment is
|
||||
refused so the peer retries it: `ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on a delivery.
|
||||
No `sms` fires. Reaching the bound logs one `warn`, and the first message accepted once both are
|
||||
down to half one `info`. A message left five minutes is dropped from the count with a `warn`; a
|
||||
later `sendResp()` still answers it. None of the three is an option.
|
||||
- **Where the body is.** A body in the `message_payload` TLV, SMPP's way of carrying up to 64 KB and
|
||||
the only place a `data_sm` has, reads exactly like one in `short_message`, concatenated messages
|
||||
and receipts included. A PDU filling both is read from `short_message`.
|
||||
@@ -472,19 +486,20 @@ carrying the worst status of the segments and each of them under `segments`. An
|
||||
report never counts. Merging needs the SMSC to number its segment ids `<base>-<n>`, this library's
|
||||
own server's convention; an SMSC that hands out unrelated ids per segment never fires it. A base is
|
||||
merged once: a later message the SMSC gives the same ids is reported through `dlr` alone, and an
|
||||
earlier one still collecting loses its merged report.
|
||||
earlier one still collecting loses its merged report. A send that returned an `err` never fires `messageDlr`,
|
||||
even where the SMSC took some of its segments; their receipts still arrive as `dlr`.
|
||||
|
||||
## Server in depth
|
||||
|
||||
**Multipart is answered on arrival.** Each segment is answered as it lands, because a relaying SMSC
|
||||
will not send the next until the last is answered. The answer is `ESME_ROK`, unless the segment
|
||||
numbers itself into no message this session can join, which refuses it, or the reassembly buffer is
|
||||
full, which asks the SMSC to keep it and try again. `sms.answeredOnArrival` says whether the message
|
||||
you hold was answered that way; a segment count cannot, since a peer may number a message one part
|
||||
of one.
|
||||
numbers itself into no message this session can join, which refuses it, or the reassembly buffer or
|
||||
the unanswered messages are at their bound, which asks the SMSC to keep it and try again.
|
||||
`sms.answeredOnArrival` says whether the message you hold was answered that way; a segment count
|
||||
cannot, since a peer may number a message one part of one.
|
||||
|
||||
- The id was fixed with the first segment, so `sendResp()` there only says you are done, and
|
||||
returns `err` for an `smsId` or a refusing `status`.
|
||||
- The id was fixed with the first segment, so `sendResp()` there puts nothing on the wire and
|
||||
releases the message, and returns `err` for an `smsId` or a refusing `status`.
|
||||
- `sms.smsId` is the base. `sendDlr()` names `<smsId>-1`, `<smsId>-2` and so on: the ids the
|
||||
`submit_sm` responses carried.
|
||||
- A `deliver_sm` is answered with no id at all, since SMPP marks that field unused, so an inbound
|
||||
@@ -598,6 +613,13 @@ if (isCommand(pduObj, 'submit_sm')) {
|
||||
and hands back the UDH where the PDU carries one.
|
||||
- `concatOf(pduObj)`: the `part`, `total` and `reference` a PDU declares and the `spelling` that
|
||||
carried them, `'udh'` or `'sar'`, or `undefined` for a whole message.
|
||||
- `pduObj.tlvs` is typed per tag, as `Tlvs`: `receipted_message_id` a string, `message_state` a
|
||||
number, `message_payload` a `Buffer`. A tag the table does not define is a `Buffer` keyed by its
|
||||
decimal id, `tlvs['5142']`.
|
||||
- `callback_num`, `callback_num_atag`, `callback_num_pres_ind`, `broadcast_area_identifier` and
|
||||
`broadcast_error_status` may repeat in one PDU, so each reads as an array of every occurrence in wire
|
||||
order: `number[]` for `callback_num_pres_ind` and `broadcast_error_status`, `Buffer[]` for the rest.
|
||||
A `broadcast_sm_resp`'s `failed_broadcast_area_identifier` reads as `broadcast_area_identifier`.
|
||||
- `messageClassOf(dataCoding)`: `0` for the flash class, `1`, `2` and `3` for the ME-, SIM- and
|
||||
TE-specific ones, `undefined` where that `data_coding`'s coding group carries no class.
|
||||
|
||||
@@ -606,6 +628,14 @@ if (isCommand(pduObj, 'submit_sm')) {
|
||||
- A string `short_message` or `message_payload` is encoded in the alphabet the PDU's `data_coding`
|
||||
names, detected from the text where you name none. One that alphabet cannot carry is refused,
|
||||
naming the character, its code point and where it is.
|
||||
- Every text field is latin1: addresses, `system_id`, `message_id`, `service_type` and the C-Octet
|
||||
String TLVs. A character past `U+00FF` is refused, as is a `U+0000` in a C-Octet String.
|
||||
- `tlvs` is keyed and typed like `pduObj.tlvs`, as `TlvInputs`, so a parsed PDU's `tlvs` relay as
|
||||
they are: `{ message_state: { tagValue: 2 } }`, or `{ 5142: { tagValue: octets } }` for a tag the
|
||||
table does not define. Any other key, a decimal id the table names, a `tagId` disagreeing with its
|
||||
key, and a number for an octet TLV are refused.
|
||||
- The five repeatable TLVs take an array, written as one TLV per element; a lone value or an empty
|
||||
array is refused.
|
||||
- A `Buffer` goes out exactly as given under any `data_coding`: binary payloads, hand-built user
|
||||
data headers, deliberately malformed bodies.
|
||||
- `session.send()` and `session.sendReturn()` build through the same codec and refuse the same bodies.
|
||||
@@ -625,13 +655,97 @@ if (isCommand(pduObj, 'submit_sm')) {
|
||||
| Messages | `encodeMessage`, `decodeMessage`, `splitMessage`, `bitCount`, `messageOctets`, `concatOf`, `concatInfo`, `detect`, `unencodable`, `messageClassOf`, `dataCodingByEncoding`, `encodingByDataCoding` |
|
||||
| Receipts | `dlrFromPdu`, `parseReceipt`, `receiptCodes` |
|
||||
| Time and ids | `smppDate`, `smppTime`, `uuidv7` |
|
||||
| Spec tables | `cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, the `cmdsById`, `constsById`, `errorsById` and `tlvsById` maps, and all of them grouped as `defs`. `isCommandName`, `isErrorName`, `isEncodingName`, `commandNameById` and `errorNameById` narrow a value into them. |
|
||||
| Spec tables | `cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, the `cmdsById`, `constsById`, `errorsById` and `tlvsById` maps, and all of them grouped as `defs`. `isCommandName`, `isErrorName`, `isEncodingName`, `isTlvName`, `commandNameById` and `errorNameById` narrow a value into them. |
|
||||
| Types | Every option, result, event payload and table entry has a named type: `ClientOptions`, `ServerOptions`, `SendSmsOptions`, `SendSmsResult`, `Sms`, `Dlr`, `MessageDlr`, `Receipt`, `PduObject`, `PduHeader`, `SmppLog`, `Result` and the rest in `dist/index.d.ts`. |
|
||||
|
||||
## What changed per release
|
||||
|
||||
See [CHANGELOG.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/CHANGELOG.md).
|
||||
|
||||
## Migrating from larvitsmpp 0.4.0
|
||||
|
||||
See [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md).
|
||||
|
||||
## Goals
|
||||
|
||||
In priority order, and the order is the point: where two of them pull against each other, the earlier
|
||||
one wins. They do not override the [hard rules](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/AGENTS.md#hard-rules).
|
||||
|
||||
1. **Correct on the wire.** SMPP 3.4 as SMSCs actually run it. Every other goal yields to this one;
|
||||
the [defect table](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/AGENTS.md#defects-found-in-040) is what the alternative costs.
|
||||
2. **Never give the application a wrong answer about what happened.** An outcome we cannot determine
|
||||
is reported as undetermined rather than guessed; a report the peer marked as not final settles
|
||||
nothing, so nothing the library concludes may rest on one; a request the peer may already have
|
||||
taken is never re-sent on the library's own initiative; work the peer has no reason to send again
|
||||
is not dropped; a call that reports a message as sent asserts that the wire carried what the caller
|
||||
wrote, so a value we cannot send as given is refused before anything goes out; each message gets
|
||||
one outcome as a whole, so a send that fails is that outcome and no merged report follows it.
|
||||
3. **Strict in what we send, generous in what we read.** The library's own senders follow 3.4, and
|
||||
the codec parses whatever arrives. Where the letter of the spec would discard traffic a real SMSC
|
||||
sends, keep the traffic.
|
||||
4. **A peer an operator never has to complain about.** No bind flooding, nothing a bind direction
|
||||
forbids, no optional parameters to a peer that declared none, nothing held without a bound.
|
||||
5. **The session layer is in here, and its defaults are what most applications should run.**
|
||||
Keepalive, reconnect, the send window, reassembly and receipt correlation. What the network says
|
||||
about a message the application sent reaches it as a report rather than as an inbound message, and
|
||||
says whether it is final, so nothing has to read the PDU to tell those apart. An option retunes a
|
||||
default or opts out of it; an option does not switch on the thing the caller obviously wanted.
|
||||
6. **Fast enough that this library is never the bottleneck.** Throughput over a few sessions rather
|
||||
than many idle ones, which is why this exists on Node at all. On four cores or more, one bound
|
||||
session sustains at least 5,000 `submit_sm`/s at a send window of 1, 20,000 at the default window
|
||||
of 10, and 30,000 at 50 or above, and asking for delivery receipts costs nothing measurable.
|
||||
Memory is bounded per session, never per process. [benchmarks/](benchmarks/README.md) is how those
|
||||
floors are checked; every release re-measures them and asks what it would take to go faster.
|
||||
7. **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.
|
||||
8. **A small, stable public surface over reshapeable internals.** Only what `src/index.ts` exports is
|
||||
published. A new option has to beat "the application can do this itself", and has to keep a
|
||||
promise this library can verify. The low-level surface is a passthrough: policy binds what the
|
||||
library composes, never what the caller wrote.
|
||||
9. **State wider than one session goes through one store.** A pool of sessions, a limit shared
|
||||
between processes, and what has to survive a restart — receipts still awaited, a message half
|
||||
reassembled — are held through a store interface and never beside it. Without a store the
|
||||
application supplies, that state is in memory and ends with the process, and the defaults need
|
||||
none. The interface carries the library's own versioned records, never an internal shape handed
|
||||
to the application to persist. Coordinating processes any other way is declined without a fresh
|
||||
argument each time.
|
||||
10. **It builds, tests and runs the same everywhere.** Container-only toolchain, no runtime
|
||||
dependencies, the Node 18 floor verified in CI, every README example executed
|
||||
by the suite.
|
||||
|
||||
## Audience
|
||||
|
||||
Who depends on this library, and what they may rely on.
|
||||
|
||||
- **The public npm audience.** Only what `src/index.ts` exports is public; everything behind it is
|
||||
reshaped freely.
|
||||
- **Node 18 and newer, ESM only, no runtime dependencies.**
|
||||
- **Real SMSCs and ESMEs as operators actually run them**, not a reference implementation. Jasmin,
|
||||
SMPPSim, Kannel, jsmpp, Cloudhopper, python-smpplib and php-smpp are the interop targets, and what
|
||||
they do in practice outranks what the specification says they should do.
|
||||
- **The SMSC operator on the far end**, who never sees this API but carries what it does to their
|
||||
link. A peer they have to complain about is a defect however well the library reads.
|
||||
- **The developer building the SMPP edge of something else.** What binds to `server()` in practice is
|
||||
an aggregator's customer-facing edge, a bridge putting SMPP in front of a modern transport, or a
|
||||
test double standing in for an SMSC. This is not a store-and-forward SMSC and will not become one:
|
||||
spooling, scheduling, retry policy and billing belong to whatever this is the edge of, and state
|
||||
shared between instances goes through the store in goal 9.
|
||||
- **Pre-1.0, so the minor is the breaking unit** and a patch never breaks. What a 0.4.0 consumer has
|
||||
to change is in [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md);
|
||||
what each later minor changes is in
|
||||
[CHANGELOG.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/CHANGELOG.md).
|
||||
|
||||
Personas this README serves, in order:
|
||||
|
||||
1. The application developer sending or receiving SMS on the defaults, who should need no options.
|
||||
2. The application developer who needs one thing retuned — an alphabet, a rate limit, a receipt
|
||||
format — through an option or a hook rather than a fork.
|
||||
3. The operator coordinating sessions across processes through a store.
|
||||
4. The developer migrating from `larvitsmpp` 0.4.0.
|
||||
|
||||
## Development
|
||||
|
||||
Everything runs in the container; nothing is installed on the host.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Benchmarks
|
||||
|
||||
What this library sustains, and against what. Run them before setting or changing a scale goal.
|
||||
|
||||
```bash
|
||||
docker compose run --rm node node benchmarks/run.ts # this library, both ends
|
||||
COUNT=100000 docker compose run --rm node node benchmarks/run.ts # longer, steadier
|
||||
```
|
||||
|
||||
`run.ts` spawns `smsc-sink.ts` — a server that answers every `submit_sm` `ESME_ROK` and stores
|
||||
nothing — and drives it with `submit-load.ts` at four window sizes. Point `submit-load.ts` at any
|
||||
SMSC to compare:
|
||||
|
||||
```bash
|
||||
docker compose -f compose.yaml -f interop-tests/compose.jasmin.yaml run --rm node \
|
||||
node benchmarks/submit-load.ts --host=jasmin --port=2775 --username=esme1 --password=esme1pw \
|
||||
--count=20000 --concurrency=50
|
||||
```
|
||||
|
||||
`GATE=1` fails the run when a window falls under goal 6's floor, and refuses to judge at all on
|
||||
fewer than four cores.
|
||||
|
||||
**A short run measures the JIT, not the library.** At 2,000 messages per point the same build
|
||||
reported 16k/s where 100,000 messages reported 40k/s. Give each point several seconds.
|
||||
|
||||
**Cores matter, though the library is single-threaded.** The run is two Node processes, and past
|
||||
them V8 marks and compiles on threads of its own while the kernel carries loopback TCP. Window 200,
|
||||
100,000 messages:
|
||||
|
||||
| Cores | msgs/s |
|
||||
| --- | --- |
|
||||
| 1 | 20,476 |
|
||||
| 2 | 29,603 |
|
||||
| 4 | 32,869 |
|
||||
| 8 | 40,046 |
|
||||
|
||||
A window of 1 is unmoved by any of it (7,280–8,659 throughout) because it waits on the round trip
|
||||
rather than the CPU. The windowed figures are for the pair: one process alone reaches about half.
|
||||
|
||||
## Results, 2026-09-20
|
||||
|
||||
Single host, 8 cores, Node 24.18.0 in the project container, loopback. Client and SMSC are separate
|
||||
processes competing for the same CPUs, so these are a floor for split hosts.
|
||||
|
||||
`maxOutstanding` is the send window, and it is the setting that matters most:
|
||||
|
||||
| Window | msgs/s | with `dlr: true` |
|
||||
| --- | --- | --- |
|
||||
| 1 | 7,385 | 7,289 |
|
||||
| 10 (default) | 25,358 | 24,282 |
|
||||
| 50 | 37,125 | 37,502 |
|
||||
| 200 | 40,046 | 39,730 |
|
||||
|
||||
Requesting delivery receipts costs nothing measurable at any window. A window of 1 — one request in
|
||||
flight at a time — costs 5x, which is the round trip rather than the codec.
|
||||
|
||||
Against real peers, same driver, window 50, 20,000 messages:
|
||||
|
||||
| SMSC | msgs/s | failed |
|
||||
| --- | --- | --- |
|
||||
| this library's sink | 37,125 | 0 |
|
||||
| Jasmin 0.11.0 | 2,207 | 0 |
|
||||
| SMPPSim 2.6.11 | — | 19,000 of 20,000 |
|
||||
|
||||
## Against the other client libraries
|
||||
|
||||
Same sink, same 100,000 single-segment messages, same host. This is the comparison that means
|
||||
something: every client is measured pushing into *our* server, so the server's work is common to all
|
||||
three and only the client differs.
|
||||
|
||||
```bash
|
||||
docker compose -f compose.yaml -f benchmarks/compose.jsmpp.yaml up -d --build
|
||||
docker compose -f compose.yaml -f benchmarks/compose.jsmpp.yaml run --rm node \
|
||||
node benchmarks/peer-load.ts --driver=http://jsmpp:8080 --count=100000 --concurrency=50
|
||||
```
|
||||
|
||||
| Window | this library | jsmpp 3.0.3 | Cloudhopper 5.0.10 |
|
||||
| --- | --- | --- | --- |
|
||||
| 10 | 25,358 | 30,771 | 27,945 |
|
||||
| 50 | 38,675 | 40,934 | 32,384 |
|
||||
| 200 | 40,046 | 42,105 | 25,497 |
|
||||
|
||||
**We are slowest at the default window**, which is the setting most callers will ever run — 25,358
|
||||
against jsmpp's 30,771. That is the throughput work worth doing, and it is worth doing there.
|
||||
|
||||
Two things the table does not show. This library does it on one event loop where both Java peers
|
||||
spend one OS thread per in-flight request, which is why Cloudhopper falls off at 200 threads and we
|
||||
do not. And all three are pushing into the same Node sink, whose own cost is in every number, so the
|
||||
differences between clients are compressed rather than exaggerated here.
|
||||
|
||||
Kannel is absent deliberately: it is a gateway rather than a client library, wired here as an ESME
|
||||
that forwards from its own spool, so loading it would measure its HTTP frontend and queue rather
|
||||
than an SMPP client. The number would not belong in this table.
|
||||
|
||||
Jasmin routes and persists where the sink does neither, so the gap is not an efficiency ratio
|
||||
between two comparable things — what it establishes is that this library is not the bottleneck
|
||||
against a production SMSC, by more than an order of magnitude. SMPPSim's store fills at roughly a
|
||||
thousand messages and it then refuses the rest, so it cannot be loaded; that is a property of the
|
||||
simulator, not a result.
|
||||
|
||||
`smppload`, the one purpose-built SMPP load generator among the peers, is blocked by a bind defect
|
||||
of its own ([findings/07-load.md](../interop-tests/findings/07-load.md)), so no third-party load
|
||||
tool drives these numbers.
|
||||
@@ -0,0 +1,25 @@
|
||||
x-log-limits: &log-limits
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-file: "3"
|
||||
max-size: 20m
|
||||
|
||||
# The peer dials the host it was given at build time, "node", so the sink answers under that name.
|
||||
services:
|
||||
cloudhopper:
|
||||
build: ./interop-tests/peers/cloudhopper
|
||||
image: interop-cloudhopper-load:5.0.10-ae6485a
|
||||
command: ["node", "2775"]
|
||||
<<: *log-limits
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"]
|
||||
interval: 1s
|
||||
retries: 30
|
||||
timeout: 2s
|
||||
|
||||
node:
|
||||
command: ["node", "benchmarks/smsc-sink.ts"]
|
||||
environment:
|
||||
NPM_CONFIG_CACHE: /tmp/npm-cache
|
||||
PORT: "2775"
|
||||
@@ -0,0 +1,25 @@
|
||||
x-log-limits: &log-limits
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-file: "3"
|
||||
max-size: 20m
|
||||
|
||||
# The peer dials the host it was given at build time, "node", so the sink answers under that name.
|
||||
services:
|
||||
jsmpp:
|
||||
build: ./interop-tests/peers/jsmpp
|
||||
image: interop-jsmpp-load:3.0.3-a24db96
|
||||
command: ["node", "2775"]
|
||||
<<: *log-limits
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"]
|
||||
interval: 1s
|
||||
retries: 30
|
||||
timeout: 2s
|
||||
|
||||
node:
|
||||
command: ["node", "benchmarks/smsc-sink.ts"]
|
||||
environment:
|
||||
NPM_CONFIG_CACHE: /tmp/npm-cache
|
||||
PORT: "2775"
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Drives a peer's HTTP control surface through the same load the local driver runs, so the number
|
||||
* that comes back is that library's own rate against our sink rather than ours against theirs.
|
||||
*/
|
||||
function arg(name: string, fallback: string): string {
|
||||
const found = process.argv.find(one => one.startsWith(`--${name}=`));
|
||||
|
||||
return found === undefined ? fallback : found.slice(name.length + 3);
|
||||
}
|
||||
|
||||
const driver = arg('driver', 'http://jsmpp:8080');
|
||||
const count = arg('count', '20000');
|
||||
const concurrency = arg('concurrency', '50');
|
||||
|
||||
async function call(path: string): Promise<unknown> {
|
||||
const response = await fetch(`${driver}${path}`);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Cloudhopper's window defaults to 1 and is set at bind, so threads alone would serialise it.
|
||||
const bound = await call(`/bind?systemId=bench&password=benchpw&windowSize=${concurrency}`);
|
||||
|
||||
if (typeof bound !== 'object' || bound === null || !('ok' in bound) || bound.ok !== true) {
|
||||
process.stdout.write(`${JSON.stringify({ bind: bound })}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const loaded = await call(`/load?count=${count}&concurrency=${concurrency}`);
|
||||
|
||||
process.stdout.write(`${JSON.stringify(loaded)}\n`);
|
||||
|
||||
await call('/unbind');
|
||||
@@ -0,0 +1,95 @@
|
||||
import { availableParallelism } from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { createInterface } from 'node:readline';
|
||||
|
||||
/** Goal 6's floors, per send window. Set GATE=1 to fail the run instead of only reporting. */
|
||||
const floors: Record<number, number> = { 1: 5000, 10: 20_000, 50: 30_000, 200: 30_000 };
|
||||
|
||||
/** Below this, client and sink contend for one core and the windowed floors are unreachable. */
|
||||
const gateCores = 4;
|
||||
|
||||
/**
|
||||
* Node rather than Python, unlike the repo's other standalone scripts: it spawns the two processes
|
||||
* it measures, and they must run on the same runtime this library is measured under.
|
||||
*/
|
||||
const concurrencies = [1, 10, 50, 200];
|
||||
const count = Number(process.env.COUNT ?? 5000);
|
||||
|
||||
function node(script: string, args: string[] = []) {
|
||||
return spawn(process.execPath, [`${import.meta.dirname}/${script}`, ...args], {
|
||||
stdio: ['ignore', 'pipe', 'inherit'],
|
||||
});
|
||||
}
|
||||
|
||||
async function firstLine(stream: NodeJS.ReadableStream): Promise<string> {
|
||||
for await (const line of createInterface({ input: stream })) {
|
||||
return line;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
const sink = node('smsc-sink.ts');
|
||||
const listening: unknown = JSON.parse(await firstLine(sink.stdout));
|
||||
|
||||
if (typeof listening !== 'object' || listening === null || !('port' in listening)) {
|
||||
throw new Error('the sink did not report a port');
|
||||
}
|
||||
|
||||
const port = String(listening.port);
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
|
||||
for (const dlr of [false, true]) {
|
||||
for (const concurrency of concurrencies) {
|
||||
const load = node('submit-load.ts', [
|
||||
`--port=${port}`,
|
||||
`--count=${String(count)}`,
|
||||
`--concurrency=${String(concurrency)}`,
|
||||
...(dlr ? ['--dlr'] : []),
|
||||
]);
|
||||
const reported: unknown = JSON.parse(await firstLine(load.stdout));
|
||||
|
||||
await once(load, 'exit');
|
||||
|
||||
if (typeof reported === 'object' && reported !== null) rows.push({ ...reported });
|
||||
}
|
||||
}
|
||||
|
||||
sink.kill('SIGTERM');
|
||||
await once(sink, 'exit');
|
||||
|
||||
process.stdout.write(`\n${'dlr'.padEnd(6)}${'window'.padEnd(9)}${'msgs/s'.padEnd(10)}seconds\n`);
|
||||
|
||||
for (const row of rows) {
|
||||
const dlr = String(row.dlr).padEnd(6);
|
||||
const window = String(row.concurrency).padEnd(9);
|
||||
const rate = String(row.perSecond).padEnd(10);
|
||||
|
||||
process.stdout.write(`${dlr}${window}${rate}${String(row.seconds)}\n`);
|
||||
}
|
||||
|
||||
const cores = availableParallelism();
|
||||
|
||||
process.stdout.write(`\n${String(cores)} cores\n`);
|
||||
|
||||
if (process.env.GATE !== '1') process.exit(0);
|
||||
|
||||
if (cores < gateCores) {
|
||||
process.stdout.write(`refusing to gate on ${String(cores)} cores; goal 6 states four or more\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const short = rows.filter(row => {
|
||||
const floor = floors[Number(row.concurrency)];
|
||||
|
||||
return floor !== undefined && Number(row.perSecond) < floor;
|
||||
});
|
||||
|
||||
for (const row of short) {
|
||||
const floor = String(floors[Number(row.concurrency)]);
|
||||
|
||||
process.stdout.write(`below goal 6: window ${String(row.concurrency)} ran ${String(row.perSecond)}/s, floor is ${floor}\n`);
|
||||
}
|
||||
|
||||
process.exit(short.length === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,33 @@
|
||||
import { server } from '../src/server.ts';
|
||||
|
||||
/**
|
||||
* Answers every submit_sm ESME_ROK and does nothing else, so a measurement against it reads this
|
||||
* library's own ceiling rather than an SMSC's storage. Prints the bound port on stdout, then waits.
|
||||
*/
|
||||
const port = Number(process.env.PORT ?? 0);
|
||||
const { err, server: smpp } = await server({ port });
|
||||
|
||||
if (err) {
|
||||
process.stderr.write(`sink failed to listen: ${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let answered = 0;
|
||||
|
||||
smpp.on('session', session => {
|
||||
session.on('sms', async sms => {
|
||||
answered++;
|
||||
await sms.sendResp();
|
||||
});
|
||||
});
|
||||
|
||||
smpp.on('serverError', reason => {
|
||||
process.stderr.write(`sink serverError: ${reason.message}\n`);
|
||||
});
|
||||
|
||||
process.stdout.write(`${JSON.stringify({ port: smpp.port })}\n`);
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
process.stderr.write(`sink answered ${String(answered)}\n`);
|
||||
void smpp.close({ signal: AbortSignal.abort() }).then(() => process.exit(0));
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { client } from '../src/client.ts';
|
||||
|
||||
/**
|
||||
* Pushes `count` single-segment messages and reports what the wire carried per second. Keeps
|
||||
* `concurrency` sends in flight so the send window, not the caller, is what bounds the rate.
|
||||
*/
|
||||
function arg(name: string, fallback: string): string {
|
||||
const found = process.argv.find(one => one.startsWith(`--${name}=`));
|
||||
|
||||
return found === undefined ? fallback : found.slice(name.length + 3);
|
||||
}
|
||||
|
||||
const host = arg('host', '127.0.0.1');
|
||||
const port = Number(arg('port', '2775'));
|
||||
const count = Number(arg('count', '2000'));
|
||||
const concurrency = Number(arg('concurrency', '20'));
|
||||
const message = arg('message', 'benchmark');
|
||||
const dlr = process.argv.includes('--dlr');
|
||||
const username = arg('username', 'user');
|
||||
const password = arg('password', 'pass');
|
||||
|
||||
const { err, session } = await client({
|
||||
host,
|
||||
maxOutstanding: concurrency,
|
||||
password,
|
||||
port,
|
||||
responseTimeout: 60_000,
|
||||
username,
|
||||
});
|
||||
|
||||
if (err) {
|
||||
process.stdout.write(`${JSON.stringify({ error: err.message })}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Narrowing from the guard above does not reach into worker(), which runs after it.
|
||||
const bound = session;
|
||||
|
||||
let issued = 0;
|
||||
let failed = 0;
|
||||
let unanswered = 0;
|
||||
|
||||
async function worker(): Promise<void> {
|
||||
while (issued < count) {
|
||||
issued++;
|
||||
|
||||
const sent = await bound.sendSms({ dlr, from: 'BENCH', message, to: '46709771337' });
|
||||
|
||||
if (sent.err) failed++;
|
||||
|
||||
unanswered += sent.unanswered;
|
||||
}
|
||||
}
|
||||
|
||||
const started = process.hrtime.bigint();
|
||||
|
||||
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
||||
|
||||
const seconds = Number(process.hrtime.bigint() - started) / 1e9;
|
||||
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
concurrency,
|
||||
count,
|
||||
dlr,
|
||||
failed,
|
||||
perSecond: Math.round(count / seconds),
|
||||
seconds: Number(seconds.toFixed(3)),
|
||||
unanswered,
|
||||
})}\n`);
|
||||
|
||||
await bound.close({ signal: AbortSignal.abort() });
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,832 @@
|
||||
# Decisions
|
||||
|
||||
The standing decisions for `@larvit/smpp`, grouped by what each one constrains. A decision is
|
||||
written down only when it cannot be put better as a goal — [AGENTS.md](../AGENTS.md) carries that
|
||||
rule and an index of the titles below.
|
||||
|
||||
## The public surface
|
||||
|
||||
- **`Session` is publicly constructible, which is what makes `SessionOptions` and `ReconnectOptions`
|
||||
public too.** Raised twice as a leak; it is not one. The collaborators `session.ts` delegates to
|
||||
(`Reassembler`, `PendingRequests`, `SendWindow`, `ReconnectLoop`, `LinkTimers`, `LinkGate`,
|
||||
`DlrMerger`, `PduTransport`, `submitSms`) stay unpublished so they can be reshaped.
|
||||
|
||||
- **`acceptsOptionalParams()` and `bindAllows()` are predicates, not chokepoints.** The library's own
|
||||
senders consult them; `session.send({ tlvs })` is passed through as written, because silently
|
||||
stripping a caller's explicit TLVs off a deliberately public low-level surface would be worse than
|
||||
sending them. Only `submit_sm`, `deliver_sm` and `data_sm` are policed by bind direction — the
|
||||
three the library dispatches by it, of which it sends the first two.
|
||||
|
||||
- **Both emitters re-declare their listener methods to accept a promise.** Maintainer's call,
|
||||
2026-08-27: `EventEmitter` types every listener as void-returning, so the
|
||||
`session.on('sms', async sms => …)` README documents reads as a misused promise in any strict
|
||||
consumer. `declare on: …` and its six siblings re-type the inherited methods to return `unknown`,
|
||||
which emits nothing and needs no cast; overriding them as real methods cannot work, because the
|
||||
`super.on()` call needs one. The cost is that a subclass can no longer reach those seven through
|
||||
`super` — re-declaring them the same way is its way out. `unknown` rather than
|
||||
`void | Promise<void>` because a listener may return anything: `session.on('close', () =>
|
||||
set.delete(session))` returns a boolean. This also settles what the drain can wait on: a listener's
|
||||
own promise would be the better completion signal, and reaching it needs `listeners()`, which
|
||||
cannot be re-declared the same way — Node types it invariantly enough that widening `void` to
|
||||
`unknown` is `TS2416`. Re-probed 2026-09-01; `sendResp()` stays the signal.
|
||||
|
||||
- **`PduRefusedError` is exported, and `sessionError` names it in the event's type.** Maintainer's
|
||||
call, 2026-09-05, from a product review: one event carries both a PDU the peer malformed and the
|
||||
session's own failure, and `instanceof` is the only way to separate them that hard rule 4 allows —
|
||||
without the class as a value an application is left string-matching `err.message`. Goal 8 is paid by
|
||||
exporting the discriminant and the struct it carries and nothing else: `PduHeader` is named because
|
||||
an application that logs or forwards a header wants a name for it, `PduRefusalReason` is not
|
||||
because `reason` is compared against string literals, and an accessor
|
||||
(`PduRefusedError['header']`) names either one where a signature wants it. The payload union
|
||||
enforces nothing — a subclass narrows out of `Error` either way — and is there so the event's own
|
||||
type names what to narrow to, which is also what makes it a half-truth if a second `Error` subclass
|
||||
ever reaches this event without joining it. Rejected: a `SessionError` alias for that union, a
|
||||
third name for a type that is structurally `Error`. Rejected: a separate `pduRefused` event, which
|
||||
splits the failure channel so an application that wants every failure listens twice and an existing
|
||||
listener silently stops seeing refusals. Rejected: coalescing or rate-limiting them, which re-opens
|
||||
the standing decision that `sessionError` carries every failure, never coalesced or suppressed —
|
||||
the filtering belongs where the application is, since only it knows which peer is routinely sloppy.
|
||||
Rejected: an error code on a plain `Error`, which reads back off an `unknown` property only through
|
||||
a cast and types nothing it carries. Accepted: a second copy of the package installed alongside
|
||||
this one defeats `instanceof`, where `err.name` still reads `PduRefusedError`.
|
||||
|
||||
- **`bitCount()`, `encodeMessage()` and `splitMessage()` keep their total signatures, because
|
||||
`EncodingName` is what keeps an alphabet with no codec away from them.** Maintainer's call,
|
||||
2026-09-09, from the architecture review of [#95](https://github.com/larvit/larvitsmpp/pull/95):
|
||||
all three index `encodings` by name and would throw on one it has no codec for, which hard rule 1
|
||||
forbids. That PR left no such name to pass — `encodings` is a `Record<EncodingName, Encoding>`, so
|
||||
every member of the union has a codec and one added without a codec, or without a segment budget,
|
||||
fails to compile in four places. What was missing is the door for a caller holding a name at
|
||||
runtime: `Object.hasOwn(encodings, x)` is the only test the published surface offered and it
|
||||
narrows nothing, so `isEncodingName()` is exported beside `isCommandName()` and `isErrorName()`,
|
||||
which serve their own tables that way. Rejected: a `Result` signature on all three, which costs
|
||||
every typed consumer a narrow forever — goal 8, 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()` widens `encoding`, `messagingMode` and the two time options to `unknown` and refuses
|
||||
each by name, which is what a caller without types gets. `smppTime.encode()` is where that reasoning lands the other way and is recorded
|
||||
under [The wire](#the-wire): `Date | number | string` is not a closed set, so it is a `Result`.
|
||||
|
||||
- **A segment the SMSC took and named no id for is `undefined` in `smsIds`, not an empty string.**
|
||||
Maintainer's call, 2026-09-12: `paramText()` resolves an absent `message_id` and one a peer wrote
|
||||
empty to the same `''`, which `string[]` then presented as an id — taking `smsIds[0]`, or keying a
|
||||
correlation table by the array, compiled and then misbehaved, and one message's empty entry
|
||||
collides with another's. Telesign names an id for the first segment of a concatenated submit only,
|
||||
so it is a documented operator's shape rather than a hypothesis. Nothing else moves:
|
||||
`parseSegmentId('')` matched nothing, so `DlrMerger` already abandoned such a send and `undefined`
|
||||
reaches that same refusal. `expect()` takes the wider type rather than a filtered `string[]`
|
||||
because the arity is what `idNumbering()` refuses on: filtering `['a-1', undefined, 'a-3']` leaves
|
||||
a numbering that spells out a whole message, and merges one that was never whole. `dlrFromPdu()`
|
||||
reads an id through `nonEmptyText()`, so no receipt could ever have matched an empty entry — and
|
||||
that reading stays separate from this one rather than sharing a helper, since it must leave a
|
||||
Buffer-valued `receipted_message_id` unresolved for `messageType()` to read the PDU as unmarked.
|
||||
What settles it here is the resolved text rather than the parameter, because `writeParams()`
|
||||
substitutes the field's own default: a peer that omits `message_id` and one that writes it empty
|
||||
build the same octets, leaving a raw-parameter test nothing to tell apart. Rejected: keeping `''`
|
||||
and documenting it, which leaves the published type promising what the value does not keep — goal
|
||||
2, a wrong answer about what the peer named. Rejected: dropping the unnamed entries, which breaks
|
||||
the positional correspondence with `pduObjs` that README promises and loses which segment a PDU
|
||||
belongs to. Rejected: `{ id?: string; pduObj: PduObject }[]`, which makes that positional promise
|
||||
structural where today the compiler cannot check it; deferred to the next breaking release, the
|
||||
first place two documented fields may become one. Accepted: every consumer reading `smsIds`
|
||||
narrows, including the majority whose SMSC names every id; indexing narrows too, except for the
|
||||
consumer who sets `noUncheckedIndexedAccess`, which typed `smsIds[0]` as `string | undefined`
|
||||
already.
|
||||
|
||||
## The wire
|
||||
|
||||
- **The declared interface version is an option on both `client()` and `server()`, and is not the
|
||||
optional-parameter threshold.** That threshold is fixed at 0x34 by the spec, so an implementation
|
||||
that must declare 5.0 throughout can, without moving it.
|
||||
|
||||
- **A peer that declared no version is pre-3.4, and `undefined` means no bind yet.** `acceptBind()`
|
||||
records what the ESME declared and the client's `bind()` records the `sc_interface_version` the
|
||||
SMSC answered with; a peer that declared nothing is recorded as `undeclaredInterfaceVersion` (0x00)
|
||||
and sent no optional parameters, which is how the spec reads an absent `sc_interface_version`.
|
||||
|
||||
- **`esm_class` decides what a `deliver_sm` is, and the body is read only when it names nothing.**
|
||||
The two types the MC writes about a message we submitted — `MC_DELIVERY_RECEIPT` (0x04) and
|
||||
`INTERMEDIATE_DELIVERY` (0x20) — are reports whatever the body parses to, so one in a format
|
||||
`dlrFromPdu()` cannot read reaches `dlr` with `smsId` undefined instead of arriving as an inbound
|
||||
SMS. The three the far-end SME writes (0x08, 0x10, 0x18) are messages and their bodies are not
|
||||
scraped: Kannel reads 0x08 as report-bearing and this does not, because a delivery acknowledgement
|
||||
is the handset's word about a message, not the network's. A message type of 0 or one of the ten
|
||||
reserved keeps the scrape, and a non-empty `receipted_message_id` TLV marks a report on the same
|
||||
footing. A report this library recognises never reaches the reassembler, so an SMSC that splits one
|
||||
across segments gets a `dlr` per segment rather than one merged report. The `message_state` TLV is
|
||||
authoritative only where it names a state in the table — SMPP reserves 0x80-0xFF for
|
||||
MC-vendor-specific values, so an unnameable one keeps its raw `statusId` and leaves `statusMsg` to
|
||||
the body.
|
||||
|
||||
- **A body is read from `message_payload` where `short_message` carries none, and `short_message`
|
||||
wins where a peer filled both.** Maintainer's call, 2026-09-06, from the Jasmin interoperability
|
||||
phase: SMPP 3.4 5.3.2.32 makes the TLV the alternative for a body the mandatory field cannot
|
||||
carry, several SMSCs use it, and Jasmin relays one faithfully — reading `short_message` alone
|
||||
handed the application an empty message
|
||||
([interop-tests/findings/03-jasmin.md](../interop-tests/findings/03-jasmin.md)). `messageOctets()` is
|
||||
the single answer to where a body is, so the message path, the reassembler and `dlrFromPdu()`
|
||||
cannot disagree about it, and `esm_class` still says whether that body starts with a UDH wherever
|
||||
it was carried, which leaves concatenation reading exactly as before. Filling both contradicts the
|
||||
spec's own instruction to leave `sm_length` zero, and taking the mandatory field there keeps the
|
||||
rule purely additive: no PDU that parsed before reads differently now. Rejected: preferring the
|
||||
TLV, which re-reads every message a peer echoes into both. Rejected: refusing a PDU carrying both,
|
||||
which discards a message that is almost certainly present twice over, where goal 3 keeps the
|
||||
traffic.
|
||||
|
||||
- **A segment's concatenation is read from its UDH, or from the `sar_*` TLVs where it declares none,
|
||||
and each spelling groups in a reference space of its own.** Maintainer's call, 2026-09-06, from
|
||||
the Jasmin and Java-client interoperability phases: SMPP 3.4 5.3.2.31-5.3.2.33 make
|
||||
`sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum` the other way to say what a UDH says,
|
||||
Jasmin documents it as its own segmentation and jsmpp writes it, and reading the UDH alone handed
|
||||
the application one `sms` per fragment
|
||||
([interop-tests/findings/05-java-clients.md](../interop-tests/findings/05-java-clients.md)).
|
||||
`concatOf()` is the single answer to how a PDU says it is a segment, as `messageOctets()` is to
|
||||
where a body is, and both are exported for the same reason: an application on the low-level
|
||||
surfaces would otherwise rewrite the read this fixed. It carries the spelling beside the
|
||||
reference, so the key is two tokens the reassembler joins and interprets neither of, and so the
|
||||
refusal can name the field the peer got wrong — `ESME_RINVESMCLASS` for a UDH, `ESME_RINVTLVVAL`
|
||||
for the TLVs, whose segment's `esm_class` is 0x00 and correct. Keying them together instead would
|
||||
assemble two of a peer's messages into one, since a UDH reference is 8 bits and `sar_msg_ref_num`
|
||||
is 16 and neither counts the other's messages; the two UDH widths share a space because they are
|
||||
one sender's counter in one layer, where a `sar_*` reference is another layer's. A UDH that names
|
||||
the concatenation wins over the TLVs — one carrying only a port leaves them to say — which keeps
|
||||
the change additive for every message that reassembled before, and leaves the library nothing to
|
||||
guess where the two disagree. Rejected: preferring the TLVs, which regroups every message a
|
||||
gateway derived them from. Rejected: comparing the parts and reporting a disagreement: the
|
||||
references are not comparable at all, and where the parts are, the UDH is still what the message
|
||||
is assembled by, so the report would name a failure the application cannot act on. Accepted: a
|
||||
peer that switches spelling mid-message now has two groups that expire rather than fragments that
|
||||
arrive, which goal 2 prefers to a message assembled from two counters. Receive-only: `sendSms()`
|
||||
goes on writing a UDH with an 8-bit reference, where a send-side `sar_*` would be a second
|
||||
spelling of one message whose only difference is which peers accept it.
|
||||
|
||||
- **`sendSms()` takes the messaging mode by name, and it is the only part of `esm_class` a caller
|
||||
writes.** Maintainer's call, 2026-09-06, closing target 5 of the interoperability plan: every peer
|
||||
the suite ran took the 0x40 this library sends on a concatenated segment, but Route Mobile and
|
||||
Kaleyra both document `esm_class` 0x43 for one, and a caller facing either had to hand-build every
|
||||
segment through `send()` — giving up the split, the per-segment ids, the send window and the
|
||||
receipt merge, which is what goal 8 means by beating "the application can do this itself". The four
|
||||
modes of SMPP 3.4 5.2.12 are a `MESSAGING_MODE` constant group and the option takes one of their
|
||||
names, so 0x43 is a composition this library makes rather than a value a caller states, and the UDH
|
||||
indicator a segment carrying a header needs cannot be cleared by anything the option can express.
|
||||
It takes three of those four: 2.10.3 carries transaction mode on `data_sm` alone, and none goes out
|
||||
of here, so `FORWARD` stays in the group that mirrors the spec table and `sendSms()` refuses it by
|
||||
that reason rather than as an unknown name — a mode this library cannot deliver is a promise goal 8
|
||||
will not let it make. `DATAGRAM` with `dlr: true` is refused on the same footing: 2.10.2 defines the
|
||||
report away, so arming `DlrMerger` for one is goal 2's wrong answer, where the mode alone and a
|
||||
report under any other mode both go out untouched. Those three names left `ESM_CLASS`, where they
|
||||
had `constsById.ESM_CLASS` read 0x03 as a whole `esm_class`. Rejected: a raw `esmClass` number,
|
||||
which is exactly that clearable state and would need refusing bit by bit to be safe. Rejected:
|
||||
taking a number beside a name, two spellings of one goal — which is why a value naming no mode is
|
||||
refused, by name, before a segment goes out. Rejected: a session-level default with a per-send
|
||||
override; an operator's requirement is a property of the link, but the library can verify nothing
|
||||
the caller's own options object does not, and shipping both buys a precedence rule to document and
|
||||
test for that. `SMSC_DEFAULT` is named so pinning the default deliberately is sayable.
|
||||
|
||||
- **An inbound `data_sm` stands in for whichever of `submit_sm` and `deliver_sm` its direction makes
|
||||
it, and none goes out.** Maintainer's call, 2026-09-06, from the Jasmin interoperability phase:
|
||||
SMPP 3.4 4.7.1 makes it a peer of both that always carries its body in `message_payload`, and
|
||||
Jasmin's `[dlr-thrower] dlr_pdu = data_sm` throws real receipts on it, which `ESME_RINVCMDID`
|
||||
dropped with nothing reported to the application at all. Every command but this one names its own
|
||||
direction, which is why the bind gate and the dispatch never had to be told which end of the link
|
||||
they are on; `linkEnd` is that fact, and it decides both. At the ESME end an inbound one is a
|
||||
delivery, so `esm_class` classifies it as it classifies a `deliver_sm`; at the SMSC end it is a
|
||||
submission and is read as one, because a report about a message this end never sent is goal 2's
|
||||
wrong answer whatever `esm_class` a peer wrote on it. A concatenated one is answered segment by
|
||||
segment either way, and 4.7.2 gives `data_sm_resp` a `message_id` where 4.6.2 leaves
|
||||
`deliver_sm_resp`'s unused, so the answer carries one. `linkEnd` is a field beside `boundAs`
|
||||
rather than a `SessionOptions` entry, so the code that knows which end this is writes it and
|
||||
nothing else can contradict what the session then binds as. Rejected: grouping the command with
|
||||
`deliver_sm` in the gate, which refuses a transmitter-bound ESME's legitimate submission, and with
|
||||
`submit_sm`, which refuses the receiver-bound delivery this was fixed for. Rejected: sending one —
|
||||
`send()` reaches the command raw, and an option choosing which command a message goes out on would
|
||||
be a second spelling of `sendSms()` whose only difference is which peers accept it.
|
||||
|
||||
- **A receipt's body is read as octets, and its own `data_coding` never says how.** Maintainer's
|
||||
call, 2026-09-05 via the SMPPSim interop run: SMPPSim copies the reported message's `data_coding`
|
||||
onto a receipt whose body it always writes as plain text, and Melrose Labs documents the same
|
||||
echo, so decoding by that field turns an Appendix B receipt into UCS-2 garbage — total loss
|
||||
against the many peers that send no TLVs to fall back on. `dlrFromPdu()` reads
|
||||
`PduObject.shortMessageOctets` through Latin-1, the one codec that maps every octet to a
|
||||
character, so the fixed fields parse whatever the PDU claims; the codec keeps both spellings
|
||||
because a message needs the text and a receipt needs the octets. Rejected: honouring `data_coding`
|
||||
where the octets yield no field, which reads one body two ways for the sake of a peer writing a
|
||||
UCS-2 receipt body that no researched SMSC is — that peer's receipt yields no fields at all here,
|
||||
which goal 2 reports as undetermined rather than guessed. An inbound message is untouched: nothing
|
||||
but `data_coding` can say how a message was written.
|
||||
|
||||
- **A message class is read where GSM 03.38 puts it, `flash` is class 0 alone, and a flash message
|
||||
with no alphabet to carry it is refused.** Maintainer's call, 2026-09-09, closing the last target
|
||||
of the interoperability plan: `sms.flash` was `(data_coding & 0xF0) === 0x10`, which called the
|
||||
ME-, SIM- and TE-specific classes immediate display and missed the 0xF0 group entirely — the only
|
||||
one SMPP 3.4 5.2.19 names, since it marks 0x0F to 0xBF reserved and hands 0xF0 to 0xFF to GSM
|
||||
03.38, and the one SMPPSim demonstrated
|
||||
([interop-tests/findings/02-smppsim.md](../interop-tests/findings/02-smppsim.md), C17).
|
||||
`messageClassOf()` is the single answer to whether a `data_coding` carries a class and which, as
|
||||
`concatOf()` is to how a PDU says it is a segment: 03.38 section 4 puts the class in bits 1-0,
|
||||
carried where bit 4 says so in every group below 0x80 and always in the 0xF0 group, and
|
||||
`encodingByDataCoding()` reads the alphabet off that same test rather than repeating the group
|
||||
masks beside it. It is exported for the reason `concatOf()` is — an application that needs a class
|
||||
other than 0 would otherwise rewrite the read this fixed. Rejected: a `messageClass` field on the
|
||||
`sms` event, which pays goal 8 for three classes nothing here acts on, where the boolean the
|
||||
application already had covers the one it does. Compressed text is out of scope and stays out —
|
||||
nothing here implements 3GPP TS 23.042, so a compressed body reaches the application as whatever
|
||||
its declared alphabet makes of it — but bit 5 does not move the class bits, so 0x30 is read as
|
||||
class 0 rather than special-cased into a wrong answer; 01xx is read for the same reason, 03.38
|
||||
coding it exactly as 00xx. Rejected: reading only the two groups the defect named, which needs an
|
||||
extra test to produce a wrong answer for a class the spec puts in plain sight. Accepted: the
|
||||
alphabet is read only where a class is, so 0x58 is UCS2 while 0x48 — the same alphabet with the
|
||||
class bit clear — stays ASCII, because below 0x10 SMPP's flat table contradicts 03.38 and wins
|
||||
(0x03 is Latin-1 there, GSM 7-bit here) and a class is the only evidence a peer below 0x80 is
|
||||
spelling 03.38 at all. Send-side: `flash`
|
||||
is that class, so it goes out as 0x18 beside UCS2 and 0x10 beside GSM 7-bit, while
|
||||
`encoding: 'LATIN1'` beside it is refused before a segment goes out, the way a messaging mode this
|
||||
library cannot deliver is — 03.38's class groups hold GSM 7-bit, 8-bit data and UCS2, and Latin-1 is
|
||||
SMPP's own flat-table alphabet, so the pair has no spelling. Rejected: 0x10 with Latin-1 octets,
|
||||
which declares an alphabet the body is not in; rejected: 0x14, 8-bit data, which is not text to
|
||||
the handset that would display it; rejected: promoting it to UCS2, which overrides the one option
|
||||
the caller wrote in order to override a choice. Rejected with them: `encoding: 'FLASH'`, which
|
||||
named `data_coding` 0x10 among the alphabets and so reached the class through the option that
|
||||
chooses a charset — a second spelling of `flash: true` that also flattened every non-GSM character
|
||||
to a space on the way. It leaves `EncodingName`, which is now exactly the three codecs `detect()`
|
||||
and `encodingByDataCoding()` return, and `encoding` is checked by name like `messagingMode` so a
|
||||
caller without types gets a refusal rather than a throw out of the codec table. `consts.ENCODING`
|
||||
keeps its `FLASH` entry: the low-level surface reaches raw constants, and nothing reads that group
|
||||
as an alphabet any more. Accepted: `flash` is now false for `data_coding` 0x11 to 0x13, which no
|
||||
peer means as immediate display.
|
||||
|
||||
- **A report is final unless its `esm_class` or its state says otherwise, and only `ENROUTE` and
|
||||
`SCHEDULED` say otherwise.** SMPP 3.4 Appendix B lists every other receipt state as final,
|
||||
`UNKNOWN` and `ACCEPTED` included, so a peer writing `ACCEPTD` for a carrier-accepted step is taken
|
||||
at its word. Rejected: reading `UNKNOWN` as non-final, which leaves a peer whose receipt body this
|
||||
library cannot read with no `messageDlr` at all — goal 2 wants that reported as undetermined, not
|
||||
withheld. Both spellings resolve into `Dlr.intermediate` at the boundary rather than being read a
|
||||
second time in `DlrMerger`, so the library cannot answer the application one way and conclude the
|
||||
other. Not every peer marks a transient report 0x20 — an ordinary receipt carrying `stat:ENROUTE`
|
||||
is common — so the state test is what the marker test cannot replace. `message_state` 0 is 5.0's
|
||||
`SCHEDULED` and undefined in 3.4; a peer that writes it is read as transient rather than as saying
|
||||
nothing, maintainer's call, 2026-09-03, since the codec refuses a zero-length integer TLV and so an
|
||||
absent one cannot land there.
|
||||
|
||||
- **A `stat:` an operator spells outside Appendix B is read as the state it names, and the two
|
||||
researched ones are `FAILED` and CM.com's `DELIVERD`.** Maintainer's call, 2026-09-08, from the
|
||||
operator-fixture phase: Kaleyra and Route Mobile both document `FAILED` in that field as a terminal
|
||||
delivery failure, and the research attributes it to Vonage as well; CM.com's own code table prints
|
||||
`DELIVERD` — eight characters — beside six correct ones. Both were left at `statusMsg: UNKNOWN` —
|
||||
the same answer a receipt really saying `stat:UNKNOWN` gets, so an application could not tell an
|
||||
operator's "it failed" from its "I do not know", nor a delivered message from one whose state
|
||||
could not be read; and `DlrMerger` ranks `UNKNOWN` below `EXPIRED`, reporting a multipart send
|
||||
carrying a failed segment as expired. They join `receiptStates` alone: `receiptCodes` goes on
|
||||
writing the seven characters 3.4 defines, so nothing this library sends gains either spelling. Rejected: a `FAILED` member of `MESSAGE_STATE`, which
|
||||
is 3.4's own numbered table — the code has no number there, so one would have to be invented, and
|
||||
every consumer's switch would grow a case no `message_state` TLV can carry. Rejected: leaving it
|
||||
`UNKNOWN` and sending the application to `dlr.receipt.stat` for the state, which reports a terminal
|
||||
failure as undetermined and leaves the merge ranking it below `EXPIRED`. Rejected: reading the
|
||||
numeric status tables Syniverse and Route Mobile publish beside it, which are vendor fields of
|
||||
their own rather than the seven characters `stat:` holds. Accepted: all three of those operators
|
||||
document `FAILED` and `UNDELIV` as separate codes, and both now resolve to `UNDELIVERABLE` — an
|
||||
application that must tell them apart reads `dlr.receipt.stat`, which carries what the SMSC wrote.
|
||||
Accepted: an unmarked `deliver_sm` whose body says one of them now reaches the application as a
|
||||
report where it used to arrive as an inbound message, which is what every code already in the table
|
||||
does. What decides a spelling is whether the corpus in `test/operator-receipts.test.ts` can cite the
|
||||
page it is printed on and no other code could be meant, which is why `DELIVERD` is read and a
|
||||
spelling nobody publishes is not: a mapping that costs nothing where an operator's own docs merely
|
||||
contain a typo saves an application everything where they do not.
|
||||
|
||||
- **A transient state goes out as an intermediate delivery notification (0x20), every other state as
|
||||
a delivery receipt (0x04).** Appendix B makes a receipt's `stat` the message's final status, so
|
||||
0x04 over `ENROUTE` emits the two disagreeing spellings of finality the reading side above has to
|
||||
reconcile, and goal 3 has our own senders write the marker 3.4 defines. `sendDlr()` takes the list
|
||||
from `transientStates` in `dlr.ts`, the same one the reader uses, so the two cannot drift.
|
||||
Rejected: 0x04 for every state, for the sake of a peer that classifies on the marker — the cost
|
||||
accepted here is that such a peer stops recognising a transient report as a report at all and hands
|
||||
its application receipt text as an inbound message, where under 0x04 it would have read the state
|
||||
from `stat:` and been right. A transient state also carries `err:000`, since a message still on its
|
||||
way has not failed.
|
||||
|
||||
- **A refused PDU is answered from its header, and any 32-bit `sequence_number` is echoed as it
|
||||
arrived.** Maintainer's call, 2026-09-05 via the interop plan. The header of a framed PDU always
|
||||
parses, so it carries the answer SMPP 3.4 4.3 asks for, with the status 3.4 names for the part
|
||||
that would not parse. Rejected: nacking a refused *response*, whose sequence number is one of
|
||||
ours — the `generic_nack` would land in the peer's own numbering and nack a request of the peer's
|
||||
we never saw, so a refused response is written back nothing and settles the request it names
|
||||
instead. An unknown command id with the response bit set takes that branch too: a peer echoing a
|
||||
sequence number of ours is answering something, and settling it reaches the undetermined outcome
|
||||
`responseTimeout` would have reached anyway, sooner. Rejected: clamping a sequence number outside 4.7.1's 0x00000001–0x7FFFFFFF into range
|
||||
before answering, which correlates with nothing at the peer — stacks write the field as a plain
|
||||
uint32 (ukarim/smscsim signs every unprompted `deliver_sm` with a raw `rand.Int()`), so goal 3
|
||||
keeps that traffic and `PendingRequests.nextSeqNr()`, the only thing that invents one, is what
|
||||
holds our own sends inside the spec.
|
||||
|
||||
- **The optional parameters run to `command_length` exactly, and the only slack tolerated is one
|
||||
NULL octet where a peer padded `short_message`.** Maintainer's call, 2026-09-06, from the
|
||||
Java-client interoperability phase: accepting any parse that merely did not error answered
|
||||
`ESME_ROK` to a `deliver_sm` whose three trailing octets were never read, dropping the
|
||||
`receipted_message_id` that makes a receipt a receipt
|
||||
([interop-tests/findings/05-java-clients.md](../interop-tests/findings/05-java-clients.md)). Goal 2
|
||||
settles it against goal 3: octets this codec cannot name are a PDU it did not read, so a region
|
||||
that does not end on `command_length` — the padded read included — is refused with the `tlvs`
|
||||
reason and `ESME_RINVTLVSTREAM` a truncated TLV value already gets. What the rule costs is paid
|
||||
once, in `readCstring()`: a trailing C-Octet String a peer left out entirely consumes no octet,
|
||||
where reporting the terminator it never sent puts every later offset past the declared end and
|
||||
refuses a bind, and every bodyless response, that used to parse. That composes, so a run of them
|
||||
at the tail all read empty — `outbind` is the only command with two, and an absent field and an
|
||||
empty one say the same thing, so goal 2 is not at stake even there. Rejected: keeping the tolerance
|
||||
for the one to three trailing octets too few to hold a TLV header, which no researched peer sends
|
||||
and which cannot be told apart from the truncated tail this fixes. Rejected: refusing it as
|
||||
`body`/`ESME_RINVCMDLEN`, which names the mandatory fields — the part the peer got right.
|
||||
|
||||
- **`smsIdFormat` names a notation per place, and normalisation never reaches inside a `<base>-<n>`
|
||||
id.** An SMSC may answer `submit_sm_resp` in hex and write the receipt's `id:` in decimal, so one
|
||||
transform over both sides cannot make them equal. `submitResp` covers the `receipted_message_id`
|
||||
TLV too, which SMPP 3.4 5.3.2.26 defines as the id the `submit_sm_resp` carried: naming one
|
||||
notation for whichever id a receipt yields would break the peer that sends both. Omitting a place
|
||||
is what leaving it alone means, so there is no `raw` notation, and a caller-supplied formatter is
|
||||
refused because it would make the promise that the two ids are comparable unverifiable — `onRequest`
|
||||
and the PDU on the `dlr` event are the escape hatches. A `<base>-<n>` id parses as no number and so
|
||||
reaches `expect()` and `collect()` unchanged, which is what keeps `DlrMerger` working; normalising
|
||||
the base instead would break that pair. The option is on `client()` only, since a `server()` session
|
||||
writes both ids itself.
|
||||
|
||||
- **A concatenated segment is budgeted at 134 octets, which is 153 septets where the SMSC packs them
|
||||
and 134 octets of anything it does not.** Maintainer's call, 2026-09-09, from the architecture
|
||||
review of [#95](https://github.com/larvit/larvitsmpp/pull/95): `segmentUnits` handed 153 to
|
||||
everything but UCS2, so a long `encoding: 'LATIN1'` message went out as segments of 153 octets plus
|
||||
a 6-octet UDH — 159 on the air where GSM 03.40 carries 140, which no SMSC can deliver. Goal 1 owns
|
||||
it. There is one budget, 140 less the UDH, and the alphabet decides only what it is counted in, so
|
||||
Latin-1 and UCS2 both take those 134 octets — 134 characters and 67 — and it is GSM 7-bit's 153
|
||||
that is the odd number rather than the other way round. `Record<EncodingName, number>` is what makes
|
||||
a fourth alphabet state its own. Rejected: 134 for GSM 7-bit too, which is the mistake
|
||||
[GSM 7-bit is sent unpacked](../AGENTS.md#gsm-7-bit-is-sent-unpacked) 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 8'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 8, 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.
|
||||
Rejected: refusing a string `message_payload` outright and demanding octets, which contradicts
|
||||
`short_message` on the same PDU. Rejected: guarding every string-valued field against
|
||||
`data_coding`, which says nothing about them — a text field on the wire has an alphabet of its
|
||||
own.
|
||||
|
||||
- **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.
|
||||
|
||||
- **Every text field on the wire is latin1, and what the field cannot carry is refused rather than
|
||||
truncated.** Maintainer's call, 2026-09-21, the refusals from the security and stability passes on
|
||||
[#16](https://gitea.larvit.se/larvit/smpp-js/pulls/16). 3.4 calls these fields ASCII, so goal 3
|
||||
settles the read alone — its generous clause is scoped to reading, and its sender clause is strict.
|
||||
Goal 1 settles the write, being 3.4 as SMSCs actually run it: an operator routing an alphanumeric
|
||||
sender through the upper half is traffic to keep, and Node's `ascii` write already put those octets
|
||||
on the wire, so naming the write latin1 makes the round trip idempotent and no peer sees a change.
|
||||
Goal 2 settles the two latin1 refusals, each a `size()` that would have agreed with a `write()`
|
||||
that put something else on the wire: a character past `U+00FF` written as its low octet, and a caller's
|
||||
own `U+0000`, which a mandatory field's reader takes as the end of the field. Goal 4 settles them
|
||||
twice over: for one character in every 256 that low octet is `0x00`, and the PDU went out malformed
|
||||
on the operator's parser. `wantText()` and `wantCstringText()` are the two places that decide the
|
||||
refusals; every read and write spells `latin1` itself. Rejected: reading latin1 and leaving the write spelled ASCII, which leaves two halves agreeing
|
||||
only by accident. Rejected: refusing the upper half on send
|
||||
to stay strict to 3.4's ASCII, which would be a new restriction taking away traffic this library
|
||||
already sends and operators already accept, on no defect. Rejected: refusing `U+0000` in every
|
||||
text field, which would buy one spelling by taking a legitimate octet away from the
|
||||
length-prefixed Octet String, whose length octet is what ends it.
|
||||
|
||||
- **A TLV input is keyed by its tag name, or by its decimal id where the table names none, and a
|
||||
`tagId` beside the key is accepted only where it agrees.** Settled in the architecture and
|
||||
product-owner reviews of [#30](https://gitea.larvit.se/larvit/smpp-js/pulls/30), 2026-09-27. The
|
||||
key is the one spelling, because a name and a `tagId` that disagreed sent the `tagId`'s tag under
|
||||
a record keyed as another. A parsed TLV carries its `tagId`, and goal 8's passthrough means a
|
||||
parsed PDU's `tlvs` relay as they are, so an agreeing copy is read past rather than refused.
|
||||
Rejected: refusing every `tagId`, which breaks relaying. Rejected: a `tagId` overriding the key,
|
||||
the 0.5.0 behaviour. Valid while parsed TLVs carry `tagId`.
|
||||
|
||||
## The session's life
|
||||
|
||||
- **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call,
|
||||
2026-08-26: most SMSCs drop the socket instead of answering, so the documented shutdown would
|
||||
otherwise always report a failure. It does mask a socket that died mid-unbind for an unrelated
|
||||
reason, which is accepted — the peer sees the same TCP close either way.
|
||||
|
||||
- **`close` means the session is over, and a drop the loop will retry is `disconnected`.**
|
||||
Maintainer's call, 2026-08-31: without the split, an application that opens a replacement client on
|
||||
`close` ends up holding two binds on one account. `teardown()` picks the event by whether the
|
||||
reconnect loop is still live, and `end()` stops that loop before tearing down, so every deliberate
|
||||
shutdown emits `close`. A retry that opens a socket and then loses it clears `closed` through
|
||||
`attach()`, which is why a second drop emits again.
|
||||
|
||||
- **An answer belongs to the link the message arrived on; a receipt does not.** Maintainer's call,
|
||||
2026-09-01. Rejected: answering on the new link, which succeeds and reports `{}` for a response
|
||||
that correlates with nothing — goal 2's wrong answer. Accepted: a receipt sent after a refused
|
||||
response names an id the peer has no record of.
|
||||
|
||||
- **`reconnect` takes `{ minDelay, maxDelay }` to retune and `false` to turn off**, so absent means
|
||||
on and there is one spelling for each. Only `client()` reconnects — a `server()` session is a
|
||||
connection the peer opened, and nothing at this end can reopen it. The retry timer is `unref()`'d,
|
||||
so a process with nothing else left to do still exits between attempts.
|
||||
|
||||
- **Coming up is not proof a link works, so only one that outlasted `maxDelay` resets the backoff.**
|
||||
An unreadable stream is found after the bind returns, so resetting on connect gave a link that died
|
||||
on arrival a fresh `minDelay` every cycle — one TCP connect and bind per second, forever. A drop
|
||||
after a healthy link still retries at `minDelay`.
|
||||
|
||||
- **`reconnect: { fromStart: true }` puts the first connect and bind through that same loop, and
|
||||
`client()` then resolves only once it is bound.** Maintainer's call, 2026-09-05: an application
|
||||
started before its SMSC is up otherwise writes that retry itself, around the one this library
|
||||
already owns. A field on `reconnect` rather than an option of its own, so the combination that
|
||||
would contradict `false` cannot be written at all — `false` carries no fields — and a top-level
|
||||
`fromStart` is refused by name rather than ignored. Nothing but the caller's `signal` ends the
|
||||
wait: a bound of its own would be a second spelling of a deadline the caller already writes with
|
||||
that signal, and giving up after one is what the default does. A bind the SMSC refuses is
|
||||
retried like any other failure — rejected: giving up on `ESME_RINVPASWD` and `ESME_RBINDFAIL`,
|
||||
which would have the initial attempts and a rebind disagree about what a refused bind means, and
|
||||
gives up on the operator whose provisioning lands a minute later; the backoff is what bounds the
|
||||
rate goal 4 cares about. The attempts before the first link report nothing, because the session
|
||||
running one has not reached the application: `disconnected` would have no listener and `close`
|
||||
would be a lie. Its wait is the one retry timer that is not `unref()`'d, for the reason
|
||||
`LinkGate`'s hold is not — it is awaited with no other handle, so a process whose only work is
|
||||
`client()` would exit unbound.
|
||||
|
||||
- **`connectTimeout` defaults to 10 s, bounds the whole connect including the TLS handshake, and
|
||||
`false` is the one way to turn it off.** Maintainer's call, 2026-09-20, serving goal 5: a connect
|
||||
that never returns is one `reconnect` cannot retry, because the operating system holds the attempt
|
||||
for around 130 s at Linux's default `tcp_syn_retries` and nothing above it is counting — and no
|
||||
application chooses that, so it is a default rather than an option that switches on what the caller
|
||||
obviously wanted. Accepted: the far end sees roughly four times the SYNs against a dead host, one
|
||||
per ~40 s rather than one per ~160 s, which goal 4 tolerates because the backoff still caps the
|
||||
rate. `false` spells the operating system's wait, as it does for `reconnect`, and `0` is refused
|
||||
naming it, so one spelling reaches each result. What expires is reported as the ordinary connect
|
||||
failure, so the loop retries it like any other, and the message names the peer and whether the TCP
|
||||
connect or the TLS handshake stalled — different faults, different answers. It settles on
|
||||
`secureConnect` for a TLS socket, so a peer that accepts and then says nothing is bounded the same
|
||||
way a black-holed SYN is. Rejected: shipping the option with no default, which left goal 5's "an
|
||||
option does not switch on the thing the caller obviously wanted" unmet, and would have cost a
|
||||
second breaking minor plus a reversal of the `0` spelling to correct later. Rejected:
|
||||
`socket.setTimeout()`, an idle timeout that goes on arming once the link is up. Rejected: bounding
|
||||
it with `responseTimeout`, which names the wait for an answer on a link that already exists and
|
||||
would retune both at once. `server()` shares the checker and ignores the option, as it already
|
||||
ignores `reconnect` — nothing at that end connects out.
|
||||
|
||||
- **A stream this library cannot frame is a dead link; one PDU it cannot parse is not.**
|
||||
Maintainer's call, 2026-08-31, narrowed 2026-09-05 via the interop plan: a `command_length` below
|
||||
16 or above `maxPduLength` leaves nothing that can say where the next PDU starts, so it tears the
|
||||
link down through `teardown()` and the reconnect loop retries it on a fresh socket with a fresh
|
||||
framer. Every other codec failure honoured `command_length`, so the stream is still in sync and
|
||||
the next PDU starts where it says — tearing the link down there cost one peer half its receipts
|
||||
and its MO to a reconnect loop (`interop-tests/findings/01-smscsim.md`), and left the peer waiting
|
||||
for answers it was owed. `sessionError` carries every failure of either kind, never coalesced or
|
||||
suppressed, so a peer that only ever sends garbage is visible in the log rather than silent.
|
||||
|
||||
- **A deliberate shutdown drains; an unusable link and an abort do not.** `close()` and `unbind()`
|
||||
wait on the send window rather than the pending map — the map misses a segment still queued behind
|
||||
a full window, and finishing a half-sent multipart message is the point. The window counts slots,
|
||||
never outcomes, and empties on a drop too, where `teardown()` settles everything the link was
|
||||
carrying, which is why `drain()` reads `closed` before it reads the count. A stream the framer or
|
||||
the codec cannot read takes `teardown()` instead, and `close({ signal })` on an aborted signal and
|
||||
a peer's own `unbind` take `end()`: nothing on a dead link can answer, an abort means stop now, and
|
||||
a peer that has declared itself finished will not answer what it still owes, so draining any of the
|
||||
three would only hold a socket open for the timeout. `unbind()` sends its own PDU through
|
||||
`request()` past both the window and the drain gate, because it must go out either way.
|
||||
`shutdownTimeout` stays a session option rather than a `close()` argument: `server()` builds
|
||||
sessions on the caller's behalf, so the option is the only composition point. `SmppServer.close()`
|
||||
reports each session's unfinished drain through `serverError`, because its own result says nothing
|
||||
but that the listener stopped.
|
||||
|
||||
- **`sendSms()` puts every segment of a message on the wire together.** Goal 6: a long message costs
|
||||
one round trip rather than one per segment. Rejected: sending each segment once the last is
|
||||
answered, which a receiver waiting for the whole message before answering would deadlock.
|
||||
|
||||
- **Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is the
|
||||
application's own signal rather than the peer's answer.** Maintainer's call, 2026-09-06, from the
|
||||
Jasmin interoperability phase: Jasmin dispatches one `submit_sm` per connector at a time and will
|
||||
not send segment 2 until segment 1 is answered, so holding a group unanswered until it was whole
|
||||
deadlocked every multi-segment message against a production gateway
|
||||
([interop-tests/findings/03-jasmin.md](../interop-tests/findings/03-jasmin.md)). Goal 1 has the answer
|
||||
a real SMSC gives — one `message_id` per `submit_sm`, immediately — so the group's id base is
|
||||
generated when it opens and each segment is answered `<base>-<n>`, the notation `sms-id.ts` owns
|
||||
and `DlrMerger` reads back. The id is therefore fixed by the first segment, which is why an `smsId`
|
||||
or a refusing `status` passed to `sendResp()` on such a message is an error rather than a silent
|
||||
no-op. `answeredOnArrival` is on `Sms` because nothing the application can compute says it, and the
|
||||
discriminant a reader would reach for instead is wrong. A message `sendResp()` still answers itself is
|
||||
untouched, and is where a caller-chosen id and a refusal live; `onRequest` is the escape hatch for
|
||||
an application that must refuse a PDU the `sms` event could not have shown it yet. `collect()`
|
||||
answers every segment it will not carry rather than leaving it unanswered, which is the same stall
|
||||
in miniature: the field that numbered it where the segment belongs to no group, the retry status
|
||||
where the segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected:
|
||||
answering every segment but the one that completes the group, which leaves the peer holding some
|
||||
segments accepted and one refused with nothing in SMPP to retract the rest, and still cannot honour
|
||||
a caller's `smsId` on the segments already gone. Rejected: a hook that mints the id per segment,
|
||||
which asks the application to name a message it cannot read yet — what it wants is `sms.smsId`
|
||||
afterwards. Rejected: an option to keep the old behaviour, a second spelling whose only
|
||||
distinguishing feature is that it deadlocks. Accepted: a group given up on — expired, evicted, or
|
||||
dropped with the link — is traffic the peer will not send again, so each one reaches `sessionError`
|
||||
as well as the log. Rejected there: an exported `MessageLostError` carrying the group, on the
|
||||
`PduRefusedError` pattern — no `sms` ever fired for that group, so there is nothing in it the
|
||||
application could act on, and goal 8 does not buy a second exported class to make a count
|
||||
distinguishable. Accepted: a completing segment whose own answer the socket would not carry still
|
||||
reaches the application, because the message is whole and correct and the failed answer is on
|
||||
`sessionError` — a peer that re-sends after the drop is the smaller risk than dropping a message
|
||||
in hand. The answer goes out before the `sms` event either way, so a listener's own receipt can
|
||||
never precede the acceptance of the message it reports on.
|
||||
|
||||
- **`server()` composes the application's `onRequest` after its own bind handling, and offers it
|
||||
every request that handling did not answer.** Maintainer's call, 2026-09-06, from a product review
|
||||
of the multipart change: `server()` filled the session's only `onRequest` slot, so the escape hatch
|
||||
the error above names was reachable only by hand-wiring a `Session` over a raw socket, giving up
|
||||
bind acceptance, `authenticate`, the session set and the drain `close()` runs over it — which is
|
||||
what goal 8 means by beating "the application can do this itself". What the library verifies is the
|
||||
ordering rather than the hook's honesty about answering: the hook is consulted only for a non-bind
|
||||
request on a session already bound, so no bind — a second one on a live session included — and
|
||||
nothing a peer sends before one can be intercepted however the hook is written. One
|
||||
`OnRequest` type on both option bags, because a second contract under one name is two spellings of
|
||||
one goal; widened to accept a plain boolean, as `authenticate` already is, so an observing hook need
|
||||
not be `async`. Nothing of ours is written for a request whose hook failed, the same on both
|
||||
surfaces: the library cannot tell one that failed before answering from one that failed after, so
|
||||
goal 2 reports the outcome as undetermined rather than guessing, and the peer's own
|
||||
`responseTimeout` is what settles it — the answer `authenticate` failing already takes. A hook that
|
||||
throws or rejects reaches `sessionError` on the way; one that never settles reaches nothing at all,
|
||||
and is visible only as the request that was never answered. That takes the keepalive with it, since
|
||||
a hook broken across the board leaves `enquire_link` unanswered and the peer drops the link — the
|
||||
back-pressure wanted, because an application that cannot serve a link should not hold one.
|
||||
`sessionError` rather than `serverError` because the
|
||||
failure belongs to one session's request, and that channel already carries every failure of one.
|
||||
The hook is consulted before the bind-direction gate, so it sees a `submit_sm` a receiver-bound
|
||||
peer may not send; first refusal means first, and one it declines still gets `ESME_RINVBNDSTS`.
|
||||
Nothing is held for a request the hook answered: `HeldMessages` is opened by the `sms` event the
|
||||
hook skipped, so the drain waits on none of it. `OnRequest` stays unexported where
|
||||
`AuthenticateInput` is exported, because that hook's argument is a shape this library invents and
|
||||
this one's are two types already published. Rejected: consulting the hook first, which puts
|
||||
bind and authentication inside the application's reach for nothing. Rejected: a narrower hook
|
||||
returning a status for the library to write, which makes the answer verifiable but pays a second
|
||||
contract under a second name for it, and could not express what the session-level hook already
|
||||
does — answer a bind, a vendor command, a `data_sm` — leaving that error naming something only
|
||||
half the surface can do. Rejected: falling the request through to the built-in handling on a
|
||||
failure, which reads as the answer the peer would have had with no hook — true only of a hook
|
||||
that failed before answering, where one that failed after put a second response on the peer's own
|
||||
sequence number, goal 1's wire violation. Rejected with it: recording what the hook wrote so the
|
||||
fall-through could be gated on it, which buys a fail-open path with state and an internal contract
|
||||
no other collaborator needs.
|
||||
|
||||
- **The drain waits on the messages the application holds, and `sendResp()` is what says it is done
|
||||
with one.** Maintainer's call, 2026-09-01: waiting on the send window alone tore a server session
|
||||
down while the application was still answering a `submit_sm`, so the peer timed out and re-sent —
|
||||
the duplicate goal 2 forbids, in the direction the window already covers. No completion signal was
|
||||
added to the `sms` event: `sendResp()` is what an application already calls when it is done with a
|
||||
message, so it is the one the drain waits for. Counting every inbound request until `sendReturn()`
|
||||
answered it was rejected: an `onRequest` that deliberately answers nothing would then cost a full
|
||||
`shutdownTimeout` on every close. The response reaching the wire ends the wait, so a `sendResp()`
|
||||
the library refused or the socket would not carry leaves `close()` still reporting the message the
|
||||
peer is owed.
|
||||
|
||||
- **The drain's wait on the application ignores `shutdownTimeout: 0`.** Waiting forever is safe for
|
||||
the peer, whose every request is bounded by `responseTimeout` unless the caller set that to 0 as
|
||||
well, and unsafe for the application, which nothing bounds — `close()` is what you reach for when
|
||||
the application is stuck, so it may not block on the application coming unstuck. That half falls
|
||||
back to `responseTimeout`, the same answer the link gate's hold already takes — and to that
|
||||
option's default where it is 0 as well, since neither option is an answer about the application.
|
||||
|
||||
- **What the application holds unanswered is capped on constants, and a message past the cap is
|
||||
refused.** A bound the application cannot raise is the point: an application that answers nothing
|
||||
would otherwise hold ever more messages, which goal 4 forbids. Reassembly's `maxOctets` is an
|
||||
option because it bounds what the peer sends; this bounds what the application leaves unanswered.
|
||||
Maintainer's call, 2026-09-26. Refusing leaves the message with the peer, which will send it again
|
||||
(goal 2). Rejected: dropping the oldest to make room, which frees nothing while the application
|
||||
still holds its `Sms`, and stops the drain waiting for a message the peer is owed. Rejected:
|
||||
pausing the socket, which also stalls every answer and `enquire_link` on the link. Reaching the
|
||||
bound shows only in the log (goal 8): an event or a public count would be surface for what the
|
||||
application already knows, since it is the one not answering. A message held past its timeout is
|
||||
still dropped, so `close()` can report fewer unanswered than there were — accepted, because the
|
||||
alternative is holding what nothing will answer.
|
||||
|
||||
- **A store at its bound answers `ESME_RTHROTTLED` to a submission and `ESME_RX_T_APPN` to a
|
||||
delivery, a `data_sm` by whichever it stands in for.** Maintainer's call, 2026-09-26, for
|
||||
reassembly and held messages alike, so "keep it and retry" has one spelling per direction.
|
||||
`ESME_RTHROTTLED` asks the sender to slow down, which is what the peer outrunning us needs, and
|
||||
operators send it (Vonage, LINK Mobility, Route Mobile, Jasmin), so clients built against them
|
||||
meet it (goal 1). Rejected: `ESME_RMSGQFUL`, which names an exhausted queue and no rate.
|
||||
`ESME_RTHROTTLED` is the SMSC's to send, so an ESME answers with SMPP 3.4's temporary receiver
|
||||
error, the one an SMSC retries on (goal 3).
|
||||
|
||||
- **A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.**
|
||||
`onDelivery()` answers each receipt before the group it belongs to is complete, and `teardown()`
|
||||
runs on every path — an idle timeout and a failed rebind, not only `close()` — so clearing the
|
||||
merges there loses receipts no peer has a reason to send again. They are cleared where the session
|
||||
is over instead. Inbound segments stay in `teardown()`: a concatenation reference is the
|
||||
peer's own counter, so a half-arrived group kept across a drop would take a later message's
|
||||
segments as readily as the rest of its own, and goal 2 will not hand the application a message
|
||||
assembled that way. What goes there is traffic already answered, which is why each group reaches
|
||||
`sessionError` like every other one given up on.
|
||||
|
||||
- **A message id base is merged at most once.** A receipt carries nothing but `<base>-<n>`, so a
|
||||
straggler for a message whose group is gone cannot be told from a receipt for a later message the
|
||||
peer handed the same ids — an SMSC whose id counter restarts with its process is the realistic
|
||||
case. `DlrMerger` remembers the bases it has finished with, capped and expiring exactly like the
|
||||
groups, and refuses to open one a second time: the later message gets no `messageDlr`, and an
|
||||
earlier one whose receipts are still arriving is dropped rather than left to collect the later
|
||||
one's. Every segment still reaches the application as a `dlr`. `expect()` ignores a lone id, so a
|
||||
single-part message never claims a base.
|
||||
|
||||
- **A send that never reached the socket waits for the next link; one that did is counted, not
|
||||
resent.** Maintainer's call, 2026-09-01: re-queueing everything unanswered would resend a
|
||||
`submit_sm` the SMSC accepted and answered into a dead socket, which is delivered and billed twice,
|
||||
while a request that never left this process can be lost for free. `attempt()` therefore wraps all
|
||||
three ways a written request can fail in `UnansweredError`; counting only the dropped-link case, as
|
||||
the first cut did, would have called the commonest one safe to resend. A count rather than a
|
||||
boolean because `sendSms()` aggregates segments into one `err` slot, and required rather than
|
||||
optional so every construction site answers. `UnansweredError` stays unexported: `unanswered` is
|
||||
the one spelling on the public surface. The hold is bounded by `responseTimeout` rather than an
|
||||
option of its own — that is already the answer to how long one request may wait — and its clock
|
||||
starts when the send is issued rather than when it first finds the gate shut, so one budget covers
|
||||
every hold a single call makes. That timer is the one here that is not `unref()`'d: a held request
|
||||
is awaited with the socket already destroyed, so an unref'd one lets a process whose only remaining
|
||||
work is that send exit without settling it.
|
||||
|
||||
- **A send queued for a send-window slot is bounded by the caller's `signal`, and by nothing else.**
|
||||
Maintainer's call, 2026-09-06, from a review of PR #71: the hold above observes the signal and the
|
||||
`acquire()` on the next line did not, so a caller that aborted while the window was full waited for
|
||||
a slot it no longer wanted — at `responseTimeout: 0` for as long as the peer stayed quiet, which is
|
||||
the deadline the README sends the caller to that signal for. Goal 4 is not re-opened by an
|
||||
unbounded wait here: the queue is the application's own backlog, unbounded in depth as well as in
|
||||
time because capping it would refuse a send the application asked for, and nothing in it keeps the
|
||||
peer waiting — which is what separates it from the inbound stores capped on constants. Rejected:
|
||||
having `release()` skip a waiter whose signal already fired, which leaves the departed waiter in
|
||||
the queue where `unfinished()` still counts it and the drain waits on it; the waiter leaves as it
|
||||
settles instead. Rejected: bounding this wait by `responseTimeout` as the hold is bounded — a full
|
||||
window is this end's own concurrency draining as the peer answers rather than a link going nowhere,
|
||||
and that bound would fail a message with more segments than `maxOutstanding` partway through
|
||||
against a slow peer. The failure is a plain `Error` rather than `UnansweredError`, the same answer
|
||||
an abort at the gate already gives. The drain half needs nothing: `close({ signal })` already hands the signal to
|
||||
`window.idle()`, and `unbind()` taking none is the shape README states.
|
||||
|
||||
- **The gate decides whether a link can carry a request, and a bind is what makes it one.**
|
||||
Maintainer's call, 2026-09-01: `attach()` clears `closed` the moment a socket is handed over, one
|
||||
round trip before the bind is answered, so gating on `closed` let a send arriving in that window go
|
||||
out unbound and come back `ESME_RINVBNDSTS`. `LinkGate` owns the answer instead — `shut(returning)`
|
||||
on every teardown, `open()` only once `comeBackUp()` has a bound link — and
|
||||
`OutgoingRequests.linkDown()` reads it rather than `closed`. The bind itself cannot wait for what it
|
||||
creates, so `pastDrain()` lets the three bind commands past the gate and the window, the same door
|
||||
`unbind()` takes through `now()`. The gate is told what happened and never reads back into the
|
||||
session: a collaborator that has to ask does not own its decision, which is how the first cut ended
|
||||
up answering the same question two different ways at admit and at release. For the same reason the
|
||||
retry in `pastDrain()` asks `gate.isUp()` rather than `linkDown()`, which also reads the socket — a
|
||||
condition that loops on something the gate does not gate on spins against a gate that admits it
|
||||
straight back. `LinkGate.returning` is a copy of `retrying()` taken at teardown, and stays true
|
||||
only because nothing stops the reconnect loop without `emitClose()` following it: `drain()` and
|
||||
`end()` are the only callers of `stop()`. A third caller has to shut the gate itself.
|
||||
|
||||
|
||||
## Internals and tests
|
||||
|
||||
- **A listener that rejects is routed by Node's `captureRejections`, not by hand-dispatching.** Both
|
||||
emitters construct with `captureRejections: true` and implement
|
||||
`[EventEmitter.captureRejectionSymbol]`, which lands a rejected `async` listener on `sessionError`
|
||||
or `serverError` beside the synchronous guard in `emit()`. Dispatching `rawListeners()` from
|
||||
`emit()` instead needs a cast to call them with the event's argument tuple, which hard rule 4
|
||||
forbids. A rejection reason is `unknown` and `String()` throws on a null-prototype object, so both
|
||||
handlers normalise through `errorFrom()` rather than inline — a route out of the handler would land
|
||||
on a bare `process.nextTick` with nothing to catch it.
|
||||
|
||||
- **The four-line abort dance is copied across `LinkGate`, `IdleWaiters`, `PendingRequests` and
|
||||
`SendWindow` rather than extracted.** Architecture review, 2026-09-06: pre-check `aborted`, attach
|
||||
`{ once: true }`, detach on settle, leave the registry. What differs at each site is the registry
|
||||
and what settling means — a FIFO handing over a slot, a set released together, a map keyed by
|
||||
sequence number, a count recomputed at settle — so a shared `Waiters<T>` fits two of the four and
|
||||
is a shallower module than the copies. Extract it once a fifth appears.
|
||||
|
||||
- **`SmppLog` is a five-method contract this library declares, not a dependency.** `debug`, `error`,
|
||||
`info`, `verbose` and `warn` are what the code actually calls, so an application can satisfy it
|
||||
with an object literal. `@larvit/log` implements it structurally and stays a devDependency, where
|
||||
`test/tls.test.ts` passing a real `Log` as the server's logger keeps that compatibility compiled.
|
||||
|
||||
- **The TLS tests build their own self-signed certificate in DER** (`test/tls.test.ts`) instead of
|
||||
adding a devDependency or shelling out to openssl. Maintainer's call, 2026-08-26: the dev image
|
||||
`node:24.18.0-bookworm-slim` ships no openssl binary, so a shelled-out fixture would pass in CI and
|
||||
fail on every developer machine, and a committed key leaks in a public repository. Valid while the
|
||||
dev image has no openssl.
|
||||
|
||||
- **`src/` stays flat until a module has to move for another reason.** Architecture review,
|
||||
2026-09-06: the grouping the [file map](../AGENTS.md#architecture) already implies — `wire/` for `pdu*` and `defs`,
|
||||
`link/` for `link-*`, `reconnect-*`, `pdu-transport` and `send-window`, `messages/` for `sms*`,
|
||||
`dlr*`, `message*`, `reassembly` and `udh` — rewrites every import for no change to
|
||||
`dist/index.js`, the one published entry. Valid while that map is what a reader navigates by.
|
||||
|
||||
- **`test/` stays flat too, and a file there is named for the question it answers rather than for the
|
||||
module it covers.** Architecture review, 2026-09-08, at 18 test files: what keeps that count honest
|
||||
is the naming rule rather than a tree — `operator-receipts.test.ts` holds a corpus defined by where
|
||||
it came from, cutting across four modules, where filing it by module would enter each new operator
|
||||
twice. A split also has to be made twice, since `test` and `test:compiled` each carry a path of
|
||||
their own. The four files that are not tests are the exception the rule needs stated:
|
||||
`dummy-smsc.ts`, `raw-pdus.ts`, `reference-smpp.d.ts` and `teardown.ts` answer no question and are
|
||||
named for what they hold.
|
||||
|
||||
- **CI tests on Linux only; `src/` keeps off what is known to break on macOS or Windows.** Maintainer's
|
||||
call, 2026-09-14. Nothing verifies either platform, so the code avoids what is known to differ there:
|
||||
shelling out, a path joined by hand, a signal Windows does not deliver, a Unix socket or a file mode.
|
||||
That binds what `dist/` runs; the container tooling, `interop-tests/` and the `package.json` scripts
|
||||
run on Linux by goal 10. Rejected: macOS and Windows runners, on GitHub's mirror or as Gitea
|
||||
host-mode runners on a Windows VM and a Mac.
|
||||
|
||||
- **GitHub mirrors Gitea without pruning, and a ref deleted on Gitea is deleted on GitHub by a run of
|
||||
its own.** Maintainer's call, 2026-09-14; valid while nothing deploys from GitHub.
|
||||
`.gitea/workflows/mirror.yaml` never prunes, and `mirror-delete.yaml` runs once per deleted ref. A
|
||||
delete run that fails or outlives Gitea's queue timeout, or a push run that cloned before the
|
||||
delete, leaves the ref on GitHub until the delete run is re-run. Accepted: a stale ref there is
|
||||
harmless, and refs only GitHub has must survive.
|
||||
@@ -93,7 +93,7 @@ ways the next experiment must see. One fix per defect class, as its own change:
|
||||
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
|
||||
then the implementation; then the decision record in `docs/decisions.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 `main`. When it marks the PR
|
||||
ready, fast-forward it.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# interop-tests
|
||||
|
||||
Eight real SMPP implementations, run against this library in both directions, with every session
|
||||
Ten real SMPP implementations, run against this library in both directions, with every session
|
||||
decoded independently by tshark so no result rests on our own view of the wire. It exists because
|
||||
the unit suite and this library's own dummy peers agree with themselves; these peers do not.
|
||||
|
||||
@@ -40,7 +40,7 @@ These bind to our server:
|
||||
|
||||
| Peer | What it is for | Run |
|
||||
| --- | --- | --- |
|
||||
| **Kannel 1.4.5** | The most deployed real ESME there is; parses our receipts with the parser most operators' customers run, and declares 3.4 or 3.3 on demand | `debian:bookworm-slim` + the distribution package; four `.conf` variants under `peers/kannel/` |
|
||||
| **Kannel 1.4.5** | The most deployed real ESME there is; parses our receipts with the parser most operators' customers run, and declares 3.4 or 3.3 on demand | `debian:bookworm-20260824-slim` + the distribution package; four `.conf` variants under `peers/kannel/` |
|
||||
| **jsmpp** | Strict and low-level: the driver builds UDH, `sar_*` and `message_payload` bytes by hand, and rejects an answer it dislikes | Maven build at a pinned commit, `peers/jsmpp/` |
|
||||
| **Cloudhopper** | The one peer with real windowing knobs, plus a TLS client | Maven build at a pinned commit, `peers/cloudhopper/`. Its 2015-era TLS client cannot do 1.3, so that scenario caps the server at 1.2 |
|
||||
| **python-smpplib 2.2.4** | An independent GSM 03.38 table to cross-check ours character by character | `python:3.12.14-slim-bookworm`, `peers/python/` |
|
||||
|
||||
@@ -12,27 +12,37 @@ x-dumbclient-healthcheck: &dumbclient-healthcheck
|
||||
timeout: 2s
|
||||
|
||||
services:
|
||||
# Network owner for every dumbclient-* service and the capture sidecar below (see the comment on
|
||||
# `capture`): all four are pure outbound TCP clients with nothing of their own listening, so
|
||||
# sharing one netns is only ever a source-IP detail, never a port collision.
|
||||
# Network owner for every dumbclient-* service and the capture sidecar below: all four are pure
|
||||
# outbound TCP clients, so sharing one netns is only a source-IP detail. It never exits, because a
|
||||
# client that finishes early would take the namespace, and every other conversation, with it.
|
||||
dumbclient-netns:
|
||||
image: nicolaka/netshoot:v0.16
|
||||
<<: *log-limits
|
||||
command: ["sleep", "infinity"]
|
||||
init: true
|
||||
|
||||
dumbclient-w2000:
|
||||
build: ./interop-tests/peers/dumbclient
|
||||
image: interop-dumbclient:de0334b
|
||||
<<: *log-limits
|
||||
network_mode: "service:dumbclient-netns"
|
||||
command: ["conf/window2000.yml"]
|
||||
healthcheck: *dumbclient-healthcheck
|
||||
depends_on:
|
||||
dumbclient-netns:
|
||||
condition: service_started
|
||||
|
||||
# S9's comparison run: window below maxHeldMessages (1000, session-options.ts), where nothing
|
||||
# should ever be evicted - see findings/07-load.md.
|
||||
# should ever be throttled - see findings/07-load.md.
|
||||
dumbclient-w500:
|
||||
build: ./interop-tests/peers/dumbclient
|
||||
image: interop-dumbclient:de0334b
|
||||
<<: *log-limits
|
||||
network_mode: "service:dumbclient-w2000"
|
||||
network_mode: "service:dumbclient-netns"
|
||||
command: ["conf/window500.yml"]
|
||||
healthcheck: *dumbclient-healthcheck
|
||||
depends_on:
|
||||
dumbclient-w2000:
|
||||
dumbclient-netns:
|
||||
condition: service_started
|
||||
|
||||
# S6: sends one message, then never speaks again - the no-ping binary (see the Dockerfile) sends
|
||||
@@ -41,13 +51,13 @@ services:
|
||||
build: ./interop-tests/peers/dumbclient
|
||||
image: interop-dumbclient:de0334b
|
||||
<<: *log-limits
|
||||
network_mode: "service:dumbclient-w2000"
|
||||
network_mode: "service:dumbclient-netns"
|
||||
environment:
|
||||
DUMBCLIENT_BIN: /app/smpp-dumb-client-noping
|
||||
command: ["conf/idle.yml"]
|
||||
healthcheck: *dumbclient-healthcheck
|
||||
depends_on:
|
||||
dumbclient-w2000:
|
||||
dumbclient-netns:
|
||||
condition: service_started
|
||||
|
||||
# The long soak: the longest run the time-box allows, fast handler, watched for anything that
|
||||
@@ -56,25 +66,25 @@ services:
|
||||
build: ./interop-tests/peers/dumbclient
|
||||
image: interop-dumbclient:de0334b
|
||||
<<: *log-limits
|
||||
network_mode: "service:dumbclient-w2000"
|
||||
network_mode: "service:dumbclient-netns"
|
||||
command: ["conf/soak.yml"]
|
||||
healthcheck: *dumbclient-healthcheck
|
||||
depends_on:
|
||||
dumbclient-w2000:
|
||||
dumbclient-netns:
|
||||
condition: service_started
|
||||
|
||||
# Every dumbclient-* service shares dumbclient-w2000's netns (see above), so this one sidecar
|
||||
# Every dumbclient-* service shares dumbclient-netns's namespace (see above), so this one sidecar
|
||||
# sees all four conversations with node:2775 - the same pattern compose.kannel.yaml uses for its
|
||||
# four bearerbox variants, one namespace deeper.
|
||||
capture:
|
||||
image: nicolaka/netshoot:v0.16
|
||||
network_mode: "service:dumbclient-w2000"
|
||||
network_mode: "service:dumbclient-netns"
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
depends_on:
|
||||
dumbclient-w2000:
|
||||
condition: service_healthy
|
||||
dumbclient-netns:
|
||||
condition: service_started
|
||||
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/dumbclient.pcapng"]
|
||||
volumes:
|
||||
- ./interop-tests/captures:/captures
|
||||
|
||||
@@ -206,17 +206,26 @@ after(async () => {
|
||||
|
||||
// S9 (target 11) and the backpressure-at-server scenario: window 2000 at a high rate against a
|
||||
// handler slowed enough to build a real backlog. window500 is the same shape with a window below
|
||||
// maxHeldMessages (1000, session-options.ts defaults.maxHeldMessages) - see findings/07-load.md for
|
||||
// what that constant, rather than maxOutstanding, turns out to be the one that interacts with a
|
||||
// peer's window.
|
||||
describe('S9 - bounded window against a slowed handler', () => {
|
||||
for (const [name, expectedCount] of [['dumb-w500', 20_000], ['dumb-w2000', 20_000]] as const) {
|
||||
test(`${name}: every message answered exactly once, ordering holds`, async () => {
|
||||
const done = await waitFor(() => (statsFor(name).answered >= expectedCount ? true : undefined), 180_000);
|
||||
// maxHeldMessages (1000, session-options.ts defaults.maxHeldMessages), the bound past which a
|
||||
// peer's window is answered ESME_RTHROTTLED. smpp-dumb-client counts a throttled message as sent
|
||||
// and never resends it, so window 2000 accounts for 20,000 as answered plus throttled.
|
||||
const throttleMessage = 'session - unanswered messages at their bound, asking the peer to retry';
|
||||
|
||||
assert.ok(done, `${name} did not answer ${String(expectedCount)} messages within budget`);
|
||||
// window500's peak (<=500) and the soak's never reach the 1000 default, so every refusal is
|
||||
// necessarily from the w2000 session - the runs share one server and one log.
|
||||
function throttled(name: string): number {
|
||||
return name === 'dumb-w2000' ? logEntries.filter(entry => entry.message === throttleMessage).length : 0;
|
||||
}
|
||||
|
||||
describe('S9 - bounded window against a slowed handler', () => {
|
||||
for (const name of ['dumb-w500', 'dumb-w2000'] as const) {
|
||||
test(`${name}: every message answered or throttled exactly once, ordering holds`, async () => {
|
||||
const done = await waitFor(() => (statsFor(name).answered + throttled(name) >= 20_000 ? true : undefined), 180_000);
|
||||
|
||||
assert.ok(done, `${name} did not account for 20000 messages within budget`);
|
||||
|
||||
const s = statsFor(name);
|
||||
const expectedCount = 20_000 - throttled(name);
|
||||
|
||||
assert.equal(s.arrived, expectedCount);
|
||||
assert.equal(s.answered, expectedCount);
|
||||
@@ -227,17 +236,9 @@ describe('S9 - bounded window against a slowed handler', () => {
|
||||
});
|
||||
}
|
||||
|
||||
test('window 2000 pressed past maxHeldMessages (1000): the internal held-message cap evicts, window500 never does', async () => {
|
||||
await waitFor(() => (statsFor('dumb-w2000').answered >= 20_000 ? true : undefined), 180_000);
|
||||
|
||||
const evictions = logEntries.filter(entry => entry.message === 'heldMessages - buffer full, dropping the oldest message');
|
||||
|
||||
// window500's peak (<=500) never reaches the 1000 default, so any eviction observed is
|
||||
// necessarily from the w2000 session - the two runs share one server and one log.
|
||||
assert.ok(evictions.length > 0, 'expected at least one held-message eviction under window 2000');
|
||||
// The peer's own window, respected exactly both runs (peakOutstanding read 500 and 2000 on
|
||||
// the nose) - the lower bound is what distinguishes this from window500's own eviction-free run.
|
||||
assert.ok(statsFor('dumb-w2000').peakOutstanding > 1000 && statsFor('dumb-w2000').peakOutstanding <= 2000);
|
||||
test('window 2000 pressed past maxHeldMessages (1000): the peer is throttled, window500 never is', () => {
|
||||
assert.ok(throttled('dumb-w2000') > 0, 'expected at least one ESME_RTHROTTLED under window 2000');
|
||||
assert.ok(statsFor('dumb-w2000').peakOutstanding <= 1000);
|
||||
assert.equal(statsFor('dumb-w500').peakOutstanding <= 500, true);
|
||||
});
|
||||
|
||||
@@ -284,10 +285,8 @@ describe('S6 - idle peer, no enquire_link at all', () => {
|
||||
});
|
||||
|
||||
// The long soak: the longest run the time-box allows, fast handler, watched for anything that
|
||||
// grows without bound (held messages, listeners, memory). Bounded by wall-clock rather than a
|
||||
// target count: smpp-dumb-client's own TX-tracking window bookkeeping stalls under sustained load
|
||||
// (findings/07-load.md, Peer quirks) well short of the configured count, on the client's side only
|
||||
// - our own arrived/answered stay in lockstep throughout, which is what this asserts.
|
||||
// grows without bound (held messages, listeners, memory). Bounded by wall-clock, and asserting that
|
||||
// arrived/answered stay in lockstep.
|
||||
describe('Long soak', () => {
|
||||
const SOAK_DURATION_MS = 300_000;
|
||||
|
||||
|
||||
@@ -39,12 +39,33 @@ Traced as far as `oserl`'s `smpp_pdu_syntax:pack/2` (the `trx_deadlock_fix_1` br
|
||||
`rebar.config` pins), which builds the header as plain 32-bit bit-syntax
|
||||
(`<<Len:32, CmdId:32, 0:32, SeqNum:32>>`) - correct on inspection, so the corruption happens
|
||||
somewhere between that call and the socket write, not chased further given the time-box. Reproduced
|
||||
identically on three separate runs (byte-for-byte). Recorded as **blocked**; `smppload.test.ts`
|
||||
Re-examined 2026-09-20 to see whether it could be unblocked for the throughput comparison in
|
||||
`benchmarks/`. Four things are now established, and one earlier suspicion is ruled out:
|
||||
|
||||
- **It is one write, not a split one.** A raw listener that accumulates every chunk rather than
|
||||
reading the first receives `40 octets across 1 chunks`, byte-identical to the 2026-09-06 capture,
|
||||
with `command_length` reading 2,752,512. So the two leading zero octets are absent from the socket
|
||||
write itself; nothing about our framing or the capture is involved.
|
||||
- **The compiled `pack/2` is correct**, checked in the built tree rather than the repository:
|
||||
`Len = size(BodyBin) + 16` written as `<<Len:32, CmdId:32, 0:32, SeqNum:32>>`, returned as the
|
||||
iolist `[Header, BodyBin]`. For this bind that is 16 + 26 = 42.
|
||||
- **The remaining suspect is `smpp_session.erl:158`**, which writes with `erlang:port_command/2`
|
||||
rather than `gen_tcp:send/2` — an undocumented fast path in oserl code that predates OTP 27.
|
||||
- **That suspect is untested.** Two attempts to swap it were both invalidated by rebar3 dep caching:
|
||||
editing a fetched dependency's source does not rebuild its beam, and the `_checkouts/` route
|
||||
re-verifies every dependency, which needs network and git in the build container. Whoever picks
|
||||
this up should patch before the first compile, or force the dep to rebuild, and confirm the beam
|
||||
actually changed before believing a result.
|
||||
|
||||
Enough for an upstream report — a reproducer needing no SMSC, the exact octets, and a named
|
||||
suspect — but not enough for a patch, since the one-line candidate has never actually run.
|
||||
|
||||
Recorded as **blocked**; `smppload.test.ts`
|
||||
keeps a live reproducer asserting what our server does when it receives it (refuses the stream as
|
||||
unframeable - see Scenarios) rather than removing the peer. `smpp-dumb-client` covers S9, and
|
||||
substitutes for S6 and (partially) S8 - see below.
|
||||
|
||||
### smpp-dumb-client: builds and interoperates cleanly; its own window bookkeeping stalls under sustained load
|
||||
### smpp-dumb-client: builds and interoperates cleanly
|
||||
|
||||
No build friction. Two binaries from the same pinned source: `smpp-dumb-client` (unmodified) and
|
||||
`smpp-dumb-client-noping` (its two `enquireSender()` call sites in `smpp.go` commented out at build
|
||||
@@ -57,25 +78,18 @@ One integration snag, not a build one: `smpp.remote` in `config.yml` is fed stra
|
||||
there directly. Fixed in the entrypoint: every `conf/*.yml` carries a `NODE_HOST` placeholder,
|
||||
resolved with `getent hosts` and substituted into a writable copy before the real binary starts.
|
||||
|
||||
Four one-shot scenarios share `dumbclient-w2000`'s network namespace (`network_mode:
|
||||
"service:dumbclient-w2000"`) - they are pure outbound clients with nothing of their own listening,
|
||||
so the only shared cost is a source IP, and one capture sidecar sees all four conversations with
|
||||
`node:2775` the same way `compose.kannel.yaml`'s does for its four bearerbox variants.
|
||||
The four scenarios share one network namespace, owned by `dumbclient-netns`, a container that
|
||||
never exits - they are pure outbound clients with nothing of their own listening, so the only shared
|
||||
cost is a source IP, and one capture sidecar sees all four conversations with `node:2775` the same
|
||||
way `compose.kannel.yaml`'s does for its four bearerbox variants.
|
||||
|
||||
The long soak (below) surfaced a peer-side limit worth designing around rather than fighting: with
|
||||
a fast, immediate-response handler and a window of 100 - nothing our server should ever have
|
||||
trouble draining - the peer's own reported in-flight count (`GetTrackQueueSize`, read from
|
||||
`len(TrackTX)`) gets stuck pinned at the window within the first minute, and its log fills with
|
||||
`Expired TX packet` lines (`libsmpp`'s hardcoded, non-configurable 7000ms `TX_MAX_TIMEOUT_MS`) -
|
||||
throughput drops from ~500/s to a trickle of tens per second, gated by how many tracked entries
|
||||
individually cross that 7s mark each second rather than by real responses being matched. Our own
|
||||
server-side counters (`arrived`/`answered`/`peakOutstanding`, tracked independently in
|
||||
`dumbclient.test.ts`) stay in lockstep throughout with a low peak - see Scenarios - which places the
|
||||
stall entirely on the peer's own window bookkeeping, not on anything our server did or failed to
|
||||
do. The soak test was redesigned around this: bounded by wall-clock (5 minutes) rather than a
|
||||
target count, asserting the invariants that matter regardless of how much the peer's own bug lets
|
||||
through (every arrival answered, nothing duplicated, memory shape), and reporting whatever
|
||||
throughput was actually reached rather than requiring a specific one.
|
||||
Runs 1 and 2 had `dumbclient-w2000` own the namespace. It exits once it has sent its 20,000, which
|
||||
took every other client's network with it: the soak's responses stopped arriving, and its log filled
|
||||
with `Expired TX packet` lines (`libsmpp`'s 7000ms `TX_MAX_TIMEOUT_MS`). Those runs read that as the
|
||||
peer's own window bookkeeping stalling; run 3 (2026-09-26), with the namespace owned by a container
|
||||
that outlives them all, reached 173,820 soak messages in 300s where run 2 reached 22,440. The soak
|
||||
stays bounded by wall-clock (5 minutes), asserting every arrival answered, nothing duplicated, and
|
||||
the memory shape.
|
||||
|
||||
One test-harness bug found and fixed between the two runs below, not a library defect: the S6 test's
|
||||
first version attached its `session.on('close', ...)` listener lazily inside the test body, after
|
||||
@@ -84,11 +98,11 @@ the S6 test ran, the idle session had already closed, and an `EventEmitter` neve
|
||||
event to a listener added after it fired. Fixed by attaching every session's `close` listener at
|
||||
`session`-creation time, recording it in the same per-scenario stats every other assertion reads.
|
||||
|
||||
Two runs of `./interop-tests/run.py dumbclient`. Run 1 (the original 300,000-count soak) surfaced
|
||||
both the peer's TX-tracking stall and the S6 harness bug above; run 2, after both fixes, is the one
|
||||
reported below. `smppload.test.ts` passed on every run it was given (three, across the investigation
|
||||
above); its one scenario needs no repeat - a second run reproduces the identical corrupted PDU,
|
||||
adding nothing.
|
||||
Three runs of `./interop-tests/run.py dumbclient`. Run 1 (the original 300,000-count soak) surfaced
|
||||
the S6 harness bug above; run 2 fixed it; run 3, with the namespace owner above and the held-message
|
||||
throttle in `src/`, is the one Scenarios reports. The capture figures below are run 2's.
|
||||
`smppload.test.ts` passed on every run it was given (three, across the investigation above); its one
|
||||
scenario needs no repeat - a second run reproduces the identical corrupted PDU, adding nothing.
|
||||
|
||||
```
|
||||
dumbclient run 2: frames 111300, bind_transceiver 4/4, enquire_link 12 (enquire_link_resp 9 - the
|
||||
@@ -106,29 +120,24 @@ every session's own `arrived` exactly, and every session's own `answered` matche
|
||||
|
||||
## Throughput and memory
|
||||
|
||||
`dumb-w500` and `dumb-w2000` (S9) both ran to their full 20,000-message count in ~44s each,
|
||||
concurrently, against a handler serialised to answer roughly one message every 2ms
|
||||
(`SLOW_HANDLER_DELAY_MS`) - `peakOutstanding` read exactly 500 and exactly 2000, the two configured
|
||||
windows, confirming the peer never let more than its own window ride at once.
|
||||
Run 3. `dumb-w500` ran to its full 20,000 in ~44s against a handler serialised to answer roughly
|
||||
one message every 2ms (`SLOW_HANDLER_DELAY_MS`), `peakOutstanding` exactly 500. `dumb-w2000`, run
|
||||
concurrently, held exactly 1000 and was throttled for the rest.
|
||||
|
||||
The soak (fast, immediate-response handler; window 100) reached 22,440 `submit_sm` over its fixed
|
||||
300s observation window - about 75/s, well under the peer's own configured `rate: 500` and under
|
||||
what our server can sustain (see Setup: `smpp-dumb-client`'s own TX-tracking bookkeeping is the
|
||||
ceiling here, not our server - `peakOutstanding` stayed at 25 throughout). Sampled every 5s across
|
||||
the whole run (69 samples over 340s, all four scenarios combined): rss first=170MiB, min=124MiB,
|
||||
max=306MiB (during the two window runs' backlog), last=125MiB, heapUsed at the last sample 15MiB -
|
||||
back below its own starting point once the backlog drained, not merely flat. No monotonic trend in
|
||||
either direction.
|
||||
The soak (fast, immediate-response handler; window 100) reached 173,820 `submit_sm` over its fixed
|
||||
300s, about 580/s, `peakOutstanding` 15. Sampled every 5s across the whole run (69 samples over
|
||||
340s, all four scenarios combined, the harness's own per-message bookkeeping included): rss
|
||||
first=165MiB, min=165MiB, max=298MiB, last=298MiB, heapUsed at the last sample 81MiB.
|
||||
|
||||
## Scenarios (PLAN.md)
|
||||
|
||||
| Id | Result | Evidence |
|
||||
| --- | --- | --- |
|
||||
| S6 (idleTimeout, no peer ever pings) | pass | `dumbclient.test.ts` "S6 - idle peer..." - dropped at idleTimeout, `linkTimers - closing an idle peer` logged, no response past the one owed |
|
||||
| S8 (throughput, long messages, receipts) | blocked (smppload) / partial substitute | smppload's own scenario is blocked - see Setup. The soak below gives a genuine submit_sm/s figure without long messages or receipts, which `smpp-dumb-client` does not support (`research/esme-clients-and-validators.md` section B) - and is itself capped well below what our server can sustain by the peer's own TX-tracking stall, also see Setup |
|
||||
| S9 (bounded window) | pass | `dumbclient.test.ts` "S9 - bounded window..." - 20,000/20,000 answered on both window 500 and window 2000, in arrival order, no duplicate ids, `peakOutstanding` exactly 500 and exactly 2000 |
|
||||
| Backpressure at the server | pass | Same run: `peakOutstanding` 2000 exceeds `maxHeldMessages` (1000, session-options.ts) and the eviction warning fires; window 500 (`peakOutstanding` 500) never does; memory sampled before/after the window runs (170MiB before, 306MiB after, 125MiB once the soak's own run had also settled) |
|
||||
| Long soak | pass (run once at the redesigned, wall-clock-bounded shape - see Setup) | `dumbclient.test.ts` "Long soak" - 22,440 arrived, 22,440 answered, 0 duplicates, 0 unanswered errors, `close()` drains with no error |
|
||||
| S8 (throughput, long messages, receipts) | blocked (smppload) / partial substitute | smppload's own scenario is blocked - see Setup. The soak below gives a genuine submit_sm/s figure without long messages or receipts, which `smpp-dumb-client` does not support (`research/esme-clients-and-validators.md` section B) |
|
||||
| S9 (bounded window) | pass | Run 3: `dumbclient.test.ts` "S9 - bounded window..." - window 500 20,000/20,000 answered in ~44s, `peakOutstanding` exactly 500; window 2000 5,639 answered and 14,361 throttled, in arrival order, no duplicate ids |
|
||||
| Backpressure at the server | pass | Run 3: window 2000 holds exactly 1000 (`maxHeldMessages`, session-options.ts) and the rest is answered `ESME_RTHROTTLED`; smpp-dumb-client counts a throttled message as sent and never resends it; window 500 is never throttled |
|
||||
| Long soak | pass | Run 3: `dumbclient.test.ts` "Long soak" - 173,820 arrived, 173,820 answered, 0 duplicates, 0 unanswered errors, `close()` drains with no error; rss 165MiB first, 298MiB max and last, heapUsed 81MiB last |
|
||||
| smppload bind corruption (not in PLAN.md - found this phase) | blocked | `smppload.test.ts` - our server refuses the unreadable stream instead of hanging |
|
||||
|
||||
## Defects in @larvit/smpp
|
||||
@@ -136,19 +145,16 @@ either direction.
|
||||
None found. `smppload.test.ts`'s own scenario is smppload's defect, not ours: our server's reaction
|
||||
(refusing the stream as unframeable, per the decision in the root `AGENTS.md`, "A stream this
|
||||
library cannot frame...") is the documented behaviour working exactly as designed against a peer
|
||||
that never gets as far as a readable PDU. The soak's throughput ceiling is the peer's own defect
|
||||
(see Setup) - our own `arrived`/`answered`/`peakOutstanding` counters stayed clean throughout every
|
||||
run.
|
||||
that never gets as far as a readable PDU.
|
||||
|
||||
## Peer quirks
|
||||
|
||||
- **smppload's `bind_transceiver` is corrupted on the wire** - see Setup. Not chased past `oserl`'s
|
||||
`pack/2` (which is correct on inspection) given the time-box.
|
||||
- **`smpp-dumb-client`'s window bookkeeping stalls under sustained load, throttling its own
|
||||
throughput far below what a promptly-answering server can sustain** - see Setup. Its `enquire_link`
|
||||
interval (10s once bound as an ESME) is also hardcoded (`smpp.go`, `enquireSender(10)`), not
|
||||
exposed through `config.yml` at all - the no-ping binary built for S6 patches the call site out
|
||||
rather than configuring it.
|
||||
- **`smpp-dumb-client` treats `ESME_RTHROTTLED` as final** - a throttled message counts as sent
|
||||
and is never resubmitted. Its `enquire_link` interval (10s once bound as an ESME) is hardcoded
|
||||
(`smpp.go`, `enquireSender(10)`), not exposed through `config.yml` at all - the no-ping binary
|
||||
built for S6 patches the call site out rather than configuring it.
|
||||
- **`smpp.remote` takes a literal IP, never a hostname** (`net.ParseIP`, no DNS resolution) - see
|
||||
Setup.
|
||||
|
||||
@@ -156,7 +162,3 @@ run.
|
||||
|
||||
- Whether smppload's bind corruption is in `oserl`'s `gen_esme_session`/`smpp_session` send path
|
||||
(not reached, given the time-box) or something specific to this build's dependency versions.
|
||||
- Whether `smpp-dumb-client`'s stall is a sequence-number correlation bug (a response failing to
|
||||
match its `TrackTX` entry, falling back to the 7s expiry) or something else in its own window
|
||||
accounting - not chased past the observation in Setup, given the time-box and that the fault is
|
||||
clearly on the peer's side (our own counters stayed clean throughout).
|
||||
|
||||
@@ -64,6 +64,7 @@ public final class Driver {
|
||||
server.createContext("/bind", Driver::handleBind);
|
||||
server.createContext("/unbind", Driver::handleUnbind);
|
||||
server.createContext("/submit", Driver::handleSubmit);
|
||||
server.createContext("/load", Driver::handleLoad);
|
||||
server.createContext("/windowBurst", Driver::handleWindowBurst);
|
||||
server.createContext("/sendWindowSize", Driver::handleSendWindowSize);
|
||||
server.setExecutor(null);
|
||||
@@ -224,6 +225,62 @@ public final class Driver {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes count messages and reports only the rate. Cloudhopper's submit blocks on the response,
|
||||
* so the pool size is what puts requests in flight — the same shape as the other peers' load.
|
||||
*/
|
||||
private static void handleLoad(HttpExchange exchange) {
|
||||
Map<String, String> p = queryParams(exchange);
|
||||
SmppSession session = sessions.get(p.getOrDefault("session", "default"));
|
||||
|
||||
if (session == null) {
|
||||
respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session")));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
int count = Integer.parseInt(p.getOrDefault("count", "20000"));
|
||||
int concurrency = Integer.parseInt(p.getOrDefault("concurrency", "50"));
|
||||
long timeoutMs = Long.parseLong(p.getOrDefault("timeoutMs", "60000"));
|
||||
String from = p.getOrDefault("from", "1000");
|
||||
String to = p.getOrDefault("to", "2000");
|
||||
AtomicInteger issued = new AtomicInteger();
|
||||
AtomicInteger failed = new AtomicInteger();
|
||||
ExecutorService pool = Executors.newFixedThreadPool(concurrency);
|
||||
long started = System.nanoTime();
|
||||
|
||||
for (int worker = 0; worker < concurrency; worker++) {
|
||||
pool.execute(() -> {
|
||||
while (issued.getAndIncrement() < count) {
|
||||
try {
|
||||
session.submit(buildSubmit(from, to, "benchmark"), timeoutMs);
|
||||
} catch (Exception e) {
|
||||
failed.incrementAndGet();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pool.shutdown();
|
||||
|
||||
try {
|
||||
if (!pool.awaitTermination(10, java.util.concurrent.TimeUnit.MINUTES)) pool.shutdownNow();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
|
||||
double seconds = (System.nanoTime() - started) / 1e9;
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
|
||||
result.put("ok", true);
|
||||
result.put("count", count);
|
||||
result.put("concurrency", concurrency);
|
||||
result.put("failed", failed.get());
|
||||
result.put("seconds", Math.round(seconds * 1000d) / 1000d);
|
||||
result.put("perSecond", Math.round(count / seconds));
|
||||
respond(exchange, 200, Json.write(result));
|
||||
}
|
||||
|
||||
/** Fires `count` submits at once, each tagged by index in its text, to probe window pressure. */
|
||||
private static void handleWindowBurst(HttpExchange exchange) {
|
||||
Map<String, String> p = queryParams(exchange);
|
||||
|
||||
@@ -38,6 +38,10 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* An HTTP-driven jsmpp ESME: each request binds (if needed), performs one scenario action against
|
||||
@@ -64,6 +68,7 @@ public final class Driver {
|
||||
server.createContext("/unbind", Driver::handleUnbind);
|
||||
server.createContext("/enquireLink", Driver::handleEnquireLink);
|
||||
server.createContext("/submit", Driver::handleSubmit);
|
||||
server.createContext("/load", Driver::handleLoad);
|
||||
server.createContext("/querySm", exchange -> handleUnhandledCommand(exchange, "query"));
|
||||
server.createContext("/cancelSm", exchange -> handleUnhandledCommand(exchange, "cancel"));
|
||||
server.createContext("/replaceSm", exchange -> handleUnhandledCommand(exchange, "replace"));
|
||||
@@ -267,6 +272,70 @@ public final class Driver {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes count messages over an already-bound session and reports the rate. jsmpp's submit is
|
||||
* blocking, so threads are what put requests in flight here — the window is the pool size.
|
||||
*/
|
||||
private static void handleLoad(HttpExchange exchange) {
|
||||
Map<String, String> p = queryParams(exchange);
|
||||
SMPPSession session = sessions.get(p.getOrDefault("session", "default"));
|
||||
|
||||
if (session == null) {
|
||||
Map<String, Object> missing = new LinkedHashMap<>();
|
||||
missing.put("ok", false);
|
||||
missing.put("error", "no such session");
|
||||
respondOk(exchange, missing);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
int count = Integer.parseInt(p.getOrDefault("count", "20000"));
|
||||
int concurrency = Integer.parseInt(p.getOrDefault("concurrency", "50"));
|
||||
String from = p.getOrDefault("from", "BENCH");
|
||||
String to = p.getOrDefault("to", "46709771337");
|
||||
String text = p.getOrDefault("text", "benchmark");
|
||||
AtomicInteger issued = new AtomicInteger();
|
||||
AtomicInteger failed = new AtomicInteger();
|
||||
ExecutorService pool = Executors.newFixedThreadPool(concurrency);
|
||||
long started = System.nanoTime();
|
||||
|
||||
for (int worker = 0; worker < concurrency; worker++) {
|
||||
pool.execute(() -> {
|
||||
while (issued.getAndIncrement() < count) {
|
||||
try {
|
||||
session.submitShortMessage("CMT",
|
||||
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, from,
|
||||
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, to,
|
||||
new ESMClass(), (byte) 0, (byte) 1, null, null,
|
||||
new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0,
|
||||
dataCoding("ascii"), (byte) 0, encode(text, "ascii"));
|
||||
} catch (Exception e) {
|
||||
failed.incrementAndGet();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pool.shutdown();
|
||||
|
||||
try {
|
||||
if (!pool.awaitTermination(10, TimeUnit.MINUTES)) pool.shutdownNow();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
|
||||
double seconds = (System.nanoTime() - started) / 1e9;
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
|
||||
result.put("ok", true);
|
||||
result.put("count", count);
|
||||
result.put("concurrency", concurrency);
|
||||
result.put("failed", failed.get());
|
||||
result.put("seconds", Math.round(seconds * 1000d) / 1000d);
|
||||
result.put("perSecond", Math.round(count / seconds));
|
||||
respondOk(exchange, result);
|
||||
}
|
||||
|
||||
private static void submitPlain(SMPPSession session, String from, String to, String text, String encoding,
|
||||
java.util.List<Map<String, Object>> segments) throws Exception {
|
||||
SubmitSmResult r = session.submitShortMessage("CMT",
|
||||
|
||||
+30
-2
@@ -22,6 +22,7 @@ export type ClientOptions = {
|
||||
addrNpi?: number;
|
||||
addrTon?: number;
|
||||
bindType?: BindType;
|
||||
connectTimeout?: number | false;
|
||||
enquireLinkInterval?: number;
|
||||
host?: string;
|
||||
idleTimeout?: number;
|
||||
@@ -42,6 +43,7 @@ export type ClientOptions = {
|
||||
|
||||
const defaults = {
|
||||
bindType: 'transceiver',
|
||||
connectTimeout: 10_000,
|
||||
enquireLinkInterval: 20_000,
|
||||
host: 'localhost',
|
||||
/** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */
|
||||
@@ -52,7 +54,30 @@ const defaults = {
|
||||
username: 'user',
|
||||
} as const;
|
||||
|
||||
function armConnectTimeout(
|
||||
sock: Socket,
|
||||
connectTimeout: number | false,
|
||||
target: { peer: string; secure: boolean },
|
||||
settle: (result: Result<{ sock: Socket }>) => void,
|
||||
): NodeJS.Timeout | undefined {
|
||||
if (connectTimeout === false) return undefined;
|
||||
|
||||
let phase = `connecting to ${target.peer}`;
|
||||
|
||||
if (target.secure) {
|
||||
sock.once('connect', () => { phase = `completing the TLS handshake with ${target.peer}`; });
|
||||
}
|
||||
|
||||
return setTimeout(() => {
|
||||
sock.destroy();
|
||||
settle({
|
||||
err: new Error(`Timed out ${phase} after ${String(connectTimeout)} ms; raise connectTimeout or set it to false`),
|
||||
});
|
||||
}, connectTimeout).unref();
|
||||
}
|
||||
|
||||
function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
|
||||
const connectTimeout = options.connectTimeout ?? defaults.connectTimeout;
|
||||
const host = options.host ?? defaults.host;
|
||||
const port = options.port ?? defaults.port;
|
||||
const secure = options.tls !== undefined && options.tls !== false;
|
||||
@@ -77,11 +102,14 @@ function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
|
||||
return;
|
||||
}
|
||||
|
||||
const settle = (result: Result<{ sock: Socket }>): void => {
|
||||
const timer = armConnectTimeout(sock, connectTimeout, { peer: `${host}:${String(port)}`, secure }, settle);
|
||||
|
||||
function settle(result: Result<{ sock: Socket }>): void {
|
||||
clearTimeout(timer);
|
||||
sock.removeListener('error', onError);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
resolve(result);
|
||||
};
|
||||
}
|
||||
|
||||
function onError(err: Error): void {
|
||||
settle({ err });
|
||||
|
||||
@@ -4,7 +4,6 @@ import { buffer, cstring, dest_address_array, int8, unsuccess_sme_array } from '
|
||||
type CommandSpec = {
|
||||
id: number;
|
||||
params?: Record<string, WireType>;
|
||||
tlvMap?: Record<string, string>;
|
||||
};
|
||||
|
||||
const bindParams = {
|
||||
@@ -59,7 +58,6 @@ const specs = {
|
||||
broadcast_sm_resp: {
|
||||
id: 0x80000111,
|
||||
params: { message_id: cstring },
|
||||
tlvMap: { broadcast_area_identifier: 'failed_broadcast_area_identifier' },
|
||||
},
|
||||
cancel_broadcast_sm: {
|
||||
id: 0x00000113,
|
||||
|
||||
+218
-58
@@ -1,18 +1,15 @@
|
||||
import type { ParamValue, WireType } from './types.ts';
|
||||
import type { ParamValue, TlvValue, WireType } from './types.ts';
|
||||
import type { Result } from '../result.ts';
|
||||
import { tlv } from './types.ts';
|
||||
|
||||
export type TlvDefinition = {
|
||||
id: number;
|
||||
multiple?: boolean;
|
||||
tag: string;
|
||||
type: WireType;
|
||||
};
|
||||
/** Only a tag read as octets or as a number may repeat, since its occurrences are listed as one of those. */
|
||||
type Definition<Tag> = { id: number; multiple?: false; tag: Tag; type: WireType<Buffer | number | string> }
|
||||
| { id: number; multiple: true; tag: Tag; type: WireType<Buffer> | WireType<number> };
|
||||
|
||||
export type TlvDefinition = Definition<string>;
|
||||
|
||||
/** The constraint keys every definition to its own name, so a `tag` that drifts fails to compile. */
|
||||
const tlvSpecs = <T extends { [K in keyof T]: { id: number; multiple?: boolean; tag: K; type: WireType } }>(
|
||||
definitions: T,
|
||||
): T => definitions;
|
||||
const tlvSpecs = <T extends { [K in keyof T]: Definition<K> }>(definitions: T): T => definitions;
|
||||
|
||||
// Ordered by tag id, mirroring the SMPP 5.0 TLV table.
|
||||
const specs = tlvSpecs({
|
||||
@@ -82,15 +79,18 @@ const specs = tlvSpecs({
|
||||
its_session_info: { id: 0x1383, tag: 'its_session_info', type: tlv.buffer },
|
||||
});
|
||||
|
||||
export type TlvName = keyof typeof specs;
|
||||
type Specs = typeof specs;
|
||||
|
||||
export const tlvs: Record<TlvName, TlvDefinition> & Record<string, TlvDefinition> = {
|
||||
...specs,
|
||||
// Alternate spellings; the definition behind each keeps its canonical name.
|
||||
alert_on_msg_delivery: specs.alert_on_message_delivery,
|
||||
failed_broadcast_area_identifier: specs.broadcast_area_identifier,
|
||||
export type TlvName = keyof Specs;
|
||||
|
||||
// SMPP 5.0's other spellings, which only name the tag to key instead.
|
||||
const alternates: Record<string, TlvName> = {
|
||||
alert_on_msg_delivery: 'alert_on_message_delivery',
|
||||
failed_broadcast_area_identifier: 'broadcast_area_identifier',
|
||||
};
|
||||
|
||||
export const tlvs: Record<TlvName, TlvDefinition> = specs;
|
||||
|
||||
export const tlvsById: Record<number, TlvDefinition> = {};
|
||||
|
||||
for (const definition of Object.values<TlvDefinition>(specs)) {
|
||||
@@ -98,67 +98,227 @@ for (const definition of Object.values<TlvDefinition>(specs)) {
|
||||
}
|
||||
|
||||
/** Fallback for tags this table does not know: keep the raw octets. */
|
||||
export const tlvDefault: WireType = tlv.buffer;
|
||||
export const tlvDefault: WireType<Buffer> = tlv.buffer;
|
||||
|
||||
export type Tlv = {
|
||||
tagId: number;
|
||||
tagName: string | undefined;
|
||||
tagValue: ParamValue;
|
||||
};
|
||||
type Repeated<K extends TlvName, V> = Specs[K] extends { multiple: true } ? V[] : V;
|
||||
|
||||
export type TlvInput = {
|
||||
/** Resolved from the record key; pass it for a tag the TLV table does not define. */
|
||||
tagId?: number | undefined;
|
||||
tagValue: ParamValue;
|
||||
};
|
||||
type WireValue<K extends TlvName> = Specs[K]['type']['default'];
|
||||
|
||||
export function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> {
|
||||
const tagId = input.tagId ?? tlvs[name]?.id;
|
||||
type ReadValue<K extends TlvName> = Repeated<K, WireValue<K>>;
|
||||
|
||||
if (tagId === undefined) {
|
||||
return { err: new Error(`TLV "${name}": unknown tag name, give it a tagId`) };
|
||||
/** A lone text field also takes a number, and an octet field text, which goes out as latin1. */
|
||||
type WriteValue<K extends TlvName> = Specs[K] extends { multiple: true } ? ReadValue<K>
|
||||
: WireValue<K> extends number ? number
|
||||
: WireValue<K> extends string ? number | string
|
||||
: Buffer | string;
|
||||
|
||||
type KnownTlv<K extends TlvName> = { tagId: number; tagName: K; tagValue: ReadValue<K> };
|
||||
|
||||
type UnknownTlv = { tagId: number; tagName: undefined; tagValue: Buffer };
|
||||
|
||||
/** Keyed by tag name, or by its decimal id where the table defines no name. */
|
||||
export type Tlvs = { [K in TlvName]?: KnownTlv<K> } & Partial<Record<`${number}`, UnknownTlv>>;
|
||||
|
||||
export type Tlv = { [K in TlvName]: KnownTlv<K> }[TlvName] | UnknownTlv;
|
||||
|
||||
/** Keyed like `Tlvs`. */
|
||||
export type TlvInputs = { [K in TlvName]?: { tagValue: WriteValue<K> } }
|
||||
& Partial<Record<`${number}`, { tagValue: Buffer | string }>>;
|
||||
|
||||
function isTlvInput(input: unknown): input is { tagValue: TlvValue } {
|
||||
if (typeof input !== 'object' || input === null || !('tagValue' in input)) return false;
|
||||
|
||||
const value = input.tagValue;
|
||||
|
||||
if (!Array.isArray(value)) return Buffer.isBuffer(value) || typeof value === 'number' || typeof value === 'string';
|
||||
|
||||
return value.every(one => Buffer.isBuffer(one)) || value.every(one => typeof one === 'number');
|
||||
}
|
||||
|
||||
function keyedTagId(name: string): Result<{ tagId: number }> {
|
||||
if (isTlvName(name)) return { tagId: specs[name].id };
|
||||
|
||||
const alternate = Object.hasOwn(alternates, name) ? alternates[name] : undefined;
|
||||
|
||||
if (alternate) return { err: new Error(`TLV "${name}": key it ${alternate}, the name it reads back under`) };
|
||||
|
||||
if (!/^(0|[1-9]\d*)$/.test(name)) {
|
||||
return { err: new Error(`TLV "${name}": unknown tag name; key a tag the table does not define by its decimal id`) };
|
||||
}
|
||||
|
||||
if (!Number.isInteger(tagId) || tagId < 0 || tagId > 0xFFFF) {
|
||||
return { err: new Error(`TLV "${name}": tagId ${String(tagId)} out of range 0-65535`) };
|
||||
const tagId = Number(name);
|
||||
|
||||
if (tagId > 0xFFFF) return { err: new Error(`TLV "${name}": tag id out of range 0-65535`) };
|
||||
|
||||
const known = tlvsById[tagId];
|
||||
|
||||
return known ? { err: new Error(`TLV "${name}": the table names this tag ${known.tag}, key it by that`) } : { tagId };
|
||||
}
|
||||
|
||||
function entryOf(name: string, input: unknown): Result<{ tagId: number; tagValue: TlvValue }> {
|
||||
if (!isTlvInput(input)) {
|
||||
return { err: new Error(`TLV "${name}": give it as { tagValue }, holding a Buffer, a number, a string, or an array of Buffers or of numbers`) };
|
||||
}
|
||||
|
||||
return { tagId };
|
||||
const keyed = keyedTagId(name);
|
||||
|
||||
if (keyed.err) return { err: keyed.err };
|
||||
|
||||
if ('tagId' in input && input.tagId !== undefined && input.tagId !== keyed.tagId) {
|
||||
return { err: new Error(`TLV "${name}": its tagId does not match ${String(keyed.tagId)}, the tag its key names; drop the tagId`) };
|
||||
}
|
||||
|
||||
return { tagId: keyed.tagId, tagValue: input.tagValue };
|
||||
}
|
||||
|
||||
/** 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[] }> {
|
||||
export function writeTlvs(inputs: TlvInputs | undefined): Result<{ chunks: Buffer[] }> {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
for (const [name, input] of Object.entries(inputs ?? {})) {
|
||||
const tag = tagIdOf(name, input);
|
||||
for (const [name, input] of Object.entries<unknown>(inputs ?? {})) {
|
||||
const tag = entryOf(name, input);
|
||||
|
||||
if (tag.err) return { err: tag.err };
|
||||
|
||||
const type = tlvsById[tag.tagId]?.type ?? tlvDefault;
|
||||
const sized = type.size(input.tagValue);
|
||||
const definition = tlvsById[tag.tagId];
|
||||
const values = occurrences(tag.tagValue, definition?.multiple === true);
|
||||
|
||||
if (sized.err) {
|
||||
return { err: new Error(`TLV "${name}": ${sized.err.message}`) };
|
||||
if (values.err) return { err: new Error(`TLV "${name}": ${values.err.message}`) };
|
||||
|
||||
for (const value of values.values) {
|
||||
const chunk = writeTlv(tag.tagId, definition?.type ?? tlvDefault, value);
|
||||
|
||||
if (chunk.err) return { err: new Error(`TLV "${name}": ${chunk.err.message}`) };
|
||||
|
||||
chunks.push(chunk.chunk);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function occurrences(value: TlvValue, multiple: boolean): Result<{ values: ParamValue[] }> {
|
||||
if (!multiple) {
|
||||
return Array.isArray(value) ? { err: new Error('takes one value, not an array') } : { values: [value] };
|
||||
}
|
||||
|
||||
if (!Array.isArray(value)) return { err: new Error('is repeatable, wrap it in an array: [value]') };
|
||||
|
||||
if (value.length === 0) return { err: new Error('holds no values, omit it instead') };
|
||||
|
||||
return { values: value };
|
||||
}
|
||||
|
||||
function writeTlv(tagId: number, type: WireType, value: ParamValue): Result<{ chunk: Buffer }> {
|
||||
if (type === tlvDefault && typeof value === 'number') {
|
||||
return { err: new Error('holds octets, which a number would write as its digits; give a Buffer or a string') };
|
||||
}
|
||||
|
||||
const sized = type.size(value);
|
||||
|
||||
if (sized.err) return { err: sized.err };
|
||||
|
||||
if (sized.size > 0xffff) {
|
||||
return { err: new Error(`${String(sized.size)} octets overflow the two octet length`) };
|
||||
}
|
||||
|
||||
const chunk = Buffer.alloc(sized.size + 4);
|
||||
|
||||
chunk.writeUInt16BE(tagId, 0);
|
||||
chunk.writeUInt16BE(sized.size, 2);
|
||||
|
||||
const written = type.write(value, chunk, 4);
|
||||
|
||||
return written.err ? { err: written.err } : { chunk };
|
||||
}
|
||||
|
||||
type Occurrence = { definition: TlvDefinition | undefined; tagId: number; value: Buffer | number | string };
|
||||
|
||||
function readTlv(pdu: Buffer, offset: number): Result<{ octets: number; occurrence: Occurrence }> {
|
||||
const tagId = pdu.readUInt16BE(offset);
|
||||
const tagLength = pdu.readUInt16BE(offset + 2);
|
||||
|
||||
if (offset + 4 + tagLength > pdu.length) {
|
||||
return { err: new Error(`TLV ${String(tagId)} runs past the end of the PDU`) };
|
||||
}
|
||||
|
||||
const definition = tlvsById[tagId];
|
||||
const read = (definition?.type ?? tlvDefault).read(pdu, offset + 4, tagLength);
|
||||
|
||||
if (read.err) return { err: read.err };
|
||||
|
||||
// Copied, so holding a TLV pins no more than its own octets.
|
||||
const value = Buffer.isBuffer(read.value) ? Buffer.from(read.value) : read.value;
|
||||
|
||||
return { occurrence: { definition, tagId, value }, octets: 4 + tagLength };
|
||||
}
|
||||
|
||||
export function isTlvName(name: string): name is TlvName {
|
||||
return Object.hasOwn(specs, name);
|
||||
}
|
||||
|
||||
function readsAs(definition: TlvDefinition, value: unknown): boolean {
|
||||
const kind = definition.type.default;
|
||||
const fits = (one: unknown): boolean => typeof one === typeof kind && Buffer.isBuffer(one) === Buffer.isBuffer(kind);
|
||||
|
||||
return definition.multiple === true ? Array.isArray(value) && value.every(fits) : fits(value);
|
||||
}
|
||||
|
||||
function isTlvShape(tlv: unknown): tlv is { tagId: number; tagName: unknown; tagValue: unknown } {
|
||||
return typeof tlv === 'object' && tlv !== null && 'tagId' in tlv && typeof tlv.tagId === 'number'
|
||||
&& 'tagName' in tlv && 'tagValue' in tlv;
|
||||
}
|
||||
|
||||
function isTlv(key: string, tlv: unknown): boolean {
|
||||
if (!isTlvShape(tlv)) return false;
|
||||
if (tlv.tagName === undefined) return /^\d+$/.test(key) && Buffer.isBuffer(tlv.tagValue);
|
||||
|
||||
return tlv.tagName === key && isTlvName(key) && readsAs(specs[key], tlv.tagValue);
|
||||
}
|
||||
|
||||
function isTlvs(record: Record<string, unknown>): record is Tlvs {
|
||||
return Object.entries(record).every(([key, tlv]) => isTlv(key, tlv));
|
||||
}
|
||||
|
||||
/** Keyed by tag name, a repeatable tag listing every occurrence in wire order and any other keeping its last. */
|
||||
function keyedTlvs(occurrences: Occurrence[]): Result<{ tlvs: Tlvs }> {
|
||||
const repeated = new Map<string, { tagId: number; values: Occurrence['value'][] }>();
|
||||
const tlvs: Record<string, unknown> = {};
|
||||
|
||||
for (const { definition, tagId, value } of occurrences) {
|
||||
const key = definition?.tag ?? tagId.toString();
|
||||
|
||||
if (definition?.multiple === true) {
|
||||
const entry = repeated.get(key) ?? { tagId, values: [] };
|
||||
|
||||
entry.values.push(value);
|
||||
repeated.set(key, entry);
|
||||
} else {
|
||||
tlvs[key] = { tagId, tagName: definition?.tag, tagValue: value };
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, { tagId, values }] of repeated) {
|
||||
tlvs[key] = { tagId, tagName: key, tagValue: values };
|
||||
}
|
||||
|
||||
return isTlvs(tlvs) ? { tlvs } : { err: new Error('A TLV did not read as its table type, a defect in this library') };
|
||||
}
|
||||
|
||||
export function parseTlvs(pdu: Buffer, start: number): Result<{ offset: number; tlvs: Tlvs }> {
|
||||
const occurrences: Occurrence[] = [];
|
||||
let offset = start;
|
||||
|
||||
while (offset + 4 <= pdu.length) {
|
||||
const read = readTlv(pdu, offset);
|
||||
|
||||
if (read.err) return { err: read.err };
|
||||
|
||||
occurrences.push(read.occurrence);
|
||||
offset += read.octets;
|
||||
}
|
||||
|
||||
const keyed = keyedTlvs(occurrences);
|
||||
|
||||
return keyed.err ? { err: keyed.err } : { offset, tlvs: keyed.tlvs };
|
||||
}
|
||||
|
||||
+81
-22
@@ -1,4 +1,5 @@
|
||||
import type { Result, VoidResult } from '../result.ts';
|
||||
import { unencodableText } from './encodings.ts';
|
||||
|
||||
export type DestAddress =
|
||||
| { dest_addr_npi: number; dest_addr_ton: number; destination_addr: string }
|
||||
@@ -13,6 +14,24 @@ export type UnsuccessSme = {
|
||||
|
||||
export type ParamValue = Buffer | DestAddress[] | UnsuccessSme[] | number | string;
|
||||
|
||||
/** A tag defined `multiple` holds every occurrence, in wire order; any other tag holds one value. */
|
||||
export type TlvValue = Buffer | Buffer[] | number | number[] | string;
|
||||
|
||||
/** Octets a value holds, counting a string by its length. */
|
||||
export function tlvOctets(value: TlvValue): number {
|
||||
if (Buffer.isBuffer(value)) return value.length;
|
||||
if (typeof value === 'string') return value.length;
|
||||
if (typeof value === 'number') return 0;
|
||||
|
||||
let octets = 0;
|
||||
|
||||
for (const one of value) {
|
||||
octets += typeof one === 'number' ? 0 : one.length;
|
||||
}
|
||||
|
||||
return octets;
|
||||
}
|
||||
|
||||
/**
|
||||
* One field on the wire. `read` reports how many octets it consumed so callers never have to
|
||||
* re-derive a length that could disagree with what was actually written.
|
||||
@@ -25,10 +44,10 @@ export type WireType<T extends ParamValue = ParamValue> = {
|
||||
};
|
||||
|
||||
/** Renders a parameter as text without ever falling back to "[object Object]". */
|
||||
export function paramText(value: ParamValue | undefined): string {
|
||||
export function paramText(value: ParamValue | TlvValue | undefined): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number') return value.toString();
|
||||
if (Buffer.isBuffer(value)) return value.toString('ascii');
|
||||
if (Buffer.isBuffer(value)) return value.toString('latin1');
|
||||
|
||||
return '';
|
||||
}
|
||||
@@ -37,6 +56,11 @@ export function paramNumber(value: ParamValue | undefined, fallback: number): nu
|
||||
return typeof value === 'number' ? value : fallback;
|
||||
}
|
||||
|
||||
/** Spells a refused value for the caller who wrote it; JSON spells NaN and the infinities `null`. */
|
||||
export function valueText(value: ParamValue): string {
|
||||
return typeof value === 'number' ? String(value) : JSON.stringify(value);
|
||||
}
|
||||
|
||||
function outOfRange(buffer: Buffer, offset: number, needed: number): Error | undefined {
|
||||
if (offset < 0 || needed < 0 || offset + needed > buffer.length) {
|
||||
return new Error(
|
||||
@@ -49,7 +73,7 @@ function outOfRange(buffer: Buffer, offset: number, needed: number): Error | und
|
||||
|
||||
function wantInt(value: ParamValue, max: number): Result<{ int: number }> {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
||||
return { err: new Error(`Expected an integer, got ${JSON.stringify(value)}`) };
|
||||
return { err: new Error(`Expected an integer, got ${valueText(value)}`) };
|
||||
}
|
||||
|
||||
if (value < 0 || value > max) {
|
||||
@@ -79,11 +103,46 @@ function writeInt32(value: ParamValue, buf: Buffer, offset: number): VoidResult
|
||||
return {};
|
||||
}
|
||||
|
||||
function wantText(value: ParamValue): Result<{ text: string }> {
|
||||
if (typeof value === 'string') return { text: value };
|
||||
if (typeof value === 'number') return { text: value.toString() };
|
||||
function pastLatin1(text: string): { err: Error } | undefined {
|
||||
const index = text.search(/[\u0100-\uFFFF]/);
|
||||
|
||||
return { err: new Error(`Expected a string, got ${typeof value}`) };
|
||||
if (index === -1) return undefined;
|
||||
|
||||
const char = String.fromCodePoint(text.codePointAt(index) ?? 0);
|
||||
|
||||
return {
|
||||
err: new Error(
|
||||
`latin1 cannot carry ${unencodableText({ char, index })}, and every text field on the wire is written in it; strip or transliterate it`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function wantText(value: ParamValue): Result<{ text: string }> {
|
||||
if (typeof value !== 'number' && typeof value !== 'string') {
|
||||
return { err: new Error(`Expected a string or a number, got ${typeof value}`) };
|
||||
}
|
||||
|
||||
if (typeof value === 'number' && !Number.isFinite(value)) {
|
||||
return { err: new Error(`Expected a finite number, got ${String(value)}`) };
|
||||
}
|
||||
|
||||
const text = String(value);
|
||||
|
||||
return pastLatin1(text) ?? { text };
|
||||
}
|
||||
|
||||
function wantCstringText(value: ParamValue): Result<{ text: string }> {
|
||||
const { err, text } = wantText(value);
|
||||
|
||||
if (err) return { err };
|
||||
|
||||
const index = text.indexOf('\u0000');
|
||||
|
||||
if (index === -1) return { text };
|
||||
|
||||
return {
|
||||
err: new Error(`U+0000 at index ${String(index)} would end the C-Octet String there`),
|
||||
};
|
||||
}
|
||||
|
||||
function wantBytes(value: ParamValue): Result<{ bytes: Buffer }> {
|
||||
@@ -91,7 +150,7 @@ function wantBytes(value: ParamValue): Result<{ bytes: Buffer }> {
|
||||
|
||||
const { err, text } = wantText(value);
|
||||
|
||||
return err ? { err } : { bytes: Buffer.from(text, 'ascii') };
|
||||
return err ? { err } : { bytes: Buffer.from(text, 'latin1') };
|
||||
}
|
||||
|
||||
function isDestAddress(value: unknown): value is DestAddress {
|
||||
@@ -167,7 +226,7 @@ function readCstring(buffer: Buffer, offset: number): Result<{ bytesRead: number
|
||||
}
|
||||
}
|
||||
|
||||
return { bytesRead: length + 1, value: buffer.toString('ascii', offset, offset + length) };
|
||||
return { bytesRead: length + 1, value: buffer.toString('latin1', offset, offset + length) };
|
||||
}
|
||||
|
||||
function writeCstring(text: string, buffer: Buffer, offset: number): VoidResult {
|
||||
@@ -175,7 +234,7 @@ function writeCstring(text: string, buffer: Buffer, offset: number): VoidResult
|
||||
|
||||
if (err) return { err };
|
||||
|
||||
buffer.write(text, offset, 'ascii');
|
||||
buffer.write(text, offset, 'latin1');
|
||||
buffer[offset + text.length] = 0;
|
||||
|
||||
return {};
|
||||
@@ -248,7 +307,7 @@ export const string: WireType<string> = {
|
||||
|
||||
if (err) return { err };
|
||||
|
||||
return { bytesRead: length + 1, value: buffer.toString('ascii', offset + 1, offset + 1 + length) };
|
||||
return { bytesRead: length + 1, value: buffer.toString('latin1', offset + 1, offset + 1 + length) };
|
||||
},
|
||||
size(value) {
|
||||
const { err, text } = wantText(value);
|
||||
@@ -271,7 +330,7 @@ export const string: WireType<string> = {
|
||||
if (rangeErr) return { err: rangeErr };
|
||||
|
||||
buffer.writeUInt8(text.length, offset);
|
||||
buffer.write(text, offset + 1, 'ascii');
|
||||
buffer.write(text, offset + 1, 'latin1');
|
||||
|
||||
return {};
|
||||
},
|
||||
@@ -288,12 +347,12 @@ export const cstring: WireType<string> = {
|
||||
default: '',
|
||||
read: readCstring,
|
||||
size(value) {
|
||||
const { err, text } = wantText(value);
|
||||
const { err, text } = wantCstringText(value);
|
||||
|
||||
return err ? { err } : { size: text.length + 1 };
|
||||
},
|
||||
write(value, buffer, offset) {
|
||||
const { err, text } = wantText(value);
|
||||
const { err, text } = wantCstringText(value);
|
||||
|
||||
return err ? { err } : writeCstring(text, buffer, offset);
|
||||
},
|
||||
@@ -401,7 +460,7 @@ export const dest_address_array: WireType<DestAddress[]> = {
|
||||
if ('dl_name' in dest) {
|
||||
buf.writeUInt8(2, offset++);
|
||||
|
||||
const name = writeCstring(dest.dl_name, buf, offset);
|
||||
const name = cstring.write(dest.dl_name, buf, offset);
|
||||
|
||||
if (name.err) return { err: name.err };
|
||||
|
||||
@@ -417,7 +476,7 @@ export const dest_address_array: WireType<DestAddress[]> = {
|
||||
|
||||
if (npi.err) return { err: npi.err };
|
||||
|
||||
const addr = writeCstring(dest.destination_addr, buf, offset);
|
||||
const addr = cstring.write(dest.destination_addr, buf, offset);
|
||||
|
||||
if (addr.err) return { err: addr.err };
|
||||
|
||||
@@ -505,7 +564,7 @@ export const unsuccess_sme_array: WireType<UnsuccessSme[]> = {
|
||||
|
||||
if (npi.err) return { err: npi.err };
|
||||
|
||||
const addr = writeCstring(sme.destination_addr, buf, offset);
|
||||
const addr = cstring.write(sme.destination_addr, buf, offset);
|
||||
|
||||
if (addr.err) return { err: addr.err };
|
||||
|
||||
@@ -538,15 +597,15 @@ export const tlv = {
|
||||
? offset + length
|
||||
: terminator;
|
||||
|
||||
return { bytesRead: length, value: buf.toString('ascii', offset, end) };
|
||||
return { bytesRead: length, value: buf.toString('latin1', offset, end) };
|
||||
},
|
||||
size(value: ParamValue) {
|
||||
const { err, text } = wantText(value);
|
||||
const { err, text } = wantCstringText(value);
|
||||
|
||||
return err ? { err } : { size: text.length + 1 };
|
||||
},
|
||||
write(value: ParamValue, buf: Buffer, offset: number) {
|
||||
const { err, text } = wantText(value);
|
||||
const { err, text } = wantCstringText(value);
|
||||
|
||||
return err ? { err } : writeCstring(text, buf, offset);
|
||||
},
|
||||
@@ -559,7 +618,7 @@ export const tlv = {
|
||||
read(buf: Buffer, offset: number, length = 0) {
|
||||
const err = outOfRange(buf, offset, length);
|
||||
|
||||
return err ? { err } : { bytesRead: length, value: buf.toString('ascii', offset, offset + length) };
|
||||
return err ? { err } : { bytesRead: length, value: buf.toString('latin1', offset, offset + length) };
|
||||
},
|
||||
size(value: ParamValue) {
|
||||
const { err, text } = wantText(value);
|
||||
@@ -575,7 +634,7 @@ export const tlv = {
|
||||
|
||||
if (rangeErr) return { err: rangeErr };
|
||||
|
||||
buf.write(text, offset, 'ascii');
|
||||
buf.write(text, offset, 'latin1');
|
||||
|
||||
return {};
|
||||
},
|
||||
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
import type { MessageState } from './defs/constants.ts';
|
||||
import type { ParamValue } from './defs/types.ts';
|
||||
import type { TlvValue } from './defs/types.ts';
|
||||
import type { PduObject } from './pdu.ts';
|
||||
import type { SmsIdFormat } from './sms-id.ts';
|
||||
import { consts, constsById, hasUdh, messageTypeOf } from './defs/constants.ts';
|
||||
@@ -150,7 +150,7 @@ const smeMessageTypes: readonly number[] = [
|
||||
consts.ESM_CLASS.USER_ACKNOWLEDGEMENT,
|
||||
];
|
||||
|
||||
function nonEmptyText(value: ParamValue | undefined): string | undefined {
|
||||
function nonEmptyText(value: TlvValue | undefined): string | undefined {
|
||||
return typeof value === 'string' && value !== '' ? value : undefined;
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ function receiptBody(pduObj: PduObject): string {
|
||||
}
|
||||
|
||||
function receiptId(
|
||||
tlvId: ParamValue | undefined,
|
||||
tlvId: TlvValue | undefined,
|
||||
receipt: Receipt | undefined,
|
||||
format: SmsIdFormat,
|
||||
): string | undefined {
|
||||
@@ -198,7 +198,7 @@ function isMessageState(name: string | undefined): name is MessageState {
|
||||
|
||||
/** The state TLV wins where it names a state we know; an unnameable one leaves the body to say. */
|
||||
function receiptStatus(
|
||||
tlvState: ParamValue | undefined,
|
||||
tlvState: TlvValue | undefined,
|
||||
receipt: Receipt | undefined,
|
||||
): { statusId: number; statusMsg: MessageState | undefined } {
|
||||
const scraped = receiptStates[receipt?.stat?.toUpperCase() ?? ''];
|
||||
|
||||
+4
-2
@@ -9,7 +9,9 @@ export function errorFrom(reason: unknown): Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** String() throws on a null-prototype object or a symbol, so only a string or number is printed. */
|
||||
const printable: readonly string[] = ['boolean', 'number', 'string'];
|
||||
|
||||
/** String() throws on a null-prototype object, so anything but these is named by its type. */
|
||||
export function namedValue(value: unknown): string {
|
||||
return typeof value === 'string' || typeof value === 'number' ? String(value) : typeof value;
|
||||
return printable.includes(typeof value) ? String(value) : typeof value;
|
||||
}
|
||||
|
||||
+45
-24
@@ -2,10 +2,12 @@ import type { PduObject } from './pdu.ts';
|
||||
import type { SmppLog } from './log.ts';
|
||||
import { ExpiringGroups } from './expiring-groups.ts';
|
||||
import { IdleWaiters } from './idle-waiters.ts';
|
||||
import { retainedOctets } from './retained-pdu.ts';
|
||||
|
||||
export type HeldMessagesOptions = {
|
||||
log: SmppLog;
|
||||
max: number;
|
||||
maxOctets: number;
|
||||
/** Injected so expiry can be exercised without a wall clock. */
|
||||
now?: (() => number) | undefined;
|
||||
timeout: number;
|
||||
@@ -18,12 +20,18 @@ function keyOf(pduObjs: PduObject[]): string | undefined {
|
||||
return first ? String(first.seqNr) : undefined;
|
||||
}
|
||||
|
||||
type Held = {
|
||||
octets: number;
|
||||
pduObjs: PduObject[];
|
||||
};
|
||||
|
||||
/** The messages handed to the application that it has not answered yet, held by their segments. */
|
||||
export class HeldMessages {
|
||||
private readonly held: ExpiringGroups<PduObject[]>;
|
||||
private readonly held: ExpiringGroups<Held>;
|
||||
private readonly idleWaiters = new IdleWaiters();
|
||||
private readonly log: SmppLog;
|
||||
private readonly max: number;
|
||||
private readonly maxOctets: number;
|
||||
private octets = 0;
|
||||
|
||||
constructor(options: HeldMessagesOptions) {
|
||||
this.held = new ExpiringGroups({
|
||||
@@ -33,14 +41,24 @@ export class HeldMessages {
|
||||
timeout: options.timeout,
|
||||
});
|
||||
this.log = options.log;
|
||||
this.max = options.max;
|
||||
this.maxOctets = options.maxOctets;
|
||||
}
|
||||
|
||||
get octetsHeld(): number {
|
||||
return this.octets;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.held.size;
|
||||
}
|
||||
|
||||
/** An application that answers no message at all may not grow this without end. */
|
||||
/** Whether a message arriving now is past the bound, once the expired are swept. */
|
||||
full(): boolean {
|
||||
this.sweep();
|
||||
|
||||
return this.held.full || this.octets >= this.maxOctets;
|
||||
}
|
||||
|
||||
hold(pduObjs: PduObject[]): void {
|
||||
const key = keyOf(pduObjs);
|
||||
|
||||
@@ -48,35 +66,42 @@ export class HeldMessages {
|
||||
|
||||
this.sweep();
|
||||
|
||||
if (this.held.get(key)) {
|
||||
const replaced = this.held.get(key);
|
||||
|
||||
if (replaced) {
|
||||
this.log.warn('heldMessages - replacing a message on a re-used sequence number', { seqNr: Number(key) });
|
||||
} else if (this.held.full) {
|
||||
this.dropOldest();
|
||||
this.delete(key, replaced);
|
||||
}
|
||||
|
||||
this.held.set(key, pduObjs);
|
||||
const octets = pduObjs.reduce((sum, pduObj) => sum + retainedOctets(pduObj), 0);
|
||||
|
||||
this.held.set(key, { octets, pduObjs });
|
||||
this.octets += octets;
|
||||
}
|
||||
|
||||
/** Whether a drain is still waiting for this message to be answered. */
|
||||
has(pduObjs: PduObject[]): boolean {
|
||||
const key = keyOf(pduObjs);
|
||||
|
||||
return key !== undefined && this.held.get(key) === pduObjs;
|
||||
return key !== undefined && this.held.get(key)?.pduObjs === pduObjs;
|
||||
}
|
||||
|
||||
release(pduObjs: PduObject[]): void {
|
||||
const key = keyOf(pduObjs);
|
||||
|
||||
// Identity, not the key: a wrapped sequence number must not release someone else's message.
|
||||
if (key === undefined || this.held.get(key) !== pduObjs) return;
|
||||
const held = key === undefined ? undefined : this.held.get(key);
|
||||
|
||||
this.held.delete(key);
|
||||
if (key === undefined || held?.pduObjs !== pduObjs) return;
|
||||
|
||||
this.delete(key, held);
|
||||
this.settle();
|
||||
}
|
||||
|
||||
/** Drops every message: their segments went with the link, so no answer of ours correlates now. */
|
||||
clear(): void {
|
||||
this.held.takeAll();
|
||||
this.octets = 0;
|
||||
this.idleWaiters.settle();
|
||||
}
|
||||
|
||||
@@ -85,31 +110,27 @@ export class HeldMessages {
|
||||
return this.idleWaiters.wait(() => this.held.size, timeout, signal);
|
||||
}
|
||||
|
||||
private dropOldest(): void {
|
||||
const oldest = this.held.takeOldest();
|
||||
|
||||
if (!oldest) return;
|
||||
|
||||
const [seqNr] = oldest;
|
||||
|
||||
this.log.warn('heldMessages - buffer full, dropping the oldest message', {
|
||||
max: this.max,
|
||||
seqNr: Number(seqNr),
|
||||
});
|
||||
}
|
||||
|
||||
/** Drops every message past its deadline. Runs before each hold and on its own timer. */
|
||||
sweep(): void {
|
||||
const expired = this.held.takeExpired();
|
||||
|
||||
if (expired.length === 0) return;
|
||||
|
||||
for (const [, held] of expired) {
|
||||
this.octets -= held.octets;
|
||||
}
|
||||
|
||||
this.log.warn('heldMessages - messages the application never answered', {
|
||||
messages: expired.length,
|
||||
});
|
||||
this.settle();
|
||||
}
|
||||
|
||||
private delete(key: string, held: Held): void {
|
||||
this.held.delete(key);
|
||||
this.octets -= held.octets;
|
||||
}
|
||||
|
||||
private settle(): void {
|
||||
if (this.held.size === 0) this.idleWaiters.settle();
|
||||
}
|
||||
|
||||
@@ -13,11 +13,17 @@ import { Reassembler, decodeSegments } from './reassembly.ts';
|
||||
import { bindCommands, defaults, standsInFor } from './session-options.ts';
|
||||
import { concatOf } from './concat.ts';
|
||||
import { createSms } from './sms.ts';
|
||||
import { detach } from './retained-pdu.ts';
|
||||
import { dlrFromPdu } from './dlr.ts';
|
||||
import { paramText } from './defs/types.ts';
|
||||
import { respIdParams, segmentId } from './sms-id.ts';
|
||||
import { respNameFor } from './defs/commands.ts';
|
||||
|
||||
/** Asks the peer to keep the message and retry. */
|
||||
function throttledStatus(carriedAs: string): ErrorName {
|
||||
return carriedAs === 'submit_sm' ? 'ESME_RTHROTTLED' : 'ESME_RX_T_APPN';
|
||||
}
|
||||
|
||||
/** SMPP 3.4 lists ESME_RMSGQFUL under submit_sm_resp only; 4.6.2's retryable code is another. */
|
||||
export function refusedSegmentStatus(
|
||||
carriedAs: string,
|
||||
refusal: Refusal,
|
||||
@@ -28,7 +34,7 @@ export function refusedSegmentStatus(
|
||||
return spelling === 'sar' ? 'ESME_RINVTLVVAL' : 'ESME_RINVESMCLASS';
|
||||
}
|
||||
|
||||
return carriedAs === 'submit_sm' ? 'ESME_RMSGQFUL' : 'ESME_RX_T_APPN';
|
||||
return throttledStatus(carriedAs);
|
||||
}
|
||||
|
||||
const lostReasons: Record<LostGroup['reason'], string> = {
|
||||
@@ -65,12 +71,14 @@ export class IncomingRequests {
|
||||
private readonly smsIdFormat: SmsIdFormat;
|
||||
private readonly systemId: string;
|
||||
private linkGeneration = 0;
|
||||
private refusing = false;
|
||||
|
||||
constructor(options: IncomingRequestsOptions) {
|
||||
this.dlrMerger = options.dlrMerger;
|
||||
this.held = new HeldMessages({
|
||||
log: options.log,
|
||||
max: defaults.maxHeldMessages,
|
||||
maxOctets: defaults.maxHeldOctets,
|
||||
timeout: defaults.heldMessageTimeout,
|
||||
});
|
||||
this.log = options.log;
|
||||
@@ -141,6 +149,7 @@ export class IncomingRequests {
|
||||
/** Drops the segments of every message that never became whole, and of every one still held. */
|
||||
clear(): void {
|
||||
this.linkGeneration++;
|
||||
this.refusing = false;
|
||||
this.held.clear();
|
||||
this.reassembler.clear();
|
||||
}
|
||||
@@ -171,6 +180,12 @@ export class IncomingRequests {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!respNameFor(pduObj.cmdName)) {
|
||||
this.log.verbose('session - ignoring a command SMPP gives no response', { cmdName: pduObj.cmdName });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.log.info('session - no handler for command', { cmdName: pduObj.cmdName });
|
||||
await this.session.sendReturn(pduObj, 'ESME_RINVCMDID');
|
||||
}
|
||||
@@ -198,15 +213,49 @@ export class IncomingRequests {
|
||||
await this.session.sendReturn(pduObj);
|
||||
}
|
||||
|
||||
private async refusedAtBound(pduObj: PduObject): Promise<boolean> {
|
||||
if (this.held.full()) {
|
||||
if (!this.refusing) {
|
||||
this.refusing = true;
|
||||
this.log.warn('session - unanswered messages at their bound, refusing new ones until the application answers', {
|
||||
messages: this.held.size,
|
||||
octets: this.held.octetsHeld,
|
||||
});
|
||||
}
|
||||
|
||||
this.log.verbose('session - unanswered messages at their bound, asking the peer to retry', {
|
||||
cmdName: pduObj.cmdName,
|
||||
seqNr: pduObj.seqNr,
|
||||
});
|
||||
await this.session.sendReturn(pduObj, throttledStatus(this.carriedAs(pduObj)));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Half, so a peer keeping its window full does not flip this on every answer.
|
||||
if (
|
||||
this.refusing
|
||||
&& this.held.size <= defaults.maxHeldMessages / 2
|
||||
&& this.held.octetsHeld <= defaults.maxHeldOctets / 2
|
||||
) {
|
||||
this.refusing = false;
|
||||
this.log.info('session - unanswered messages down to half their bound, accepting again', { messages: this.held.size });
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A concatenated message is answered segment by segment as it arrives: a peer that dispatches
|
||||
* one request at a time never sends the second segment until the first has been answered.
|
||||
*/
|
||||
private async onMessage(pduObj: PduObject): Promise<void> {
|
||||
if (await this.refusedAtBound(pduObj)) return;
|
||||
|
||||
const concat = concatOf(pduObj);
|
||||
|
||||
if (!concat) {
|
||||
this.emitSms([pduObj]);
|
||||
this.emitSms([detach(pduObj)]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
+4
-4
@@ -6,7 +6,7 @@ export { cmds, cmdsById, commandNameById, isCommandName } from './defs/commands.
|
||||
export { consts, constsById } from './defs/constants.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 { isTlvName, tlvs, tlvsById } from './defs/tlvs.ts';
|
||||
export { types } from './defs/types.ts';
|
||||
|
||||
export {
|
||||
@@ -64,11 +64,11 @@ export type { CommandName, PduParams, PduParamsInput } from './defs/commands.ts'
|
||||
export type { ConstGroup, MessageState, SubmitMessagingMode } from './defs/constants.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 { PduObject, PduObjectInput, TlvInputs } from './pdu.ts';
|
||||
export type { PduHeader } from './pdu-refusal.ts';
|
||||
export type { SplitOptions } from './message.ts';
|
||||
export type { Tlv, TlvDefinition, TlvName } from './defs/tlvs.ts';
|
||||
export type { DestAddress, ParamValue, UnsuccessSme, WireType } from './defs/types.ts';
|
||||
export type { Tlv, TlvDefinition, TlvName, Tlvs } from './defs/tlvs.ts';
|
||||
export type { DestAddress, ParamValue, TlvValue, UnsuccessSme, WireType } from './defs/types.ts';
|
||||
|
||||
/** The spec tables, grouped the way `larvitsmpp.defs` was in 0.4.0. */
|
||||
export { defs } from './defs/index.ts';
|
||||
|
||||
+22
-66
@@ -3,14 +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, TlvInput } from './defs/tlvs.ts';
|
||||
import type { TlvInputs, Tlvs } from './defs/tlvs.ts';
|
||||
import { PduRefusedError, framingRefusal } from './pdu-refusal.ts';
|
||||
import { cmds, commandNameById, respNameFor } from './defs/commands.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 { tagIdOf, tlvDefault, tlvs, tlvsById, writeTlvs } from './defs/tlvs.ts';
|
||||
import { paramNumber, valueText } from './defs/types.ts';
|
||||
import { parseTlvs, 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;
|
||||
@@ -23,7 +23,7 @@ export type PduObjectInput<C extends CommandName = CommandName> = {
|
||||
cmdStatus?: ErrorName;
|
||||
params?: PduParamsInput<C>;
|
||||
seqNr?: number;
|
||||
tlvs?: Record<string, TlvInput> | undefined;
|
||||
tlvs?: TlvInputs | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -43,10 +43,10 @@ export type PduObject = {
|
||||
* the peer put in `message_payload` is not here; `messageOctets()` is what reads either.
|
||||
*/
|
||||
shortMessageOctets: Buffer | undefined;
|
||||
tlvs: Record<string, Tlv>;
|
||||
tlvs: Tlvs;
|
||||
};
|
||||
|
||||
export type { TlvInput };
|
||||
export type { TlvInputs };
|
||||
|
||||
const respBit = 0x80000000;
|
||||
|
||||
@@ -68,30 +68,13 @@ export function isCommand<C extends CommandName>(
|
||||
|
||||
type ResolvedBody = {
|
||||
params: Record<string, ParamValue | undefined>;
|
||||
tlvs: Record<string, TlvInput> | undefined;
|
||||
tlvs: TlvInputs | undefined;
|
||||
};
|
||||
|
||||
function codingOf(params: Record<string, ParamValue | undefined>): number | undefined {
|
||||
return typeof params.data_coding === 'number' ? params.data_coding : undefined;
|
||||
}
|
||||
|
||||
type CarriedBody = { name: string; text: string; tlv: TlvInput };
|
||||
|
||||
/** 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 });
|
||||
}
|
||||
}
|
||||
|
||||
return carried;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -105,19 +88,21 @@ function writtenBody(definition: CommandDefinition, value: ParamValue | undefine
|
||||
/** Encoded in place, settling data_coding where `settles` says no mandatory field will carry it. */
|
||||
function resolveCarried(
|
||||
resolved: ResolvedBody,
|
||||
inputs: Record<string, TlvInput> | undefined,
|
||||
inputs: TlvInputs | undefined,
|
||||
dataCoding: number | undefined,
|
||||
settles: boolean,
|
||||
): VoidResult {
|
||||
for (const carried of carriedBodies(inputs)) {
|
||||
const encoded = encodeBody(carried.text, dataCoding);
|
||||
const text = inputs?.message_payload?.tagValue;
|
||||
|
||||
if (encoded.err) return { err: new Error(`TLV "${carried.name}": ${encoded.err.message}`) };
|
||||
if (typeof text !== 'string') return {};
|
||||
|
||||
if (settles) resolved.params.data_coding = encoded.dataCoding;
|
||||
const encoded = encodeBody(text, dataCoding);
|
||||
|
||||
resolved.tlvs = { ...resolved.tlvs, [carried.name]: { ...carried.tlv, tagValue: encoded.buffer } };
|
||||
}
|
||||
if (encoded.err) return { err: new Error(`TLV "message_payload": ${encoded.err.message}`) };
|
||||
|
||||
if (settles) resolved.params.data_coding = encoded.dataCoding;
|
||||
|
||||
resolved.tlvs = { ...resolved.tlvs, message_payload: { tagValue: encoded.buffer } };
|
||||
|
||||
return {};
|
||||
}
|
||||
@@ -125,7 +110,7 @@ function resolveCarried(
|
||||
/** 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,
|
||||
tlvs: TlvInputs | undefined,
|
||||
definition: CommandDefinition,
|
||||
): Result<ResolvedBody> {
|
||||
const message = writtenBody(definition, params.short_message);
|
||||
@@ -197,7 +182,7 @@ function buildBody(
|
||||
cmdName: CommandName,
|
||||
cmdStatus: ErrorName,
|
||||
params: Record<string, ParamValue | undefined>,
|
||||
tlvs: Record<string, TlvInput> | undefined,
|
||||
tlvs: TlvInputs | undefined,
|
||||
): Result<{ body: Buffer }> {
|
||||
if (errors[cmdStatus] !== 0 && definition.id >= respBit) return { body: Buffer.alloc(0) };
|
||||
|
||||
@@ -221,7 +206,7 @@ function buildPdu(
|
||||
cmdStatus: ErrorName,
|
||||
seqNr: number,
|
||||
params: Record<string, ParamValue | undefined>,
|
||||
tlvs: Record<string, TlvInput> | undefined,
|
||||
tlvs: TlvInputs | undefined,
|
||||
): Result<{ buffer: Buffer }> {
|
||||
const definition = cmds[cmdName];
|
||||
|
||||
@@ -234,7 +219,7 @@ function buildPdu(
|
||||
}
|
||||
|
||||
if (!Number.isInteger(seqNr) || seqNr < 0 || seqNr > maxWireSeqNr) {
|
||||
return { err: new Error(`Invalid seqNr: ${JSON.stringify(seqNr)}`) };
|
||||
return { err: new Error(`Invalid seqNr: ${valueText(seqNr)}`) };
|
||||
}
|
||||
|
||||
const built = buildBody(definition, cmdName, cmdStatus, params, tlvs);
|
||||
@@ -262,35 +247,6 @@ export function objToPdu<C extends CommandName>(obj: PduObjectInput<C>): Result<
|
||||
);
|
||||
}
|
||||
|
||||
function parseTlvs(pdu: Buffer, start: number): Result<{ offset: number; tlvs: Record<string, Tlv> }> {
|
||||
const tlvs: Record<string, Tlv> = {};
|
||||
let offset = start;
|
||||
|
||||
while (offset + 4 <= pdu.length) {
|
||||
const tagId = pdu.readUInt16BE(offset);
|
||||
const tagLength = pdu.readUInt16BE(offset + 2);
|
||||
|
||||
if (offset + 4 + tagLength > pdu.length) {
|
||||
return { err: new Error(`TLV ${String(tagId)} runs past the end of the PDU`) };
|
||||
}
|
||||
|
||||
const definition = tlvsById[tagId];
|
||||
const read = (definition?.type ?? tlvDefault).read(pdu, offset + 4, tagLength);
|
||||
|
||||
if (read.err) return { err: read.err };
|
||||
|
||||
tlvs[definition?.tag ?? tagId.toString()] = {
|
||||
tagId,
|
||||
tagName: definition?.tag,
|
||||
tagValue: read.value,
|
||||
};
|
||||
|
||||
offset += 4 + tagLength;
|
||||
}
|
||||
|
||||
return { offset, tlvs };
|
||||
}
|
||||
|
||||
function readParams(
|
||||
cmdName: CommandName,
|
||||
pdu: Buffer,
|
||||
@@ -322,7 +278,7 @@ function readOptionalParams(
|
||||
pdu: Buffer,
|
||||
start: number,
|
||||
afterShortMessage: boolean,
|
||||
): Result<{ tlvs: Record<string, Tlv> }> {
|
||||
): Result<{ tlvs: Tlvs }> {
|
||||
const plain = parseTlvs(pdu, start);
|
||||
|
||||
if (!plain.err && plain.offset === pdu.length) return { tlvs: plain.tlvs };
|
||||
@@ -447,7 +403,7 @@ export function pduReturn(
|
||||
pdu: Buffer | PduObject,
|
||||
status: ErrorName = 'ESME_ROK',
|
||||
params: Record<string, ParamValue> = {},
|
||||
tlvs?: Record<string, TlvInput>,
|
||||
tlvs?: TlvInputs,
|
||||
): Result<{ buffer: Buffer }> {
|
||||
if (Buffer.isBuffer(pdu)) {
|
||||
const parsed = pduToObj(pdu);
|
||||
|
||||
+3
-48
@@ -1,10 +1,9 @@
|
||||
import type { Concat } from './concat.ts';
|
||||
import type { ParamValue } from './defs/types.ts';
|
||||
import type { PduObject } from './pdu.ts';
|
||||
import type { SmppLog } from './log.ts';
|
||||
import type { Tlv } from './defs/tlvs.ts';
|
||||
import { ExpiringGroups } from './expiring-groups.ts';
|
||||
import { decodeMessage } from './message.ts';
|
||||
import { detach, retainedOctets } from './retained-pdu.ts';
|
||||
import { messageOctets } from './message-body.ts';
|
||||
import { paramNumber, paramText } from './defs/types.ts';
|
||||
import { uuidv7 } from './uuid.ts';
|
||||
@@ -43,7 +42,7 @@ export type Collected =
|
||||
whole?: PduObject[] | undefined;
|
||||
};
|
||||
|
||||
const defaultMaxOctets = 64 * 1024 * 1024;
|
||||
export const defaultMaxOctets = 64 * 1024 * 1024;
|
||||
|
||||
type Group = {
|
||||
octets: number;
|
||||
@@ -52,50 +51,6 @@ type Group = {
|
||||
total: number;
|
||||
};
|
||||
|
||||
/** Wire reads hand back views, so retaining one segment would pin the whole PDU it arrived in. */
|
||||
function detach(pduObj: PduObject): PduObject {
|
||||
const params: Record<string, ParamValue> = {};
|
||||
const tlvs: Record<string, Tlv> = {};
|
||||
|
||||
for (const [name, value] of Object.entries(pduObj.params)) {
|
||||
params[name] = Buffer.isBuffer(value) ? Buffer.from(value) : value;
|
||||
}
|
||||
|
||||
for (const [name, tlv] of Object.entries(pduObj.tlvs)) {
|
||||
tlvs[name] = Buffer.isBuffer(tlv.tagValue)
|
||||
? { ...tlv, tagValue: Buffer.from(tlv.tagValue) }
|
||||
: tlv;
|
||||
}
|
||||
|
||||
// short_message holds the same octets wherever it was not decoded, so one copy covers both.
|
||||
const octets = Buffer.isBuffer(params.short_message)
|
||||
? params.short_message
|
||||
: pduObj.shortMessageOctets && Buffer.from(pduObj.shortMessageOctets);
|
||||
|
||||
return { ...pduObj, params, shortMessageOctets: octets, tlvs };
|
||||
}
|
||||
|
||||
// A cstring param arrives as a string, and source_addr alone can carry most of a 1 MiB PDU.
|
||||
function sizeOf(value: unknown): number {
|
||||
if (Buffer.isBuffer(value)) return value.length;
|
||||
|
||||
return typeof value === 'string' ? value.length : 0;
|
||||
}
|
||||
|
||||
function octetsOf(pduObj: PduObject): number {
|
||||
let octets = 0;
|
||||
|
||||
for (const value of Object.values(pduObj.params)) {
|
||||
octets += sizeOf(value);
|
||||
}
|
||||
|
||||
for (const tlv of Object.values(pduObj.tlvs)) {
|
||||
octets += sizeOf(tlv.tagValue);
|
||||
}
|
||||
|
||||
return octets;
|
||||
}
|
||||
|
||||
// NUL: the one octet a C-Octet String address cannot hold, so no sender can forge another's key.
|
||||
function groupKey(pduObj: PduObject, concat: Concat): string {
|
||||
return [
|
||||
@@ -165,7 +120,7 @@ export class Reassembler {
|
||||
const group = existing ?? this.open(key, concat.total);
|
||||
const replaced = group.parts.get(concat.part);
|
||||
const segment = detach(pduObj);
|
||||
const delta = octetsOf(segment) - (replaced === undefined ? 0 : octetsOf(replaced));
|
||||
const delta = retainedOctets(segment) - (replaced === undefined ? 0 : retainedOctets(replaced));
|
||||
|
||||
group.parts.set(concat.part, segment);
|
||||
group.octets += delta;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ParamValue } from './defs/types.ts';
|
||||
import type { PduObject } from './pdu.ts';
|
||||
import { tlvOctets } from './defs/types.ts';
|
||||
|
||||
/** Wire reads hand back views, so retaining one PDU would pin the whole chunk it arrived in. */
|
||||
export function detach(pduObj: PduObject): PduObject {
|
||||
const params: Record<string, ParamValue> = {};
|
||||
|
||||
for (const [name, value] of Object.entries(pduObj.params)) {
|
||||
params[name] = Buffer.isBuffer(value) ? Buffer.from(value) : value;
|
||||
}
|
||||
|
||||
// short_message holds the same octets wherever it was not decoded, so one copy covers both.
|
||||
const octets = Buffer.isBuffer(params.short_message)
|
||||
? params.short_message
|
||||
: pduObj.shortMessageOctets && Buffer.from(pduObj.shortMessageOctets);
|
||||
|
||||
return { ...pduObj, params, shortMessageOctets: octets };
|
||||
}
|
||||
|
||||
// Measured heap beyond the octets, so a PDU of empty fields or empty TLVs is not free.
|
||||
const pduObjectOverhead = 1000;
|
||||
const tlvObjectOverhead = 300;
|
||||
|
||||
// A cstring param arrives as a string, and source_addr alone can carry most of a 1 MiB PDU.
|
||||
function sizeOf(value: ParamValue): number {
|
||||
if (Buffer.isBuffer(value)) return value.length;
|
||||
|
||||
return typeof value === 'string' ? value.length : 0;
|
||||
}
|
||||
|
||||
/** Roughly the heap a detached PDU holds. */
|
||||
export function retainedOctets(pduObj: PduObject): number {
|
||||
let octets = pduObjectOverhead;
|
||||
|
||||
for (const value of Object.values(pduObj.params)) {
|
||||
octets += sizeOf(value);
|
||||
}
|
||||
|
||||
for (const tlv of Object.values(pduObj.tlvs)) {
|
||||
if (tlv === undefined) continue;
|
||||
|
||||
const listed = Array.isArray(tlv.tagValue) ? tlv.tagValue.length : 0;
|
||||
|
||||
octets += tlvOctets(tlv.tagValue) + (1 + listed) * tlvObjectOverhead;
|
||||
}
|
||||
|
||||
return octets;
|
||||
}
|
||||
+17
-3
@@ -7,10 +7,10 @@ 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 { cstring, paramText } from './defs/types.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';
|
||||
import { maxSegments, smppTime, splitMessage } from './message.ts';
|
||||
|
||||
export type SendSmsOptions = {
|
||||
@@ -211,8 +211,23 @@ function checkFlash(encoding: EncodingName, flash: boolean): Error | undefined {
|
||||
return new Error('flash has no Latin-1 spelling: a message class carries GSM 7-bit, 8-bit data or UCS2, and 8-bit data is not text a handset will display, so send it as UCS2 or drop flash');
|
||||
}
|
||||
|
||||
/** Asked of the wire type itself, so the codec cannot refuse an address this let through. */
|
||||
function checkAddresses(sms: SendSmsInput): Error | undefined {
|
||||
for (const option of ['from', 'to'] as const) {
|
||||
const { err } = cstring.size(sms[option]);
|
||||
|
||||
if (err) return new Error(`${option}: ${err.message}`);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Every option a send can be refused for, so nothing is built for a message that will not go. */
|
||||
function checkOptions(sms: SendSmsInput): Result<CheckedOptions> {
|
||||
const unwritable = checkAddresses(sms);
|
||||
|
||||
if (unwritable) return { err: unwritable };
|
||||
|
||||
const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true);
|
||||
|
||||
if (mode.err) return { err: mode.err };
|
||||
@@ -299,8 +314,7 @@ export async function submitSms(deps: SendSmsDeps, sms: SendSmsInput): Promise<S
|
||||
|
||||
deps.log.debug('sendSms() - sending', { encoding, segments: segments.length, to: sms.to });
|
||||
|
||||
// Segments go out together rather than one-after-a-response: a receiver that waits for every
|
||||
// segment before answering — this library's own server does — would otherwise deadlock.
|
||||
// Segments go out together: a receiver that waits for every segment before answering would otherwise deadlock.
|
||||
const sent = await Promise.all(segments.map(segment => deps.send({
|
||||
cmdName: 'submit_sm',
|
||||
params: submitSmParams(sms, segment, {
|
||||
|
||||
+4
-3
@@ -1,5 +1,5 @@
|
||||
import type { CloseOptions, OnRequest } from './session-options.ts';
|
||||
import type { PduObject, TlvInput } from './pdu.ts';
|
||||
import type { PduObject, TlvInputs } from './pdu.ts';
|
||||
import type { Result, VoidResult } from './result.ts';
|
||||
import type { Server as NetServer, Socket } from 'node:net';
|
||||
import type { Server as TlsServer, TlsOptions } from 'node:tls';
|
||||
@@ -13,6 +13,7 @@ import { defaultInterfaceVersion } from './defs/constants.ts';
|
||||
import { errorFrom } from './error-from.ts';
|
||||
import { paramText } from './defs/types.ts';
|
||||
import { guardedLog } from './log.ts';
|
||||
import { respNameFor } from './defs/commands.ts';
|
||||
|
||||
export type AuthenticateResult = { userData?: unknown } | boolean;
|
||||
|
||||
@@ -161,7 +162,7 @@ async function authenticate(
|
||||
}
|
||||
|
||||
/** An ESME reads a missing sc_interface_version as this SMSC having none. */
|
||||
function bindRespTlvs(session: Session, options: ServerOptions): Record<string, TlvInput> | undefined {
|
||||
function bindRespTlvs(session: Session, options: ServerOptions): TlvInputs | undefined {
|
||||
if (!session.acceptsOptionalParams()) return undefined;
|
||||
|
||||
return {
|
||||
@@ -224,7 +225,7 @@ async function handleRequest(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pduObj.cmdName === 'unbind') return false;
|
||||
if (pduObj.cmdName === 'unbind' || !respNameFor(pduObj.cmdName)) return false;
|
||||
|
||||
session.log.debug('server - command before bind', { cmdName: pduObj.cmdName });
|
||||
await session.sendReturn(pduObj, 'ESME_RINVBNDSTS');
|
||||
|
||||
+39
-8
@@ -9,6 +9,7 @@ import type { SmsIdFormat } from './sms-id.ts';
|
||||
import type { Sms } from './sms.ts';
|
||||
import type { Socket } from 'node:net';
|
||||
import { backoffDefaults } from './reconnect-loop.ts';
|
||||
import { defaultMaxOctets } from './reassembly.ts';
|
||||
import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './sms-id.ts';
|
||||
import { namedValue } from './error-from.ts';
|
||||
|
||||
@@ -127,6 +128,7 @@ export const defaults = {
|
||||
heldMessageTimeout: 300_000,
|
||||
maxDlrMerges: 1000,
|
||||
maxHeldMessages: 1000,
|
||||
maxHeldOctets: 64 * 1024 * 1024,
|
||||
maxOutstanding: 10,
|
||||
maxReassembly: 1000,
|
||||
reassemblyTimeout: 300_000,
|
||||
@@ -144,14 +146,11 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult {
|
||||
return { err: new Error('fromStart is part of the reconnect policy, spell it reconnect: { fromStart: true }') };
|
||||
}
|
||||
|
||||
const checked = checkLimits([
|
||||
['idleTimeout', options.idleTimeout ?? 0, 0],
|
||||
['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1],
|
||||
['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1],
|
||||
['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0],
|
||||
['responseTimeout', options.responseTimeout ?? defaults.responseTimeout, 0],
|
||||
['shutdownTimeout', options.shutdownTimeout ?? defaults.shutdownTimeout, 0],
|
||||
]);
|
||||
const connect = checkConnectTimeout(options.connectTimeout);
|
||||
|
||||
if (connect.err) return connect;
|
||||
|
||||
const checked = checkLimits(limitsOf(options));
|
||||
|
||||
if (checked.err) return checked;
|
||||
|
||||
@@ -160,6 +159,36 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult {
|
||||
return backoff.err ? backoff : checkSmsIdFormat(options.smsIdFormat);
|
||||
}
|
||||
|
||||
function limitsOf(options: CheckableOptions): [string, number, number][] {
|
||||
return [
|
||||
['idleTimeout', options.idleTimeout ?? 0, 0],
|
||||
['maxOctets', options.maxOctets ?? defaultMaxOctets, 1],
|
||||
['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1],
|
||||
['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1],
|
||||
['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0],
|
||||
['responseTimeout', options.responseTimeout ?? defaults.responseTimeout, 0],
|
||||
['shutdownTimeout', options.shutdownTimeout ?? defaults.shutdownTimeout, 0],
|
||||
];
|
||||
}
|
||||
|
||||
const maxTimerDelay = 2_147_483_647;
|
||||
|
||||
function checkConnectTimeout(connectTimeout: unknown): VoidResult {
|
||||
if (connectTimeout === undefined || connectTimeout === false) return {};
|
||||
|
||||
const got = typeof connectTimeout === 'string' ? `"${connectTimeout}"` : namedValue(connectTimeout);
|
||||
|
||||
if (typeof connectTimeout !== 'number' || !Number.isInteger(connectTimeout) || connectTimeout < 1) {
|
||||
return { err: new Error(`connectTimeout must be a whole number of milliseconds, 1 or more, got ${got}; false waits the OS out instead`) };
|
||||
}
|
||||
|
||||
if (connectTimeout > maxTimerDelay) {
|
||||
return { err: new Error(`connectTimeout must be ${String(maxTimerDelay)} ms or less (about 24 days), got ${got}; false waits the OS out instead`) };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function checkLimits(limits: [string, number, number][]): VoidResult {
|
||||
for (const [name, value, min] of limits) {
|
||||
if (!Number.isInteger(value) || value < min) {
|
||||
@@ -238,9 +267,11 @@ function checkSmsIdFormat(smsIdFormat: unknown): VoidResult {
|
||||
|
||||
/** What the checker reads, as it arrives: a caller without types can put anything in it. */
|
||||
export type CheckableOptions = {
|
||||
connectTimeout?: unknown;
|
||||
/** Not an option: the one spelling is inside reconnect, and this is where the other is refused. */
|
||||
fromStart?: unknown;
|
||||
idleTimeout?: number | undefined;
|
||||
maxOctets?: number | undefined;
|
||||
maxOutstanding?: number | undefined;
|
||||
maxReassembly?: number | undefined;
|
||||
reassemblyTimeout?: number | undefined;
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import type { ErrorName } from './defs/errors.ts';
|
||||
import type { MessageDlr } from './dlr-merger.ts';
|
||||
import type { ParamValue } from './defs/types.ts';
|
||||
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
|
||||
import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts';
|
||||
import type { PduRefusedError } from './pdu-refusal.ts';
|
||||
import type { BindType, CloseOptions, LinkEnd, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
|
||||
import type { Result, VoidResult } from './result.ts';
|
||||
@@ -175,7 +175,7 @@ export class Session extends EventEmitter<SessionEvents> {
|
||||
pdu: PduObject,
|
||||
status: ErrorName = 'ESME_ROK',
|
||||
params: Record<string, ParamValue> = {},
|
||||
tlvs?: Record<string, TlvInput>,
|
||||
tlvs?: TlvInputs,
|
||||
): Promise<VoidResult> {
|
||||
return Promise.resolve(
|
||||
this.answer(pduReturn(pdu, status, params, tlvs), pdu.cmdName, pdu.seqNr),
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import type { ErrorName } from './defs/errors.ts';
|
||||
import type { MessageState } from './defs/constants.ts';
|
||||
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
|
||||
import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts';
|
||||
import type { Result, VoidResult } from './result.ts';
|
||||
import type { Session } from './session.ts';
|
||||
import { UnansweredError } from './unanswered-error.ts';
|
||||
@@ -179,7 +179,7 @@ function receiptText(sms: Sms, smsId: string, status: MessageState): string {
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function receiptTlvs(smsId: string, status: MessageState): Record<string, TlvInput> {
|
||||
function receiptTlvs(smsId: string, status: MessageState): TlvInputs {
|
||||
return {
|
||||
message_state: { tagValue: consts.MESSAGE_STATE[status] },
|
||||
receipted_message_id: { tagValue: smsId },
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import { consts } from '../src/defs/constants.ts';
|
||||
import { dlrFromPdu, parseReceipt, receiptCodes } from '../src/dlr.ts';
|
||||
import { encodeMessage } from '../src/message.ts';
|
||||
import { objToPdu, pduToObj } from '../src/pdu.ts';
|
||||
import type { PduObject, TlvInput } from '../src/pdu.ts';
|
||||
import type { PduObject, TlvInputs } from '../src/pdu.ts';
|
||||
|
||||
const receiptText = 'id:0195f0c7 sub:001 dlvrd:001 submit date:2508251430 done date:2508251431 stat:DELIVRD err:000 text:hello there';
|
||||
|
||||
@@ -14,7 +14,7 @@ const textReceipt = `id:${textReceiptId} sub:001 dlvrd:001 submit date:250905143
|
||||
|
||||
function deliverSm(
|
||||
message: Buffer | string,
|
||||
tlvs?: Record<string, TlvInput>,
|
||||
tlvs?: TlvInputs,
|
||||
esmClass: number = consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
|
||||
dataCoding = 0,
|
||||
): PduObject {
|
||||
|
||||
@@ -95,8 +95,8 @@ describe('our encoder against the reference parser', () => {
|
||||
},
|
||||
seqNr: 77,
|
||||
tlvs: {
|
||||
message_state: { tagId: 0x0427, tagValue: 2 },
|
||||
receipted_message_id: { tagId: 0x001E, tagValue: 'abc123' },
|
||||
message_state: { tagValue: 2 },
|
||||
receipted_message_id: { tagValue: 'abc123' },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test, { describe } from 'node:test';
|
||||
import type { Dlr, Receipt } from '../src/dlr.ts';
|
||||
import type { MessageDlr } from '../src/session.ts';
|
||||
import type { PduObject, TlvInput } from '../src/pdu.ts';
|
||||
import type { PduObject, TlvInputs } from '../src/pdu.ts';
|
||||
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
|
||||
import { consts } from '../src/defs/constants.ts';
|
||||
import { dlrFromPdu, parseReceipt, receiptCodes, transientStates } from '../src/dlr.ts';
|
||||
@@ -16,7 +16,7 @@ import { objToPdu, pduToObj } from '../src/pdu.ts';
|
||||
|
||||
function deliverSm(
|
||||
body: string,
|
||||
tlvs?: Record<string, TlvInput>,
|
||||
tlvs?: TlvInputs,
|
||||
esmClass: number = consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
|
||||
): PduObject {
|
||||
const { buffer } = objToPdu({
|
||||
@@ -53,7 +53,7 @@ type ReceiptFixture = {
|
||||
name: string;
|
||||
receipt: Receipt;
|
||||
source: string;
|
||||
tlvs?: Record<string, TlvInput>;
|
||||
tlvs?: TlvInputs;
|
||||
};
|
||||
|
||||
const fixtures: readonly ReceiptFixture[] = [
|
||||
|
||||
+180
-25
@@ -3,6 +3,7 @@ import test, { describe } from 'node:test';
|
||||
import { PduRefusedError, refusalAnswer } from '../src/pdu-refusal.ts';
|
||||
import { isCommand, isResp, objToPdu, pduReturn, pduToObj } from '../src/pdu.ts';
|
||||
import { paramText } from '../src/defs/types.ts';
|
||||
import { isTlvName, tlvsById } from '../src/defs/tlvs.ts';
|
||||
|
||||
function encode(...args: Parameters<typeof objToPdu>): Buffer {
|
||||
const { buffer, err } = objToPdu(...args);
|
||||
@@ -51,6 +52,11 @@ describe('header', () => {
|
||||
assert.equal(decode(encode({ cmdName: 'enquire_link', seqNr: 0x80000001 })).seqNr, 0x80000001);
|
||||
assert.equal(decode(encode({ cmdName: 'enquire_link', seqNr: 0xFFFFFFFF })).seqNr, 0xFFFFFFFF);
|
||||
assert.ok(objToPdu({ cmdName: 'submit_sm', seqNr: 0x100000000 }).err instanceof Error);
|
||||
|
||||
const notANumber = objToPdu({ cmdName: 'submit_sm', seqNr: NaN });
|
||||
|
||||
assert.ok(notANumber.err instanceof Error);
|
||||
assert.match(notANumber.err.message, /NaN/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,6 +133,36 @@ describe('parsing real PDUs', () => {
|
||||
assert.equal(pduObj.params.short_message, 'hej 一');
|
||||
assert.equal(pduObj.tlvs.message_state?.tagValue, 2);
|
||||
});
|
||||
|
||||
test('keeps an alphanumeric sender whole through the wire and back', () => {
|
||||
const pdu = encode({
|
||||
cmdName: 'deliver_sm',
|
||||
params: {
|
||||
destination_addr: '46709771337',
|
||||
short_message: 'hej',
|
||||
source_addr: 'Kaffeé',
|
||||
source_addr_ton: 5,
|
||||
},
|
||||
seqNr: 9,
|
||||
});
|
||||
|
||||
assert.ok(pdu.includes(Buffer.from('Kaffeé', 'latin1')));
|
||||
assert.equal(decode(pdu).params.source_addr, 'Kaffeé');
|
||||
});
|
||||
|
||||
test('refuses an address the field cannot carry rather than truncating or coercing it', () => {
|
||||
const smuggled = objToPdu({
|
||||
cmdName: 'submit_sm',
|
||||
params: { destination_addr: '46709771337', source_addr: '46701113311\u0000EVIL' },
|
||||
});
|
||||
|
||||
assert.ok(smuggled.err instanceof Error);
|
||||
assert.equal(smuggled.buffer, undefined);
|
||||
assert.ok(objToPdu({ cmdName: 'deliver_sm', params: { source_addr: '一' } }).err instanceof Error);
|
||||
|
||||
assert.ok(objToPdu({ cmdName: 'deliver_sm', params: { source_addr: NaN } }).err instanceof Error);
|
||||
assert.ok(objToPdu({ cmdName: 'deliver_sm', params: { source_addr: Infinity } }).err instanceof Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encoding submit_sm', () => {
|
||||
@@ -342,7 +378,7 @@ describe('TLVs', () => {
|
||||
},
|
||||
seqNr: 393,
|
||||
tlvs: {
|
||||
5142: { tagId: 5142, tagValue: Buffer.from('blajfoo', 'ascii') },
|
||||
5142: { tagValue: Buffer.from('blajfoo', 'ascii') },
|
||||
receipted_message_id: { tagValue: '293f293' },
|
||||
},
|
||||
}));
|
||||
@@ -356,7 +392,7 @@ describe('TLVs', () => {
|
||||
assert.deepEqual(unknown.tagValue, Buffer.from('blajfoo', 'ascii'));
|
||||
});
|
||||
|
||||
test('keeps a binary TLV byte for byte through pduToObj and back', () => {
|
||||
test('keeps a binary TLV byte for byte through pduToObj and back, copied off the chunk it arrived in', () => {
|
||||
const payload = Buffer.from('deadbeef00ff', 'hex');
|
||||
const params = {
|
||||
destination_addr: '46709771337',
|
||||
@@ -364,16 +400,21 @@ describe('TLVs', () => {
|
||||
short_message: 'binary payload follows',
|
||||
source_addr: '46701113311',
|
||||
};
|
||||
const parsed = decode(encode({
|
||||
const pdu = encode({
|
||||
cmdName: 'deliver_sm',
|
||||
params,
|
||||
seqNr: 7,
|
||||
tlvs: { message_payload: { tagValue: payload } },
|
||||
}));
|
||||
const carried = parsed.tlvs.message_payload;
|
||||
});
|
||||
const chunk = Buffer.alloc(64 * 1024);
|
||||
|
||||
pdu.copy(chunk, 100);
|
||||
|
||||
const carried = decode(chunk.subarray(100, 100 + pdu.length)).tlvs.message_payload;
|
||||
|
||||
assert.ok(carried);
|
||||
assert.deepEqual(carried.tagValue, payload);
|
||||
assert.notEqual(carried.tagValue.buffer, chunk.buffer, 'a TLV holds its own octets, not the chunk it arrived in');
|
||||
|
||||
const rebuilt = decode(encode({
|
||||
cmdName: 'deliver_sm',
|
||||
@@ -397,25 +438,27 @@ describe('TLVs', () => {
|
||||
assert.deepEqual(pduObj.tlvs.source_port, { tagId: 0x020A, tagName: 'source_port', tagValue: 1234 });
|
||||
});
|
||||
|
||||
test('refuses an unknown tag name rather than putting a wrong tag on the wire', () => {
|
||||
const { buffer, err } = objToPdu({
|
||||
cmdName: 'deliver_sm',
|
||||
params: { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' },
|
||||
tlvs: { nils: { tagValue: 'blajfoo' } },
|
||||
});
|
||||
test('refuses a TLV keyed any way but by its name, or by its decimal id where the table names none', () => {
|
||||
const params = { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' };
|
||||
const refusals = [
|
||||
// @ts-expect-error nils is no tag name
|
||||
{ built: objToPdu({ cmdName: 'deliver_sm', params, tlvs: { nils: { tagValue: 'blajfoo' } } }), reason: /decimal id/ },
|
||||
{ built: objToPdu({ cmdName: 'deliver_sm', params, tlvs: { 5142: { tagId: 5143, tagValue: 'blajfoo' } } }), reason: /does not match 5142/ },
|
||||
// @ts-expect-error the key names the tag
|
||||
{ built: objToPdu({ cmdName: 'deliver_sm', params, tlvs: { message_state: { tagId: 5, tagValue: 2 } } }), reason: /does not match 1063/ },
|
||||
{ built: objToPdu({ cmdName: 'deliver_sm', params, tlvs: { 5142: { tagValue: 300 } } }), reason: /Buffer/ },
|
||||
{ built: objToPdu({ cmdName: 'deliver_sm', params, tlvs: { 65536: { tagValue: 'blajfoo' } } }), reason: /out of range/ },
|
||||
{ built: objToPdu({ cmdName: 'deliver_sm', params, tlvs: { '05142': { tagValue: 'blajfoo' } } }), reason: /decimal id/ },
|
||||
{ built: objToPdu({ cmdName: 'deliver_sm', params, tlvs: { 1063: { tagValue: 2 } } }), reason: /message_state/ },
|
||||
// @ts-expect-error the value goes in a { tagValue } wrapper
|
||||
{ built: objToPdu({ cmdName: 'deliver_sm', params, tlvs: { message_state: 2 } }), reason: /give it as \{ tagValue \}/ },
|
||||
];
|
||||
|
||||
assert.equal(buffer, undefined);
|
||||
assert.ok(err instanceof Error);
|
||||
});
|
||||
|
||||
test('refuses a tag id that does not fit the two octet field', () => {
|
||||
const { err } = objToPdu({
|
||||
cmdName: 'deliver_sm',
|
||||
params: { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' },
|
||||
tlvs: { nils: { tagId: 0x10000, tagValue: 'blajfoo' } },
|
||||
});
|
||||
|
||||
assert.ok(err instanceof Error);
|
||||
for (const { built: { buffer, err }, reason } of refusals) {
|
||||
assert.equal(buffer, undefined);
|
||||
assert.ok(err instanceof Error);
|
||||
assert.match(err.message, reason);
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses a TLV too long for the two octet length field', () => {
|
||||
@@ -428,6 +471,118 @@ describe('TLVs', () => {
|
||||
assert.ok(err instanceof Error);
|
||||
});
|
||||
|
||||
test('keeps every occurrence of a repeatable TLV, in wire order', () => {
|
||||
const first = Buffer.from('0146709771337', 'hex');
|
||||
const second = Buffer.from('0146701113311', 'hex');
|
||||
const pduObj = decode(encode({
|
||||
cmdName: 'submit_sm',
|
||||
params: { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' },
|
||||
tlvs: {
|
||||
callback_num: { tagValue: [first, second] },
|
||||
callback_num_pres_ind: { tagValue: [1] },
|
||||
},
|
||||
}));
|
||||
|
||||
const firstRead: Buffer | undefined = pduObj.tlvs.callback_num?.tagValue[0];
|
||||
const presentation: number | undefined = pduObj.tlvs.callback_num_pres_ind?.tagValue[0];
|
||||
|
||||
assert.deepEqual([firstRead, presentation], [first, 1]);
|
||||
assert.deepEqual(pduObj.tlvs.callback_num?.tagValue, [first, second]);
|
||||
assert.deepEqual(pduObj.tlvs.callback_num_pres_ind?.tagValue, [1]);
|
||||
});
|
||||
|
||||
test('writes and reads the failed areas of a broadcast_sm_resp as broadcast_area_identifier', () => {
|
||||
const areas = [Buffer.from('0001', 'hex'), Buffer.from('0002', 'hex')];
|
||||
const params = { message_id: '01a0d051-b588-76eb-a5c5-a8cb8b854e68' };
|
||||
const pduObj = decode(encode({ cmdName: 'broadcast_sm_resp', params, tlvs: { broadcast_area_identifier: { tagValue: areas } } }));
|
||||
|
||||
assert.deepEqual(pduObj.tlvs.broadcast_area_identifier?.tagValue, areas);
|
||||
|
||||
// @ts-expect-error the alternate spelling is read back under the name, so only the name is written
|
||||
const { err } = objToPdu({ cmdName: 'broadcast_sm_resp', params, tlvs: { failed_broadcast_area_identifier: { tagValue: areas } } });
|
||||
|
||||
assert.match(err?.message ?? '', /key it broadcast_area_identifier/);
|
||||
assert.deepEqual(
|
||||
['broadcast_area_identifier', 'failed_broadcast_area_identifier', 'constructor'].map(isTlvName),
|
||||
[true, false, false],
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses a repeatable TLV given one value, and a lone TLV given several', () => {
|
||||
const params = { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' };
|
||||
|
||||
for (const { buffer, err } of [
|
||||
// @ts-expect-error callback_num repeats, so it takes an array
|
||||
objToPdu({ cmdName: 'submit_sm', params, tlvs: { callback_num: { tagValue: Buffer.from('01', 'hex') } } }),
|
||||
objToPdu({ cmdName: 'submit_sm', params, tlvs: { callback_num: { tagValue: [] } } }),
|
||||
// @ts-expect-error source_port does not repeat
|
||||
objToPdu({ cmdName: 'submit_sm', params, tlvs: { source_port: { tagValue: [1234, 1235] } } }),
|
||||
]) {
|
||||
assert.equal(buffer, undefined);
|
||||
assert.ok(err instanceof Error);
|
||||
}
|
||||
});
|
||||
|
||||
test('reads every tag in the table as the type its entry declares', () => {
|
||||
const bare = encode({ cmdName: 'deliver_sm', params: { destination_addr: '46709771337', source_addr: '46701113311' } });
|
||||
|
||||
for (const { id, tag, type } of Object.values(tlvsById)) {
|
||||
const sized = type.size(type.default);
|
||||
|
||||
assert.ok(sized.size !== undefined);
|
||||
|
||||
const tlv = Buffer.alloc(4 + sized.size);
|
||||
|
||||
tlv.writeUInt16BE(id, 0);
|
||||
tlv.writeUInt16BE(sized.size, 2);
|
||||
assert.equal(type.write(type.default, tlv, 4).err, undefined);
|
||||
|
||||
const pdu = Buffer.concat([bare, tlv]);
|
||||
|
||||
pdu.writeUInt32BE(pdu.length, 0);
|
||||
assert.ok(Object.hasOwn(decode(pdu).tlvs, tag), tag);
|
||||
}
|
||||
});
|
||||
|
||||
test('relays a parsed PDU\'s TLVs back out as they arrived', () => {
|
||||
const params = { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' };
|
||||
const parsed = decode(encode({
|
||||
cmdName: 'deliver_sm',
|
||||
params,
|
||||
tlvs: {
|
||||
5142: { tagValue: Buffer.from('01', 'hex') },
|
||||
callback_num: { tagValue: [Buffer.from('0146709771337', 'hex')] },
|
||||
receipted_message_id: { tagValue: '0199d8a4-5e2c-7b3f-9a61-c4e07f2d8b15' },
|
||||
},
|
||||
}));
|
||||
|
||||
assert.deepEqual(decode(encode({ cmdName: 'deliver_sm', params, tlvs: parsed.tlvs })).tlvs, parsed.tlvs);
|
||||
// @ts-expect-error the key names the tag, and an undefined tagId names none
|
||||
assert.equal(objToPdu({ cmdName: 'deliver_sm', params, tlvs: { message_state: { tagId: undefined, tagValue: 2 } } }).err, undefined);
|
||||
});
|
||||
|
||||
test('types each known TLV by its tag, and an unknown one as octets', () => {
|
||||
const pduObj = decode(encode({
|
||||
cmdName: 'deliver_sm',
|
||||
params: { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' },
|
||||
tlvs: {
|
||||
5142: { tagValue: Buffer.from('01', 'hex') },
|
||||
message_state: { tagValue: 2 },
|
||||
receipted_message_id: { tagValue: '0199d8a4-5e2c-7b3f-9a61-c4e07f2d8b15' },
|
||||
},
|
||||
}));
|
||||
const id: string | undefined = pduObj.tlvs.receipted_message_id?.tagValue;
|
||||
const state: number | undefined = pduObj.tlvs.message_state?.tagValue;
|
||||
const unknown: Buffer | undefined = pduObj.tlvs['5142']?.tagValue;
|
||||
|
||||
assert.deepEqual([id, state, unknown], ['0199d8a4-5e2c-7b3f-9a61-c4e07f2d8b15', 2, Buffer.from('01', 'hex')]);
|
||||
|
||||
// @ts-expect-error an octet field takes no number, which would go out as its digits
|
||||
objToPdu({ cmdName: 'deliver_sm', params: {}, tlvs: { network_error_code: { tagValue: 5 } } });
|
||||
// @ts-expect-error message_state is an integer
|
||||
assert.ok(objToPdu({ cmdName: 'deliver_sm', params: {}, tlvs: { message_state: { tagValue: 'ENROUTE' } } }).err);
|
||||
});
|
||||
|
||||
test('round-trips a receipt with message_state and receipted_message_id', () => {
|
||||
const receipt = 'id:450 sub:001 dlvrd:1 submit date:1504031342 done date:1504031342 stat:DELIVRD err:0 text:xxx';
|
||||
const pduObj = decode(encode({
|
||||
@@ -440,8 +595,8 @@ describe('TLVs', () => {
|
||||
},
|
||||
seqNr: 323,
|
||||
tlvs: {
|
||||
message_state: { tagId: 1063, tagValue: 2 },
|
||||
receipted_message_id: { tagId: 30, tagValue: 450 },
|
||||
message_state: { tagValue: 2 },
|
||||
receipted_message_id: { tagValue: 450 },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
+299
-43
@@ -24,7 +24,7 @@ import { Session } from '../src/session.ts';
|
||||
import { DlrMerger } from '../src/dlr-merger.ts';
|
||||
import { PduRefusedError } from '../src/pdu-refusal.ts';
|
||||
import { objToPdu } from '../src/pdu.ts';
|
||||
import { checkSessionOptions, standsInFor } from '../src/session-options.ts';
|
||||
import { checkSessionOptions, defaults, standsInFor } from '../src/session-options.ts';
|
||||
import { client } from '../src/client.ts';
|
||||
import { closeAfter, closeListenerAfter } from './teardown.ts';
|
||||
import { concatOf } from '../src/concat.ts';
|
||||
@@ -84,6 +84,21 @@ function within<T>(ms: number, promise: Promise<T>): Promise<T | undefined> {
|
||||
return Promise.race([promise, delay(ms).then((): undefined => undefined)]);
|
||||
}
|
||||
|
||||
/** A client still retrying holds a socket and a timer nothing else releases. */
|
||||
function abortAfter(
|
||||
t: TestContext,
|
||||
controller: AbortController,
|
||||
connecting: ReturnType<typeof client>,
|
||||
): void {
|
||||
t.after(async () => {
|
||||
controller.abort();
|
||||
|
||||
const { session } = await connecting;
|
||||
|
||||
await session?.close({ signal: AbortSignal.abort() });
|
||||
});
|
||||
}
|
||||
|
||||
function submitPdu(seqNr: number, cmdStatus: ErrorName = 'ESME_ROK'): PduObject {
|
||||
return {
|
||||
cmdId: 0x00000004,
|
||||
@@ -798,21 +813,6 @@ describe('reconnect from the first bind', () => {
|
||||
return port;
|
||||
}
|
||||
|
||||
/** A client still retrying holds a socket and a timer nothing else releases. */
|
||||
function abortAfter(
|
||||
t: TestContext,
|
||||
controller: AbortController,
|
||||
connecting: ReturnType<typeof client>,
|
||||
): void {
|
||||
t.after(async () => {
|
||||
controller.abort();
|
||||
|
||||
const { session } = await connecting;
|
||||
|
||||
await session?.close({ signal: AbortSignal.abort() });
|
||||
});
|
||||
}
|
||||
|
||||
test('gives up on the first attempt where reconnect alone is asked for', async () => {
|
||||
const port = await closedPort();
|
||||
const spy = logSpy();
|
||||
@@ -1018,6 +1018,121 @@ describe('reconnect from the first bind', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('connectTimeout', () => {
|
||||
/** Accepts and then says nothing, so a TLS handshake started on it never completes. */
|
||||
async function stalledListener(t: TestContext): Promise<{ accepted: net.Socket[]; port: number }> {
|
||||
const accepted: net.Socket[] = [];
|
||||
const listener = net.createServer(sock => {
|
||||
accepted.push(sock);
|
||||
sock.resume();
|
||||
});
|
||||
|
||||
closeListenerAfter(t, listener, accepted);
|
||||
await new Promise<void>(resolve => { listener.listen(0, '127.0.0.1', resolve); });
|
||||
|
||||
const address = listener.address();
|
||||
|
||||
return { accepted, port: typeof address === 'object' && address !== null ? address.port : 0 };
|
||||
}
|
||||
|
||||
test('gives up on a connect the peer never completes', async t => {
|
||||
const { port } = await stalledListener(t);
|
||||
const settled = await within(2000, client({
|
||||
connectTimeout: 150,
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
reconnect: false,
|
||||
tls: true,
|
||||
}));
|
||||
|
||||
assert.ok(settled, 'a handshake nothing answers is what the OS wait would swallow for minutes');
|
||||
assert.ok(settled.err instanceof Error);
|
||||
assert.match(
|
||||
settled.err.message,
|
||||
new RegExp(`Timed out completing the TLS handshake with 127\\.0\\.0\\.1:${String(port)} after 150 ms; raise connectTimeout`),
|
||||
'a firewall and a peer that accepts then stalls need different answers, and whoever reads this has never heard of the option',
|
||||
);
|
||||
assert.equal(settled.session, undefined);
|
||||
});
|
||||
|
||||
// Waits the default out for real: node:test mock timers land in Node 20.4, and the floor is 18.
|
||||
test('bounds a connect nobody asked to bound, at the default ten seconds', async t => {
|
||||
const { port } = await stalledListener(t);
|
||||
const connecting = client({ host: '127.0.0.1', port, reconnect: false, tls: true });
|
||||
const settled = await within(13_000, connecting);
|
||||
|
||||
assert.ok(settled, 'no bound was armed, so nothing ever settled this connect');
|
||||
assert.ok(settled.err instanceof Error);
|
||||
assert.match(settled.err.message, /after 10000 ms/);
|
||||
});
|
||||
|
||||
test('retries a connect it timed out on, like any other failed attempt', async t => {
|
||||
const { accepted, port } = await stalledListener(t);
|
||||
const controller = new AbortController();
|
||||
const connecting = client({
|
||||
connectTimeout: 60,
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
reconnect: { fromStart: true, maxDelay: 40, minDelay: 10 },
|
||||
signal: controller.signal,
|
||||
tls: true,
|
||||
});
|
||||
|
||||
abortAfter(t, controller, connecting);
|
||||
await delay(400);
|
||||
|
||||
assert.ok(accepted.length >= 3, `the loop retried what timed out, got ${String(accepted.length)} attempts`);
|
||||
});
|
||||
|
||||
test('disarms on the connect that completed, rather than on the socket that follows it', async t => {
|
||||
const smpp = await startServer(t);
|
||||
const { session } = await connect(t, smpp, { connectTimeout: 200 });
|
||||
|
||||
assert.ok(session);
|
||||
await delay(300);
|
||||
|
||||
const probe = await session.send({ cmdName: 'enquire_link' });
|
||||
|
||||
assert.equal(probe.err, undefined);
|
||||
assert.equal(session.sock.destroyed, false);
|
||||
});
|
||||
|
||||
test('refuses a connect timeout that would turn itself off', async () => {
|
||||
assert.match(
|
||||
checkSessionOptions({ connectTimeout: 0 }).err?.message ?? '',
|
||||
/false waits the OS out/,
|
||||
'off is spelled false, so 0 may not stand in for it',
|
||||
);
|
||||
assert.equal(checkSessionOptions({ connectTimeout: false }).err, undefined);
|
||||
assert.match(checkSessionOptions({ connectTimeout: -1 }).err?.message ?? '', /connectTimeout/);
|
||||
assert.match(checkSessionOptions({ connectTimeout: 1.5 }).err?.message ?? '', /whole number/);
|
||||
assert.match(
|
||||
checkSessionOptions({ connectTimeout: '5000' }).err?.message ?? '',
|
||||
/got "5000"/,
|
||||
'an env var read without Number() is the commonest untyped value, and it is a correct number',
|
||||
);
|
||||
assert.match(checkSessionOptions({ connectTimeout: true }).err?.message ?? '', /got true/);
|
||||
assert.match(
|
||||
checkSessionOptions({ connectTimeout: 2_147_483_648 }).err?.message ?? '',
|
||||
/2147483647 ms or less \(about 24 days\), got 2147483648; false waits the OS out instead/,
|
||||
'a delay Node cannot hold in 32 bits fires after 1 ms, the inverse of what it asked for',
|
||||
);
|
||||
assert.equal(checkSessionOptions({ connectTimeout: 2_147_483_647 }).err, undefined);
|
||||
assert.equal(checkSessionOptions({ connectTimeout: 1000 }).err, undefined);
|
||||
|
||||
const refused = await client({ connectTimeout: 0, port: 1 });
|
||||
|
||||
assert.ok(refused.err instanceof Error);
|
||||
assert.match(refused.err.message, /false waits/, 'the socket may not be opened before the option is refused');
|
||||
assert.equal(refused.session, undefined);
|
||||
|
||||
const off = await client({ connectTimeout: false, port: 1 });
|
||||
|
||||
assert.ok(off.err instanceof Error);
|
||||
assert.match(off.err.message, /ECONNREFUSED/, 'false opts out of the bound without breaking the connect');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sends across a reconnect', () => {
|
||||
/** Answers every message after the first, which is left to hold the send window open. */
|
||||
function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Latch {
|
||||
@@ -1367,28 +1482,133 @@ describe('held message bounds', () => {
|
||||
return [submitPdu(seqNr)];
|
||||
}
|
||||
|
||||
test('drops the message held longest rather than holding every one', () => {
|
||||
const held = new HeldMessages({ log: silentLog, max: 2, timeout: 10_000 });
|
||||
const oldest = message(1);
|
||||
test('is full at its count, and a re-used sequence number replaces rather than adding', () => {
|
||||
const held = new HeldMessages({ log: silentLog, max: 2, maxOctets: 1_000_000, timeout: 10_000 });
|
||||
const first = message(1);
|
||||
|
||||
held.hold(oldest);
|
||||
held.hold(first);
|
||||
held.hold(message(2));
|
||||
held.hold(message(2));
|
||||
|
||||
assert.equal(held.size, 2, 'a re-used sequence number replaces rather than evicting');
|
||||
assert.equal(held.has(oldest), true);
|
||||
|
||||
held.hold(message(3));
|
||||
|
||||
assert.equal(held.size, 2);
|
||||
assert.equal(held.has(oldest), false);
|
||||
assert.equal(held.full(), true);
|
||||
assert.equal(held.has(first), true);
|
||||
|
||||
held.clear();
|
||||
});
|
||||
|
||||
// submitPdu() holds 1026 octets by the maxOctets charge: its object, and the three text fields.
|
||||
test('is full at its octet cap, until a message leaves by any way out', () => {
|
||||
let now = 0;
|
||||
const held = new HeldMessages({ log: silentLog, max: 10, maxOctets: 2000, now: () => now, timeout: 10_000 });
|
||||
const answered = message(1);
|
||||
|
||||
held.hold(answered);
|
||||
assert.equal(held.full(), false);
|
||||
held.hold(message(2));
|
||||
assert.equal(held.full(), true);
|
||||
|
||||
held.release(answered);
|
||||
assert.equal(held.full(), false, 'after a release');
|
||||
held.hold(message(3));
|
||||
|
||||
now = 20_000;
|
||||
held.sweep();
|
||||
now = 0;
|
||||
assert.equal(held.full(), false, 'after a sweep');
|
||||
held.hold(message(4));
|
||||
held.hold(message(5));
|
||||
|
||||
held.clear();
|
||||
assert.equal(held.full(), false, 'after a clear');
|
||||
});
|
||||
|
||||
// Dropping one the application still holds frees nothing, and the drain stops waiting for it.
|
||||
test('refuses what arrives past the bound with a status that asks the peer to retry', async t => {
|
||||
const session = new Session({ sock: new net.Socket() });
|
||||
|
||||
closeAfter(t, session);
|
||||
session.boundAs = 'transceiver';
|
||||
|
||||
const warnings: string[] = [];
|
||||
const incoming = new IncomingRequests({
|
||||
dlrMerger: new DlrMerger({ log: silentLog, max: 10, timeout: 10_000 }),
|
||||
log: { ...silentLog, warn: message => { warnings.push(message); } },
|
||||
sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }),
|
||||
session,
|
||||
});
|
||||
const answers: (ErrorName | undefined)[] = [];
|
||||
const received: Sms[] = [];
|
||||
|
||||
session.sendReturn = (_pdu, status) => {
|
||||
answers.push(status);
|
||||
|
||||
return Promise.resolve({});
|
||||
};
|
||||
session.on('sms', sms => { received.push(sms); });
|
||||
|
||||
for (let seqNr = 1; seqNr <= defaults.maxHeldMessages; seqNr++) {
|
||||
await incoming.handle(submitPdu(seqNr));
|
||||
}
|
||||
|
||||
assert.equal(received.length, defaults.maxHeldMessages);
|
||||
|
||||
await incoming.handle(submitPdu(defaults.maxHeldMessages + 1));
|
||||
await incoming.handle(segment(7, 1, 2));
|
||||
|
||||
assert.equal(received.length, defaults.maxHeldMessages);
|
||||
assert.deepEqual(answers, ['ESME_RTHROTTLED', 'ESME_RTHROTTLED']);
|
||||
assert.equal(warnings.length, 1, 'reaching the bound warns once, not per refusal');
|
||||
|
||||
// The refused first segment joined no group, so the second one is taken and completes nothing.
|
||||
await received[0]?.sendResp();
|
||||
await new Promise(resolve => { setImmediate(resolve); });
|
||||
await incoming.handle(segment(7, 2, 2));
|
||||
|
||||
assert.equal(answers.at(-1), 'ESME_ROK');
|
||||
assert.equal(received.length, defaults.maxHeldMessages);
|
||||
|
||||
// A peer keeping its window full crosses the bound on every answer, and that is still one warning.
|
||||
await received[1]?.sendResp();
|
||||
await new Promise(resolve => { setImmediate(resolve); });
|
||||
await incoming.handle(submitPdu(defaults.maxHeldMessages + 2));
|
||||
await incoming.handle(submitPdu(defaults.maxHeldMessages + 3));
|
||||
await incoming.handle(submitPdu(defaults.maxHeldMessages + 4));
|
||||
|
||||
assert.equal(answers.at(-1), 'ESME_RTHROTTLED');
|
||||
assert.equal(warnings.length, 1);
|
||||
incoming.clear();
|
||||
});
|
||||
|
||||
test('holds a message detached from the chunk it was read from', async t => {
|
||||
const session = new Session({ sock: new net.Socket() });
|
||||
|
||||
closeAfter(t, session);
|
||||
session.boundAs = 'transceiver';
|
||||
|
||||
const incoming = new IncomingRequests({
|
||||
dlrMerger: new DlrMerger({ log: silentLog, max: 10, timeout: 10_000 }),
|
||||
log: silentLog,
|
||||
sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }),
|
||||
session,
|
||||
});
|
||||
const chunk = Buffer.alloc(64 * 1024);
|
||||
const carried = submitPdu(1);
|
||||
let received: Sms | undefined;
|
||||
|
||||
session.on('sms', sms => { received = sms; });
|
||||
await incoming.handle({ ...carried, params: { ...carried.params, short_message: chunk.subarray(16, 20) } });
|
||||
|
||||
const retained = received?.pduObjs[0]?.params.short_message;
|
||||
|
||||
assert.ok(Buffer.isBuffer(retained));
|
||||
assert.notEqual(retained.buffer, chunk.buffer);
|
||||
incoming.clear();
|
||||
});
|
||||
|
||||
test('gives up on a message the application never answers', () => {
|
||||
let now = 0;
|
||||
const held = new HeldMessages({ log: silentLog, max: 10, now: () => now, timeout: 60 });
|
||||
const held = new HeldMessages({ log: silentLog, max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 });
|
||||
|
||||
held.hold(message(1));
|
||||
now = 61;
|
||||
@@ -1404,7 +1624,7 @@ describe('held message bounds', () => {
|
||||
// Without this the drain sits out its whole budget before returning what a sweep already settled.
|
||||
test('wakes a waiting drain when the last message expires', async () => {
|
||||
let now = 0;
|
||||
const held = new HeldMessages({ log: silentLog, max: 10, now: () => now, timeout: 60 });
|
||||
const held = new HeldMessages({ log: silentLog, max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 });
|
||||
|
||||
held.hold(message(1));
|
||||
|
||||
@@ -1588,7 +1808,7 @@ describe('where a segment says it is concatenated', () => {
|
||||
});
|
||||
|
||||
test('reads no concatenation where a sar_* TLV is missing', () => {
|
||||
const lone = {
|
||||
const lone: PduObject = {
|
||||
...sarSegment(5, 1, 2),
|
||||
tlvs: { sar_msg_ref_num: { tagId: 0x020c, tagName: 'sar_msg_ref_num', tagValue: 5 } },
|
||||
};
|
||||
@@ -1690,7 +1910,7 @@ describe('reassembly bounds', () => {
|
||||
assert.deepEqual(lost, [], 'the peer holds the only segment there was, so nothing was lost');
|
||||
});
|
||||
|
||||
// The two addresses are 22 octets, so only the 14 the TLV carries can overrun a cap of 30.
|
||||
// The two addresses and the segment's and TLV's objects are 1322 octets, so only the 14 it carries can overrun 1330.
|
||||
test('counts a body carried in message_payload against the octet cap', () => {
|
||||
function collectPayload(maxOctets: number): Collected {
|
||||
const reassembler = new Reassembler({
|
||||
@@ -1705,8 +1925,45 @@ describe('reassembly bounds', () => {
|
||||
return collectPdu(reassembler, payloadSegment(9, 1, 2));
|
||||
}
|
||||
|
||||
assert.equal(collectPayload(30).kept, false, 'a TLV body the cap cannot hold is refused, not dropped later');
|
||||
assert.equal(collectPayload(40).kept, true);
|
||||
assert.equal(collectPayload(1330).kept, false, 'a TLV body the cap cannot hold is refused, not dropped later');
|
||||
assert.equal(collectPayload(1340).kept, true);
|
||||
});
|
||||
|
||||
test('counts the objects a segment and each of its TLVs hold against the octet cap, empty ones included', () => {
|
||||
function collectTlvs(maxOctets: number, tlvs: PduObject['tlvs']): Collected {
|
||||
const reassembler = new Reassembler({
|
||||
log: silentLog,
|
||||
max: 10,
|
||||
maxOctets,
|
||||
now: () => 0,
|
||||
onLost: () => undefined,
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
return collectPdu(reassembler, { ...segment(9, 1, 2), tlvs });
|
||||
}
|
||||
function collectCallbacks(maxOctets: number, tagValue: Buffer[]): Collected {
|
||||
return collectTlvs(maxOctets, { callback_num: { tagId: 0x0381, tagName: 'callback_num', tagValue } });
|
||||
}
|
||||
const unknownTags = Object.fromEntries(Array.from({ length: 10_000 }, (_, i) => {
|
||||
const tagId = 0x4000 + i;
|
||||
|
||||
return [String(tagId), { tagId, tagName: undefined, tagValue: Buffer.alloc(0) }];
|
||||
}));
|
||||
const numbers = [Buffer.alloc(10_000, 0x31), Buffer.alloc(10_000, 0x32)];
|
||||
|
||||
assert.equal(collectCallbacks(20_000, numbers).kept, false);
|
||||
assert.equal(collectCallbacks(30_000, numbers).kept, true);
|
||||
assert.equal(
|
||||
collectCallbacks(30_000, Array.from({ length: 10_000 }, () => Buffer.alloc(0))).kept,
|
||||
false,
|
||||
'an empty occurrence still holds an object',
|
||||
);
|
||||
// The segment itself is 1036 octets, so the tags must be charged 300 each to overrun 3,001,000.
|
||||
assert.equal(collectTlvs(3_001_000, unknownTags).kept, false, 'an empty tag still holds an object');
|
||||
assert.equal(collectTlvs(3_002_000, unknownTags).kept, true);
|
||||
assert.equal(collectTlvs(1_000, {}).kept, false, 'a segment holds objects beyond its 36 octets');
|
||||
assert.equal(collectTlvs(1_036, {}).kept, true);
|
||||
});
|
||||
|
||||
// The segments before it were answered ESME_ROK, so dropping those is not the same as refusing one.
|
||||
@@ -1715,8 +1972,8 @@ describe('reassembly bounds', () => {
|
||||
const reassembler = new Reassembler({
|
||||
log: silentLog,
|
||||
max: 10,
|
||||
// One segment is 36 octets, so the second overruns a group already holding the first.
|
||||
maxOctets: 50,
|
||||
// One segment is 1036 octets, so the second overruns a group already holding the first.
|
||||
maxOctets: 1050,
|
||||
now: () => 0,
|
||||
onLost: one => { lost.push(one); },
|
||||
timeout: 60_000,
|
||||
@@ -1780,9 +2037,9 @@ describe('reassembly bounds', () => {
|
||||
assert.equal(counted.size, 1, 'the second group evicted the first, as a UDH group would');
|
||||
counted.clear();
|
||||
|
||||
// A sar_* segment is the two 11-octet addresses plus an 8-octet body, with no UDH to carry.
|
||||
assert.equal(collectSar(capped(20), 3, 1, 2).kept, false);
|
||||
assert.equal(collectSar(capped(30), 3, 1, 2).kept, true);
|
||||
// A sar_* segment is the two 11-octet addresses, an 8-octet body, its own object and three TLVs'.
|
||||
assert.equal(collectSar(capped(1920), 3, 1, 2).kept, false);
|
||||
assert.equal(collectSar(capped(1930), 3, 1, 2).kept, true);
|
||||
});
|
||||
|
||||
// Nothing else says a message the peer has already been answered for was thrown away.
|
||||
@@ -1898,8 +2155,8 @@ describe('reassembly bounds', () => {
|
||||
const reassembler = new Reassembler({
|
||||
log: silentLog,
|
||||
max: 10,
|
||||
// One segment is 36 octets: 14 of short_message plus the two 11-octet addresses.
|
||||
maxOctets: 80,
|
||||
// One segment is 1036 octets: 14 of short_message, the two 11-octet addresses and its object.
|
||||
maxOctets: 2100,
|
||||
now: () => 0,
|
||||
onLost: () => undefined,
|
||||
timeout: 60_000,
|
||||
@@ -1982,16 +2239,15 @@ describe('reassembly bounds', () => {
|
||||
// Jasmin dispatches one request per connector at a time: holding a group unanswered until it was
|
||||
// whole deadlocked every multi-segment message against it (interop-tests/findings/03-jasmin.md).
|
||||
describe('the status a refused segment is answered with', () => {
|
||||
// SMPP 3.4 lists ESME_RMSGQFUL under submit_sm_resp only; 4.6.2's retryable code is another.
|
||||
test('names one the command the segment arrived on defines', () => {
|
||||
assert.equal(refusedSegmentStatus('submit_sm', 'full', 'udh'), 'ESME_RMSGQFUL');
|
||||
assert.equal(refusedSegmentStatus('submit_sm', 'full', 'udh'), 'ESME_RTHROTTLED');
|
||||
assert.equal(refusedSegmentStatus('deliver_sm', 'full', 'udh'), 'ESME_RX_T_APPN');
|
||||
assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable', 'udh'), 'ESME_RINVESMCLASS');
|
||||
assert.equal(refusedSegmentStatus('deliver_sm', 'unplaceable', 'udh'), 'ESME_RINVESMCLASS');
|
||||
// esm_class is 0x00 on a sar_* segment and entirely valid: the TLV values are what cannot be honoured.
|
||||
assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable', 'sar'), 'ESME_RINVTLVVAL');
|
||||
assert.equal(refusedSegmentStatus('deliver_sm', 'unplaceable', 'sar'), 'ESME_RINVTLVVAL');
|
||||
assert.equal(refusedSegmentStatus('submit_sm', 'full', 'sar'), 'ESME_RMSGQFUL');
|
||||
assert.equal(refusedSegmentStatus('submit_sm', 'full', 'sar'), 'ESME_RTHROTTLED');
|
||||
});
|
||||
|
||||
// Which command that is, for the one that travels both ways, is what the end it arrived at says.
|
||||
@@ -2001,7 +2257,7 @@ describe('the status a refused segment is answered with', () => {
|
||||
assert.equal(standsInFor('deliver_sm', 'esme'), 'deliver_sm');
|
||||
assert.equal(standsInFor('submit_sm', 'smsc'), 'submit_sm');
|
||||
assert.equal(standsInFor('enquire_link', 'smsc'), 'enquire_link');
|
||||
assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'smsc'), 'full', 'udh'), 'ESME_RMSGQFUL');
|
||||
assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'smsc'), 'full', 'udh'), 'ESME_RTHROTTLED');
|
||||
assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'esme'), 'full', 'udh'), 'ESME_RX_T_APPN');
|
||||
});
|
||||
});
|
||||
|
||||
+56
-4
@@ -12,6 +12,7 @@ import { DlrMerger } from '../src/dlr-merger.ts';
|
||||
import { PduFramer } from '../src/pdu-framer.ts';
|
||||
import { ReconnectLoop } from '../src/reconnect-loop.ts';
|
||||
import { Session, bindCommands } from '../src/session.ts';
|
||||
import { checkSessionOptions } from '../src/session-options.ts';
|
||||
import { client } from '../src/client.ts';
|
||||
import { closeAfter, closeListenerAfter } from './teardown.ts';
|
||||
import { consts } from '../src/defs/constants.ts';
|
||||
@@ -316,6 +317,25 @@ describe('bind', () => {
|
||||
assert.equal(await responded, '00000010800000150000000400000001');
|
||||
});
|
||||
|
||||
test('leaves an outbind from an unbound peer unanswered', async t => {
|
||||
const smpp = await startServer(t);
|
||||
const errors: Error[] = [];
|
||||
|
||||
smpp.on('session', session => { session.on('sessionError', err => { errors.push(err); }); });
|
||||
|
||||
const peer = rawPeer(t, smpp.port);
|
||||
|
||||
peer.write({ cmdName: 'outbind', params: { password: 'pass', system_id: 'smsc' }, seqNr: 1 });
|
||||
peer.write({ cmdName: 'enquire_link', seqNr: 2 });
|
||||
|
||||
const answered = await raceWithin(2000, peer.next());
|
||||
|
||||
assert.ok(answered, 'the peer was never answered');
|
||||
assert.equal(answered.cmdStatus, 'ESME_RINVBNDSTS');
|
||||
assert.equal(answered.seqNr, 2);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test('answers the enquire_link a bound peer sends', async t => {
|
||||
const smpp = await startServer(t);
|
||||
const peer = rawPeer(t, smpp.port);
|
||||
@@ -890,8 +910,8 @@ describe('receiving', () => {
|
||||
|
||||
// The refusal a submission gets is the one submit_sm_resp defines, whichever command carried it.
|
||||
test('refuses a data_sm segment a server has no room for with the submit code', async t => {
|
||||
// The two addresses are 22 octets, so the 6-octet UDH and its text are what overrun 30.
|
||||
const smpp = await startServer(t, { maxOctets: 30 });
|
||||
// The two addresses and the objects are 1322 octets, so the 6-octet UDH and its text are what overrun 1330.
|
||||
const smpp = await startServer(t, { maxOctets: 1330 });
|
||||
const { session } = await connect(t, smpp, { bindType: 'transmitter' });
|
||||
|
||||
assert.ok(session);
|
||||
@@ -912,7 +932,7 @@ describe('receiving', () => {
|
||||
|
||||
assert.ok(refused.pduObj);
|
||||
assert.equal(refused.pduObj.cmdName, 'data_sm_resp');
|
||||
assert.equal(refused.pduObj.cmdStatus, 'ESME_RMSGQFUL');
|
||||
assert.equal(refused.pduObj.cmdStatus, 'ESME_RTHROTTLED');
|
||||
});
|
||||
|
||||
test('reassembles a concatenated message whose segments arrived in message_payload', async t => {
|
||||
@@ -1493,7 +1513,6 @@ describe('robustness', () => {
|
||||
// 0.4.0 registered a listener per sequence number and waited forever, leaking one per call.
|
||||
test('gives up on a peer that never answers', async t => {
|
||||
const accepted: net.Socket[] = [];
|
||||
// resume() so the socket drains; an unread socket never notices the peer hanging up.
|
||||
const silent = net.createServer(sock => { accepted.push(sock); sock.resume(); });
|
||||
|
||||
await new Promise<void>(resolve => silent.listen(0, resolve));
|
||||
@@ -1508,6 +1527,30 @@ describe('robustness', () => {
|
||||
assert.ok(Date.now() - started < 5000, 'should have given up quickly');
|
||||
});
|
||||
|
||||
test('leaves an alert_notification and an outbind unanswered, since SMPP names no response', async t => {
|
||||
const peer = await smscPeer(t);
|
||||
const { session } = await client({ port: peer.port });
|
||||
const errors: Error[] = [];
|
||||
|
||||
assert.ok(session);
|
||||
closeAfter(t, session);
|
||||
session.on('sessionError', err => { errors.push(err); });
|
||||
peer.writeRaw(pduBytes({
|
||||
cmdName: 'alert_notification',
|
||||
params: { esme_addr: '46709771337', source_addr: '46701113311' },
|
||||
seqNr: 10,
|
||||
}));
|
||||
peer.writeRaw(pduBytes({ cmdName: 'outbind', params: { password: 'pass', system_id: 'smsc' }, seqNr: 11 }));
|
||||
peer.writeRaw(pduBytes({ cmdName: 'enquire_link', seqNr: 12 }));
|
||||
|
||||
const answered = await raceWithin(2000, peer.next());
|
||||
|
||||
assert.ok(answered, 'the peer was never answered');
|
||||
assert.equal(answered.cmdName, 'enquire_link_resp');
|
||||
assert.equal(answered.seqNr, 12);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test('stops a connection attempt on an aborted signal', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
@@ -2646,6 +2689,15 @@ describe('option validation', () => {
|
||||
assert.match(hookRefused.err?.message ?? '', /onRequest must be a function/);
|
||||
});
|
||||
|
||||
test('refuses a reassembly octet cap below 1 at startup', async () => {
|
||||
const listening = await server({ maxOctets: 0, port: 0 });
|
||||
|
||||
if (listening.server) await listening.server.close();
|
||||
|
||||
assert.match(listening.err?.message ?? '', /maxOctets must be 1 or more, got 0/);
|
||||
assert.match(checkSessionOptions({ maxOctets: Infinity }).err?.message ?? '', /maxOctets must be 1 or more, got Infinity/);
|
||||
});
|
||||
|
||||
test('returns an error rather than rejecting on an impossible port', async () => {
|
||||
const listening = await server({ port: 70_000 });
|
||||
|
||||
|
||||
+106
-2
@@ -1,8 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { describe } from 'node:test';
|
||||
import type { DestAddress, UnsuccessSme } from '../src/defs/types.ts';
|
||||
import { paramText, types } from '../src/defs/types.ts';
|
||||
import { tlvs } from '../src/defs/tlvs.ts';
|
||||
import { types } from '../src/defs/types.ts';
|
||||
|
||||
describe('integers', () => {
|
||||
test('int8 reads, sizes and writes one octet', () => {
|
||||
@@ -39,6 +39,11 @@ describe('integers', () => {
|
||||
assert.ok(types.int8.write(-1, Buffer.alloc(1), 0).err instanceof Error);
|
||||
assert.ok(types.int16.write(1.5, Buffer.alloc(2), 0).err instanceof Error);
|
||||
assert.ok(types.int8.write('nope', Buffer.alloc(1), 0).err instanceof Error);
|
||||
|
||||
const notANumber = types.int8.write(NaN, Buffer.alloc(1), 0);
|
||||
|
||||
assert.ok(notANumber.err instanceof Error);
|
||||
assert.match(notANumber.err.message, /NaN/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,6 +74,27 @@ describe('string (Octet String)', () => {
|
||||
|
||||
assert.deepEqual(target, encoded);
|
||||
});
|
||||
|
||||
test('carries every latin1 octet, and refuses a character past it', () => {
|
||||
const target = Buffer.alloc(4);
|
||||
|
||||
assert.deepEqual(types.string.read(Buffer.from([3, 0xE9, 0x80, 0xFF]), 0), {
|
||||
bytesRead: 4,
|
||||
value: 'é\u0080ÿ',
|
||||
});
|
||||
assert.deepEqual(types.string.write('é\u0080ÿ', target, 0), {});
|
||||
assert.deepEqual(target, Buffer.from([3, 0xE9, 0x80, 0xFF]));
|
||||
|
||||
assert.ok(types.string.size('一').err instanceof Error);
|
||||
assert.ok(types.string.write('一', Buffer.alloc(4), 0).err instanceof Error);
|
||||
});
|
||||
|
||||
test('carries a NULL octet, which its length octet already bounds', () => {
|
||||
const target = Buffer.alloc(4);
|
||||
|
||||
assert.deepEqual(types.string.write('a\u0000b', target, 0), {});
|
||||
assert.deepEqual(target, Buffer.from([3, 0x61, 0x00, 0x62]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('cstring (C-Octet String)', () => {
|
||||
@@ -91,13 +117,39 @@ describe('cstring (C-Octet String)', () => {
|
||||
assert.deepEqual(target, encoded);
|
||||
});
|
||||
|
||||
test('coerces a numeric value to its decimal string', () => {
|
||||
test('coerces a numeric value to its decimal string, and refuses a non-finite one', () => {
|
||||
const target = Buffer.alloc(4);
|
||||
|
||||
types.cstring.write(123, target, 0);
|
||||
|
||||
assert.deepEqual(target, Buffer.from([0x31, 0x32, 0x33, 0x00]));
|
||||
assert.deepEqual(types.cstring.size(123), { size: 4 });
|
||||
|
||||
for (const value of [NaN, Infinity, -Infinity]) {
|
||||
assert.ok(types.cstring.size(value).err instanceof Error, String(value));
|
||||
assert.ok(types.cstring.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value));
|
||||
assert.ok(types.string.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value));
|
||||
assert.ok(types.buffer.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value));
|
||||
assert.ok(types.tlv.string.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value));
|
||||
assert.ok(types.tlv.cstring.write(value, Buffer.alloc(9), 0).err instanceof Error, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
test('carries every latin1 octet, and refuses a character past it', () => {
|
||||
const address = Buffer.from([0x4B, 0x61, 0x66, 0x66, 0x65, 0xE9, 0x00]);
|
||||
const target = Buffer.alloc(7);
|
||||
|
||||
assert.deepEqual(types.cstring.read(address, 0), { bytesRead: 7, value: 'Kaffeé' });
|
||||
assert.deepEqual(types.cstring.write('Kaffeé', target, 0), {});
|
||||
assert.deepEqual(target, address);
|
||||
|
||||
assert.ok(types.cstring.size('一').err instanceof Error);
|
||||
assert.ok(types.cstring.write('一', Buffer.alloc(4), 0).err instanceof Error);
|
||||
});
|
||||
|
||||
test('refuses a NULL of its own rather than ending the field early', () => {
|
||||
assert.ok(types.cstring.size('46701113311\u0000EVIL').err instanceof Error);
|
||||
assert.ok(types.cstring.write('46701113311\u0000EVIL', Buffer.alloc(17), 0).err instanceof Error);
|
||||
});
|
||||
|
||||
test('refuses a string with no terminator rather than running off the end', () => {
|
||||
@@ -142,6 +194,33 @@ describe('integer TLVs', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('text TLVs', () => {
|
||||
const encoded = Buffer.from([0xE9, 0x80, 0xFF]);
|
||||
|
||||
test('carry every latin1 octet, and refuse a character past it', () => {
|
||||
const target = Buffer.alloc(3);
|
||||
|
||||
assert.deepEqual(types.tlv.string.read(encoded, 0, 3), { bytesRead: 3, value: 'é\u0080ÿ' });
|
||||
assert.deepEqual(types.tlv.string.write('é\u0080ÿ', target, 0), {});
|
||||
assert.deepEqual(target, encoded);
|
||||
|
||||
assert.ok(types.tlv.string.size('一').err instanceof Error);
|
||||
assert.ok(types.tlv.string.write('一', Buffer.alloc(3), 0).err instanceof Error);
|
||||
});
|
||||
|
||||
test('carry them through a cstring tag too, terminator or none', () => {
|
||||
const target = Buffer.alloc(4);
|
||||
|
||||
assert.deepEqual(types.tlv.cstring.read(encoded, 0, 3), { bytesRead: 3, value: 'é\u0080ÿ' });
|
||||
assert.deepEqual(types.tlv.cstring.write('é\u0080ÿ', target, 0), {});
|
||||
assert.deepEqual(target, Buffer.from([0xE9, 0x80, 0xFF, 0x00]));
|
||||
|
||||
assert.ok(types.tlv.cstring.size('一').err instanceof Error);
|
||||
assert.ok(types.tlv.cstring.write('一', Buffer.alloc(4), 0).err instanceof Error);
|
||||
assert.ok(types.tlv.cstring.write('a\u0000b', Buffer.alloc(4), 0).err instanceof Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buffer', () => {
|
||||
const expected = Buffer.from('abcd1234');
|
||||
|
||||
@@ -170,6 +249,20 @@ describe('buffer', () => {
|
||||
|
||||
assert.deepEqual(target, expected);
|
||||
});
|
||||
|
||||
test('takes a string as the latin1 octets it stands for', () => {
|
||||
const target = Buffer.alloc(3);
|
||||
|
||||
assert.deepEqual(types.buffer.size('é\u0080ÿ'), { size: 3 });
|
||||
assert.deepEqual(types.buffer.write('é\u0080ÿ', target, 0), {});
|
||||
assert.deepEqual(target, Buffer.from([0xE9, 0x80, 0xFF]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('paramText()', () => {
|
||||
test('renders a Buffer parameter as the latin1 text its octets spell', () => {
|
||||
assert.equal(paramText(Buffer.from([0x4B, 0x61, 0x66, 0x66, 0x65, 0xE9])), 'Kaffeé');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dest_address_array', () => {
|
||||
@@ -206,6 +299,11 @@ describe('dest_address_array', () => {
|
||||
types.dest_address_array.write(expected, target, 0);
|
||||
|
||||
assert.deepEqual(target, encoded);
|
||||
|
||||
const smuggled: DestAddress[] = [{ dest_addr_npi: 1, dest_addr_ton: 1, destination_addr: '46\u0000EVIL' }];
|
||||
|
||||
assert.ok(types.dest_address_array.write(smuggled, Buffer.alloc(16), 0).err instanceof Error);
|
||||
assert.ok(types.dest_address_array.write([{ dl_name: '一' }], Buffer.alloc(16), 0).err instanceof Error);
|
||||
});
|
||||
|
||||
test('refuses a field value the wire cannot hold instead of throwing', () => {
|
||||
@@ -245,6 +343,12 @@ describe('unsuccess_sme_array', () => {
|
||||
types.unsuccess_sme_array.write(expected, target, 0);
|
||||
|
||||
assert.deepEqual(target, encoded);
|
||||
|
||||
const smuggled: UnsuccessSme[] = [
|
||||
{ dest_addr_npi: 1, dest_addr_ton: 1, destination_addr: 'a\u0000b', error_status_code: 0 },
|
||||
];
|
||||
|
||||
assert.ok(types.unsuccess_sme_array.write(smuggled, Buffer.alloc(16), 0).err instanceof Error);
|
||||
});
|
||||
|
||||
test('refuses a field value the wire cannot hold instead of throwing', () => {
|
||||
|
||||
+30
-22
@@ -173,18 +173,17 @@ describe('a body the PDU\'s own data_coding cannot carry', () => {
|
||||
assert.match(built.err.message, /U\+3042/);
|
||||
});
|
||||
|
||||
test('refuses the body TLV under whatever name the caller keyed its tagId to', () => {
|
||||
test('refuses the body TLV keyed by its id, so only message_payload is written as text', () => {
|
||||
const built = objToPdu({
|
||||
cmdName: 'data_sm',
|
||||
params: { data_coding: 0x03, destination_addr: to, source_addr: from },
|
||||
tlvs: { body: { tagId: 0x0424, tagValue: 'あいう' } },
|
||||
params: { data_coding: 0x08, destination_addr: to, source_addr: from },
|
||||
tlvs: { 1060: { tagValue: 'あいう' }, message_payload: { 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/);
|
||||
assert.match(built.err.message, /"1060"/);
|
||||
assert.match(built.err.message, /message_payload/);
|
||||
});
|
||||
|
||||
test('leaves data_coding to short_message wherever it carries octets, as messageOctets() reads it', () => {
|
||||
@@ -205,22 +204,6 @@ describe('a body the PDU\'s own data_coding cannot carry', () => {
|
||||
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({
|
||||
@@ -345,6 +328,31 @@ describe('a body the PDU\'s own data_coding cannot carry', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('an address the field cannot carry', () => {
|
||||
test('refuses it through sendSms(), with nothing reaching the socket', async t => {
|
||||
const smsc = await dummySmsc(t);
|
||||
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
|
||||
|
||||
const refusals: ['from' | 'to', string, RegExp][] = [
|
||||
['from', '46701113311\u0000EVIL', /U\+0000 at index 11/],
|
||||
['from', 'Kaffe一', /"一" \(U\+4E00\) at index 5/],
|
||||
['from', '😀', /"😀" \(U\+1F600\) at index 0/],
|
||||
['to', 'Kaffe一', /"一" \(U\+4E00\) at index 5/],
|
||||
];
|
||||
|
||||
for (const [option, address, names] of refusals) {
|
||||
const sent = await session.sendSms({ from, message: 'Hello world', to, [option]: address });
|
||||
|
||||
assert.ok(sent.err instanceof Error, address);
|
||||
assert.match(sent.err.message, new RegExp(`^${option}: `), 'names the option the caller wrote');
|
||||
assert.match(sent.err.message, names);
|
||||
assert.deepEqual(sent.smsIds, [], address);
|
||||
}
|
||||
|
||||
assert.deepEqual(smsc.octets, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a time no peer can read', () => {
|
||||
const invalid = new Date('nope');
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# todo.md
|
||||
|
||||
Remaining work for `@larvit/smpp`. Read [AGENTS.md](AGENTS.md) first — the goals and hard rules
|
||||
there constrain every item below.
|
||||
Remaining work for `@larvit/smpp`. Read [README.md](README.md)'s goals and [AGENTS.md](AGENTS.md)'s
|
||||
hard rules first — they constrain every item below.
|
||||
|
||||
This is a working file that sets its own rules. The documentation conventions in AGENTS.md do not
|
||||
govern it, and nothing here is a source anything else may cite.
|
||||
@@ -9,7 +9,9 @@ govern it, and nothing here is a source anything else may cite.
|
||||
## Status
|
||||
|
||||
The rewrite is **feature complete and green**: the suite, lint and typecheck are clean on Node 18
|
||||
to 26. What is left is release work and a few things worth adding before or after 0.5.0.
|
||||
to 26, and 0.5.0 is on npm. 0.6.0 is next, and it is a quality cut rather than a feature one — the
|
||||
comprehension gate and the defects under [0.6.0](#060) come first, then the gaps a comparison with
|
||||
other SMPP libraries found.
|
||||
|
||||
## The agreed API
|
||||
|
||||
@@ -73,8 +75,8 @@ Every defect listed in the AGENTS.md table has a regression test naming the beha
|
||||
|
||||
## Move the repository to Gitea
|
||||
|
||||
`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.
|
||||
`gitea.larvit.se/larvit/smpp-js` is the repository. `github.com/larvit/smpp-js` mirrors it and is the
|
||||
place issues are filed. Maintainer's calls, 2026-09-13 and 2026-09-14.
|
||||
|
||||
- [x] `larvit/smpp-js` holds `main`, from `typescript`, and `v0.4.0`, from `master`. `rewrite-base`
|
||||
and the `renovate/*` branches stayed behind.
|
||||
@@ -97,47 +99,55 @@ of it and the place issues are filed. Maintainer's calls, 2026-09-13 and 2026-09
|
||||
|
||||
- [x] 0.5.0 rather than 1.0.0, while usage is this low. Maintainer's call, 2026-09-14.
|
||||
- [x] `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`.
|
||||
- [ ] `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.
|
||||
|
||||
## 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.
|
||||
deleting GitHub's old branches closes every pull request based on them without a reply, and GitHub
|
||||
refuses to delete its default branch.
|
||||
|
||||
- [ ] 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`
|
||||
- [x] Close the backlog below.
|
||||
- [x] Close [#71](https://github.com/larvit/larvitsmpp/pull/71), pointing at Gitea.
|
||||
- [x] Rename `larvit/larvitsmpp` to `larvit/smpp-js`. GitHub redirects the old URLs, and the `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.
|
||||
- [x] Push `main` and make it GitHub's default branch.
|
||||
- [x] Renovate is Silent for this repository in the Mend Developer Portal, so it opens nothing on
|
||||
GitHub while the organization-wide installation stays. CodeRabbit stays installed.
|
||||
Maintainer's call, 2026-09-14.
|
||||
- [x] Mirror to GitHub from `.gitea/workflows/mirror.yaml` and `mirror-delete.yaml`, with
|
||||
`MIRROR_GITHUB_TOKEN`. A push of a commit carrying the workflow, and the nightly run, send all of
|
||||
Gitea's branches and tags, overwriting a same-named ref; a branch or tag deleted on Gitea is
|
||||
deleted there too. Refs only GitHub has stay. Maintainer's call, 2026-09-14.
|
||||
- [x] GitHub's wiki, projects and Actions are off, the Travis app and webhook are gone, and its About
|
||||
matches the package. Gitea carries the same description, website and topics, and sends issues
|
||||
to GitHub as its external tracker.
|
||||
|
||||
## Close the GitHub backlog
|
||||
|
||||
**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`.
|
||||
- [ ] [#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`.
|
||||
- [ ] [#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.
|
||||
- [ ] [#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.
|
||||
- [ ] [#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.
|
||||
- [ ] [#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.
|
||||
- [ ] [#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.
|
||||
- [ ] [#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`
|
||||
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`,
|
||||
per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the fork found real
|
||||
defects.
|
||||
@@ -145,7 +155,7 @@ the push mirror replaces GitHub's branches with Gitea's, which closes every pull
|
||||
**Close as superseded**, all against 0.4.0 dependencies the rewrite does not have — `async`,
|
||||
`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),
|
||||
[#42](https://github.com/larvit/larvitsmpp/pull/42),
|
||||
[#45](https://github.com/larvit/larvitsmpp/pull/45),
|
||||
@@ -160,15 +170,278 @@ the push mirror replaces GitHub's branches with Gitea's, which closes every pull
|
||||
[#70](https://github.com/larvit/larvitsmpp/pull/70) is the open `uuid` advisory GitHub reports
|
||||
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.
|
||||
[#60](https://github.com/larvit/larvitsmpp/issues/60) is Renovate's dashboard and stays open after
|
||||
the rewrite, for a dependency added later. Maintainer's call, 2026-09-14.
|
||||
|
||||
**Leave open:** [#8](https://github.com/larvit/larvitsmpp/issues/8), the socket's remote host and
|
||||
port on log messages. Only `server - incoming connection` carries them today; putting them on every
|
||||
session message is a change to every call site.
|
||||
**Close as tracked here**, the reply saying it will be implemented on Gitea:
|
||||
|
||||
- [x] [#8](https://github.com/larvit/larvitsmpp/issues/8) The socket's remote host and port on log
|
||||
messages: under Worth doing, not blocking. Maintainer's call, 2026-09-14.
|
||||
|
||||
## 0.6.0
|
||||
|
||||
A nine-reader comprehension panel read the whole project on 2026-09-20 and scored it 7 overall,
|
||||
mean 6.8. Navigation (7–8) capped nobody. **Locality capped every unit reader at 5–6 and Shape
|
||||
capped both architects at 6**, and those two are what this release lifts. The gate is 7 on all four
|
||||
dimensions, higher where it is cheap. Maintainer's call, 2026-09-20. A systems-architect review the
|
||||
same day returned ALIGN with one blocking-severity finding, which is the first item under Locality
|
||||
and is also what the panel ranked hardest — two methods, one answer.
|
||||
|
||||
### Correctness, ahead of everything below
|
||||
|
||||
- [ ] **Settle what a repeated tag not marked `multiple` reads as, and pin it in a test.** A vendor
|
||||
tag or a known single-value tag a peer sends twice keeps the last occurrence and drops the
|
||||
rest silently, which goal 3 argues against; listing it would change every such tag's shape.
|
||||
From the architecture review of #25.
|
||||
|
||||
- [ ] **Test that a multipart send which errors never fires `messageDlr`.** Goal 2 now says so and
|
||||
README promises it; `session-extras.test.ts` covers a drop *after* the send, not one during it.
|
||||
|
||||
- [ ] **Return an `err` where `message` is not a string, rather than throwing.**
|
||||
`sendSms({ message: undefined })` — a forgotten property — reaches `value.replace()` in
|
||||
`defs/encodings.ts` through the alphabet detection `checkOptions()` runs, and the `TypeError`
|
||||
escapes `submitSms()` into the caller's process; `NaN` and `12345` do the same. README promises
|
||||
"Never throws. Every fallible call resolves to `{ err?, … }`" and AGENTS.md hard rule 1 says it
|
||||
again, so the docs are false for the likeliest caller mistake there is. From the stability
|
||||
review of #18.
|
||||
|
||||
- [ ] **Derive `sm_length` for a numeric body, or refuse one.** `resolveBody()` in `pdu.ts` reads the
|
||||
length only where the body is a Buffer or a string, so
|
||||
`objToPdu({ cmdName: 'submit_sm', params: { short_message: 12345 } })` writes `sm_length: 0`,
|
||||
then five octets after it, and reports success — and this library's own parser refuses what it
|
||||
built, as "TLV 12594 runs past the end of the PDU". Goals 1 and 2. From the stability review
|
||||
of #18.
|
||||
|
||||
- [ ] **Settle which numbers may spell a text field, refuse the rest, and say so where a consumer
|
||||
reads it.** `wantText()` takes every finite number through `String()`, so `message_id: 1e21`
|
||||
writes `1e+21`, `from: 0.1 + 0.2` writes `0.30000000000000004` and `source_addr: -5` writes
|
||||
`-5` — none of them is the id or the address the caller meant, and all three are reported as
|
||||
sent. The numeric branch exists for a digit sequence (`message_id: 123`); the product-owner
|
||||
review of #18 recommends `Number.isSafeInteger(value) && value >= 0` with the refusal naming
|
||||
the fix, since a 64-bit SMSC id loses digits to a JS number before this library ever sees it.
|
||||
Goals 2 then 3: `from: 1e21` is reported as sent to an address that reaches nobody, which is
|
||||
the wrong answer about what happened before it is laxness in what we send. That a number is
|
||||
accepted at all reaches a consumer in no sentence either: only the type comment at
|
||||
`defs/commands.ts:239`, and one CHANGELOG line that stops being visible when
|
||||
0.7.0 is cut, while README's Building bullet reads as the whole rule for a text field. Whether
|
||||
this is a supported spelling or 0.4.0 tolerance decides whether that sentence lands in
|
||||
README.md or in MIGRATION.md — write it in the same change as the rule, so it is worded once.
|
||||
From the stability and product-owner reviews of #18.
|
||||
|
||||
### Throughput — goal 6, and the default window is where we are slowest
|
||||
|
||||
- [ ] **Close the gap to jsmpp at `maxOutstanding: 10`.** Measured 2026-09-20 against the same sink,
|
||||
100,000 messages each: this library 25,358/s, jsmpp 30,771/s, Cloudhopper 27,945/s — we are
|
||||
last at the one window most callers will ever run, while leading Cloudhopper and trailing jsmpp
|
||||
by only 5% at 50 and 200. So the cost is not the codec, which the higher windows exercise just
|
||||
as hard; it is something per-request that the window hides once enough requests overlap.
|
||||
`benchmarks/` reproduces all three. Goal 6.
|
||||
|
||||
### Locality — 5–6 today, and the gate is 7
|
||||
|
||||
- [ ] **Give `IncomingRequests` a port instead of the `Session` it drives.** It holds its owner and
|
||||
calls eight members of it 18 times, including `this.session.close()` on an inbound `unbind` —
|
||||
a collaborator ending its owner's life. `OutgoingRequests` is the mirror half of the same
|
||||
boundary and takes no session at all. AGENTS.md names this as the one way back up; the port
|
||||
removes that exception, and `docs/decisions.md` already states the rule under The session's life: "a collaborator
|
||||
that has to ask does not own its decision". It is also the missing test seam — inbound routing,
|
||||
reassembly dispatch, `onRequest` ordering and bind-direction refusal have no unit test because
|
||||
the class cannot be built without a live socket. Carry the eight members as `IncomingDeps`,
|
||||
exactly as `sendPastDrain` is carried now. No public surface changes. **Do this before the
|
||||
store (goal 9), or the back-edge is baked into the store's published interface.**
|
||||
|
||||
- [ ] **Route `sms.ts` through its handlers, all of it.** `createSms()` already injects
|
||||
`handlers.send`, and then reaches `sms.session.sendReturn()`, `sms.session.bindAllows()` and
|
||||
`sms.session.acceptsOptionalParams()` anyway — two channels to one collaborator. `Sms.session`
|
||||
stays public as data the application reads. The cheaper half of the item above, and the one
|
||||
that shows the shape.
|
||||
|
||||
- [ ] **Give the held-message protocol one name and one home.** `emitSms()` is the unit 8 of 9
|
||||
readers named and 4 would least want to modify, and every one proposed the same fix. It runs
|
||||
five mechanisms in one scope: a hold keyed by array identity, a `working` counter seeded from
|
||||
`listenerCount('sms')`, a `WeakMap` keyed by the `Sms` object, a `setImmediate`-deferred
|
||||
release, and a captured `linkGeneration` — with the counter decremented from `session.ts`'s
|
||||
`captureRejectionSymbol` in another file. A `MessageHold` owning `hold/release/listenerGaveUp`
|
||||
collapses three files into one readable object. Every way of getting it wrong is silent: a hung
|
||||
shutdown, or a receipt refused.
|
||||
|
||||
- [ ] **Derive `Reassembler`'s octet total instead of maintaining it at five sites.** `this.octets`
|
||||
and each `group.octets` must agree, adjusted in `collect`, `trim`, `takeOldest`, `sweep` and
|
||||
`clear`, and `collect()` discovers its own eviction by re-reading the map by identity. Push the
|
||||
budget into `ExpiringGroups` as a weighed capacity, and have `trim()` report whether the
|
||||
current group survived. Named by 6 of 9 readers.
|
||||
|
||||
- [ ] **Let the two address arrays size a C-Octet String through `cstring.size()`.**
|
||||
`sizeDestAddresses()` and `sizeUnsuccessSmes()` spell "len + 1" themselves, and each `offset +=`
|
||||
after a write spells it a third time, so `dest_address_array` and `unsuccess_sme_array` each
|
||||
know the cost in three places. Route both through `cstring.size()` and advance the offset by
|
||||
what it returns. That also makes `size()` refuse where `write()` already does, so the error
|
||||
arrives from the first call rather than the second; today the pair only fails closed because
|
||||
`writeParams()` and `writeTlvs()` both bail on the write. From the stability review of #16.
|
||||
|
||||
- [ ] **Split the two questions `OutgoingRequests.linkDown()` answers.** `Session.drain()` calls it
|
||||
twice for opposite conclusions — "nothing to drain, success" and "the link died under us,
|
||||
failure" — and `outgoing-requests.ts` reads it a third way. Two named predicates. Named by 7
|
||||
of 9 readers, who each reconstructed the ordering by hand.
|
||||
|
||||
- [ ] **Name `pastDrain()`'s retry condition and what makes the loop end.** The exit is a
|
||||
three-term disjunction over two collaborators, whose comment covers the first term only, and
|
||||
the method is named for what it bypasses. Do not change what it asks: `gate.isUp()` rather than
|
||||
`linkDown()` is deliberate and recorded.
|
||||
|
||||
- [ ] **Replace `resolveBody`'s `settles` boolean with the decision it stands for.** One boolean
|
||||
chooses both whether to overwrite `data_coding` and which params to read it from, across four
|
||||
helpers all named some abstraction of "body". Return a named source — `'short_message' |
|
||||
'payload' | 'caller'` — and branch once. Ranked hardest by three readers and picked by one as
|
||||
the unit they would least want to touch, because a mistake here does not throw, does not fail
|
||||
the types, and reaches the peer as somebody's message rendered wrong.
|
||||
|
||||
### Shape — 6 today, and the gate is 7
|
||||
|
||||
- [ ] **Group `src/` into a second level, and retire whichever record loses.** 34 files on one
|
||||
plane, where `src/defs/` at 7 proves the shape is known one level down. `docs/decisions.md`
|
||||
says "`src/` stays flat until a module has to move for another reason. Valid while that map is
|
||||
what a reader navigates by" — and both architects reported that the map is now AGENTS.md rather
|
||||
than the tree, which is that premise failing. `todo.md` already carries the opposite
|
||||
instruction under Worth doing. Two records, opposite answers; one has to go. Do it in the same
|
||||
change as the `IncomingRequests` port or the imports are rewritten twice.
|
||||
|
||||
- [ ] **Split `test/session-extras.test.ts` by the question each block answers.** 3,010 lines, 19
|
||||
unrelated `describe` blocks whose names are already the file names they should be. With
|
||||
`session.test.ts` it is 54% of all test code and 84% the size of `src/`. "extras" names neither
|
||||
a question nor a module — it names the rest — and AGENTS.md's own convention forbids exactly
|
||||
that. `max-lines` covers `src/**` only, so nothing has stopped it growing.
|
||||
|
||||
- [ ] **Collapse the three objects named `defaults`.** `client.ts`, `server.ts` and
|
||||
`session-options.ts` each export or hold one; `port: 2775` is written twice and the idle
|
||||
timeout is derived two ways to the same 40 000. "What is the default for X" has three answers
|
||||
depending on the entrypoint, and nothing fails when they drift. Named by both architects as the
|
||||
most likely first bug a new contributor ships.
|
||||
|
||||
- [ ] **Rename `EncodingName`'s `ASCII` to `GSM7`, with `ASCII` a deprecated alias for one minor.**
|
||||
It is GSM 03.38, where `$` is 0x02 and `@` is 0x00, and `segmentUnits.ASCII = 153` is a septet
|
||||
budget under a name that says octets. The 2026-09-09 decision removed `consts.ENCODING.ASCII`
|
||||
for exactly this reason and left the option's own vocabulary carrying it. Pre-1.0 the minor is
|
||||
the breaking unit, so this is as cheap as it will ever be, and `todo.md` already requires the
|
||||
`consts.ENCODING` names settled before the custom-encoding registry — this is the other half.
|
||||
|
||||
- [ ] **Split `session-options.ts` into the things it is.** Option types and their validator, the
|
||||
`SessionEvents` map, and the bind-direction rules (`bindCommands`, `bindTypeFromCommand`,
|
||||
`standsInFor`, `bindCarries`) are three questions in one file, and the `defaults` table mixes
|
||||
option defaults with four hard bounds that are not options. Both architects named it as where
|
||||
the codebase rots first: at 34-wide it is where anything session-shaped lands.
|
||||
|
||||
- [ ] **Name the base-versus-segment distinction in the message id types.** `Sms.smsId` is a base,
|
||||
`sendSms().smsIds[]` are segment ids, `Dlr.smsId` is a segment id and `MessageDlr.smsId` is a
|
||||
base again — four fields, one type, `string`. The whole multipart receipt mechanism turns on
|
||||
telling them apart and only `parseSegmentId()` knows.
|
||||
|
||||
### Self-sufficiency — 6–7 today, and the gate is 7
|
||||
|
||||
- [ ] **Move the one-line facts out of the decision log and back to the code.** Five of nine readers
|
||||
independently reported being sent to `docs/decisions.md` for a question they hit while reading,
|
||||
with no link from the code; one counted roughly fifty index redirects. The four worth inlining
|
||||
as one line each: that `segmentUnits`' three numbers are in two units (septets and octets),
|
||||
which body settles `data_coding`, that a receipt's body is read as octets whatever its
|
||||
`data_coding` says, and the `<base>-<n>` id notation. The reasoning stays in the log; the
|
||||
definition belongs at the code.
|
||||
|
||||
- [ ] **Document the two delivery-receipt merge bounds.** `maxDlrMerges` (1000) and
|
||||
`dlrMergeTimeout` (24 h) are hardcoded, are not options, and appear in no README and no test —
|
||||
while README states the equivalent held-message bounds explicitly ("Neither bound is an
|
||||
option"). A sender with more than 1000 concurrent multipart `dlr: true` messages silently
|
||||
evicts the oldest at `warn`. The inherited architect hit this on the 3am walk.
|
||||
|
||||
- [ ] **Add a ten-line SMPP glossary to the README.** Both juniors and the no-domain mid reported
|
||||
the same largest cost: nothing in the repo says what a PDU, `esm_class`, `data_coding`, TON/NPI
|
||||
or `submit_sm`-versus-`deliver_sm` are, and the inline spec citations mark a rule without
|
||||
stating it. One of them put it at a third of their reading time. Four commands, three octets,
|
||||
one sentence each.
|
||||
|
||||
### Doc claims this review falsified
|
||||
|
||||
- [ ] **Make `LinkGate.isUp()`'s doc true or its state match it.** It says a link attached but not
|
||||
yet bound cannot carry a request, while `up` starts `true`, so the first link and a server
|
||||
session are up before any bind. From the comprehension panel of #25.
|
||||
|
||||
- [ ] **Move `checkSessionOptions()`'s doc comment to what it describes.** It explains why a count
|
||||
below 1 is refused, which is `checkLimits`' job, and says nothing of the function it heads.
|
||||
From the comprehension panel of #25.
|
||||
|
||||
- [ ] **Log why `DlrMerger.expect()` registered no merge.** Ids with no common `<base>-<n>`
|
||||
numbering return silently, the likeliest cause of a `messageDlr` that never fires and the one
|
||||
that leaves no trace. From the comprehension panel of #25.
|
||||
|
||||
- [ ] **Make "every README example is executed by the suite" true, or stop claiming it.** Goal 10 and
|
||||
the Done table both promise it; `test/readme.test.ts` transcribes the examples by hand and has
|
||||
drifted — 15 fenced `javascript` blocks in the README against 10 tests, and the test named "the
|
||||
documented sending options" passes none of the five options the README's example passes. Read
|
||||
the fenced blocks at test time and assert each appears verbatim in the executed source, so an
|
||||
edit to either fails the gate.
|
||||
|
||||
- [ ] **Narrow the `src/defs/*` lint exemption to the four table files.** Its stated reason — "the
|
||||
spec tables are data: their length tracks the specification, not any complexity" — is false for
|
||||
`defs/types.ts`, which is 595 lines of wire codec with 25 functions and is the file that parses
|
||||
hostile input from the network. It carries more over-budget methods than any other file in the
|
||||
repo, under a suppression written for something else.
|
||||
|
||||
- [ ] **Run the interop suite before cutting a minor, and date the claim.** README states
|
||||
"Interoperable. Tested as a client against Jasmin and SMPPSim, and as a server against Kannel,
|
||||
jsmpp, Cloudhopper, python-smpplib and php-smpp" in the present tense; `interop-tests/README.md`
|
||||
is honest that the run was 2026-09-08. Nothing runs the peers on a schedule or before a tag, so
|
||||
the claim rots silently. Goals 1 and 9.
|
||||
|
||||
## Worth doing, not blocking
|
||||
|
||||
- [ ] **Move the decisions out of AGENTS.md's fixtures paragraph and the two tooling READMEs.** The
|
||||
one dummy SMSC, `smscPeer()` staying separate and which copied helpers are tolerated
|
||||
(AGENTS.md Conventions), and why Kannel is absent from `benchmarks/README.md`, go to
|
||||
`docs/decisions.md` with index lines. Move the planned work written into
|
||||
`interop-tests/AGENTS.md` (an expected count per peer) and `benchmarks/README.md` (the default
|
||||
window gap) to this file. Give the `run.py`-in-background footgun in `interop-tests/AGENTS.md`
|
||||
a rule of its own. Delete AGENTS.md's "message_id values … are UUID v7" line. From the prose
|
||||
pass of #30.
|
||||
|
||||
- [ ] **Make the dumbclient soak's memory sample evidence of no library leak again.** Its rss ends at
|
||||
its maximum (298 MiB, heapUsed 81 MiB after 173,820 messages), which the harness's own per-id
|
||||
`Set` and `answerOrder` explain but cannot separate from a leak in `src/`: sample the heap
|
||||
after the bookkeeping is cleared. From the stability review of #29.
|
||||
|
||||
- [ ] **Decide whether `alert_notification` reaches the application as more than `incomingPduObj`.**
|
||||
It is the SMSC saying a handset it could not reach is reachable again (`esme_addr`,
|
||||
`ms_availability_status`); a client has no `onRequest`, so the raw PDU event is the only way in.
|
||||
Raised by the stability review of #21.
|
||||
|
||||
- [ ] **Cut the three teardown sentences `test/teardown.ts` already says.** Under AGENTS.md's
|
||||
Conventions, "`test/teardown.ts` covers a session, a server and a listener" restates its two
|
||||
exported names, "Its close aborts rather than drains" restates `closeAfter`'s own doc comment,
|
||||
and the `net.Server.close()` sentence restates `closeListenerAfter`'s. Keep the registered-at-
|
||||
creation rule and the FIFO one, which nothing else states, and drop "CI's ten-minute cap" —
|
||||
that number lives in `.gitea/workflows/test.yaml`. Raised by the prose pass, 2026-09-20.
|
||||
|
||||
- [ ] **Leave AGENTS.md hard rule 1 the rule, and the decision log its reasoning.** Rule 1's fourth
|
||||
sentence — "a function whose argument types are a closed set is guarded by the compiler and
|
||||
stays total, which is why the encoding helpers return plainly, and the check belongs at
|
||||
whichever boundary the argument arrives untyped at" — is the reasoning of the
|
||||
`bitCount()`/`encodeMessage()`/`splitMessage()` entry in `docs/decisions.md`, which AGENTS.md's
|
||||
own Documentation section makes a defect: it scopes AGENTS.md to an index of the decisions. It
|
||||
also reads two ways — "wherever the types admit one" as an exemption for a typed field,
|
||||
"the check belongs at whichever boundary the argument arrives untyped at" as a requirement at
|
||||
`sendSms()` — and the `message` `TypeError` item sits exactly between them, so one rewrite
|
||||
settles both. Maintainer's call, since it changes what a hard rule asks. From the prose pass
|
||||
of #18.
|
||||
|
||||
- [ ] **Refuse a delay Node's timers cannot hold, in `checkLimits`.** `idleTimeout`,
|
||||
`reassemblyTimeout`, `responseTimeout` and `shutdownTimeout` take any integer, and `setTimeout`
|
||||
fires after 1 ms for anything above 2147483647 — so a value in the wrong unit gets the inverse
|
||||
of what it asked for, explained only by a warning on stderr. `connectTimeout` refuses one
|
||||
already, which is the asymmetry to close. The same four print an untyped value bare, so
|
||||
`idleTimeout: '5000'` is refused with `got 5000` — a value the reader reads as correct — where
|
||||
`connectTimeout` quotes it. `namedValue()`'s four sites — `messagingMode`, `encoding`, the time
|
||||
options and `smsIdFormat` — are the same defect once more: there `true` and `'true'` both print
|
||||
as `true`. One fix closes all three, and `valueText()` in `defs/types.ts` is the quoted
|
||||
spelling to take it from. Raised by review, 2026-09-20.
|
||||
|
||||
- [ ] **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
|
||||
@@ -201,12 +474,6 @@ session message is a change to every call site.
|
||||
each Gitea pull request to GitHub for it to review there. Maintainer's ask, 2026-09-14; not
|
||||
started until asked.
|
||||
|
||||
- [ ] **Group the session's collaborators under `src/session/`.** `session.ts` imports
|
||||
`dlr-merger`, `incoming-requests`, `link-timers`, `outgoing-requests`, `pdu-transport`,
|
||||
`reconnect-loop` and `send-sms`, and nothing else does, so the directory would make that
|
||||
boundary visible. The `OutgoingRequests` extraction this was to be done with landed on
|
||||
2026-09-01, so it is the remaining half. Raised by review, 2026-09-01.
|
||||
|
||||
- [ ] **`leftOf()` and the link gate's own budget are one concept counted twice.**
|
||||
`idle-waiters.ts` reads what is left of a budget as `Math.max(1, deadline - now)`, because 0
|
||||
means "forever" there; `link-gate.ts` runs the same subtraction and calls `<= 0` expired.
|
||||
@@ -225,6 +492,13 @@ session message is a change to every call site.
|
||||
`tls.test.ts` wait forever, so an event that never fires still hangs the run the way an
|
||||
unclosed listener used to. One shared, guarded copy closes the rest of that class.
|
||||
|
||||
- [ ] **The peer's address and bind on every session log message.** `remoteAddress` and `remotePort`
|
||||
reach only `server - incoming connection`, and `systemId` only the bind messages, so with
|
||||
several peers connected one session's lines cannot be told apart, and a reconnect leaves nothing
|
||||
stable to filter on. Carrying them in every session message's metadata is a change to every
|
||||
call site. From [#8](https://github.com/larvit/larvitsmpp/issues/8), closed there as tracked
|
||||
here; maintainer's call, 2026-09-14.
|
||||
|
||||
- [ ] **A peer whose message ids share one base logs a refused merge on every send.** `smsc01-000123`
|
||||
and `smsc01-000124` carry the same base, so `DlrMerger` merges the first message and refuses
|
||||
every one after it, one log line per send. Left at `info` — nothing the operator can fix is
|
||||
@@ -234,9 +508,6 @@ session message is a change to every call site.
|
||||
end to end. The interop suite is the natural place.
|
||||
- [ ] **Move to TypeScript 7** once `typescript-eslint` supports it; `renovate.json` pins TypeScript
|
||||
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,
|
||||
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
|
||||
@@ -244,18 +515,191 @@ session message is a change to every call site.
|
||||
Mirror the `onRequest` seam — return a `Dlr` to own the receipt, `undefined` to fall through
|
||||
to the built-in parser.
|
||||
|
||||
## Gaps against other SMPP libraries
|
||||
|
||||
From comparing 0.5.0 with `smpp`, `@semyonf/smpp`, `@leissner/node-red-smpp`, `node-smpp-next`,
|
||||
`smpp-js-sdk`, `smppjs`, cloudhopper-smpp, jsmpp, go-smpp, Kannel, Jasmin and php-smpp, 2026-09-14.
|
||||
Each lands under goal 7: 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
|
||||
|
||||
- **Merge state surviving a process restart.** Declined by AGENTS.md goal 7, maintainer's call,
|
||||
2026-09-02. A restart loses every incomplete receipt group and a peer has no reason to resend one it
|
||||
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 0.5.0 as a minor.
|
||||
- **A check that warns before `MIRROR_GITHUB_TOKEN` expires.** Gitea mails no one about a failed
|
||||
scheduled run, since its Actions bot triggers those, so a nightly check would fail unseen. The
|
||||
maintainer relies on GitHub's own expiry reminders, and on the mirror's first failed push run after
|
||||
expiry, which mails whoever pushed. Maintainer's call, 2026-09-14.
|
||||
|
||||
- **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
|
||||
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
|
||||
`maxOutstanding` stays — a window slot frees on the peer's next response, which is self-limiting in
|
||||
a way a rate ceiling is not.
|
||||
- **CommonJS.** ESM only, maintainer's call reaffirmed 2026-09-14, though `node-smpp-next` ships both.
|
||||
`require()` of an ES module works unflagged from Node 20.19 and 22.12.
|
||||
|
||||
## An optional store
|
||||
|
||||
- [ ] **Pooling, and state that survives a restart, through an optional store.** Maintainer's call,
|
||||
2026-09-14. It replaces two declines — merge state surviving a restart, and a pool of sessions —
|
||||
and goal 9 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 8). 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 10).
|
||||
- **When the store fails.** Open: a send whose awaited receipt cannot be recorded is refused, or
|
||||
sent and reported as undetermined (goal 2); a pool whose store is down stops, or falls back to
|
||||
memory. Either way, an application that supplied no store never waits on one.
|
||||
- **One spelling.** A store-backed cap and the limiter hook both reach a limit shared between
|
||||
processes; settle which owns that case before building the second.
|
||||
|
||||
+1
-1
@@ -24,5 +24,5 @@
|
||||
"types": ["node"],
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["eslint.config.ts", "interop-tests", "src", "test"]
|
||||
"include": ["benchmarks", "eslint.config.ts", "interop-tests", "src", "test"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user